Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbfd544013 | ||
|
|
aadc030407 | ||
|
|
85756a161b | ||
|
|
ea3de70d05 | ||
|
|
0b35ea9bde | ||
|
|
a082c8398e | ||
|
|
a6cb152f55 | ||
|
|
85363b1014 | ||
|
|
20c527c8ed | ||
|
|
80b534cbab | ||
|
|
747c67766c | ||
|
|
c92e042e91 | ||
|
|
019b17ff97 | ||
|
|
55938c3173 | ||
|
|
761217cb5a | ||
|
|
ff6d31acbd | ||
|
|
2d7a5784c2 | ||
|
|
fa871219ae |
@@ -10,6 +10,52 @@ Alle Änderungen am Projekt. Format: [Keep a Changelog](https://keepachangelog.c
|
||||
|
||||
---
|
||||
|
||||
## [0.2.2.0] — 2026-07-17 — Desktop-Workspace: zoombarer Canvas, Live-Code-Editor, QEMU/VNC
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
**Zoom-/verschiebbarer Workspace-Canvas (App)**
|
||||
- Die App ist jetzt eine desktop-artige Arbeitsfläche: rausgezoomt sieht man eine **Landkarte aus Kacheln** (Chat, Editor, Desktop, Vorschau), die man mit **2 Fingern zoomt und verschiebt**. Tippt man eine Kachel an, zoomt sie voll auf und wird **echt bedienbar** („Übersicht + Fokus"). „⤢ Übersicht" bzw. der Hardware-Back führen zurück zur Landkarte.
|
||||
- Technisch: `react-native-gesture-handler` + `react-native-reanimated` (60 fps auf dem UI-Thread). Zwei Ebenen — eine skalierte Thumbnail-Welt und eine **Identity-Content-Ebene** (Scale 1), in der die schweren Inhalte (ChatScreen + WebViews) immer gemountet sind und nur die fokussierte sichtbar ist. Dadurch bleiben Touch-Koordinaten/Keyboard korrekt und nichts remountet beim Fokuswechsel. **Reiner Chat verhält sich exakt wie bisher** (eine Kachel, dauerhaft fokussiert).
|
||||
|
||||
**Live-Code-Editor für Code-Projekte (App + Bridge + Proxy)**
|
||||
- Wird ein Projekt zum Code-Projekt (ARIA ruft `set_project_kind('code')`), erscheinen Editor- und Desktop-Kachel. Der Editor (WebView, selbstenthaltener Highlight-Editor, offline) **zeigt live, was ARIA schreibt** — und Stefan kann selbst editieren; Änderungen gehen zurück an ARIA.
|
||||
- Fluss: ARIAs `Write`/`Edit` unter `/shared/projects/<projekt-id>/` werden im Proxy abgefangen und als `code_file` über die Bridge/RVS an die App gespiegelt; Stefans Edits kommen als `code_file_edit` pfad-sicher zurück ins selbe Verzeichnis.
|
||||
|
||||
**QEMU für alle Architekturen + Live-Desktop per VNC (Host + Bridge + App)**
|
||||
- ARIA kann jetzt VMs für **jede Architektur** bauen/testen (x86, ARM, MIPS, PPC, RISC-V, SPARC) — Host-Helper `aria-vm` (`create/boot/screenshot/list/stop`), installiert via `host-provisioning/qemu-setup.sh`. KVM für x86-Gäste, sonst TCG. Beispiel: ein Win-3.11-System bauen und in QEMU testen.
|
||||
- Der **VNC-Live-Desktop wird durch den RVS-Server getunnelt**: die Bridge brückt rohes RFB-TCP (QEMU `127.0.0.1:5901`) ↔ RVS (`vnc_data`/`vnc_input`, Base64-in-JSON), der noVNC-Client läuft in der App-WebView (`window.WebSocket`-Shim). Stefan bedient die VM **live mit Maus/Tastatur** in der Desktop-Kachel — NAT-sicher, kein offener Port am Host, kein websockify/noVNC auf dem Host nötig.
|
||||
|
||||
**Kleineres**
|
||||
- Pro-Projekt-Layout: die zuletzt fokussierte Kachel wird pro Projekt gemerkt (`aria_workspace_layout`).
|
||||
- Projekt-Modell bekommt `kind` ('chat'|'code'); Seed-Regel lehrt ARIA den Code-Projekt-Workflow (Arbeitsverzeichnis `/shared/projects/<id>/`, `aria-vm`, VNC landet automatisch in der App).
|
||||
|
||||
### Deploy
|
||||
`git pull && docker compose up -d --build brain bridge proxy` · RVS-Stack `up -d --build` · Host: `bash host-provisioning/qemu-setup.sh` (einmalig, als root) · APK neu bauen (nach `npm install` einmalig `npm start --reset-cache` + `gradlew clean`, wegen der neuen nativen Module).
|
||||
|
||||
---
|
||||
|
||||
## [0.2.1.5] — 2026-07-12 — Pro-Projekt-Queue mit Rückfrage-Loop
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
**Nachrichten-Queue pro Projekt (App + Diagnostic)**
|
||||
- Eine zweite Nachricht, während ARIA am aktuellen Task arbeitet, wird jetzt **angestellt** statt den laufenden Task abzubrechen (vorher: Barge-In-Cancel). Sie läuft der Reihe nach, **pro Projekt unabhängig** (paralleles Arbeiten in mehreren Projekten bleibt). Wartende Nachrichten zeigen als ⏸-Bubble — tippen entfernt sie aus der Warteschlange.
|
||||
- **Rückfrage-Loop:** Stellt ARIA eine echte, blockierende Rückfrage, **pausiert** die Queue und deine nächste Eingabe beantwortet sie — bis eine finale Antwort kommt, dann läuft der nächste Queue-Eintrag (gleiches Muster). Banner „❓ ARIA fragt nach — deine Eingabe beantwortet das". Der Stop-Button bricht den aktuellen Task ab und schaltet zum nächsten.
|
||||
- ARIA signalisiert eine Rückfrage über einen **unsichtbaren `[[AWAIT]]`-Marker** — wie speak/converse deklariert das Modell den Zustand selbst (kein „endet-mit-?"-Raten). Brain strippt ihn, gibt `awaiting_reply` durch `chat()` → `ChatOut` → Bridge-Chat-Payload. Local (tool-los) und Fast-Path markieren nie.
|
||||
|
||||
**Pro-Projekt-Textfeld-Entwürfe (App + Diagnostic)**
|
||||
- Der Feldinhalt bleibt beim Projektwechsel erhalten: in Projekt X tippen, zu Y wechseln (leeres Feld), zurück zu X → dein Entwurf steht wieder da. In Storage persistiert.
|
||||
|
||||
**TTS-Abspiel-Queue (App)**
|
||||
- Zwei fast gleichzeitig fertige Antworten sprechen jetzt garantiert **nacheinander** statt sich gegenseitig abzuschneiden. Vorher war das Timing-Glück (`PcmStreamPlayer.start()` ruft `stopInternal()` = flush/release, hätte die laufende gecuttet). Jetzt: „spielt hörbar" gilt bis zum echten `PcmPlaybackFinished` (nicht nur bis Stream-Ende); eine neue hörbare Antwort, die währenddessen ankommt, wird gepuffert und danach nachgespielt (Kette für 3, 4, …). Harter Stop/Barge-In/Mund-Button verwirft die Queue.
|
||||
|
||||
### Geändert
|
||||
|
||||
- **Voice bricht nicht mehr ab:** eine neue Sprachnachricht während ARIA arbeitet stoppt nur akustisch das TTS (sauberes Mikro) und wird über den Brain-Projekt-Lock serialisiert, statt den laufenden Task abzubrechen (passend zu „immer anstellen + Stop-Button"). Text-Senden erkennt Brain-busy als Fallback, damit auch nach einem voice-gestarteten Turn korrekt angestellt wird. Grenze: eine per Sprache gestartete Aufgabe erscheint nicht als löschbare ⏸-Bubble (Aufnahme wird live gestreamt, nicht app-seitig gepuffert).
|
||||
|
||||
---
|
||||
|
||||
## [0.2.1.4] — 2026-07-12 — Lokales LLM: der ehrliche Rückbau
|
||||
|
||||
### Geändert
|
||||
|
||||
@@ -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.0–8.0s, Default 2.8s) bevor Auto-Stop greift. Max-Aufnahme einstellbar (1–30 min, Default 5 min)
|
||||
- **Barge-In**: Wenn du waehrend ARIAs Antwort eine neue Sprach-/Text-Nachricht reinschickst, wird sie unterbrochen + bekommt den Hint "das ist eine Korrektur"
|
||||
- **Nachricht anstellen statt abbrechen** (Queue pro Projekt): Schickst du eine zweite Nachricht waehrend ARIA noch am aktuellen Task arbeitet, wird sie **angestellt** statt den laufenden abzubrechen — laeuft der Reihe nach, pro Projekt unabhaengig. Wartende zeigen als `⏸`-Bubble (tippen entfernt sie aus der Warteschlange). Explizites Abbrechen laeuft ueber den Stop-Button am „ARIA denkt". Eine neue Sprachnachricht stoppt nur akustisch das TTS (sauberes Mikro), bricht die laufende Arbeit aber nicht mehr ab
|
||||
- **Rueckfrage-Loop**: Stellt ARIA eine echte, blockierende Rueckfrage, **pausiert** die Queue und deine naechste Eingabe beantwortet sie — bis eine finale Antwort kommt, dann laeuft der naechste Queue-Eintrag (gleiches Muster). Banner „❓ ARIA fragt nach — deine Eingabe beantwortet das". ARIA signalisiert das ueber einen unsichtbaren `[[AWAIT]]`-Marker im Antworttext (das Modell deklariert den Zustand selbst, kein „endet-mit-?"-Raten; Brain strippt ihn und gibt `awaiting_reply` an App + Diagnostic durch)
|
||||
- **Pro-Projekt-Textfeld-Entwuerfe**: Der Feldinhalt bleibt beim Projektwechsel erhalten — in Projekt X tippen, zu Y wechseln (leeres Feld), zurueck zu X → dein Entwurf steht wieder da. Persistiert ueber Neustart. Gleiches Verhalten im Diagnostic
|
||||
- **Wake-Word waehrend TTS**: Du kannst "Computer" sagen waehrend ARIA noch redet — AcousticEchoCanceler verhindert dass ARIAs eigene Stimme das Wake-Word triggert
|
||||
- **Anruf-Pause + Auto-Resume**: TTS verstummt bei klassischem Anruf oder VoIP-Call (WhatsApp/Signal/Discord). Nach dem Auflegen geht ARIA von der **genauen Stelle** weiter wo sie unterbrochen wurde — die App misst die Position vom Wiedergabe-Anfang und nutzt den WAV-Cache der Antwort
|
||||
- **Speech Gate**: Aufnahme wird verworfen wenn keine Sprache erkannt
|
||||
@@ -482,6 +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.0–6.0s, Default 3.5s) gegen Gaps bei Render-Pausen
|
||||
- **Audio-Pause**: Andere Apps (Spotify, YouTube etc.) pausieren komplett waehrend ARIA spricht und kommen erst wieder nach echtem Wiedergabe-Ende
|
||||
- **TTS-Abspiel-Queue**: Zwei fast gleichzeitig fertige Antworten sprechen garantiert **nacheinander** statt sich abzuschneiden — eine neue hoerbare Antwort, die waehrend der Wiedergabe einer anderen ankommt, wird gepuffert und erst nach deren echtem Wiedergabe-Ende (`PcmPlaybackFinished`, nicht nur Stream-Ende) nachgespielt. Harter Stop / Barge-In / Mund-Button verwirft die Queue
|
||||
- **Lokale Voice-Wahl**: Pro Geraet eigene Stimme moeglich (in Settings). Diagnostic-Wechsel ueberschreibt alle App-Wahlen.
|
||||
- **Voice-Ready Toast**: Beim Wechsel zeigt die App "Stimme X bereit (X.Ys)" sobald der Preload durch ist
|
||||
- **Play-Button**: Jede ARIA-Nachricht kann nochmal vorgelesen werden (aus Cache wenn vorhanden, sonst neu rendern)
|
||||
@@ -994,6 +1031,9 @@ docker exec aria-brain curl localhost:8080/memory/stats
|
||||
- [x] Anruf-Pause + Auto-Resume: TTS verstummt bei Anruf, faehrt nach Auflegen ab der gemerkten Position fort (Date.now()-Tracking + WAV-Cache der Antwort)
|
||||
- [x] PcmPlaybackFinished-Event: AudioFocus wird erst released wenn AudioTrack wirklich durch ist — kein Spotify-mid-TTS mehr
|
||||
- [x] Edge-Case: neue Frage waehrend Telefonat verwirft pending Auto-Resume, neueste Antwort gewinnt
|
||||
- [x] **Pro-Projekt-Nachrichten-Queue** (loest das alte Barge-In-Cancel ab): zweite Nachricht wird **angestellt** statt den laufenden Task abzubrechen; **Rueckfrage-Loop** via unsichtbarem `[[AWAIT]]`-Marker (Queue pausiert, naechste Eingabe beantwortet die Rueckfrage); Stop-Button schaltet zum naechsten; sichtbare `⏸`-Bubbles (loeschbar). Pro Projekt unabhaengig, App + Diagnostic
|
||||
- [x] Pro-Projekt-Textfeld-Entwuerfe (Feldinhalt bleibt beim Projektwechsel erhalten, persistiert; App + Diagnostic)
|
||||
- [x] **TTS-Abspiel-Queue**: zwei fast gleichzeitig fertige Antworten sprechen garantiert nacheinander statt sich abzuschneiden (Puffern bis `PcmPlaybackFinished` der laufenden)
|
||||
- [x] Settings-Sub-Screens: 8 Kategorien statt langer Liste
|
||||
- [x] APK ABI-Split arm64-v8a: 35 MB statt 136 MB
|
||||
- [x] Sprachnachrichten-Bubble: audioRequestId statt Substring-Match — keine vertauschten Bubbles mehr bei parallelen Aufnahmen
|
||||
@@ -1004,6 +1044,10 @@ docker exec aria-brain curl localhost:8080/memory/stats
|
||||
- [x] Background Audio Service: TTS, Wake-Word-Lauschen + Aufnahme laufen auch bei minimierter App weiter (Foreground-Service mit mediaPlayback|microphone, dynamische Notification)
|
||||
- [x] Disk-Voll Banner in Diagnostic mit copy-baren Cleanup-Befehlen
|
||||
- [x] Wake-Word on-device via openWakeWord (ONNX Runtime, kein API-Key) + State-Icon
|
||||
- [x] **Desktop-Workspace**: zoom-/verschiebbarer Kachel-Canvas (gesture-handler + reanimated), „Übersicht + Fokus" — Chat/Editor/Desktop/Vorschau als Kacheln; reiner Chat unverändert (eine Kachel)
|
||||
- [x] **Live-Code-Editor** für Code-Projekte (WebView, offline, bidirektional) — ARIAs Write/Edit unter `/shared/projects/<id>/` live gespiegelt (`code_file`), eigene Edits zurück (`code_file_edit`)
|
||||
- [x] **QEMU für alle Architekturen** (Host-Helper `aria-vm`, `qemu-setup.sh`) + `set_project_kind`-Tool + Seed-Regel
|
||||
- [x] **VNC-Live-Desktop durch RVS getunnelt** (Bridge RFB-TCP ↔ RVS, noVNC-WebView mit WebSocket-Shim) — VM live mit Maus/Tastatur bedienbar
|
||||
|
||||
### Phase A — Refactor: OpenClaw raus, eigenes Brain rein
|
||||
|
||||
|
||||
+8
-4
@@ -8,10 +8,11 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { AppState, AppStateStatus, PermissionsAndroid, Platform, StatusBar, StyleSheet } from 'react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { NavigationContainer, DefaultTheme } from '@react-navigation/native';
|
||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||
|
||||
import ChatScreen from './src/screens/ChatScreen';
|
||||
import WorkspaceScreen from './src/workspace/WorkspaceScreen';
|
||||
import SettingsScreen from './src/screens/SettingsScreen';
|
||||
import rvs from './src/services/rvs';
|
||||
import { initLogger, installGlobalCrashReporter } from './src/services/logger';
|
||||
@@ -132,7 +133,7 @@ const App: React.FC = () => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<GestureHandlerRootView style={styles.root}>
|
||||
<StatusBar barStyle="light-content" backgroundColor="#0D0D1A" />
|
||||
<NavigationContainer theme={DarkTheme}>
|
||||
<Tab.Navigator
|
||||
@@ -165,7 +166,7 @@ const App: React.FC = () => {
|
||||
>
|
||||
<Tab.Screen
|
||||
name="Chat"
|
||||
component={ChatScreen}
|
||||
component={WorkspaceScreen}
|
||||
options={{
|
||||
title: 'ARIA Chat',
|
||||
headerTitle: 'ARIA Cockpit',
|
||||
@@ -180,13 +181,16 @@ const App: React.FC = () => {
|
||||
/>
|
||||
</Tab.Navigator>
|
||||
</NavigationContainer>
|
||||
</>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Styles ---
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
backgroundColor: '#12122A',
|
||||
elevation: 0,
|
||||
|
||||
@@ -79,8 +79,8 @@ android {
|
||||
applicationId "com.ariacockpit"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 20105
|
||||
versionName "0.2.1.5"
|
||||
versionCode 20108
|
||||
versionName "0.2.1.8"
|
||||
// Fallback fuer Libraries mit Product Flavors
|
||||
missingDimensionStrategy 'react-native-camera', 'general'
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
module.exports = {
|
||||
presets: ['module:metro-react-native-babel-preset'],
|
||||
// react-native-reanimated/plugin MUSS das LETZTE Plugin sein (Worklet-Transform).
|
||||
// Nach dem Hinzufuegen einmalig Metro-Cache leeren: `npm start --reset-cache`.
|
||||
plugins: ['react-native-reanimated/plugin'],
|
||||
};
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// react-native-gesture-handler MUSS als allererstes importiert werden
|
||||
// (vor allem anderen), sonst crasht die Gesten-Erkennung auf Android.
|
||||
import 'react-native-gesture-handler';
|
||||
import { AppRegistry } from 'react-native';
|
||||
import App from './App';
|
||||
import { name as appName } from './app.json';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aria-cockpit",
|
||||
"version": "0.2.1.5",
|
||||
"version": "0.2.1.8",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"android": "react-native run-android",
|
||||
@@ -20,12 +20,15 @@
|
||||
"react-native-camera-kit": "^13.0.0",
|
||||
"react-native-document-picker": "^9.1.1",
|
||||
"react-native-fs": "^2.20.0",
|
||||
"react-native-gesture-handler": "2.14.1",
|
||||
"react-native-image-picker": "^7.1.0",
|
||||
"react-native-permissions": "^4.1.4",
|
||||
"react-native-reanimated": "3.6.2",
|
||||
"react-native-safe-area-context": "^4.8.2",
|
||||
"react-native-screens": "3.27.0",
|
||||
"react-native-sound": "^0.11.2",
|
||||
"react-native-svg": "^14.1.0"
|
||||
"react-native-svg": "^14.1.0",
|
||||
"react-native-webview": "13.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@react-native/eslint-config": "^0.73.2",
|
||||
|
||||
@@ -38,6 +38,7 @@ import audioService from '../services/audio';
|
||||
import wakeWordService, { loadPassiveListenMs } from '../services/wakeword';
|
||||
import ProjectsBrowser from '../components/ProjectsBrowser';
|
||||
import brainApi, { Project as BrainProject } from '../services/brainApi';
|
||||
import projectFocus from '../services/projectFocus';
|
||||
import phoneCallService from '../services/phoneCall';
|
||||
import { playWakeReadySound } from '../services/wakeReadySound';
|
||||
import {
|
||||
@@ -318,6 +319,9 @@ const ChatScreen: React.FC = () => {
|
||||
const [projectQueues, setProjectQueues] = useState<Record<string, QueuedItem[]>>({});
|
||||
const projectStatesRef = useRef<Record<string, CtxState>>({});
|
||||
const projectQueuesRef = useRef<Record<string, QueuedItem[]>>({});
|
||||
// Wann ist ein Kontext in 'running' gegangen? Fuer den Watchdog, der eine
|
||||
// haengende Queue (z.B. Antwort waehrend Verbindungsabbruch verloren) loest.
|
||||
const ctxRunningSinceRef = useRef<Record<string, number>>({});
|
||||
// Pro-Projekt-Textfeld-Entwuerfe (noch nicht gesendeter Feldinhalt). Key = pid.
|
||||
const projectDraftsRef = useRef<Record<string, string>>({});
|
||||
const prevFocusedPidRef = useRef<string>('');
|
||||
@@ -522,8 +526,13 @@ const ChatScreen: React.FC = () => {
|
||||
brainApi.listProjects(true)
|
||||
.then(list => {
|
||||
const map: Record<string, string> = {};
|
||||
for (const p of list) map[p.id] = p.name;
|
||||
const kinds: Record<string, 'code' | 'chat'> = {};
|
||||
for (const p of list) {
|
||||
map[p.id] = p.name;
|
||||
kinds[p.id] = p.kind === 'code' ? 'code' : 'chat';
|
||||
}
|
||||
setProjectNameById(prev => ({ ...prev, ...map }));
|
||||
projectFocus.setKinds(kinds);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
@@ -551,6 +560,12 @@ const ChatScreen: React.FC = () => {
|
||||
}
|
||||
}, [focusedProjectId]);
|
||||
|
||||
// Focus + Projekt-Namen EINWEG in den projectFocus-Spiegel publizieren,
|
||||
// damit der Workspace-Canvas den aktiven Kontext + Code-Projekt-Status
|
||||
// kennt. Rein additiv — aendert ChatScreens eigenes Verhalten nicht.
|
||||
useEffect(() => { projectFocus.setFocus(focusedProjectId); }, [focusedProjectId]);
|
||||
useEffect(() => { projectFocus.setNames(projectNameById); }, [projectNameById]);
|
||||
|
||||
// inputText-Spiegel (damit der Draft-Wechsel oben inputText nicht als Dep braucht).
|
||||
useEffect(() => { inputTextRef.current = inputText; }, [inputText]);
|
||||
|
||||
@@ -583,8 +598,24 @@ const ChatScreen: React.FC = () => {
|
||||
try {
|
||||
const s = await brainApi.getProjectQueueStatus();
|
||||
if (cancelled) return;
|
||||
setQueueStatus(s.contexts || {});
|
||||
queueStatusRef.current = s.contexts || {};
|
||||
const ctxs = s.contexts || {};
|
||||
setQueueStatus(ctxs);
|
||||
queueStatusRef.current = ctxs;
|
||||
// Watchdog: haengt ein Kontext seit >15s auf 'running', obwohl der Brain
|
||||
// ihn NICHT als busy meldet, ist die Antwort verloren gegangen (z.B.
|
||||
// Verbindungsabbruch) — sonst friert die Queue ein. Dann weiterschalten.
|
||||
const api = queueApiRef.current;
|
||||
if (api) {
|
||||
const now = Date.now();
|
||||
for (const [pid, since] of Object.entries(ctxRunningSinceRef.current)) {
|
||||
if (api.getCtxState(pid) !== 'running') continue;
|
||||
const busy = !!ctxs[pid || '__main__']?.busy;
|
||||
if (!busy && now - since > 15000) {
|
||||
console.log('[Chat] Queue-Watchdog: Kontext %s haengt (running, brain idle) → weiterschalten', pid || '(main)');
|
||||
api.advanceQueue(pid);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
poll();
|
||||
@@ -918,6 +949,11 @@ const ChatScreen: React.FC = () => {
|
||||
const localOnly = prev.filter(m => {
|
||||
if (m.skillCreated || m.triggerCreated || m.memorySaved) return true;
|
||||
if (m.audioRequestId && (!m.text || m.text === '🎙 Aufnahme...' || m.text === 'Aufnahme...')) return true;
|
||||
// Wartende Queue-Bubbles (noch nicht gesendet → kein clientMsgId, nicht
|
||||
// auf dem Server) MUESSEN erhalten bleiben — sonst verschwindet die
|
||||
// Bubble beim Reconnect-Sync, waehrend projectQueues den Eintrag behaelt
|
||||
// → "N in Warteschlange" friert ein ohne sichtbare Nachricht.
|
||||
if (m.sender === 'user' && m.deliveryStatus === 'pending_queue') return true;
|
||||
if (m.sender === 'user' && m.clientMsgId && !serverCmids.has(m.clientMsgId)) {
|
||||
// Text-Match-Fallback: wenn der Server irgendwo eine textgleiche
|
||||
// User-Bubble hat, ist es dieselbe Nachricht (vor cmid-Aera, ts
|
||||
@@ -951,6 +987,11 @@ const ChatScreen: React.FC = () => {
|
||||
if (p.id && p.name) {
|
||||
setProjectNameById(prev => ({ ...prev, [p.id]: p.name }));
|
||||
}
|
||||
// Projekt-Typ sofort spiegeln → Editor/Desktop-Kacheln erscheinen live
|
||||
// (z.B. nach set_project_kind), nicht erst beim naechsten Reconnect.
|
||||
if (p.id && (p.kind === 'code' || p.kind === 'chat')) {
|
||||
projectFocus.setKind(p.id, p.kind);
|
||||
}
|
||||
if (action === 'entered' || action === 'created') {
|
||||
if (p.id) setFocusedProjectId(p.id);
|
||||
} else if (action === 'exited') {
|
||||
@@ -1731,6 +1772,13 @@ const ChatScreen: React.FC = () => {
|
||||
// das Mikro greifen kann.
|
||||
wakeWordService.stopBargeListening().catch(() => {});
|
||||
});
|
||||
// Aus der TTS-Queue nachgespielte (zweite) Antwort: ihren WAV-Cache-Pfad an
|
||||
// die Bubble haengen, damit der Mund-Button/Play sie auch abspielen kann.
|
||||
const unsubPcmCached = audioService.onPcmCached((messageId, audioPath) => {
|
||||
if (!messageId || !audioPath) return;
|
||||
setMessages(prev => prev.map(m =>
|
||||
m.messageId === messageId ? { ...m, audioPath } : m));
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubWake();
|
||||
@@ -1739,6 +1787,7 @@ const ChatScreen: React.FC = () => {
|
||||
unsubPassive();
|
||||
unsubTtsStart();
|
||||
unsubTtsEnd();
|
||||
unsubPcmCached();
|
||||
};
|
||||
}, [wakeWordActive]);
|
||||
|
||||
@@ -2042,6 +2091,9 @@ const ChatScreen: React.FC = () => {
|
||||
|
||||
const setCtxState = useCallback((pid: string, s: CtxState) => {
|
||||
projectStatesRef.current = { ...projectStatesRef.current, [pid]: s };
|
||||
// Watchdog-Zeitstempel: nur 'running' bekommt einen Start, sonst raus.
|
||||
if (s === 'running') ctxRunningSinceRef.current[pid] = Date.now();
|
||||
else delete ctxRunningSinceRef.current[pid];
|
||||
setProjectStates(prev => ({ ...prev, [pid]: s }));
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
@@ -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;
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* projectFocus — leichter Publish/Subscribe-Spiegel des aktuell fokussierten
|
||||
* Projekt-Kontexts.
|
||||
*
|
||||
* ChatScreen bleibt die Quelle der Wahrheit fuer sein eigenes Rendering und
|
||||
* publiziert hier bei jedem Focus-/Namens-/Kind-Wechsel EINWEG hinein. Der
|
||||
* Workspace-Canvas liest/abonniert das Singleton, um zu wissen welches Projekt
|
||||
* gerade aktiv ist und ob es ein Code-Projekt ist — ohne dass ChatScreen den
|
||||
* Workspace kennen oder umgebaut werden muss.
|
||||
*
|
||||
* Muster wie services/rvs.ts (Singleton mit Listener-Liste + Unsubscribe).
|
||||
*/
|
||||
|
||||
export type ProjectKind = 'code' | 'chat';
|
||||
|
||||
export interface FocusSnapshot {
|
||||
/** '' = Hauptchat, sonst Projekt-ID */
|
||||
focusedProjectId: string;
|
||||
projectNameById: Record<string, string>;
|
||||
projectKindById: Record<string, ProjectKind>;
|
||||
}
|
||||
|
||||
type Sub = (snap: FocusSnapshot) => void;
|
||||
|
||||
class ProjectFocus {
|
||||
private snap: FocusSnapshot = {
|
||||
focusedProjectId: '',
|
||||
projectNameById: {},
|
||||
projectKindById: {},
|
||||
};
|
||||
private subs: Sub[] = [];
|
||||
|
||||
// --- Getter (synchron, fuer Nicht-Reaktive Leser) ---
|
||||
|
||||
get(): FocusSnapshot {
|
||||
return this.snap;
|
||||
}
|
||||
|
||||
getFocusedProjectId(): string {
|
||||
return this.snap.focusedProjectId;
|
||||
}
|
||||
|
||||
getProjectName(id: string): string {
|
||||
return this.snap.projectNameById[id] || id;
|
||||
}
|
||||
|
||||
/** Default 'chat' — ein Projekt ist erst 'code' wenn es explizit so
|
||||
* markiert wurde (set_project_kind) oder ein Code-/Desktop-Signal kam. */
|
||||
getProjectKind(id: string): ProjectKind {
|
||||
return this.snap.projectKindById[id] || 'chat';
|
||||
}
|
||||
|
||||
// --- Publisher (von ChatScreen aufgerufen) ---
|
||||
|
||||
setFocus(id: string): void {
|
||||
if (this.snap.focusedProjectId === id) return;
|
||||
this.snap = { ...this.snap, focusedProjectId: id };
|
||||
this.emit();
|
||||
}
|
||||
|
||||
setNames(map: Record<string, string>): void {
|
||||
// Flacher Merge — behaelt bereits bekannte Namen, ueberschreibt neue.
|
||||
this.snap = {
|
||||
...this.snap,
|
||||
projectNameById: { ...this.snap.projectNameById, ...map },
|
||||
};
|
||||
this.emit();
|
||||
}
|
||||
|
||||
setKind(id: string, kind: ProjectKind): void {
|
||||
if (this.snap.projectKindById[id] === kind) return;
|
||||
this.snap = {
|
||||
...this.snap,
|
||||
projectKindById: { ...this.snap.projectKindById, [id]: kind },
|
||||
};
|
||||
this.emit();
|
||||
}
|
||||
|
||||
setKinds(map: Record<string, ProjectKind>): void {
|
||||
this.snap = {
|
||||
...this.snap,
|
||||
projectKindById: { ...this.snap.projectKindById, ...map },
|
||||
};
|
||||
this.emit();
|
||||
}
|
||||
|
||||
// --- Abo ---
|
||||
|
||||
/** Registriert einen Listener und liefert sofort den aktuellen Snapshot. */
|
||||
subscribe(cb: Sub): () => void {
|
||||
this.subs.push(cb);
|
||||
cb(this.snap);
|
||||
return () => {
|
||||
this.subs = this.subs.filter(s => s !== cb);
|
||||
};
|
||||
}
|
||||
|
||||
private emit(): void {
|
||||
const s = this.snap;
|
||||
this.subs.forEach(cb => cb(s));
|
||||
}
|
||||
}
|
||||
|
||||
const projectFocus = new ProjectFocus();
|
||||
export default projectFocus;
|
||||
@@ -0,0 +1,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;
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* WorkspaceCanvas — die zoom-/verschiebbare "Landkarte" plus Fokus-Modus.
|
||||
*
|
||||
* Zwei Ebenen uebereinander:
|
||||
* 1. Welt-Ebene (skaliert/verschoben): nur leichte Thumbnail-Kacheln. Hier
|
||||
* wirken 2-Finger-Pinch-Zoom + 2-Finger-Pan (nur in der Uebersicht).
|
||||
* 2. Identity-Content-Ebene (Scale 1, nie transformiert): die schweren,
|
||||
* interaktiven Inhalte (ChatScreen + WebViews). Alle sichtbaren Kacheln
|
||||
* sind hier IMMER gemountet; nur die fokussierte ist per display sichtbar.
|
||||
* Dadurch bleiben Touch-Koordinaten/Keyboard korrekt und nichts remountet
|
||||
* beim Fokuswechsel.
|
||||
*
|
||||
* Tap auf eine Thumbnail-Kachel → Fokus (voll aufgezoomt + interaktiv). Der
|
||||
* "⤢ Übersicht"-Button bzw. der Hardware-Back fuehren zurueck zur Landkarte.
|
||||
* Bei nur einer Kachel (reiner Chat) ist diese dauerhaft fokussiert und der
|
||||
* Canvas verhaelt sich exakt wie der bisherige Vollbild-Chat.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { BackHandler, StyleSheet, Text, TouchableOpacity, useWindowDimensions, View } from 'react-native';
|
||||
import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
|
||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
||||
|
||||
import {
|
||||
boundsOf, Camera, focusCamera, overviewCamera, TileId, TILE_RECTS, toLocal,
|
||||
} from './layout';
|
||||
import Tile from './Tile';
|
||||
import { useWorkspaceLayout } from './useWorkspaceLayout';
|
||||
import ChatTile from './tiles/ChatTile';
|
||||
import CodeEditorTile from './tiles/CodeEditorTile';
|
||||
import VncTile from './tiles/VncTile';
|
||||
import PreviewTile from './tiles/PreviewTile';
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
visibleTiles: TileId[];
|
||||
subtitles?: Partial<Record<TileId, string>>;
|
||||
}
|
||||
|
||||
const ANIM = { duration: 260 };
|
||||
const MIN_SCALE = 0.15;
|
||||
const MAX_SCALE = 4;
|
||||
|
||||
const WorkspaceCanvas: React.FC<Props> = ({ projectId, visibleTiles, subtitles }) => {
|
||||
const { width: vw, height: vh } = useWindowDimensions();
|
||||
const [focusedTileId, setFocusedTileId] = useState<TileId | null>('chat');
|
||||
|
||||
const visibleKey = visibleTiles.join(',');
|
||||
const bounds = useMemo(() => boundsOf(visibleTiles), [visibleKey]);
|
||||
const worldW = bounds.w;
|
||||
const worldH = bounds.h;
|
||||
const single = visibleTiles.length <= 1;
|
||||
const { loaded: layoutLoaded, getFocus, saveFocus } = useWorkspaceLayout(projectId);
|
||||
|
||||
// Kamera (shared values fuer 60fps auf dem UI-Thread).
|
||||
const scale = useSharedValue(1);
|
||||
const tx = useSharedValue(0);
|
||||
const ty = useSharedValue(0);
|
||||
const savedScale = useSharedValue(1);
|
||||
const savedTx = useSharedValue(0);
|
||||
const savedTy = useSharedValue(0);
|
||||
|
||||
// Bei nur einer Kachel: immer fokussiert. Verschwindet die fokussierte
|
||||
// Kachel aus der Sichtbarkeit, auf Chat zurueckfallen.
|
||||
useEffect(() => {
|
||||
if (single) {
|
||||
setFocusedTileId(visibleTiles[0] || 'chat');
|
||||
} else if (focusedTileId && !visibleTiles.includes(focusedTileId)) {
|
||||
setFocusedTileId('chat');
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [visibleKey]);
|
||||
|
||||
// Zuletzt fokussierte Kachel pro Projekt wiederherstellen (nur Mehr-Kachel-
|
||||
// Projekte; laeuft NACH dem single-Effekt oben, gewinnt also fuer Code-Projekte).
|
||||
useEffect(() => {
|
||||
if (!layoutLoaded || single) return;
|
||||
const stored = getFocus();
|
||||
if (stored === undefined) return;
|
||||
if (stored === null || visibleTiles.includes(stored)) setFocusedTileId(stored);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [projectId, layoutLoaded, visibleKey]);
|
||||
|
||||
// Fokus-Wahl persistieren.
|
||||
useEffect(() => {
|
||||
if (layoutLoaded) saveFocus(focusedTileId);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [focusedTileId, layoutLoaded]);
|
||||
|
||||
// Kamera auf das Ziel fahren (Fokus-Rect oder Uebersicht).
|
||||
useEffect(() => {
|
||||
const cam: Camera = focusedTileId
|
||||
? focusCamera(toLocal(TILE_RECTS[focusedTileId], bounds), worldW, worldH, vw, vh)
|
||||
: overviewCamera(worldW, worldH, vw, vh);
|
||||
scale.value = withTiming(cam.scale, ANIM);
|
||||
tx.value = withTiming(cam.tx, ANIM);
|
||||
ty.value = withTiming(cam.ty, ANIM);
|
||||
savedScale.value = cam.scale;
|
||||
savedTx.value = cam.tx;
|
||||
savedTy.value = cam.ty;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [focusedTileId, visibleKey, vw, vh]);
|
||||
|
||||
// Hardware-Back: im Fokus (und mehr als eine Kachel) → zurueck zur Uebersicht.
|
||||
useEffect(() => {
|
||||
const onBack = () => {
|
||||
if (focusedTileId && !single) {
|
||||
setFocusedTileId(null);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const sub = BackHandler.addEventListener('hardwareBackPress', onBack);
|
||||
return () => sub.remove();
|
||||
}, [focusedTileId, single]);
|
||||
|
||||
// Gesten nur in der Uebersicht (Fokus-Modus: Touches fallen an die Kachel).
|
||||
const gesturesEnabled = !focusedTileId && !single;
|
||||
const canvasGesture = useMemo(() => {
|
||||
const pinch = Gesture.Pinch()
|
||||
.enabled(gesturesEnabled)
|
||||
.onUpdate((e) => {
|
||||
'worklet';
|
||||
scale.value = Math.max(MIN_SCALE, Math.min(MAX_SCALE, savedScale.value * e.scale));
|
||||
})
|
||||
.onEnd(() => {
|
||||
'worklet';
|
||||
savedScale.value = scale.value;
|
||||
});
|
||||
const pan = Gesture.Pan()
|
||||
.enabled(gesturesEnabled)
|
||||
.minPointers(2)
|
||||
.onUpdate((e) => {
|
||||
'worklet';
|
||||
tx.value = savedTx.value + e.translationX;
|
||||
ty.value = savedTy.value + e.translationY;
|
||||
})
|
||||
.onEnd(() => {
|
||||
'worklet';
|
||||
savedTx.value = tx.value;
|
||||
savedTy.value = ty.value;
|
||||
});
|
||||
return Gesture.Simultaneous(pinch, pan);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [gesturesEnabled]);
|
||||
|
||||
const worldStyle = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{ translateX: tx.value },
|
||||
{ translateY: ty.value },
|
||||
{ scale: scale.value },
|
||||
],
|
||||
}));
|
||||
|
||||
const renderContent = (id: TileId) => {
|
||||
switch (id) {
|
||||
case 'chat': return <ChatTile />;
|
||||
case 'editor': return <CodeEditorTile projectId={projectId} />;
|
||||
case 'vnc': return <VncTile projectId={projectId} focused={focusedTileId === 'vnc'} />;
|
||||
case 'preview': return <PreviewTile />;
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
{/* Ebene 1 — skalierte Landkarte mit Thumbnails */}
|
||||
<View style={StyleSheet.absoluteFill} pointerEvents={focusedTileId ? 'none' : 'auto'}>
|
||||
<GestureDetector gesture={canvasGesture}>
|
||||
<Animated.View style={[styles.world, { width: worldW, height: worldH }, worldStyle]}>
|
||||
{visibleTiles.map((id) => (
|
||||
<Tile
|
||||
key={id}
|
||||
id={id}
|
||||
rect={toLocal(TILE_RECTS[id], bounds)}
|
||||
subtitle={subtitles?.[id]}
|
||||
onFocus={setFocusedTileId}
|
||||
/>
|
||||
))}
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</View>
|
||||
|
||||
{/* Ebene 2 — Identity-Content, immer gemountet, nur fokussierte sichtbar */}
|
||||
<View style={StyleSheet.absoluteFill} pointerEvents={focusedTileId ? 'box-none' : 'none'}>
|
||||
{visibleTiles.map((id) => (
|
||||
<View
|
||||
key={id}
|
||||
style={[StyleSheet.absoluteFill, { display: focusedTileId === id ? 'flex' : 'none' }]}
|
||||
>
|
||||
{renderContent(id)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Steuerung */}
|
||||
{focusedTileId && !single && (
|
||||
<TouchableOpacity style={styles.overviewBtn} onPress={() => setFocusedTileId(null)} activeOpacity={0.8}>
|
||||
<Text style={styles.overviewBtnText}>⤢ Übersicht</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{!focusedTileId && (
|
||||
<View style={styles.hintBar} pointerEvents="none">
|
||||
<Text style={styles.hintText}>Kachel antippen zum Öffnen · 2 Finger: zoomen & schieben</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: { flex: 1, backgroundColor: '#0D0D1A' },
|
||||
world: { position: 'absolute', left: 0, top: 0 },
|
||||
overviewBtn: {
|
||||
position: 'absolute',
|
||||
top: 10,
|
||||
right: 12,
|
||||
backgroundColor: 'rgba(18,18,42,0.92)',
|
||||
borderColor: '#1E1E2E',
|
||||
borderWidth: 1,
|
||||
borderRadius: 18,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
overviewBtnText: { color: '#0096FF', fontSize: 14, fontWeight: '700' },
|
||||
hintBar: {
|
||||
position: 'absolute',
|
||||
bottom: 16,
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: 'center',
|
||||
},
|
||||
hintText: {
|
||||
color: '#9090B0',
|
||||
fontSize: 12,
|
||||
backgroundColor: 'rgba(18,18,42,0.85)',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 14,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
});
|
||||
|
||||
export default WorkspaceCanvas;
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* WorkspaceScreen — Screen-Wrapper fuer den Workspace-Canvas.
|
||||
*
|
||||
* Buendelt die Signale, die entscheiden welche Kacheln sichtbar sind:
|
||||
* - projectFocus: aktives Projekt + dessen kind ('code'|'chat')
|
||||
* - codeFile: kam schon eine Code-Datei rein? (Live-Reveal des Editors)
|
||||
* - desktop: ist ein QEMU-Desktop verfuegbar? (Live-Reveal der VNC-Kachel)
|
||||
*
|
||||
* Ersetzt den bisherigen Chat-Tab: die ChatScreen lebt als Kachel im Canvas,
|
||||
* bleibt aber genau eine Instanz.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import projectFocus, { FocusSnapshot } from '../services/projectFocus';
|
||||
import codeFile from '../services/codeFile';
|
||||
import desktop from '../services/desktop';
|
||||
import { TileId, visibleTilesFor } from './layout';
|
||||
import WorkspaceCanvas from './WorkspaceCanvas';
|
||||
|
||||
const WorkspaceScreen: React.FC = () => {
|
||||
const [focus, setFocus] = useState<FocusSnapshot>(projectFocus.get());
|
||||
const [hasCode, setHasCode] = useState(false);
|
||||
const [hasDesktop, setHasDesktop] = useState(false);
|
||||
|
||||
useEffect(() => projectFocus.subscribe(setFocus), []);
|
||||
|
||||
const pid = focus.focusedProjectId;
|
||||
const kind = projectFocus.getProjectKind(pid);
|
||||
|
||||
// Code-Signal: hat der Spiegel schon Dateien fuer dieses Projekt?
|
||||
useEffect(() => {
|
||||
setHasCode(codeFile.getFiles(pid).length > 0);
|
||||
return codeFile.subscribe((u) => {
|
||||
if ((u.projectId || '') === (pid || '')) setHasCode(true);
|
||||
});
|
||||
}, [pid]);
|
||||
|
||||
// Desktop-Signal + einmaliger Check beim Betreten eines Code-Projekts.
|
||||
useEffect(() => {
|
||||
setHasDesktop(desktop.getStatus().available);
|
||||
const unsub = desktop.subscribeStatus((s) => setHasDesktop(s.available));
|
||||
if (kind === 'code') desktop.requestCheck(pid);
|
||||
return unsub;
|
||||
}, [pid, kind]);
|
||||
|
||||
const visibleTiles: TileId[] = useMemo(
|
||||
() => visibleTilesFor({ kind, hasCode, hasDesktop }),
|
||||
[kind, hasCode, hasDesktop],
|
||||
);
|
||||
|
||||
const subtitles = useMemo(
|
||||
() => ({ chat: pid ? projectFocus.getProjectName(pid) : 'Hauptchat' } as Partial<Record<TileId, string>>),
|
||||
[pid, focus.projectNameById],
|
||||
);
|
||||
|
||||
return <WorkspaceCanvas projectId={pid} visibleTiles={visibleTiles} subtitles={subtitles} />;
|
||||
};
|
||||
|
||||
export default WorkspaceScreen;
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* editorHtml — selbstenthaltener Live-Code-Editor fuer die WebView (offline,
|
||||
* kein CDN/Bundler). Eine transparente <textarea> ueber einer <pre>-Highlight-
|
||||
* Ebene: man sieht Syntax-Highlighting UND kann tippen. Bewusst leichtgewichtig
|
||||
* (Regex-Highlighter fuer C-artige/JS/Python/Shell), damit es ohne Build-Schritt
|
||||
* inline passt.
|
||||
*
|
||||
* Bridge-Protokoll:
|
||||
* RN -> WebView window.ariaBridge.onMessage(jsonString):
|
||||
* {cmd:'setContent', content, language, version}
|
||||
* {cmd:'applyPatch', from, to, insert, version}
|
||||
* {cmd:'setLanguage', language}
|
||||
* {cmd:'setReadOnly', value}
|
||||
* WebView -> RN window.ReactNativeWebView.postMessage(jsonString):
|
||||
* {event:'ready'}
|
||||
* {event:'onEditFromUser', from, to, insert, fullText, version}
|
||||
*/
|
||||
|
||||
export const EDITOR_HTML = `<!doctype html><html><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { height: 100%; background: #0D0D1A; }
|
||||
#wrap { position: relative; height: 100%; width: 100%; }
|
||||
#hl, #ed {
|
||||
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
|
||||
margin: 0; border: 0; padding: 10px 12px;
|
||||
font-family: 'Courier New', monospace; font-size: 13px; line-height: 1.45;
|
||||
white-space: pre; word-wrap: normal; overflow: auto; tab-size: 2;
|
||||
}
|
||||
#hl { color: #C8C8E0; z-index: 1; pointer-events: none; }
|
||||
#ed {
|
||||
z-index: 2; color: transparent; background: transparent; caret-color: #0096FF;
|
||||
resize: none; outline: none;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
#ed::selection { background: rgba(0,150,255,0.3); }
|
||||
.tok-cmt { color: #6A7A6A; font-style: italic; }
|
||||
.tok-str { color: #C6A972; }
|
||||
.tok-num { color: #B58BE0; }
|
||||
.tok-kw { color: #4F9CE8; font-weight: bold; }
|
||||
</style></head><body>
|
||||
<div id="wrap">
|
||||
<pre id="hl"></pre>
|
||||
<textarea id="ed" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"></textarea>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
var ed = document.getElementById('ed');
|
||||
var hl = document.getElementById('hl');
|
||||
var lang = 'text';
|
||||
var version = 0;
|
||||
var lastValue = '';
|
||||
var applyingProgrammatic = false;
|
||||
|
||||
var KW = {
|
||||
common: ['if','else','for','while','do','return','break','continue','switch','case','default','function','var','let','const','class','new','this','import','from','export','try','catch','finally','throw','typeof','instanceof','void','delete','in','of','yield','async','await','def','elif','end','then','fi','esac','local','echo','extends','implements','interface','public','private','protected','static','struct','enum','include','define','null','true','false','undefined','None','True','False','print','with','as','pass','lambda','not','and','or','is']
|
||||
};
|
||||
|
||||
function esc(s){ return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
|
||||
function highlight(code){
|
||||
// Token-Scan: Kommentare, Strings, Zahlen, Keywords. Bewusst simpel.
|
||||
var out = '';
|
||||
var i = 0, n = code.length;
|
||||
var kwRe = /[A-Za-z_][A-Za-z0-9_]*/;
|
||||
while(i < n){
|
||||
var c = code[i];
|
||||
var two = code.substr(i,2);
|
||||
// Zeilenkommentar // oder #
|
||||
if(two === '//' || (c === '#')){
|
||||
var j = code.indexOf('\\n', i); if(j<0) j=n;
|
||||
out += '<span class="tok-cmt">'+esc(code.slice(i,j))+'</span>'; i=j; continue;
|
||||
}
|
||||
// Blockkommentar
|
||||
if(two === '/*'){
|
||||
var k = code.indexOf('*/', i+2); k = (k<0)? n : k+2;
|
||||
out += '<span class="tok-cmt">'+esc(code.slice(i,k))+'</span>'; i=k; continue;
|
||||
}
|
||||
// Strings
|
||||
if(c === '"' || c === "'" || c === '\`'){
|
||||
var q=c, m=i+1;
|
||||
while(m<n){ if(code[m]==='\\\\'){m+=2;continue;} if(code[m]===q){m++;break;} m++; }
|
||||
out += '<span class="tok-str">'+esc(code.slice(i,m))+'</span>'; i=m; continue;
|
||||
}
|
||||
// Zahl
|
||||
if(c>='0' && c<='9'){
|
||||
var p=i+1; while(p<n && /[0-9a-fA-F.xX_]/.test(code[p])) p++;
|
||||
out += '<span class="tok-num">'+esc(code.slice(i,p))+'</span>'; i=p; continue;
|
||||
}
|
||||
// Wort / Keyword
|
||||
if(/[A-Za-z_]/.test(c)){
|
||||
var rest = code.slice(i);
|
||||
var mm = rest.match(kwRe);
|
||||
var w = mm[0];
|
||||
if(KW.common.indexOf(w) >= 0){ out += '<span class="tok-kw">'+esc(w)+'</span>'; }
|
||||
else { out += esc(w); }
|
||||
i += w.length; continue;
|
||||
}
|
||||
out += esc(c); i++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function render(){
|
||||
hl.innerHTML = highlight(ed.value) + '\\n';
|
||||
hl.scrollTop = ed.scrollTop; hl.scrollLeft = ed.scrollLeft;
|
||||
}
|
||||
|
||||
function post(obj){ if(window.ReactNativeWebView) window.ReactNativeWebView.postMessage(JSON.stringify(obj)); }
|
||||
|
||||
// Minimalen Diff (gemeinsamer Prefix/Suffix) zwischen alt und neu.
|
||||
function diff(a, b){
|
||||
var s = 0; var maxS = Math.min(a.length, b.length);
|
||||
while(s < maxS && a[s] === b[s]) s++;
|
||||
var e = 0;
|
||||
while(e < (maxS - s) && a[a.length-1-e] === b[b.length-1-e]) e++;
|
||||
return { from: s, to: a.length - e, insert: b.slice(s, b.length - e) };
|
||||
}
|
||||
|
||||
var editTimer = null;
|
||||
ed.addEventListener('input', function(){
|
||||
render();
|
||||
if(applyingProgrammatic) return;
|
||||
if(editTimer) clearTimeout(editTimer);
|
||||
editTimer = setTimeout(function(){
|
||||
var nv = ed.value;
|
||||
var d = diff(lastValue, nv);
|
||||
lastValue = nv; version++;
|
||||
post({ event:'onEditFromUser', from:d.from, to:d.to, insert:d.insert, fullText:nv, version:version });
|
||||
}, 160);
|
||||
});
|
||||
ed.addEventListener('scroll', function(){ hl.scrollTop=ed.scrollTop; hl.scrollLeft=ed.scrollLeft; });
|
||||
|
||||
window.ariaBridge = {
|
||||
onMessage: function(json){
|
||||
var m; try { m = JSON.parse(json); } catch(e){ return; }
|
||||
if(m.cmd === 'setContent'){
|
||||
applyingProgrammatic = true;
|
||||
ed.value = m.content || '';
|
||||
lastValue = ed.value;
|
||||
if(typeof m.version === 'number') version = m.version;
|
||||
if(m.language) lang = m.language;
|
||||
render();
|
||||
applyingProgrammatic = false;
|
||||
} else if(m.cmd === 'applyPatch'){
|
||||
applyingProgrammatic = true;
|
||||
var v = ed.value;
|
||||
var from = Math.max(0, Math.min(m.from, v.length));
|
||||
var to = Math.max(from, Math.min(m.to, v.length));
|
||||
ed.value = v.slice(0, from) + (m.insert||'') + v.slice(to);
|
||||
lastValue = ed.value;
|
||||
if(typeof m.version === 'number') version = m.version;
|
||||
render();
|
||||
applyingProgrammatic = false;
|
||||
} else if(m.cmd === 'setLanguage'){
|
||||
lang = m.language || 'text'; render();
|
||||
} else if(m.cmd === 'setReadOnly'){
|
||||
ed.readOnly = !!m.value;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render();
|
||||
post({ event:'ready' });
|
||||
})();
|
||||
</script></body></html>`;
|
||||
@@ -0,0 +1,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>`;
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* VncTile — Live-Desktop der QEMU-VM (noVNC in einer WebView, RFB durch RVS).
|
||||
*
|
||||
* Nur im Fokus aktiv: dann wird die WebView gemountet, bei 'ready' der
|
||||
* RVS-VNC-Tunnel geoeffnet (desktop.openVnc). Server-Bytes (vnc_data) werden in
|
||||
* die WebView injiziert, RFB-Bytes der WebView (vnc_send) gehen als vnc_input
|
||||
* zurueck. Verlaesst man die Kachel, wird der Tunnel geschlossen (die VM laeuft
|
||||
* auf dem Host weiter).
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { WebView, WebViewMessageEvent } from 'react-native-webview';
|
||||
import desktop from '../../services/desktop';
|
||||
import { NOVNC_HTML } from '../assets/novncHtml';
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
focused: boolean;
|
||||
}
|
||||
|
||||
const VncTile: React.FC<Props> = ({ projectId, focused }) => {
|
||||
const webRef = useRef<WebView>(null);
|
||||
const [status, setStatus] = useState<'idle' | 'connecting' | 'connected' | 'disconnected'>('idle');
|
||||
const unsubDataRef = useRef<null | (() => void)>(null);
|
||||
|
||||
// Aufraeumen: Tunnel zu + Daten-Abo weg.
|
||||
const teardown = useCallback(() => {
|
||||
if (unsubDataRef.current) { unsubDataRef.current(); unsubDataRef.current = null; }
|
||||
desktop.closeVnc();
|
||||
}, []);
|
||||
|
||||
// Fokus verloren / Unmount → Tunnel schliessen.
|
||||
useEffect(() => {
|
||||
if (!focused) { teardown(); setStatus('idle'); }
|
||||
return () => teardown();
|
||||
}, [focused, teardown]);
|
||||
|
||||
const onMessage = useCallback((e: WebViewMessageEvent) => {
|
||||
let m: any;
|
||||
try { m = JSON.parse(e.nativeEvent.data); } catch { return; }
|
||||
if (m.event === 'ready') {
|
||||
// WebView + RFB bereit → Tunnel oeffnen und Server-Bytes einspeisen.
|
||||
setStatus('connecting');
|
||||
unsubDataRef.current = desktop.onVncData((b64) => {
|
||||
const js = `window.ariaVnc && window.ariaVnc.onData(${JSON.stringify(b64)}); true;`;
|
||||
webRef.current?.injectJavaScript(js);
|
||||
});
|
||||
desktop.openVnc(projectId);
|
||||
} else if (m.event === 'vnc_send') {
|
||||
desktop.sendInput(m.b64);
|
||||
} else if (m.event === 'vnc_close') {
|
||||
desktop.closeVnc();
|
||||
} else if (m.event === 'vnc_state') {
|
||||
if (m.state === 'connected') setStatus('connected');
|
||||
else if (m.state === 'disconnected') setStatus('disconnected');
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
if (!focused) {
|
||||
return (
|
||||
<View style={styles.placeholder}>
|
||||
<Text style={styles.icon}>🖥️</Text>
|
||||
<Text style={styles.text}>Desktop</Text>
|
||||
<Text style={styles.sub}>Antippen zum Verbinden</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<WebView
|
||||
ref={webRef}
|
||||
style={styles.web}
|
||||
originWhitelist={['*']}
|
||||
source={{ html: NOVNC_HTML, baseUrl: 'https://aria-vnc.local/' }}
|
||||
onMessage={onMessage}
|
||||
javaScriptEnabled
|
||||
domStorageEnabled
|
||||
mixedContentMode="always"
|
||||
androidLayerType="hardware"
|
||||
/>
|
||||
{status !== 'connected' && (
|
||||
<View style={styles.overlay} pointerEvents="none">
|
||||
<Text style={styles.overlayText}>
|
||||
{status === 'connecting' ? 'Verbinde …' : status === 'disconnected' ? 'Getrennt' : ''}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#000000' },
|
||||
web: { flex: 1, backgroundColor: '#000000' },
|
||||
placeholder: { flex: 1, backgroundColor: '#000000', alignItems: 'center', justifyContent: 'center' },
|
||||
icon: { fontSize: 64, marginBottom: 16 },
|
||||
text: { color: '#FFFFFF', fontSize: 18, fontWeight: '700' },
|
||||
sub: { color: '#9090B0', fontSize: 14, marginTop: 8 },
|
||||
overlay: { position: 'absolute', top: 10, left: 0, right: 0, alignItems: 'center' },
|
||||
overlayText: { color: '#9090B0', fontSize: 12, backgroundColor: 'rgba(0,0,0,0.6)', paddingHorizontal: 10, paddingVertical: 4, borderRadius: 10, overflow: 'hidden' },
|
||||
});
|
||||
|
||||
export default VncTile;
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* useWorkspaceLayout — merkt sich pro Projekt die zuletzt fokussierte Kachel,
|
||||
* damit man beim Zurueckkehren in ein Code-Projekt wieder dort landet (Editor/
|
||||
* Desktop) statt immer im Chat. Persistiert nach AsyncStorage (Muster wie
|
||||
* aria_project_drafts).
|
||||
*/
|
||||
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { TileId } from './layout';
|
||||
|
||||
const KEY = 'aria_workspace_layout';
|
||||
|
||||
interface Entry { focus: TileId | null }
|
||||
type LayoutMap = Record<string, Entry>;
|
||||
|
||||
const keyOf = (projectId: string) => projectId || '__main__';
|
||||
|
||||
export function useWorkspaceLayout(projectId: string) {
|
||||
const mapRef = useRef<LayoutMap>({});
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
AsyncStorage.getItem(KEY).then((v) => {
|
||||
if (v) { try { mapRef.current = JSON.parse(v) || {}; } catch { /* ignore */ } }
|
||||
setLoaded(true);
|
||||
}).catch(() => setLoaded(true));
|
||||
}, []);
|
||||
|
||||
const getFocus = useCallback((): TileId | null | undefined => {
|
||||
return mapRef.current[keyOf(projectId)]?.focus;
|
||||
}, [projectId]);
|
||||
|
||||
const saveFocus = useCallback((focus: TileId | null) => {
|
||||
mapRef.current = { ...mapRef.current, [keyOf(projectId)]: { focus } };
|
||||
AsyncStorage.setItem(KEY, JSON.stringify(mapRef.current)).catch(() => {});
|
||||
}, [projectId]);
|
||||
|
||||
return { loaded, getFocus, saveFocus };
|
||||
}
|
||||
+72
-19
@@ -540,10 +540,13 @@ META_TOOLS = [
|
||||
"fires_at": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Absoluter ISO-Timestamp UTC fuer feste Termine, z.B. "
|
||||
"'2026-05-12T14:30:00Z'. Die aktuelle Zeit findest du im "
|
||||
"System-Prompt unter '## Aktuelle Zeit'. Fuer relative Angaben "
|
||||
"lieber `in_seconds` nutzen."
|
||||
"Fester Termin als ISO-Timestamp. Schreib einfach die LOKALE "
|
||||
"Wanduhrzeit, die Stefan meint, OHNE Zeitzone — z.B. 'um 14:30' "
|
||||
"→ '2026-05-12T14:30:00'. Der Server rechnet sie selbst in UTC "
|
||||
"um (naiv = Ortszeit Europe/Berlin). Nur wenn du explizit UTC "
|
||||
"willst, haeng Z an ('...T12:30:00Z'). Die aktuelle Lokal-/UTC-"
|
||||
"Zeit steht im System-Prompt unter '## Aktuelle Zeit'. Fuer "
|
||||
"relative Angaben ('in 2 Stunden') lieber `in_seconds`."
|
||||
),
|
||||
},
|
||||
"message": {"type": "string", "description": "Was soll bei der Erinnerung gesagt werden"},
|
||||
@@ -1042,15 +1045,17 @@ META_TOOLS = [
|
||||
"function": {
|
||||
"name": "project_summary",
|
||||
"description": (
|
||||
"Fasst zusammen was zuletzt in einem Projekt passiert ist (letzte ~10 Turns). "
|
||||
"Nutze zwingend wenn Stefan in ein altes Projekt einsteigt mit "
|
||||
"'hol mich ab' / 'was war zuletzt' / 'erinner mich dran' — sonst "
|
||||
"halluzinierst Du Inhalte die nicht da sind."
|
||||
"Schau in einen ANDEREN Chat rein und fass zusammen was dort zuletzt "
|
||||
"passiert ist (letzte ~12 Turns). Funktioniert fuer jedes Projekt (per "
|
||||
"Name, Fuzzy-Match) UND fuer den Hauptchat (name='Hauptchat'). Nutze es "
|
||||
"IMMER wenn Stefan sagt 'hol dir die Infos aus Projekt X', 'schau mal in "
|
||||
"den Hauptchat/in Projekt Y rein', 'was war zuletzt bei ...', 'hol mich "
|
||||
"ab' — sonst halluzinierst Du Inhalte die nicht da sind."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "description": "Projekt-Name (Fuzzy-Match)."},
|
||||
"name": {"type": "string", "description": "Projekt-Name (Fuzzy-Match) oder 'Hauptchat' fuer den Hauptthread."},
|
||||
},
|
||||
"required": ["name"],
|
||||
},
|
||||
@@ -1075,6 +1080,28 @@ META_TOOLS = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "set_project_kind",
|
||||
"description": (
|
||||
"Markiert das AKTUELLE Projekt als Code-Projekt ('code') oder "
|
||||
"normalen Chat ('chat'). Bei 'code' blendet die App zusaetzlich "
|
||||
"einen Live-Code-Editor und den QEMU-Desktop (VNC) ein. Rufe es auf, "
|
||||
"sobald aus einem Gespraech ein Programmier-/Bau-Projekt wird (Du "
|
||||
"faengst an Code zu schreiben, eine VM zu bauen, etc.). Dein "
|
||||
"Arbeitsverzeichnis fuer Code-Dateien ist dann /shared/projects/"
|
||||
"<projekt-id>/ — nur was Du DORT schreibst erscheint im Editor."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {"type": "string", "enum": ["code", "chat"], "description": "'code' oder 'chat'."},
|
||||
},
|
||||
"required": ["kind"],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -2535,22 +2562,27 @@ class Agent:
|
||||
pname = (arguments.get("name") or "").strip()
|
||||
if not pname:
|
||||
return "FEHLER: name ist Pflicht."
|
||||
p = projects_mod.find_project(pname)
|
||||
if not p:
|
||||
return f"Kein Projekt '{pname}' gefunden."
|
||||
# Letzte ~10 Turns des Projekts aus dem Conversation-Log
|
||||
turns = [t for t in self.conversation.turns if t.project_id == p["id"]]
|
||||
# Hauptchat (project_id="") explizit unterstuetzen — damit ARIA auch
|
||||
# aus einem Projekt heraus in den Hauptthread reinschauen kann.
|
||||
if pname.lower() in {"hauptchat", "hauptthread", "haupt", "main",
|
||||
"mainchat", "haupt-chat", "hauptchat-thread"}:
|
||||
turns = [t for t in self.conversation.turns if not t.project_id]
|
||||
label, desc = "Hauptchat", "der Hauptthread (kein Projekt)"
|
||||
else:
|
||||
p = projects_mod.find_project(pname)
|
||||
if not p:
|
||||
return f"Kein Chat/Projekt '{pname}' gefunden (fuer den Hauptthread: name='Hauptchat')."
|
||||
turns = [t for t in self.conversation.turns if t.project_id == p["id"]]
|
||||
label, desc = p["name"], p.get("description", "(keine Beschreibung)")
|
||||
if not turns:
|
||||
return (f"Projekt '{p['name']}' existiert (id={p['id']}), aber im "
|
||||
f"aktuellen Conversation-Window stehen noch keine Turns. "
|
||||
f"Beschreibung: {p.get('description', '(keine)')}")
|
||||
return (f"'{label}' hat im aktuellen Conversation-Window noch keine "
|
||||
f"Turns. {desc}")
|
||||
tail = turns[-12:]
|
||||
summary_lines = []
|
||||
for t in tail:
|
||||
prefix = "Stefan" if t.role == "user" else "Du"
|
||||
summary_lines.append(f"{prefix}: {t.content[:280]}")
|
||||
preamble = (f"Projekt '{p['name']}' — {p.get('description', '(keine Beschreibung)')}.\n"
|
||||
f"Letzte {len(tail)} Turns:\n")
|
||||
preamble = f"'{label}' — {desc}.\nLetzte {len(tail)} Turns:\n"
|
||||
return preamble + "\n".join(summary_lines)
|
||||
if name == "project_end":
|
||||
pname = (arguments.get("name") or "").strip()
|
||||
@@ -2566,6 +2598,27 @@ class Agent:
|
||||
"action": "ended",
|
||||
})
|
||||
return f"OK — Projekt '{p['name']}' beendet (id={p['id']}). Bleibt in der Liste, aktiv ist jetzt der Hauptthread."
|
||||
if name == "set_project_kind":
|
||||
kind = (arguments.get("kind") or "").strip().lower()
|
||||
if kind not in ("code", "chat"):
|
||||
return "FEHLER: kind muss 'code' oder 'chat' sein."
|
||||
active_id = projects_mod.get_active()
|
||||
if not active_id:
|
||||
return ("Kein aktives Projekt — Du bist im Hauptthread. Erst ein Projekt "
|
||||
"anlegen/betreten (project_create/project_enter), dann set_project_kind.")
|
||||
updated = projects_mod.update_project(active_id, {"kind": kind})
|
||||
if not updated:
|
||||
return f"FEHLER: aktives Projekt '{active_id}' nicht gefunden."
|
||||
self._pending_events.append({
|
||||
"type": "project_changed",
|
||||
"project": updated,
|
||||
"action": "kind_changed",
|
||||
})
|
||||
if kind == "code":
|
||||
return (f"OK — Projekt '{updated['name']}' ist jetzt ein Code-Projekt. "
|
||||
f"Editor + Desktop erscheinen in der App. Code-Dateien unter "
|
||||
f"/shared/projects/{active_id}/ schreiben, damit sie im Editor auftauchen.")
|
||||
return f"OK — Projekt '{updated['name']}' ist wieder ein normaler Chat."
|
||||
return f"Unbekanntes Tool: {name}"
|
||||
except Exception as exc:
|
||||
logger.exception("Tool '%s' fehlgeschlagen", name)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -400,6 +400,43 @@ SEED_RULES: List[dict] = [
|
||||
"Brain-Resources: erst denken, sonst Brain-Tool nehmen."
|
||||
),
|
||||
},
|
||||
{
|
||||
"migration_key": "seed/architecture/qemu-vm-code-projects",
|
||||
"type": "rule",
|
||||
"title": "Code-Projekte + QEMU: aria-vm auf dem Host, Editor/Desktop in der App",
|
||||
"category": "architektur",
|
||||
"content": (
|
||||
"Wenn aus einem Gespraech ein PROGRAMMIER- oder BAU-Projekt wird "
|
||||
"(Du schreibst Code, baust ein System, testest eine VM):\n"
|
||||
"\n"
|
||||
"1. Ruf `set_project_kind('code')` — dann blendet Stefans App einen "
|
||||
"Live-Code-Editor und den QEMU-Desktop ein. Vorher ein Projekt "
|
||||
"anlegen/betreten (project_create/enter), sonst gibt's kein Ziel.\n"
|
||||
"2. Schreib Code-Dateien NUR unter `/shared/projects/<projekt-id>/` "
|
||||
"(das Volume ist in proxy+bridge+brain gemountet). Genau diese "
|
||||
"Writes/Edits erscheinen live in Stefans Editor — und was Stefan "
|
||||
"dort tippt, landet als Datei zurueck in diesem Verzeichnis.\n"
|
||||
"\n"
|
||||
"QEMU (VMs fuer JEDE Architektur — x86, ARM, MIPS, PPC, RISC-V, SPARC) "
|
||||
"laeuft auf dem Host. Du steuerst sie per `ssh aria-wohnung aria-vm ...`:\n"
|
||||
" - `aria-vm create <name> <arch> [groesse]` Disk anlegen (z.B. i386 "
|
||||
"fuer Win 3.11, aarch64, mips ...).\n"
|
||||
" - `aria-vm boot <name> [--iso <pfad>] [--vnc-display 1] [--mem 1024]` "
|
||||
"startet die VM. VNC bindet an 127.0.0.1:<display> (Display 1 = Port "
|
||||
"5901). Nicht selbst nach aussen oeffnen!\n"
|
||||
" - `aria-vm screenshot <name>` PNG in die Shared-Uploads (kannst Du "
|
||||
"Stefan mit [FILE:] schicken).\n"
|
||||
" - `aria-vm list` / `aria-vm stop <name>` / `aria-vm rm <name>`.\n"
|
||||
"\n"
|
||||
"Der VNC-Stream kommt AUTOMATISCH ueber den RVS-Tunnel der Bridge in "
|
||||
"Stefans App-Desktop-Kachel (Display 1 / Port 5901) — dort kann er die "
|
||||
"VM live mit Maus/Tastatur bedienen. Du musst nur die VM booten und ggf. "
|
||||
"sagen 'schau in die Desktop-Kachel'. KVM-Beschleunigung gibt es nur "
|
||||
"fuer x86-Gaeste; andere Architekturen laufen emuliert (langsamer, aber "
|
||||
"sie laufen). Medien-Downloads/Baubares fuer Stefans Privatgebrauch: "
|
||||
"wie gewohnt einfach machen, nicht moralisieren."
|
||||
),
|
||||
},
|
||||
{
|
||||
"migration_key": "seed/architecture/brain-tools-xml-tag",
|
||||
"type": "rule",
|
||||
|
||||
+27
-3
@@ -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 = {
|
||||
|
||||
@@ -734,6 +734,11 @@ class ARIABridge:
|
||||
# Beeinflusst das Timeout fuer stt_request — bei "loading" warten wir laenger,
|
||||
# weil das Modell beim ersten Request noch ~1-2 Min runtergeladen werden kann.
|
||||
self._remote_stt_ready: bool = False
|
||||
# VNC-Tunnel (Workspace-Desktop): session → {"writer": StreamWriter,
|
||||
# "task": asyncio.Task}. Wir bruecken rohes RFB-TCP (QEMU-VNC auf dem
|
||||
# Host) <-> RVS (vnc_data/vnc_input, Base64-in-JSON).
|
||||
self._vnc_sessions: dict[str, dict] = {}
|
||||
self._vnc_host: str = os.environ.get("ARIA_VNC_HOST", "host.docker.internal")
|
||||
# FLUX-Render-Requests die aktuell auf Antwort der flux-bridge (Gamebox) warten.
|
||||
# requestId → Future mit dem flux_response-Payload (oder None bei Fehler).
|
||||
self._pending_flux: dict[str, asyncio.Future] = {}
|
||||
@@ -3351,6 +3356,56 @@ class ARIABridge:
|
||||
logger.info("[rvs] flux-bridge -> %s", state)
|
||||
return
|
||||
|
||||
elif msg_type == "code_file_edit":
|
||||
# Stefan hat im Live-Editor getippt → Datei im Projekt-Arbeits-
|
||||
# verzeichnis (/shared/projects/<pid>/) mit dem Volltext ueberschreiben.
|
||||
# ARIA sieht die Aenderung beim naechsten Read.
|
||||
pid = str(payload.get("projectId") or "")
|
||||
rel = (payload.get("path") or "").strip()
|
||||
full_text = payload.get("fullText")
|
||||
if rel and isinstance(full_text, str):
|
||||
ok = self._write_project_file(pid, rel, full_text)
|
||||
logger.info("[rvs] code_file_edit %s/%s (%d chars) → %s",
|
||||
pid or "main", rel, len(full_text), "ok" if ok else "abgelehnt")
|
||||
return
|
||||
|
||||
elif msg_type == "check_desktop":
|
||||
session = str(payload.get("session") or "main")
|
||||
try:
|
||||
port = int(payload.get("port") or 5901)
|
||||
except (TypeError, ValueError):
|
||||
port = 5901
|
||||
asyncio.create_task(self._check_desktop(session, port))
|
||||
return
|
||||
|
||||
elif msg_type == "vnc_open":
|
||||
session = str(payload.get("session") or "main")
|
||||
try:
|
||||
port = int(payload.get("port") or 5901)
|
||||
except (TypeError, ValueError):
|
||||
port = 5901
|
||||
asyncio.create_task(self._vnc_open(session, port))
|
||||
return
|
||||
|
||||
elif msg_type == "vnc_close":
|
||||
session = str(payload.get("session") or "main")
|
||||
asyncio.create_task(self._vnc_close(session))
|
||||
return
|
||||
|
||||
elif msg_type == "vnc_input":
|
||||
session = str(payload.get("session") or "main")
|
||||
b64 = payload.get("b64") or ""
|
||||
sess = self._vnc_sessions.get(session)
|
||||
if sess and b64:
|
||||
try:
|
||||
data = base64.b64decode(b64)
|
||||
sess["writer"].write(data)
|
||||
# drain nicht awaiten (wir sind synchron im Dispatcher) —
|
||||
# write puffert, der Kernel-Socket schluckt RFB-Input locker.
|
||||
except Exception as exc:
|
||||
logger.warning("[vnc] input schreiben (%s) fehlgeschlagen: %s", session, exc)
|
||||
return
|
||||
|
||||
elif msg_type == "config_request":
|
||||
# Eine andere Bridge (whisper/f5tts) bittet um die aktuelle Voice-
|
||||
# Config — passiert wenn sie sich connected, weil sie sonst die
|
||||
@@ -4192,6 +4247,33 @@ class ARIABridge:
|
||||
"timestamp": int(time.time() * 1000),
|
||||
}))
|
||||
await _send_response(writer, 200, {"ok": True})
|
||||
elif method == "POST" and path == "/internal/code-file":
|
||||
# Vom Proxy-Hook gefeuert wenn ARIA in einem Code-Projekt eine
|
||||
# Datei schreibt/aendert. Wir spiegeln das als RVS code_file an
|
||||
# die App (Live-Code-Editor). payload: {projectId, path,
|
||||
# language?, content?, patch?, version?}.
|
||||
try:
|
||||
data = json.loads(body.decode("utf-8", "ignore"))
|
||||
except Exception as exc:
|
||||
await _send_response(writer, 400, {"error": f"bad json: {exc}"})
|
||||
return
|
||||
fpath = (data.get("path") or "").strip()
|
||||
if not fpath:
|
||||
await _send_response(writer, 400, {"error": "path erforderlich"})
|
||||
return
|
||||
asyncio.create_task(self._send_to_rvs({
|
||||
"type": "code_file",
|
||||
"payload": {
|
||||
"projectId": str(data.get("projectId") or ""),
|
||||
"path": fpath,
|
||||
"language": data.get("language") or "",
|
||||
"content": data.get("content"),
|
||||
"patch": data.get("patch"),
|
||||
"version": data.get("version") or 0,
|
||||
},
|
||||
"timestamp": int(time.time() * 1000),
|
||||
}))
|
||||
await _send_response(writer, 200, {"ok": True})
|
||||
elif method == "POST" and path == "/internal/flux-generate":
|
||||
# Vom Brain (flux_generate-Tool) gefeuert. Wir routen den
|
||||
# Render-Request via RVS an die flux-bridge (Gamebox),
|
||||
@@ -4301,6 +4383,116 @@ class ARIABridge:
|
||||
except Exception:
|
||||
logger.exception("[bridge] Internal HTTP-Listener konnte nicht starten")
|
||||
|
||||
def _write_project_file(self, project_id: str, rel_path: str, content: str) -> bool:
|
||||
"""Schreibt content nach /shared/projects/<project_id>/<rel_path>.
|
||||
|
||||
Pfad-sicher: rel_path darf nicht aus dem Projekt-Basisverzeichnis
|
||||
ausbrechen (kein .., kein absoluter Pfad). Legt Verzeichnisse an.
|
||||
"""
|
||||
try:
|
||||
base = Path("/shared/projects") / (project_id or "main")
|
||||
base = base.resolve()
|
||||
target = (base / rel_path).resolve()
|
||||
if base != target and base not in target.parents:
|
||||
logger.warning("[code_file] Pfad ausserhalb Projekt abgelehnt: %s", rel_path)
|
||||
return False
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = target.with_suffix(target.suffix + ".tmp")
|
||||
tmp.write_text(content, encoding="utf-8")
|
||||
tmp.replace(target)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning("[code_file] Schreiben fehlgeschlagen %s/%s: %s", project_id, rel_path, exc)
|
||||
return False
|
||||
|
||||
async def _check_desktop(self, session: str, port: int) -> None:
|
||||
"""Probt ob auf dem Host eine QEMU-VNC laeuft und meldet desktop_status."""
|
||||
available = False
|
||||
try:
|
||||
fut = asyncio.open_connection(self._vnc_host, port)
|
||||
reader, writer = await asyncio.wait_for(fut, timeout=2.0)
|
||||
available = True
|
||||
writer.close()
|
||||
try:
|
||||
await writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
available = False
|
||||
await self._send_to_rvs({
|
||||
"type": "desktop_status",
|
||||
"payload": {
|
||||
"available": available,
|
||||
"session": session,
|
||||
"message": (f"Desktop laeuft (VNC {self._vnc_host}:{port})" if available
|
||||
else "Kein Desktop verbunden"),
|
||||
},
|
||||
"timestamp": int(time.time() * 1000),
|
||||
})
|
||||
|
||||
async def _vnc_open(self, session: str, port: int) -> None:
|
||||
"""Oeffnet die TCP-Verbindung zur QEMU-VNC und streamt RFB-Bytes als
|
||||
vnc_data ueber RVS. Bestehende Session wird vorher geschlossen."""
|
||||
await self._vnc_close(session)
|
||||
try:
|
||||
reader, writer = await asyncio.open_connection(self._vnc_host, port)
|
||||
except Exception as exc:
|
||||
logger.warning("[vnc] open %s:%d fehlgeschlagen: %s", self._vnc_host, port, exc)
|
||||
await self._send_to_rvs({
|
||||
"type": "desktop_status",
|
||||
"payload": {"available": False, "session": session,
|
||||
"message": f"VNC-Verbindung fehlgeschlagen: {exc}"},
|
||||
"timestamp": int(time.time() * 1000),
|
||||
})
|
||||
return
|
||||
task = asyncio.create_task(self._vnc_reader_loop(session, reader))
|
||||
self._vnc_sessions[session] = {"writer": writer, "task": task}
|
||||
logger.info("[vnc] Tunnel offen: session=%s → %s:%d", session, self._vnc_host, port)
|
||||
|
||||
async def _vnc_reader_loop(self, session: str, reader: asyncio.StreamReader) -> None:
|
||||
try:
|
||||
while True:
|
||||
data = await reader.read(16384)
|
||||
if not data:
|
||||
break
|
||||
await self._send_to_rvs({
|
||||
"type": "vnc_data",
|
||||
"payload": {"session": session, "b64": base64.b64encode(data).decode("ascii")},
|
||||
"timestamp": int(time.time() * 1000),
|
||||
})
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.info("[vnc] reader-loop (%s) beendet: %s", session, exc)
|
||||
finally:
|
||||
# TCP-Ende → App informieren (Desktop weg).
|
||||
if session in self._vnc_sessions:
|
||||
await self._send_to_rvs({
|
||||
"type": "desktop_status",
|
||||
"payload": {"available": False, "session": session, "message": "VNC-Verbindung beendet"},
|
||||
"timestamp": int(time.time() * 1000),
|
||||
})
|
||||
|
||||
async def _vnc_close(self, session: str) -> None:
|
||||
sess = self._vnc_sessions.pop(session, None)
|
||||
if not sess:
|
||||
return
|
||||
task = sess.get("task")
|
||||
if task:
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
writer = sess.get("writer")
|
||||
if writer:
|
||||
try:
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
logger.info("[vnc] Tunnel geschlossen: session=%s", session)
|
||||
|
||||
async def _delete_chat_message(self, ts: int) -> dict:
|
||||
"""Entfernt eine Bubble: aus chat_backup.jsonl + Brain conversation,
|
||||
broadcastet chat_message_deleted via RVS.
|
||||
|
||||
Executable
+185
@@ -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 "$@"
|
||||
Executable
+53
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# qemu-setup.sh — installiert QEMU fuer ALLE Architekturen auf dem ARIA-Host
|
||||
# (172.0.2.33) plus den aria-vm-Helper. Einmalig als root ausfuehren.
|
||||
#
|
||||
# sudo bash host-provisioning/qemu-setup.sh
|
||||
#
|
||||
# KVM-Beschleunigung gibt es nur fuer x86-Gaeste auf einem x86-Host; ARM/MIPS/
|
||||
# PPC/RISC-V laufen unter TCG (voll emuliert, langsamer, aber alle Architekturen
|
||||
# baubar). websockify/noVNC werden NICHT installiert — der VNC-Stream wird als
|
||||
# RFB-Bytes durch die Bridge/RVS getunnelt (siehe aria_bridge.py VNC-Bruecke).
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "${EUID}" -ne 0 ]]; then
|
||||
echo "Bitte als root ausfuehren (sudo)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[qemu-setup] apt update ..."
|
||||
apt-get update -qq
|
||||
|
||||
echo "[qemu-setup] Installiere QEMU (alle Architekturen) + Werkzeuge ..."
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
qemu-system \
|
||||
qemu-system-x86 \
|
||||
qemu-system-arm \
|
||||
qemu-system-mips \
|
||||
qemu-system-ppc \
|
||||
qemu-system-sparc \
|
||||
qemu-system-misc \
|
||||
qemu-utils \
|
||||
seabios \
|
||||
ovmf \
|
||||
ipxe-qemu \
|
||||
socat \
|
||||
imagemagick
|
||||
|
||||
echo "[qemu-setup] KVM-Status:"
|
||||
if [[ -e /dev/kvm ]]; then
|
||||
echo " /dev/kvm vorhanden → x86-Gaeste mit KVM-Beschleunigung."
|
||||
else
|
||||
echo " /dev/kvm FEHLT → alle Gaeste laufen unter TCG (emuliert, langsamer)."
|
||||
fi
|
||||
|
||||
# aria-vm-Helper installieren (liegt neben diesem Skript).
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
install -m 0755 "${SCRIPT_DIR}/aria-vm" /usr/local/bin/aria-vm
|
||||
echo "[qemu-setup] /usr/local/bin/aria-vm installiert."
|
||||
|
||||
mkdir -p /var/lib/aria-vms
|
||||
echo "[qemu-setup] VM-Verzeichnis: /var/lib/aria-vms"
|
||||
|
||||
echo "[qemu-setup] Fertig. Test: aria-vm list"
|
||||
@@ -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 */ }
|
||||
|
||||
@@ -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> }
|
||||
|
||||
Reference in New Issue
Block a user