Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28361ea97a | ||
|
|
a20e57e33a | ||
|
|
dc775ee34f | ||
|
|
c2122c43c2 | ||
|
|
f45d42cd30 | ||
|
|
0110526622 | ||
|
|
7d46d8e531 | ||
|
|
b839b059d0 | ||
|
|
9950cdea87 | ||
|
|
7329257830 | ||
|
|
bbfd544013 | ||
|
|
aadc030407 | ||
|
|
85756a161b | ||
|
|
ea3de70d05 | ||
|
|
0b35ea9bde | ||
|
|
a082c8398e | ||
|
|
a6cb152f55 | ||
|
|
85363b1014 | ||
|
|
20c527c8ed | ||
|
|
80b534cbab | ||
|
|
747c67766c |
@@ -10,6 +10,99 @@ Alle Änderungen am Projekt. Format: [Keep a Changelog](https://keepachangelog.c
|
||||
|
||||
---
|
||||
|
||||
## [0.2.2.3] — 2026-07-17 — ARIA liest andere Projekt-Chats wirklich (volle Historie)
|
||||
|
||||
### Behoben
|
||||
|
||||
- **`project_summary` fand fast nie Inhalte.** Es las nur ARIAs rollendes Kontextfenster (~50 Turns über ALLE Projekte) — ältere/andere Projekt-Chats sind da längst rausdistilliert. „Hol dir die Infos aus Projekt X" lieferte deshalb leere Ergebnisse, und ARIA wusste nicht, wie sie an die Historie kommt. Jetzt liest das Tool die **echte, volle Historie aus `chat_backup.jsonl`** (im Brain gemountet) — die letzten ~20 Turns des Zielprojekts, unabhängig davon wie lange man da nicht war. Standort-Hints in User-Turns werden rausgefiltert. Fallback aufs Fenster, falls das Backup fehlt. Tool-Beschreibung geschärft, damit ARIA es direkt aufruft.
|
||||
- Nebenbei bereinigt: `mac_os_update_fehler` war durch den alten `set_project_kind`-Bug (0.2.2.1) fälschlich `kind=code` — auf `chat` zurückgesetzt.
|
||||
|
||||
### Deploy
|
||||
`git pull && docker compose up -d --build brain` (kein APK-Rebuild nötig).
|
||||
|
||||
---
|
||||
|
||||
## [0.2.2.2] — 2026-07-17 — Voice-Projektwechsel zurück — aber gerätelokal
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **„ARIA, geh in Projekt X" per Sprache wechselt wieder das Projekt** — aber **nur auf dem Gerät, das den Befehl gab**. Andere App-Instanzen und das Diagnostic bleiben in ihrem Projekt.
|
||||
- Umsetzung: Jedes Gerät merkt sich die IDs seiner eigenen Anfragen (Text-`clientMsgId` + Voice-`audioRequestId`). Der Brain/Voice-Router hängt an jedes `project_changed`-Event die auslösende `clientMsgId` an; die App folgt dem Wechsel nur, wenn die ID eine **eigene** ist. Deckt beide Voice-Pfade ab (ARIAs `project_enter`/`exit` via `send_to_core` **und** den Bridge-Voice-Router „für Projekt X: …" / „zurück zum Hauptchat").
|
||||
- Manuelles Umschalten bleibt wie gehabt gerätelokal über den Drawer.
|
||||
|
||||
### Deploy
|
||||
`git pull && docker compose up -d --build bridge` + APK 0.2.2.2 (kein Brain-Rebuild nötig).
|
||||
|
||||
---
|
||||
|
||||
## [0.2.2.1] — 2026-07-17 — Projekt-Fokus wirklich pro Gerät unabhängig
|
||||
|
||||
### Behoben
|
||||
|
||||
- **`set_project_kind` markierte das falsche Projekt.** Es nutzte den **globalen** `active_project` (geräteübergreifend) statt des Projekts, in dem der aktuelle Request läuft. Folge: Obwohl auf der App „Basic os" aktiv war, bekam das global-aktive „Mac OS Update Fehler" `kind=code`. Jetzt wirkt das Tool auf die pro Request mitgesendete `project_id` (in `_dispatch_tool` durchgereicht) — jede App-/Diagnostic-Instanz markiert ihr eigenes Projekt.
|
||||
- **App wurde von fremden `project_changed`-Broadcasts ins andere Projekt gezogen.** Ein Projektwechsel/‑anlegen durch ARIA, das Diagnostic oder eine andere App-Instanz erzwang auf **allen** Geräten einen Fokuswechsel. Der Projekt-Fokus ist jetzt **rein gerätelokal**: Broadcasts aktualisieren nur Namen + Typ, wechseln aber nicht mehr das Projekt. Umschalten geht ausschließlich lokal über den Projekt-Drawer. So arbeitet jedes Gerät unabhängig in seinem eigenen Projekt (auch mehrere App-Instanzen).
|
||||
- Hinweis: „ARIA, geh in Projekt X" per Sprache wechselt das App-Projekt dadurch **nicht mehr** automatisch — bewusst, damit Geräte unabhängig bleiben. Wechseln über den Drawer.
|
||||
|
||||
### Deploy
|
||||
`git pull && docker compose up -d --build brain` + APK 0.2.2.1 neu bauen.
|
||||
|
||||
---
|
||||
|
||||
## [0.2.2.0] — 2026-07-17 — Workbench: Taskleisten-Dock statt Zoom-Landkarte + bedienbare VNC-Konsole
|
||||
|
||||
### Geändert
|
||||
|
||||
**Cockpit-Navigation neu gedacht — Dock statt Pinch-Canvas**
|
||||
- Die Zoom-Landkarte (mit 2 Fingern rauszoomen, Kachel finden, reintippen) fühlte sich auf 5 Zoll fummelig an. Ersetzt durch eine **Taskleiste unten im Daumenbereich** (Chat · Code · Desktop): **ein Tap wechselt sofort** das Panel, ein animierter Indikator gleitet unter das aktive Icon. Desktop-*Umfang*, Handy-*Ergonomie*.
|
||||
- Jedes Panel ist **bildschirmfüllend** und Handy-optimiert; alle bleiben gemountet (nur das aktive ist sichtbar) → kein Remount, Chat behält RVS/Audio/Queue, WebViews ihren Zustand.
|
||||
- Das Dock **blendet bei offener Tastatur aus** (mehr Platz zum Tippen) und respektiert die Gesten-Navigationsleiste (Safe-Area).
|
||||
- **Aktivitäts-Punkte** am Dock: Code blau, sobald Dateien da sind; Desktop grün, wenn die VM verbunden ist.
|
||||
- Entfällt: Pinch/Pan-Canvas, Übersichts-Button im Header, die „Landkarten"-Thumbnails.
|
||||
|
||||
**noVNC-Konsole endlich bedienbar**
|
||||
- Steuerungs-Leiste im Desktop-Panel: **⌨ Tastatur** (blendet die Handy-Tastatur ein, tippt direkt in die VM — inkl. Enter/Backspace/Pfeiltasten), **Strg+Alt+Entf**, und **⤢ Fit ↔ 1:1**. Tippen/Ziehen steuert weiterhin die Maus.
|
||||
|
||||
### Deploy
|
||||
Nur **App neu bauen** (kein Backend). APK 0.2.2.0.
|
||||
|
||||
---
|
||||
|
||||
## [0.2.1.9] — 2026-07-17 — Kompakt ↔ Cockpit: Umschalter für den Kachel-Desktop
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **Ansichts-Umschalter im Header** („⧉ Kompakt" / „⧉ Cockpit"): Die App startet in **Kompakt** — der klassische Vollbild-Chat, **exakt wie vor dem Umbau** (Default, Mama-tauglich). Ein Tap auf den Button oben rechts schaltet auf **Cockpit** — den zoom-/verschiebbaren Kachel-Desktop. Persistiert über Neustart (`aria_view_mode`).
|
||||
- **Warum:** Nach dem 0.2.1.8-Deploy sah die App „unverändert" aus — korrekt, denn im normalen Chat gibt es nur eine Kachel (= Vollbild-Chat). Der Umschalter macht den Cockpit-Modus jetzt **explizit sichtbar/steuerbar**, statt nur bei Code-Projekten aufzutauchen.
|
||||
- Im Cockpit ist die **Übersicht jetzt immer erreichbar** (auch im Hauptchat): „⤢ Übersicht" sitzt **im Header links** (kollidiert nicht mehr mit dem Abbrechen-Button der „ARIA denkt"-Leiste), dazu 2-Finger-Pinch/Pan und Hardware-Back.
|
||||
- Im Cockpit werden **immer alle vier Kacheln** gezeigt (Chat/Editor/Desktop/Vorschau) — Editor/Desktop/Vorschau als Platzhalter mit Status-Untertitel („kein Code-Projekt" / „kein Desktop"), bis ARIA ein Code-Projekt startet bzw. eine VM läuft. Vorher wirkten sie „verschwunden", weil sie erst bei Code-Projekten auftauchten.
|
||||
|
||||
---
|
||||
|
||||
## [0.2.1.8] — 2026-07-17 — Desktop-Workspace: zoombarer Canvas, Live-Code-Editor, QEMU/VNC
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
**Zoom-/verschiebbarer Workspace-Canvas (App)**
|
||||
- Die App ist jetzt eine desktop-artige Arbeitsfläche: rausgezoomt sieht man eine **Landkarte aus Kacheln** (Chat, Editor, Desktop, Vorschau), die man mit **2 Fingern zoomt und verschiebt**. Tippt man eine Kachel an, zoomt sie voll auf und wird **echt bedienbar** („Übersicht + Fokus"). „⤢ Übersicht" bzw. der Hardware-Back führen zurück zur Landkarte.
|
||||
- Technisch: `react-native-gesture-handler` + `react-native-reanimated` (60 fps auf dem UI-Thread). Zwei Ebenen — eine skalierte Thumbnail-Welt und eine **Identity-Content-Ebene** (Scale 1), in der die schweren Inhalte (ChatScreen + WebViews) immer gemountet sind und nur die fokussierte sichtbar ist. Dadurch bleiben Touch-Koordinaten/Keyboard korrekt und nichts remountet beim Fokuswechsel. **Reiner Chat verhält sich exakt wie bisher** (eine Kachel, dauerhaft fokussiert).
|
||||
|
||||
**Live-Code-Editor für Code-Projekte (App + Bridge + Proxy)**
|
||||
- Wird ein Projekt zum Code-Projekt (ARIA ruft `set_project_kind('code')`), erscheinen Editor- und Desktop-Kachel. Der Editor (WebView, selbstenthaltener Highlight-Editor, offline) **zeigt live, was ARIA schreibt** — und Stefan kann selbst editieren; Änderungen gehen zurück an ARIA.
|
||||
- Fluss: ARIAs `Write`/`Edit` unter `/shared/projects/<projekt-id>/` werden im Proxy abgefangen und als `code_file` über die Bridge/RVS an die App gespiegelt; Stefans Edits kommen als `code_file_edit` pfad-sicher zurück ins selbe Verzeichnis.
|
||||
|
||||
**QEMU für alle Architekturen + Live-Desktop per VNC (Host + Bridge + App)**
|
||||
- ARIA kann jetzt VMs für **jede Architektur** bauen/testen (x86, ARM, MIPS, PPC, RISC-V, SPARC) — Host-Helper `aria-vm` (`create/boot/screenshot/list/stop`), installiert via `host-provisioning/qemu-setup.sh`. KVM für x86-Gäste, sonst TCG. Beispiel: ein Win-3.11-System bauen und in QEMU testen.
|
||||
- Der **VNC-Live-Desktop wird durch den RVS-Server getunnelt**: die Bridge brückt rohes RFB-TCP (QEMU `127.0.0.1:5901`) ↔ RVS (`vnc_data`/`vnc_input`, Base64-in-JSON), der noVNC-Client läuft in der App-WebView (`window.WebSocket`-Shim). Stefan bedient die VM **live mit Maus/Tastatur** in der Desktop-Kachel — NAT-sicher, kein offener Port am Host, kein websockify/noVNC auf dem Host nötig.
|
||||
|
||||
**Kleineres**
|
||||
- Pro-Projekt-Layout: die zuletzt fokussierte Kachel wird pro Projekt gemerkt (`aria_workspace_layout`).
|
||||
- Projekt-Modell bekommt `kind` ('chat'|'code'); Seed-Regel lehrt ARIA den Code-Projekt-Workflow (Arbeitsverzeichnis `/shared/projects/<id>/`, `aria-vm`, VNC landet automatisch in der App).
|
||||
|
||||
### Deploy
|
||||
`git pull && docker compose up -d --build brain bridge proxy` · RVS-Stack `up -d --build` · Host: `bash host-provisioning/qemu-setup.sh` (einmalig, als root) · APK neu bauen (nach `npm install` einmalig `npm start --reset-cache` + `gradlew clean`, wegen der neuen nativen Module).
|
||||
|
||||
---
|
||||
|
||||
## [0.2.1.5] — 2026-07-12 — Pro-Projekt-Queue mit Rückfrage-Loop
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
@@ -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,6 +500,9 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
|
||||
|
||||
### Features
|
||||
|
||||
- **Desktop-Workbench (Kompakt ↔ Cockpit)**: Umschalter oben rechts im Header. **Kompakt** = klassischer Vollbild-Chat (Default). **Cockpit** = Workbench mit **Taskleiste unten** (Chat · Code · Desktop) — ein Daumen-Tap wechselt sofort das Panel, animierter Indikator, Aktivitäts-Punkte (Code blau / Desktop grün). Jedes Panel bildschirmfüllend und Handy-optimiert; Dock blendet bei offener Tastatur aus. Desktop-Umfang, aber auf 5 Zoll bedienbar
|
||||
- **Live-Code-Editor** (Code-Projekte): Wird ein Projekt zum Code-Projekt (`set_project_kind('code')`), zeigt eine Editor-Kachel **live, was ARIA schreibt** (Syntax-Highlighting, offline) und du kannst selbst editieren → zurück an ARIA. ARIAs `Write`/`Edit` unter `/shared/projects/<id>/` werden im Proxy abgefangen und als `code_file` gespiegelt; deine Edits kommen als `code_file_edit` pfad-sicher zurück
|
||||
- **Live-Desktop per VNC (durch RVS getunnelt)**: ARIA baut/testet VMs mit **QEMU für jede Architektur** (x86/ARM/MIPS/PPC/RISC-V/SPARC, Host-Helper `aria-vm`, KVM für x86). Der QEMU-Desktop erscheint **live in der Desktop-Kachel** — noVNC in der WebView, RFB-Bytes werden als Base64 über RVS gebrückt (Bridge ↔ QEMU `127.0.0.1:5901`). Du bedienst die VM **live mit Maus/Tastatur**, NAT-sicher, kein offener Port am Host
|
||||
- Text-Chat mit ARIA
|
||||
- **Sprachaufnahme**: Tap-to-Talk (tippen startet, tippen stoppt, Auto-Stop bei Stille via VAD)
|
||||
- **Gespraechsmodus** (Ohr-Button): Nach jeder ARIA-Antwort startet automatisch die Aufnahme — wie ein natuerliches Gespraech hin und her
|
||||
@@ -1010,6 +1044,11 @@ docker exec aria-brain curl localhost:8080/memory/stats
|
||||
- [x] Background Audio Service: TTS, Wake-Word-Lauschen + Aufnahme laufen auch bei minimierter App weiter (Foreground-Service mit mediaPlayback|microphone, dynamische Notification)
|
||||
- [x] Disk-Voll Banner in Diagnostic mit copy-baren Cleanup-Befehlen
|
||||
- [x] Wake-Word on-device via openWakeWord (ONNX Runtime, kein API-Key) + State-Icon
|
||||
- [x] **Desktop-Workbench**: Taskleisten-Dock (Chat · Code · Desktop), Ein-Tap-Panelwechsel im Daumenbereich, Aktivitäts-Badges, keyboard-aware; Kompakt↔Cockpit-Umschalter. (Ersetzte den anfänglichen Pinch-Zoom-Kachel-Canvas — auf 5 Zoll zu fummelig)
|
||||
- [x] **noVNC-Konsole bedienbar**: Tastatur-Einblendung (tippt in die VM), Strg+Alt+Entf, Fit↔1:1
|
||||
- [x] **Live-Code-Editor** für Code-Projekte (WebView, offline, bidirektional) — ARIAs Write/Edit unter `/shared/projects/<id>/` live gespiegelt (`code_file`), eigene Edits zurück (`code_file_edit`)
|
||||
- [x] **QEMU für alle Architekturen** (Host-Helper `aria-vm`, `qemu-setup.sh`) + `set_project_kind`-Tool + Seed-Regel
|
||||
- [x] **VNC-Live-Desktop durch RVS getunnelt** (Bridge RFB-TCP ↔ RVS, noVNC-WebView mit WebSocket-Shim) — VM live mit Maus/Tastatur bedienbar
|
||||
|
||||
### Phase A — Refactor: OpenClaw raus, eigenes Brain rein
|
||||
|
||||
|
||||
+10
-4
@@ -8,11 +8,13 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { AppState, AppStateStatus, PermissionsAndroid, Platform, StatusBar, StyleSheet } from 'react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { NavigationContainer, DefaultTheme } from '@react-navigation/native';
|
||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||
|
||||
import ChatScreen from './src/screens/ChatScreen';
|
||||
import WorkspaceScreen from './src/workspace/WorkspaceScreen';
|
||||
import SettingsScreen from './src/screens/SettingsScreen';
|
||||
import ViewModeToggle from './src/components/ViewModeToggle';
|
||||
import rvs from './src/services/rvs';
|
||||
import { initLogger, installGlobalCrashReporter } from './src/services/logger';
|
||||
import { acquireBackgroundAudio } from './src/services/backgroundAudio';
|
||||
@@ -132,7 +134,7 @@ const App: React.FC = () => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<GestureHandlerRootView style={styles.root}>
|
||||
<StatusBar barStyle="light-content" backgroundColor="#0D0D1A" />
|
||||
<NavigationContainer theme={DarkTheme}>
|
||||
<Tab.Navigator
|
||||
@@ -165,10 +167,11 @@ const App: React.FC = () => {
|
||||
>
|
||||
<Tab.Screen
|
||||
name="Chat"
|
||||
component={ChatScreen}
|
||||
component={WorkspaceScreen}
|
||||
options={{
|
||||
title: 'ARIA Chat',
|
||||
headerTitle: 'ARIA Cockpit',
|
||||
headerRight: () => <ViewModeToggle />,
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
@@ -180,13 +183,16 @@ const App: React.FC = () => {
|
||||
/>
|
||||
</Tab.Navigator>
|
||||
</NavigationContainer>
|
||||
</>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Styles ---
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
backgroundColor: '#12122A',
|
||||
elevation: 0,
|
||||
|
||||
@@ -79,8 +79,8 @@ android {
|
||||
applicationId "com.ariacockpit"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 20107
|
||||
versionName "0.2.1.7"
|
||||
versionCode 20202
|
||||
versionName "0.2.2.2"
|
||||
// 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.7",
|
||||
"version": "0.2.2.2",
|
||||
"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",
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* ViewModeToggle — kleiner Header-Button zum Umschalten zwischen Kompakt-
|
||||
* Ansicht (klassischer Chat) und Cockpit (Kachel-Desktop).
|
||||
*
|
||||
* Sitzt rechts im Navigations-Header ("ARIA Cockpit"), kollidiert also mit
|
||||
* nichts in der Chat-Ansicht. Zeigt das Ziel des naechsten Taps.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { StyleSheet, Text, TouchableOpacity } from 'react-native';
|
||||
import viewMode, { ViewModeValue } from '../services/viewMode';
|
||||
|
||||
const ViewModeToggle: React.FC = () => {
|
||||
const [mode, setMode] = useState<ViewModeValue>(viewMode.get());
|
||||
useEffect(() => viewMode.subscribe(setMode), []);
|
||||
|
||||
const isCockpit = mode === 'cockpit';
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => viewMode.toggle()}
|
||||
style={[styles.pill, isCockpit && styles.pillActive]}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
activeOpacity={0.75}
|
||||
>
|
||||
<Text style={[styles.text, isCockpit && styles.textActive]}>
|
||||
{isCockpit ? '⧉ Cockpit' : '⧉ Kompakt'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
pill: {
|
||||
marginRight: 12,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#1E1E2E',
|
||||
backgroundColor: '#12122A',
|
||||
},
|
||||
pillActive: { borderColor: '#0096FF', backgroundColor: '#0A1F33' },
|
||||
text: { color: '#9090B0', fontSize: 13, fontWeight: '700' },
|
||||
textActive: { color: '#0096FF' },
|
||||
});
|
||||
|
||||
export default ViewModeToggle;
|
||||
@@ -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 {
|
||||
@@ -406,6 +407,22 @@ const ChatScreen: React.FC = () => {
|
||||
const nextClientMsgId = (): string =>
|
||||
`cmsg_${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`;
|
||||
|
||||
// IDs (clientMsgId + audioRequestId), die DIESES Geraet ausgeloest hat.
|
||||
// Fuer den geraete-lokalen Voice-Projektwechsel: ein project_changed vom Brain
|
||||
// traegt die ausloesende ID; nur wenn sie zu einer unserer IDs gehoert, folgt
|
||||
// dieses Geraet dem Wechsel. So wechselt „ARIA, geh in Projekt X" nur das
|
||||
// Geraet, das den Befehl gab — nicht andere App-/Diagnostic-Instanzen.
|
||||
const myRequestIdsRef = useRef<Set<string>>(new Set());
|
||||
const rememberMyRequest = (id: string): void => {
|
||||
if (!id) return;
|
||||
const s = myRequestIdsRef.current;
|
||||
s.add(id);
|
||||
if (s.size > 80) {
|
||||
const oldest = s.values().next().value;
|
||||
if (oldest !== undefined) s.delete(oldest);
|
||||
}
|
||||
};
|
||||
|
||||
// Wie lange wir auf das ACK warten bevor wir retryen. Bridge sollte
|
||||
// unmittelbar zurueckmelden — 30s ist grosszuegig fuer schlechte Netze.
|
||||
// 60s — grosszuegiger als 30s, weil langsame Brain-Calls (Multi-Tool) sonst
|
||||
@@ -461,6 +478,7 @@ const ChatScreen: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
pendingPayloads.current.set(cmid, { type, payload });
|
||||
rememberMyRequest(cmid);
|
||||
const online = connectionStateRef.current === 'connected';
|
||||
if (!online) {
|
||||
updateMessageStatus(cmid, { deliveryStatus: 'queued', sendAttempts: attempt });
|
||||
@@ -525,8 +543,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(() => {});
|
||||
};
|
||||
@@ -554,6 +577,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]);
|
||||
|
||||
@@ -975,10 +1004,23 @@ const ChatScreen: React.FC = () => {
|
||||
if (p.id && p.name) {
|
||||
setProjectNameById(prev => ({ ...prev, [p.id]: p.name }));
|
||||
}
|
||||
if (action === 'entered' || action === 'created') {
|
||||
if (p.id) setFocusedProjectId(p.id);
|
||||
} else if (action === 'exited') {
|
||||
setFocusedProjectId('');
|
||||
// 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);
|
||||
}
|
||||
// Projekt-Fokus ist GERAETE-LOKAL. Ein Broadcast wechselt dieses Geraet
|
||||
// NUR, wenn er durch EINEN EIGENEN Befehl ausgeloest wurde — erkennbar an
|
||||
// der mitgelieferten clientMsgId (unsere gesendete Text-cmid oder Voice-
|
||||
// audioRequestId). So folgt „ARIA, geh in Projekt X" nur dem Geraet, das
|
||||
// den Befehl gab; Diagnostic / andere App-Instanzen bleiben unberuehrt.
|
||||
const trigCmid = typeof p.clientMsgId === 'string' ? p.clientMsgId : '';
|
||||
if (trigCmid && myRequestIdsRef.current.has(trigCmid)) {
|
||||
if (action === 'entered' || action === 'created') {
|
||||
if (p.id) setFocusedProjectId(p.id);
|
||||
} else if (action === 'exited') {
|
||||
setFocusedProjectId('');
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1603,6 +1645,7 @@ const ChatScreen: React.FC = () => {
|
||||
// dem finalen Text, dann wird die Bubble ueber audioRequestId-Match
|
||||
// aktualisiert (siehe chat-Handler oben).
|
||||
const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
|
||||
rememberMyRequest(audioRequestId);
|
||||
const wasInterrupted = interruptAriaIfBusy();
|
||||
const location = await getCurrentLocation();
|
||||
const windowMs = await loadConvWindowMs();
|
||||
@@ -1706,6 +1749,7 @@ const ChatScreen: React.FC = () => {
|
||||
// Kurze Pause damit halt durchgreift, dann neue Aufnahme starten
|
||||
await new Promise(r => setTimeout(r, 150));
|
||||
const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
|
||||
rememberMyRequest(audioRequestId);
|
||||
const location = await getCurrentLocation();
|
||||
const windowMs = await loadConvWindowMs();
|
||||
|
||||
@@ -1782,6 +1826,7 @@ const ChatScreen: React.FC = () => {
|
||||
// useCallback damit der useEffect oben die Funktion stabil capturen kann.
|
||||
const startPassiveStreamingRecording = useCallback(async () => {
|
||||
const audioRequestId = `audio_passive_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
|
||||
rememberMyRequest(audioRequestId);
|
||||
const location = await getCurrentLocation();
|
||||
const passiveMs = await loadPassiveListenMs();
|
||||
const { ok } = await audioService.startStreamingRecording({
|
||||
@@ -2185,6 +2230,7 @@ const ChatScreen: React.FC = () => {
|
||||
// ueber audioRequestId-Match nachgereicht wenn whisper das Endpoint feuert.
|
||||
const handleVoiceButtonStart = useCallback(async (): Promise<boolean> => {
|
||||
const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
|
||||
rememberMyRequest(audioRequestId);
|
||||
const wasInterrupted = interruptAriaIfBusy();
|
||||
const location = await getCurrentLocation();
|
||||
|
||||
|
||||
@@ -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,57 @@
|
||||
/**
|
||||
* viewMode — App-Ansicht: 'compact' (klassischer Vollbild-Chat wie vor 0.2.2.0)
|
||||
* oder 'cockpit' (zoombarer Kachel-Desktop).
|
||||
*
|
||||
* Default 'compact' → fuer normale Nutzung aendert sich nichts (Mama-tauglich).
|
||||
* Umschaltbar ueber den Header-Button; persistiert in AsyncStorage. Muster wie
|
||||
* services/rvs.ts (Singleton mit Listener-Liste).
|
||||
*/
|
||||
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
export type ViewModeValue = 'compact' | 'cockpit';
|
||||
|
||||
const KEY = 'aria_view_mode';
|
||||
type Sub = (mode: ViewModeValue) => void;
|
||||
|
||||
class ViewMode {
|
||||
private mode: ViewModeValue = 'compact';
|
||||
private subs: Sub[] = [];
|
||||
private loaded = false;
|
||||
|
||||
constructor() {
|
||||
AsyncStorage.getItem(KEY).then((v) => {
|
||||
if (v === 'cockpit' || v === 'compact') this.mode = v;
|
||||
this.loaded = true;
|
||||
this.emit();
|
||||
}).catch(() => { this.loaded = true; });
|
||||
}
|
||||
|
||||
get(): ViewModeValue { return this.mode; }
|
||||
isLoaded(): boolean { return this.loaded; }
|
||||
|
||||
set(mode: ViewModeValue): void {
|
||||
if (this.mode === mode) return;
|
||||
this.mode = mode;
|
||||
AsyncStorage.setItem(KEY, mode).catch(() => {});
|
||||
this.emit();
|
||||
}
|
||||
|
||||
toggle(): void {
|
||||
this.set(this.mode === 'compact' ? 'cockpit' : 'compact');
|
||||
}
|
||||
|
||||
subscribe(cb: Sub): () => void {
|
||||
this.subs.push(cb);
|
||||
cb(this.mode);
|
||||
return () => { this.subs = this.subs.filter((s) => s !== cb); };
|
||||
}
|
||||
|
||||
private emit(): void {
|
||||
const m = this.mode;
|
||||
this.subs.forEach((cb) => cb(m));
|
||||
}
|
||||
}
|
||||
|
||||
const viewMode = new ViewMode();
|
||||
export default viewMode;
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* WorkspaceDeck — die Workbench: Vollbild-Panels + Taskleisten-Dock unten.
|
||||
*
|
||||
* Statt einer Zoom-Landkarte: jedes Panel ist bildschirmfuellend und Handy-
|
||||
* optimiert, das Dock wechselt per Daumen-Tap sofort. Alle Panels sind IMMER
|
||||
* gemountet (nur das aktive ist via display sichtbar) → kein Remount, Chat
|
||||
* behaelt RVS/Audio/Queue, WebViews behalten ihren Zustand.
|
||||
*
|
||||
* Bei offener Tastatur blendet das Dock aus (mehr Platz zum Tippen).
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Keyboard, StyleSheet, View } from 'react-native';
|
||||
import { TileId } from './layout';
|
||||
import { useWorkspaceLayout } from './useWorkspaceLayout';
|
||||
import WorkspaceDock from './WorkspaceDock';
|
||||
import ChatTile from './tiles/ChatTile';
|
||||
import CodeEditorTile from './tiles/CodeEditorTile';
|
||||
import VncTile from './tiles/VncTile';
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
panels: TileId[];
|
||||
badges?: Partial<Record<TileId, string>>;
|
||||
}
|
||||
|
||||
const WorkspaceDeck: React.FC<Props> = ({ projectId, panels, badges }) => {
|
||||
const [active, setActive] = useState<TileId>('chat');
|
||||
const [kbVisible, setKbVisible] = useState(false);
|
||||
const { loaded, getFocus, saveFocus } = useWorkspaceLayout(projectId);
|
||||
|
||||
// Aktives Panel pro Projekt wiederherstellen.
|
||||
useEffect(() => {
|
||||
if (!loaded) return;
|
||||
const stored = getFocus();
|
||||
if (stored && panels.includes(stored)) setActive(stored);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [projectId, loaded]);
|
||||
|
||||
// Falls das aktive Panel wegfaellt → erstes nehmen.
|
||||
useEffect(() => {
|
||||
if (!panels.includes(active)) setActive(panels[0] || 'chat');
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [panels.join(',')]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loaded) saveFocus(active);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [active, loaded]);
|
||||
|
||||
useEffect(() => {
|
||||
const s1 = Keyboard.addListener('keyboardDidShow', () => setKbVisible(true));
|
||||
const s2 = Keyboard.addListener('keyboardDidHide', () => setKbVisible(false));
|
||||
return () => { s1.remove(); s2.remove(); };
|
||||
}, []);
|
||||
|
||||
const render = (id: TileId) => {
|
||||
switch (id) {
|
||||
case 'chat': return <ChatTile />;
|
||||
case 'editor': return <CodeEditorTile projectId={projectId} />;
|
||||
case 'vnc': return <VncTile projectId={projectId} focused={active === 'vnc'} />;
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
<View style={styles.stack}>
|
||||
{panels.map((id) => (
|
||||
<View
|
||||
key={id}
|
||||
style={[StyleSheet.absoluteFill, { display: active === id ? 'flex' : 'none' }]}
|
||||
>
|
||||
{render(id)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{!kbVisible && (
|
||||
<WorkspaceDock panels={panels} active={active} badges={badges} onSelect={setActive} />
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: { flex: 1, backgroundColor: '#0D0D1A' },
|
||||
stack: { flex: 1, position: 'relative' },
|
||||
});
|
||||
|
||||
export default WorkspaceDeck;
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* WorkspaceDock — die Taskleiste unten (Daumenzone). Ein Tap wechselt sofort
|
||||
* das Panel; ein animierter Indikator gleitet unter das aktive Icon. Kleine
|
||||
* Aktivitaets-Punkte (Badges) zeigen z.B. „Desktop verbunden" / „Code da".
|
||||
*
|
||||
* Das ist der Kern des Workbench-Gefuehls: Desktop-Umfang, aber Handy-schnell
|
||||
* per Daumen erreichbar — statt einer Zoom-Landkarte.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { LayoutChangeEvent, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { TileId, TILE_META } from './layout';
|
||||
|
||||
interface Props {
|
||||
panels: TileId[];
|
||||
active: TileId;
|
||||
badges?: Partial<Record<TileId, string>>; // TileId → Punkt-Farbe (undefined = kein Punkt)
|
||||
onSelect: (id: TileId) => void;
|
||||
}
|
||||
|
||||
const WorkspaceDock: React.FC<Props> = ({ panels, active, badges, onSelect }) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const [rowW, setRowW] = useState(0);
|
||||
const n = Math.max(1, panels.length);
|
||||
const idx = Math.max(0, panels.indexOf(active));
|
||||
const slot = rowW / n;
|
||||
|
||||
const onLayout = (e: LayoutChangeEvent) => setRowW(e.nativeEvent.layout.width);
|
||||
|
||||
const indicatorStyle = useAnimatedStyle(() => ({
|
||||
width: slot,
|
||||
transform: [{ translateX: withTiming(slot * idx, { duration: 200 }) }],
|
||||
}));
|
||||
|
||||
return (
|
||||
<View style={[styles.dock, { paddingBottom: Math.max(insets.bottom, 6) }]}>
|
||||
<View style={styles.row} onLayout={onLayout}>
|
||||
{rowW > 0 && <Animated.View style={[styles.indicator, indicatorStyle]} pointerEvents="none" />}
|
||||
{panels.map((id) => {
|
||||
const meta = TILE_META[id];
|
||||
const isActive = id === active;
|
||||
const badge = badges?.[id];
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={id}
|
||||
style={styles.item}
|
||||
onPress={() => onSelect(id)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View>
|
||||
<Text style={[styles.icon, isActive && styles.iconActive]}>{meta.icon}</Text>
|
||||
{!!badge && <View style={[styles.badge, { backgroundColor: badge }]} />}
|
||||
</View>
|
||||
<Text style={[styles.label, isActive && styles.labelActive]} numberOfLines={1}>
|
||||
{meta.title}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
dock: {
|
||||
backgroundColor: '#0B0B18',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#1E1E2E',
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
height: 58,
|
||||
position: 'relative',
|
||||
},
|
||||
indicator: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
height: 3,
|
||||
backgroundColor: '#0096FF',
|
||||
borderBottomLeftRadius: 3,
|
||||
borderBottomRightRadius: 3,
|
||||
},
|
||||
item: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 2,
|
||||
},
|
||||
icon: { fontSize: 22, opacity: 0.55 },
|
||||
iconActive: { opacity: 1 },
|
||||
label: { color: '#6A6A85', fontSize: 10, fontWeight: '600' },
|
||||
labelActive: { color: '#0096FF' },
|
||||
badge: {
|
||||
position: 'absolute',
|
||||
top: -2,
|
||||
right: -6,
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
borderWidth: 1,
|
||||
borderColor: '#0B0B18',
|
||||
},
|
||||
});
|
||||
|
||||
export default WorkspaceDock;
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* WorkspaceScreen — Screen-Wrapper fuer die Workbench.
|
||||
*
|
||||
* Kompakt-Modus → klassischer Vollbild-Chat (wie vor dem Umbau).
|
||||
* Cockpit-Modus → Workbench mit Taskleisten-Dock: Chat · Code · Desktop.
|
||||
*
|
||||
* Aktivitaets-Badges am Dock: Editor blau, wenn schon Code-Dateien da sind;
|
||||
* Desktop gruen, wenn eine VM verbunden ist.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import projectFocus, { FocusSnapshot } from '../services/projectFocus';
|
||||
import codeFile from '../services/codeFile';
|
||||
import desktop from '../services/desktop';
|
||||
import viewMode, { ViewModeValue } from '../services/viewMode';
|
||||
import ChatScreen from '../screens/ChatScreen';
|
||||
import { TileId } from './layout';
|
||||
import WorkspaceDeck from './WorkspaceDeck';
|
||||
|
||||
const COCKPIT_PANELS: TileId[] = ['chat', 'editor', 'vnc'];
|
||||
|
||||
const WorkspaceScreen: React.FC = () => {
|
||||
const [mode, setMode] = useState<ViewModeValue>(viewMode.get());
|
||||
const [focus, setFocus] = useState<FocusSnapshot>(projectFocus.get());
|
||||
const [hasCode, setHasCode] = useState(false);
|
||||
const [hasDesktop, setHasDesktop] = useState(false);
|
||||
|
||||
useEffect(() => viewMode.subscribe(setMode), []);
|
||||
useEffect(() => projectFocus.subscribe(setFocus), []);
|
||||
|
||||
const pid = focus.focusedProjectId;
|
||||
const kind = projectFocus.getProjectKind(pid);
|
||||
|
||||
// Code-Signal: hat der Spiegel schon Dateien fuer dieses Projekt?
|
||||
useEffect(() => {
|
||||
setHasCode(codeFile.getFiles(pid).length > 0);
|
||||
return codeFile.subscribe((u) => {
|
||||
if ((u.projectId || '') === (pid || '')) setHasCode(true);
|
||||
});
|
||||
}, [pid]);
|
||||
|
||||
// Desktop-Signal + einmaliger Check beim Betreten eines Code-Projekts.
|
||||
useEffect(() => {
|
||||
setHasDesktop(desktop.getStatus().available);
|
||||
const unsub = desktop.subscribeStatus((s) => setHasDesktop(s.available));
|
||||
if (kind === 'code') desktop.requestCheck(pid);
|
||||
return unsub;
|
||||
}, [pid, kind]);
|
||||
|
||||
const badges = useMemo(() => ({
|
||||
editor: hasCode ? '#0096FF' : undefined,
|
||||
vnc: hasDesktop ? '#34C759' : undefined,
|
||||
} as Partial<Record<TileId, string>>), [hasCode, hasDesktop]);
|
||||
|
||||
// Kompakt-Ansicht: klassischer Vollbild-Chat, exakt wie vor dem Umbau.
|
||||
if (mode === 'compact') {
|
||||
return <ChatScreen />;
|
||||
}
|
||||
|
||||
// Cockpit: Workbench mit Dock.
|
||||
return <WorkspaceDeck projectId={pid} panels={COCKPIT_PANELS} badges={badges} />;
|
||||
};
|
||||
|
||||
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,125 @@
|
||||
/**
|
||||
* 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');
|
||||
|
||||
// Verstecktes Eingabefeld → Software-Tastatur des Handys tippt in die VM.
|
||||
var kbd=document.createElement('input');
|
||||
kbd.setAttribute('autocomplete','off'); kbd.setAttribute('autocorrect','off');
|
||||
kbd.setAttribute('autocapitalize','off'); kbd.spellcheck=false;
|
||||
kbd.style.cssText='position:absolute;left:-1000px;top:0;width:1px;height:1px;opacity:0;';
|
||||
document.body.appendChild(kbd);
|
||||
var SPECIAL={Enter:0xff0d,Backspace:0xff08,Tab:0xff09,Escape:0xff1b,Delete:0xffff,
|
||||
ArrowLeft:0xff51,ArrowUp:0xff52,ArrowRight:0xff53,ArrowDown:0xff54,Home:0xff50,End:0xff57};
|
||||
|
||||
import('https://cdn.jsdelivr.net/npm/@novnc/novnc@1.4.0/core/rfb.js').then(function(mod){
|
||||
var RFB = mod.default;
|
||||
var rfb = new RFB(document.getElementById('screen'), 'ws://aria-vnc/', {});
|
||||
var fit=true;
|
||||
rfb.scaleViewport = true;
|
||||
rfb.clipViewport = false;
|
||||
rfb.addEventListener('connect', function(){ msg.style.display='none'; post({event:'vnc_state', state:'connected'}); });
|
||||
rfb.addEventListener('disconnect', function(e){
|
||||
msg.style.display='block'; msg.textContent='Desktop getrennt';
|
||||
post({event:'vnc_state', state:'disconnected'});
|
||||
});
|
||||
window.__rfb = rfb;
|
||||
|
||||
// Tasten aus dem versteckten Feld an die VM schicken.
|
||||
function tap(keysym, code){ try{ rfb.sendKey(keysym, code||null, true); rfb.sendKey(keysym, code||null, false); }catch(_){} }
|
||||
kbd.addEventListener('keydown', function(e){
|
||||
if(SPECIAL[e.key]!==undefined){ tap(SPECIAL[e.key], e.code); e.preventDefault(); }
|
||||
});
|
||||
kbd.addEventListener('input', function(){
|
||||
var v=kbd.value; for(var i=0;i<v.length;i++){ tap(v.charCodeAt(i)); } kbd.value='';
|
||||
});
|
||||
|
||||
// Steuerungs-API fuer die App (per injectJavaScript).
|
||||
window.ariaVncCtl = {
|
||||
focusKeyboard: function(){ try{ kbd.focus(); }catch(_){} },
|
||||
blurKeyboard: function(){ try{ kbd.blur(); }catch(_){} },
|
||||
cad: function(){ try{ rfb.sendCtrlAltDel(); }catch(_){} },
|
||||
toggleFit: function(){ fit=!fit; rfb.scaleViewport=fit; rfb.clipViewport=!fit; post({event:'vnc_fit', fit:fit}); }
|
||||
};
|
||||
|
||||
post({event:'ready'});
|
||||
}).catch(function(err){
|
||||
msg.textContent='noVNC konnte nicht geladen werden (Internet?)';
|
||||
post({event:'vnc_state', state:'error', error:String(err)});
|
||||
});
|
||||
})();
|
||||
</script></body></html>`;
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* layout — Panel-Definitionen der Workbench (Metadaten fuer das Dock).
|
||||
*/
|
||||
|
||||
export type TileId = 'chat' | 'editor' | 'vnc' | 'preview';
|
||||
|
||||
export interface TileDef { id: TileId; title: string; icon: string }
|
||||
|
||||
export const TILE_META: Record<TileId, TileDef> = {
|
||||
chat: { id: 'chat', title: 'Chat', icon: '💬' },
|
||||
editor: { id: 'editor', title: 'Code', icon: '📝' },
|
||||
vnc: { id: 'vnc', title: 'Desktop', icon: '🖥️' },
|
||||
preview: { id: 'preview', title: 'Vorschau', icon: '🖼️' },
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* ChatTile — hostet die bestehende ChatScreen unveraendert als Workspace-Kachel.
|
||||
*
|
||||
* ChatScreen bleibt genau EINE Instanz (der Workspace-Tab ersetzt den alten
|
||||
* Chat-Tab) und wird nie beim Fokuswechsel remountet — sie liegt in der
|
||||
* Identity-Content-Ebene und wird nur per display ein-/ausgeblendet. So
|
||||
* behaelt sie RVS-Abos, Audio, Queue-State und Keyboard-Verhalten wie bisher.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import ChatScreen from '../../screens/ChatScreen';
|
||||
|
||||
const ChatTile: React.FC = () => <ChatScreen />;
|
||||
|
||||
export default React.memo(ChatTile);
|
||||
@@ -0,0 +1,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,143 @@
|
||||
/**
|
||||
* VncTile — Live-Desktop der QEMU-VM (noVNC in einer WebView, RFB durch RVS).
|
||||
*
|
||||
* Nur aktiv, wenn das Desktop-Panel offen ist (focused): dann WebView mounten,
|
||||
* bei 'ready' den RVS-VNC-Tunnel oeffnen. Eine kleine Steuerungs-Leiste macht
|
||||
* die VM auf dem Handy bedienbar: Tastatur einblenden (tippt in die VM),
|
||||
* Strg-Alt-Entf, und Fit ↔ 1:1 umschalten.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { WebView, WebViewMessageEvent } from 'react-native-webview';
|
||||
import desktop from '../../services/desktop';
|
||||
import { NOVNC_HTML } from '../assets/novncHtml';
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
focused: boolean;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
const teardown = useCallback(() => {
|
||||
if (unsubDataRef.current) { unsubDataRef.current(); unsubDataRef.current = null; }
|
||||
desktop.closeVnc();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focused) { teardown(); setStatus('idle'); }
|
||||
return () => teardown();
|
||||
}, [focused, teardown]);
|
||||
|
||||
const ctl = useCallback((fn: string) => {
|
||||
webRef.current?.injectJavaScript(`window.ariaVncCtl && window.ariaVncCtl.${fn}(); true;`);
|
||||
}, []);
|
||||
|
||||
const onMessage = useCallback((e: WebViewMessageEvent) => {
|
||||
let m: any;
|
||||
try { m = JSON.parse(e.nativeEvent.data); } catch { return; }
|
||||
if (m.event === 'ready') {
|
||||
setStatus('connecting');
|
||||
unsubDataRef.current = desktop.onVncData((b64) => {
|
||||
const js = `window.ariaVnc && window.ariaVnc.onData(${JSON.stringify(b64)}); true;`;
|
||||
webRef.current?.injectJavaScript(js);
|
||||
});
|
||||
desktop.openVnc(projectId);
|
||||
} 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}>Panel öffnen zum Verbinden</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const connected = status === 'connected';
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<WebView
|
||||
ref={webRef}
|
||||
style={styles.web}
|
||||
originWhitelist={['*']}
|
||||
source={{ html: NOVNC_HTML, baseUrl: 'https://aria-vnc.local/' }}
|
||||
onMessage={onMessage}
|
||||
javaScriptEnabled
|
||||
domStorageEnabled
|
||||
mixedContentMode="always"
|
||||
androidLayerType="hardware"
|
||||
keyboardDisplayRequiresUserAction={false}
|
||||
/>
|
||||
|
||||
{/* Steuerungs-Leiste — nur wenn verbunden */}
|
||||
{connected && (
|
||||
<View style={styles.ctlBar}>
|
||||
<TouchableOpacity style={styles.ctlBtn} onPress={() => ctl('focusKeyboard')} activeOpacity={0.7}>
|
||||
<Text style={styles.ctlText}>⌨</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.ctlBtn} onPress={() => ctl('cad')} activeOpacity={0.7}>
|
||||
<Text style={styles.ctlTextSmall}>Strg+Alt+Entf</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.ctlBtn} onPress={() => ctl('toggleFit')} activeOpacity={0.7}>
|
||||
<Text style={styles.ctlText}>⤢</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!connected && (
|
||||
<View style={styles.overlay} pointerEvents="none">
|
||||
<Text style={styles.overlayText}>
|
||||
{status === 'connecting' ? 'Verbinde …' : status === 'disconnected' ? 'Getrennt' : ''}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#000000' },
|
||||
web: { flex: 1, backgroundColor: '#000000' },
|
||||
placeholder: { flex: 1, backgroundColor: '#000000', alignItems: 'center', justifyContent: 'center' },
|
||||
icon: { fontSize: 64, marginBottom: 16 },
|
||||
text: { color: '#FFFFFF', fontSize: 18, fontWeight: '700' },
|
||||
sub: { color: '#9090B0', fontSize: 14, marginTop: 8 },
|
||||
ctlBar: {
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
flexDirection: 'row',
|
||||
gap: 6,
|
||||
},
|
||||
ctlBtn: {
|
||||
backgroundColor: 'rgba(18,18,42,0.9)',
|
||||
borderColor: '#2A2A3E',
|
||||
borderWidth: 1,
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 7,
|
||||
minWidth: 38,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
ctlText: { color: '#E0E0F0', fontSize: 16, fontWeight: '700' },
|
||||
ctlTextSmall: { color: '#E0E0F0', fontSize: 11, fontWeight: '700' },
|
||||
overlay: { position: 'absolute', top: 12, left: 0, right: 0, alignItems: 'center' },
|
||||
overlayText: { color: '#9090B0', fontSize: 12, backgroundColor: 'rgba(0,0,0,0.6)', paddingHorizontal: 10, paddingVertical: 4, borderRadius: 10, overflow: 'hidden' },
|
||||
});
|
||||
|
||||
export default VncTile;
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* useWorkspaceLayout — merkt sich pro Projekt die zuletzt fokussierte Kachel,
|
||||
* damit man beim Zurueckkehren in ein Code-Projekt wieder dort landet (Editor/
|
||||
* Desktop) statt immer im Chat. Persistiert nach AsyncStorage (Muster wie
|
||||
* aria_project_drafts).
|
||||
*/
|
||||
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { TileId } from './layout';
|
||||
|
||||
const KEY = 'aria_workspace_layout';
|
||||
|
||||
interface Entry { focus: TileId | null }
|
||||
type LayoutMap = Record<string, Entry>;
|
||||
|
||||
const keyOf = (projectId: string) => projectId || '__main__';
|
||||
|
||||
export function useWorkspaceLayout(projectId: string) {
|
||||
const mapRef = useRef<LayoutMap>({});
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
AsyncStorage.getItem(KEY).then((v) => {
|
||||
if (v) { try { mapRef.current = JSON.parse(v) || {}; } catch { /* ignore */ } }
|
||||
setLoaded(true);
|
||||
}).catch(() => setLoaded(true));
|
||||
}, []);
|
||||
|
||||
const getFocus = useCallback((): TileId | null | undefined => {
|
||||
return mapRef.current[keyOf(projectId)]?.focus;
|
||||
}, [projectId]);
|
||||
|
||||
const saveFocus = useCallback((focus: TileId | null) => {
|
||||
mapRef.current = { ...mapRef.current, [keyOf(projectId)]: { focus } };
|
||||
AsyncStorage.setItem(KEY, JSON.stringify(mapRef.current)).catch(() => {});
|
||||
}, [projectId]);
|
||||
|
||||
return { loaded, getFocus, saveFocus };
|
||||
}
|
||||
+118
-19
@@ -1045,12 +1045,15 @@ META_TOOLS = [
|
||||
"function": {
|
||||
"name": "project_summary",
|
||||
"description": (
|
||||
"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."
|
||||
"Liest die ECHTE gespeicherte Chat-Historie eines ANDEREN Projekts "
|
||||
"(letzte ~20 Turns aus dem persistierten Verlauf — nicht nur Deinem "
|
||||
"aktuellen Kontextfenster) und fasst sie zusammen. Funktioniert fuer "
|
||||
"JEDES Projekt (per Name, Fuzzy-Match) UND fuer den Hauptchat "
|
||||
"(name='Hauptchat'). Rufe es DIREKT auf, sobald Stefan sagt 'hol dir "
|
||||
"die Infos aus Projekt X', 'schau mal in Projekt Y / den Hauptchat "
|
||||
"rein', 'was war zuletzt bei ...', 'hol mich ab' — antworte NICHT aus "
|
||||
"dem Gedaechtnis, sonst halluzinierst Du. Der Verlauf des Projekts steht "
|
||||
"sicher zur Verfuegung, auch wenn Ihr da lange nicht wart."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
@@ -1080,6 +1083,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"],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -1493,7 +1518,7 @@ class Agent:
|
||||
targs = {}
|
||||
logger.info("[router] lokal Tool-Call: %s(%s)", tname,
|
||||
", ".join(targs.keys()))
|
||||
tresult = self._dispatch_tool(tname, targs)
|
||||
tresult = self._dispatch_tool(tname, targs, active_project_id)
|
||||
# Echter Skill (run_*) erfolgreich? Dann uebernimmt dieser
|
||||
# Turn das speak-Flag des Skills (Steuerbefehl=stumm,
|
||||
# Antwort-Skill=vorlesen). Letzter Skill gewinnt.
|
||||
@@ -1785,7 +1810,7 @@ class Agent:
|
||||
))
|
||||
# Tools ausfuehren + Ergebnis als role=tool zurueck
|
||||
for tc in result.tool_calls:
|
||||
tool_result = self._dispatch_tool(tc["name"], tc["arguments"])
|
||||
tool_result = self._dispatch_tool(tc["name"], tc["arguments"], active_project_id)
|
||||
# Steuerbefehl-Skill (run_*, speak=false) erfolgreich
|
||||
# ausgefuehrt → Antwort nicht vorlesen (letzter Skill gewinnt).
|
||||
_tn = tc.get("name") or ""
|
||||
@@ -1873,10 +1898,52 @@ class Agent:
|
||||
|
||||
# ── Tool-Dispatcher ───────────────────────────────────────
|
||||
|
||||
def _dispatch_tool(self, name: str, arguments: dict) -> str:
|
||||
def _read_project_history(self, project_id: str, limit: int = 20) -> list:
|
||||
"""Liest die VOLLE Chat-Historie eines Projekts aus dem geteilten
|
||||
chat_backup.jsonl (/shared/config, auch im Brain gemountet).
|
||||
|
||||
Noetig, weil das rollende Conversation-Window nur die letzten ~50 Turns
|
||||
ueber ALLE Projekte haelt — aeltere/andere Projekt-Chats sind da laengst
|
||||
rausdistilliert. Gibt die letzten `limit` (role, text)-Paare zurueck,
|
||||
gefiltert auf project_id ('' = Hauptthread). Fuehrende Hint-Bloecke
|
||||
([Standort: ...]) in User-Turns werden entfernt."""
|
||||
path = "/shared/config/chat_backup.jsonl"
|
||||
out: list = []
|
||||
try:
|
||||
if not os.path.exists(path):
|
||||
return []
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
o = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
role = o.get("role")
|
||||
text = o.get("text")
|
||||
if not role or text is None:
|
||||
continue
|
||||
# nur echte Gespraechs-Turns (keine skill/memory/system-Eintraege)
|
||||
if role not in ("user", "assistant", "aria"):
|
||||
continue
|
||||
if (o.get("project_id") or "") != (project_id or ""):
|
||||
continue
|
||||
if role == "user":
|
||||
text = _strip_leading_hint_blocks(text)
|
||||
out.append((role, text))
|
||||
except Exception as exc:
|
||||
logger.warning("project_summary: chat_backup lesen fehlgeschlagen: %s", exc)
|
||||
return []
|
||||
return out[-limit:]
|
||||
|
||||
def _dispatch_tool(self, name: str, arguments: dict, project_id: str = "") -> str:
|
||||
"""Fuehrt einen Tool-Call aus und gibt ein kurzes Text-Resultat zurueck.
|
||||
Niemals werfen — Fehler werden als Text-Resultat reportet damit Claude
|
||||
weitermachen kann."""
|
||||
weitermachen kann. project_id = Projekt DIESES Requests (fuer Tools die
|
||||
aufs aktuelle Projekt wirken, z.B. set_project_kind) — NICHT der globale
|
||||
active_project (geraeteuebergreifend)."""
|
||||
try:
|
||||
if name == "skill_create":
|
||||
# ARIA-Skills sind immer Python — execution ist nicht mehr im Schema
|
||||
@@ -2544,23 +2611,29 @@ class Agent:
|
||||
# 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]
|
||||
target_pid = ""
|
||||
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"]]
|
||||
target_pid = p["id"]
|
||||
label, desc = p["name"], p.get("description", "(keine Beschreibung)")
|
||||
# VOLLE Historie aus chat_backup.jsonl (nicht nur das rollende
|
||||
# Window — sonst fehlen aeltere/andere Projekt-Chats komplett).
|
||||
turns = self._read_project_history(target_pid, limit=20)
|
||||
if not turns:
|
||||
return (f"'{label}' hat im aktuellen Conversation-Window noch keine "
|
||||
f"Turns. {desc}")
|
||||
tail = turns[-12:]
|
||||
# Fallback aufs In-Memory-Window, falls chat_backup fehlt/leer.
|
||||
turns = [(t.role, t.content) for t in self.conversation.turns
|
||||
if (t.project_id or "") == target_pid]
|
||||
if not turns:
|
||||
return (f"'{label}' hat noch keine Chat-Historie. {desc}")
|
||||
tail = turns[-20:]
|
||||
summary_lines = []
|
||||
for t in tail:
|
||||
prefix = "Stefan" if t.role == "user" else "Du"
|
||||
summary_lines.append(f"{prefix}: {t.content[:280]}")
|
||||
preamble = f"'{label}' — {desc}.\nLetzte {len(tail)} Turns:\n"
|
||||
for role, text in tail:
|
||||
prefix = "Stefan" if role == "user" else "Du"
|
||||
summary_lines.append(f"{prefix}: {(text or '')[:280]}")
|
||||
preamble = f"'{label}' — {desc}.\nLetzte {len(tail)} Turns aus der Historie:\n"
|
||||
return preamble + "\n".join(summary_lines)
|
||||
if name == "project_end":
|
||||
pname = (arguments.get("name") or "").strip()
|
||||
@@ -2576,6 +2649,32 @@ 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."
|
||||
# WICHTIG: das Projekt des AKTUELLEN Requests markieren, NICHT den
|
||||
# globalen active_project — der ist geraeteuebergreifend und wuerde
|
||||
# (bei mehreren App-/Diagnostic-Instanzen) das falsche Projekt
|
||||
# treffen. project_id kommt pro Request vom Client.
|
||||
active_id = (project_id or "").strip()
|
||||
if not active_id:
|
||||
return ("Kein aktives Projekt — dieser Chat laeuft im Hauptthread. "
|
||||
"set_project_kind wirkt nur, wenn der Request in einem Projekt "
|
||||
"laeuft (der Client sendet die project_id mit).")
|
||||
updated = projects_mod.update_project(active_id, {"kind": kind})
|
||||
if not updated:
|
||||
return f"FEHLER: 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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
+202
-2
@@ -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] = {}
|
||||
@@ -2002,10 +2007,15 @@ class ARIABridge:
|
||||
elif etype == "project_changed":
|
||||
# ARIA hat ein Projekt erstellt / betreten / verlassen / beendet.
|
||||
# App + Diagnostic refreshen ihren Projekt-Banner anhand des Events.
|
||||
# clientMsgId der ausloesenden Anfrage mitgeben (Text-cmid oder
|
||||
# Voice-audioRequestId) → das ausloesende Geraet kann einen
|
||||
# geraete-lokalen Projektwechsel machen ("geh in Projekt X"),
|
||||
# ohne andere App-/Diagnostic-Instanzen mitzuziehen.
|
||||
await self._send_to_rvs({
|
||||
"type": "project_changed",
|
||||
"payload": {
|
||||
"action": event.get("action") or "",
|
||||
"clientMsgId": client_msg_id or "",
|
||||
**(event.get("project") or {}),
|
||||
},
|
||||
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||
@@ -3351,6 +3361,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
|
||||
@@ -3563,8 +3623,11 @@ class ARIABridge:
|
||||
)
|
||||
|
||||
if meta_action in ("back_to_main", "project_prefix"):
|
||||
# UI-Focus-Update broadcasten
|
||||
payload = {"action": "entered" if meta_action == "project_prefix" else "exited"}
|
||||
# UI-Focus-Update broadcasten — clientMsgId (= audio_request_id der
|
||||
# Aufnahme) mitgeben, damit NUR das Geraet folgt, das den Sprach-
|
||||
# befehl gab (geraete-lokaler Fokus, keine anderen Instanzen ziehen).
|
||||
payload = {"action": "entered" if meta_action == "project_prefix" else "exited",
|
||||
"clientMsgId": audio_request_id or ""}
|
||||
if meta_action == "project_prefix" and project_id:
|
||||
# Namen aus dem Cache holen — best effort
|
||||
try:
|
||||
@@ -4192,6 +4255,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 +4391,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