Compare commits

..

47 Commits

Author SHA1 Message Date
duffyduck 8be34e7284 fix(diagnostic): logBoxes.tts entfernt — Element gibt's nicht mehr, null.addEventListener crashte das ganze JS
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:44:23 +02:00
duffyduck b56cef6298 rename(diagnostic): Pipeline-Tab → Trace (End-to-End-Mitschnitt einer Anfrage)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:41:54 +02:00
duffyduck 0d203af8fb chore(diagnostic): TTS-Diagnose-Tab raus (Voice-Settings sind eh in Einstellungen)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:37:35 +02:00
duffyduck 0468d0e603 release: bump version to 0.1.2.1 2026-05-11 19:28:32 +02:00
duffyduck 7cfc2ba058 release: bump version to 0.1.2.0 2026-05-11 19:27:33 +02:00
duffyduck da795d14f5 release: 0.1.2.0 — Text-Chat fertig, Ping-Pong stabil, OpenClaw als Basis (letzte OpenClaw-Version)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:25:46 +02:00
duffyduck d60c7e9110 docs(issue/readme): heutige Features dokumentiert — ARIA-Files, Reparatur-Buttons, Pinch-Zoom, Auto-Compact
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:20:10 +02:00
duffyduck 83c99a5e65 release: bump version to 0.1.1.9 2026-05-11 19:17:30 +02:00
duffyduck e438bb11ff feat(app): Pinch-Zoom + Pan im Vollbild-Modal
Neue ZoomableImage-Komponente — reine RN-Implementation mit
PanResponder + Animated, ohne extra Dependency.

- 2-Finger-Pinch: Zoom 1x..5x, Focal-Point folgt der Geste
- 1-Finger-Pan: nur aktiv wenn gezoomt, mit Bounds-Clamping
- Doppel-Tap: Toggle 1x ↔ 2.5x

Vollbild-Modal ersetzt das simple <Image> durch ZoomableImage fuer
JPEG/PNG/etc. SVGs bleiben non-zoomable (SvgUri-Limitation), Tap
schliesst sie. Plus dedicated ✕-Close-Button oben rechts da Tap-to-
Close mit PanResponder kollidiert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:15:58 +02:00
duffyduck 8b4f75bf91 fix(diagnostic): /shared/uploads/-Bilder im History-Load auch inline rendern
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:12:59 +02:00
duffyduck d7e7386954 fix(diagnostic): ARIA-Datei-Bubbles ueberleben Browser-Refresh
Beim Page-Reload laedt Diagnostic die Chat-History aus dem OpenClaw-
Session-File. file_from_aria-Events sind nur live-broadcast, nicht im
jsonl gespeichert → nach F5 waren die Anhang-Bubbles weg.

Fix: Server parsed [FILE: ...]-Marker aus assistant-messages beim
History-Load und schickt fuer existierende Files ein "aria_file"-
Message-Stueck mit allen Metadaten (Pfad, MIME, Groesse). Frontend
ruft addAriaFile mit denen, sodass die Bubbles wieder erscheinen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:08:06 +02:00
duffyduck 2100c64b91 release: bump version to 0.1.1.8 2026-05-11 19:04:25 +02:00
duffyduck 74ebf59c6f feat(ui): ARIA-Abkuerzung ausgeschrieben in App-Ueber + Diagnostic-Einstellungen
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:02:30 +02:00
duffyduck 53b49eacad release: bump version to 0.1.1.7 2026-05-11 18:59:57 +02:00
duffyduck 0f11d23c75 fix(bridge): User informieren wenn ARIA Marker fuer nicht-existente Datei setzt
Bridge-Logs zeigten: ARIA setzt zwei Marker (aria_rave2.mid und .mp3),
hat die .mid aber nie wirklich erstellt. Bridge filterte sie silent →
Stefan sah nur eine Bubble und dachte das Marker-System ist kaputt.

Jetzt: _extract_file_markers gibt auch eine Liste der "missing"-Pfade
zurueck, und im Antworttext steht ein Hinweis-Block fuer den User
welche Files versprochen aber nicht erstellt wurden.

Plus System-Prompt geschaerft: ARIA soll vor dem Marker pruefen ob
das File wirklich existiert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 18:41:22 +02:00
duffyduck 311030bdaa fix(diagnostic): [FILE: ...]-Marker-Filter ueberall (in addChat statt nur chat_final)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 18:33:49 +02:00
duffyduck 1e05c66baa release: bump version to 0.1.1.6 2026-05-11 02:25:38 +02:00
duffyduck 4082a6bf2a feat: Auto-Compact nach N User-Messages — verhindert E2BIG bei langer Session
E2BIG (Argument list too long) tritt auf wenn aria-core's Subprocess-
Spawn das Linux argv-Limit (~128KB-2MB) sprengt. Bei >140 Messages
samt Memory + System-Prompt + Tools laeuft das voll, ARIA antwortet
nur noch leer auf jede Anfrage.

Bridge zaehlt jetzt User-Nachrichten in send_to_core; bei COMPACT_AFTER_MESSAGES
(env, default 140) wird automatisch:
- Sessions geleert (rm sessions/*.jsonl + sessions.json = {})
- aria-core neu gestartet
- User informiert "Konversation komprimiert, letzte Nachricht nochmal"

Plus manueller "🧹 Konversation komprimieren"-Button in App-Settings
und 🧹 Compact-Button in Diagnostic-Thinking-Indicator.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:24:30 +02:00
duffyduck 3485642b3e fix(diagnostic): aria-restart ueber Docker-Socket-API statt CLI (Container hat kein docker installiert)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:03:08 +02:00
duffyduck 1240ae3829 release: bump version to 0.1.1.5 2026-05-11 01:59:58 +02:00
duffyduck 2dd4d38dce feat: "ARIA hart neu starten"-Button (docker restart aria-core)
Zweiter Eskalations-Button neben dem Reparieren-Button — fuer Faelle
wo doctor --fix nicht reicht (Run alive aber haengt im Tool-Call).
Mit Confirmation-Dialog damit's nicht versehentlich gedrueckt wird.

Wege:
- App-Settings: Reparatur-Sektion, zwei Buttons (Reparieren + Hart neu)
- App-Thinking-Bubble: 🔧 + 🚨 + Abbrechen
- Diagnostic-Thinking-Indicator: 🔧 + 🚨 + Abbrechen
- Diagnostic-Server: POST /api/aria-restart → child_process exec
  `docker restart aria-core`
- Bridge: rvs aria_restart → HTTP zu Diagnostic

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:58:44 +02:00
duffyduck 7f862ce1f4 release: bump version to 0.1.1.4 2026-05-11 01:48:33 +02:00
duffyduck 528fe97b59 feat: "ARIA reparieren"-Button in App + Diagnostic
Bei stuck OpenClaw-Runs (ARIA antwortet nicht mehr / "Antwort ohne Text"
auf jede Anfrage) kann der User jetzt selbst openclaw doctor --fix
anstossen — ohne SSH/docker exec.

Pfad:
- App-Button → rvs.send('doctor_fix') → Bridge → HTTP POST an
  Diagnostic /api/doctor-fix → dockerExec aria-core
- Diagnostic-Button → direkt HTTP POST /api/doctor-fix

Zwei Plaetze in der App: oben in der Thinking-Bubble (wenn ARIA denkt
aber haengt) und in Settings → Reparatur (immer erreichbar). In
Diagnostic neben dem Abbrechen-Button im Thinking-Indicator.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:46:35 +02:00
duffyduck 3483d1bfce release: bump version to 0.1.1.3 2026-05-10 18:47:10 +02:00
duffyduck 158423c155 fix(app): SVG im Vollbild via SvgUri rendern (statt Image) — preserveAspectRatio damit nicht gestreckt
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:46:13 +02:00
duffyduck 087e91dca1 release: bump version to 0.1.1.2 2026-05-10 18:44:05 +02:00
duffyduck 2de4cbc00f fix(app): SVG-Anhaenge mit SvgUri rendern statt Image
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:42:53 +02:00
duffyduck 03fc465057 fix(app): react-native-svg auf 14.1 (kompatibel mit RN 0.73) — 15.x braucht neuere RN-Version
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:35:15 +02:00
duffyduck b696b47feb feat(app): SVG-Inline-Rendering via react-native-svg
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:33:30 +02:00
duffyduck 6aae565541 docs(prompt): ARIA soll externe Bilder/Files runterladen statt nur verlinken
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:32:16 +02:00
duffyduck 214bd218a0 feat(app): Inline-Bilder in Chat-Nachrichten anzeigen (wie in Diagnostic)
MessageText erkennt http(s)-URLs auf Bilder (jpg/png/gif/webp/bmp/ico)
und rendert sie als <Image> unter dem Text. Markdown-Syntax
![alt](url) wird durch dasselbe Regex erfasst weil die URL drin ist.
SVGs ausgespart — React Native Image kann SVG nicht ohne Extra-Lib.

Aspect-Ratio wird via Image.getSize ermittelt, gecapped auf 0.5..2.5
damit Panorama-/Streifen-Bilder die Bubble nicht sprengen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:30:55 +02:00
duffyduck 2afeee29ee feat(diagnostic): ARIA-Datei-Pfad als kleiner Debug-Footer in der Bubble
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:27:17 +02:00
duffyduck c8dee4c416 fix(diagnostic): [FILE: ...]-Marker aus chat_final rausfiltern
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:26:35 +02:00
duffyduck f49f3c3b08 fix(prompt): File-Marker-Anweisung in BOOTSTRAP.md (echter System-Prompt) statt AGENT.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:23:22 +02:00
duffyduck c4bbb06710 docs(agent): File-Marker-Anweisung deutlich schaerfer formuliert
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:18:35 +02:00
duffyduck 4411cc4fff chore: init.sh — Setup-Script materialisiert *.example zu Config-Dateien
Frisch geclonte Repo / git pull nach .gitignore-Aenderungen lassen
USER.md (und andere Config-Files) fehlen — docker compose up failt
dann beim Bind-Mount. init.sh kopiert idempotent alle *.example zu
ihren Originalen wenn die noch nicht existieren.

Nach git clone und git pull empfohlen: bash init.sh

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:14:21 +02:00
duffyduck 24a91887ef fix(android): FileOpenerModule — kein '*/*' im Source (Kotlin-Lexer-Verwirrung)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:03:59 +02:00
duffyduck 4e62b2919f chore(config): USER.md aus Repo nehmen — enthielt interne Tool-Liste
USER.md hatte Stefan-spezifische Infos (Gitea-URL hackersoft.de,
OpenCRM, STARFACE, RustDesk Tool-Stack). Hat im Repo nichts zu suchen.

USER.md ist jetzt in .gitignore, USER.md.example als Vorlage
eingecheckt. Lokale USER.md bleibt funktional unangetastet — nur
nicht mehr getrackt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:00:45 +02:00
duffyduck fa774156fe docs(agent): ARIA-System-Prompt um File-Marker-Anweisung ergaenzt
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:58:36 +02:00
duffyduck 3b19f05c5b feat: ARIA kann Dateien an User zurueckgeben (PDFs, Bilder, Office-Docs, ...)
ARIA setzt im Antworttext einen Marker `[FILE: /shared/uploads/aria_xxx.ext]`.
Bridge extrahiert ihn (Marker wird aus dem TTS-Text entfernt) und sendet
ein neues file_from_aria-Event ueber RVS an App + Diagnostic.

Diagnostic:
- Eigene Bubble mit Datei-Icon + Klick-Handler
- PDF/Bild → neuer Browser-Tab via /shared/* HTTP-Route
- Andere → Download via download-Attribut

App:
- Neues FileOpenerModule (Kotlin) — Intent.ACTION_VIEW mit FileProvider,
  Android-Picker waehlt App nach MIME-Type
- file_paths.xml erweitert (cache + files + external)
- file_response liefert jetzt mimeType mit
- Klick auf ARIA-Anhang: lokal vorhanden → direkt oeffnen, sonst
  file_request mit autoOpen-Flag → bei Empfang persistAttachment + open

Stefan muss noch im aria-core/OpenClaw System-Prompt einen Hinweis
einbauen: "Wenn du dem User eine Datei erstellt hast (Pfad in
/shared/uploads/), haenge am Ende deiner Antwort einmalig
[FILE: /shared/uploads/aria_<name>.<ext>] an. Der Marker wird aus dem
sichtbaren Text entfernt und als Anhang in App und Diagnostic angezeigt."

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:56:47 +02:00
duffyduck fc3ecaacca docs(issue): heutige Session-Fixes ergaenzt
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:32:05 +02:00
duffyduck 08857093b5 release: bump version to 0.1.0.9 2026-05-10 17:25:48 +02:00
duffyduck 62018b3e51 revert(audio): kickReleaseMedia raus — bricht Spotify's Auto-Resume
Logs zeigen jetzt KEINEN haengenden RNSound-Focus mehr (Library-Version
oder Sound-Lifecycle hat sich geaendert). Der Kick mit AUDIOFOCUS_GAIN
(permanent) sagte Spotify "user hat manuell etwas anderes gestartet" →
Spotify resumed nicht automatisch.

Ohne Kick: unser Focus war AUDIOFOCUS_GAIN_TRANSIENT (USAGE_ASSISTANT) —
beim release bekommt Spotify einen sauberen GAIN nach TRANSIENT-Loss
und resumed automatisch.

Native kickReleaseMedia bleibt fuer den Fall dass es nochmal gebraucht
wird, wird aber nicht mehr gerufen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:24:55 +02:00
duffyduck 89e3a195a3 release: bump version to 0.1.0.8 2026-05-10 17:21:46 +02:00
duffyduck f023ba0ac5 fix(audio): Mute-Button = Stop fuer aktuelle Antwort, nie Resume
Bisheriges Verhalten: Mute drueckt → stopPlayback. Mute zurueck → noch
eingehende chunks der gleichen Antwort starteten einen neuen Stream und
ARIA redete weiter wo sie war. Funktionierte nur 2x weil dann isFinal
schon kam und keine chunks mehr fluten.

Stefan: "Mund verbieten = Stop, fertig". Neue Antworten sollen normal
spielen.

Fix: _stoppedMessageId-Tracking. Bei Mute=true wird die aktuelle msgId
gemerkt — alle weiteren chunks dieser msgId bleiben silent, auch wenn
Mute zurueckgenommen wird. Reset bei neuer msgId.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:20:29 +02:00
duffyduck a0570ef8f7 release: bump version to 0.1.0.7 2026-05-10 17:14:11 +02:00
duffyduck facde1fef7 fix(audio): kickReleaseMedia auch im PCM-Pfad — re-renderte Antworten muteten Spotify dauerhaft
Stefan: ältere Nachrichten (deren Cache-WAV weg ist) gehen ueber
tts_request neu rendern → kommen als PCM-Stream zurueck → werden ueber
PcmStreamPlayer abgespielt. Beim Mute lief stopPlayback aber ohne den
Spotify-resume-Kick weil hadRnSound=false war (kein currentSound).

Jetzt: kickReleaseMedia immer in stopPlayback rufen — kostet nichts,
deckt PCM- und RNSound-Pfad ab.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:13:12 +02:00
22 changed files with 1251 additions and 237 deletions
+4
View File
@@ -13,6 +13,10 @@ aria-data/config/*.env
!aria-data/config/*.env.example
!aria-data/config/openclaw.env
# Privater User-Profile-Snippet (Tool-Stack, interne URLs)
aria-data/config/USER.md
!aria-data/config/USER.md.example
# ── ARIAs Gedächtnis (nur per tar gesichert) ────
aria-data/brain/
+10 -2
View File
@@ -114,6 +114,7 @@ apt install -y docker.io docker-compose-plugin git curl jq
git clone git@gitea.hackersoft.de:aria/aria.git ~/ARIA-AGENT
cd ~/ARIA-AGENT
cp .env.example .env
bash init.sh # legt USER.md aus Vorlage an (idempotent, schadet nicht)
```
`.env` Datei editieren (Details siehe `.env.example`):
@@ -397,10 +398,17 @@ API-Endpoint fuer andere Services: `GET http://localhost:3001/api/session`
- **Mehrere Anhaenge**: Bilder + Dateien sammeln, Text hinzufuegen, dann zusammen senden
- **Paste-Support**: Bilder aus Zwischenablage einfuegen (Diagnostic)
- **Anhaenge**: Bridge speichert in Shared Volume, ARIA kann darauf zugreifen, Re-Download ueber RVS
- **Einstellungen**: TTS-aktiv, F5-TTS-Voice, Pre-Roll-Buffer, Stille-Toleranz, Speicherort, Auto-Download, GPS
- **Einstellungen**: TTS-aktiv, F5-TTS-Voice, Pre-Roll-Buffer, Stille-Toleranz, Speicherort, Auto-Download, GPS, Verbose-Logging
- **Auto-Update**: Prueft beim Start + per Button auf neue Version, Download + Installation ueber RVS (FileProvider)
- GPS-Position (optional)
- GPS-Position (optional, mit Runtime-Permission-Request) — wird in jeden Chat/Audio-Payload mitgegeben und ist in Diagnostic als Debug-Block einblendbar
- QR-Code Scanner fuer Token-Pairing
- **ARIA-Dateien empfangen**: Wenn ARIA eine PDF/Bild/Markdown/ZIP fuer dich erstellt (Marker `[FILE: /shared/uploads/aria_*]` in der Antwort), erscheint sie als eigene Anhang-Bubble. Tippen → wird via RVS geladen + mit Android-Intent-Picker geoeffnet (PDF-Viewer, Bildbetrachter, Standard-App). Inline-Bilder aus Markdown-`![alt](url)`-Syntax werden direkt unter dem Text gerendert (PNG/JPG via Image, SVG via react-native-svg)
- **Vollbild mit Pinch-Zoom**: Bilder im Vollbild-Modal sind pinch-zoombar (1x..5x), 1-Finger-Pan wenn gezoomt, Doppel-Tap toggelt 1x↔2.5x — alles ohne externe Lib
- **ARIA-Reparatur-Buttons** (Settings → Reparatur und in der "ARIA denkt"-Bubble):
- 🔧 *Reparieren*`openclaw doctor --fix` (stuck Runs aufloesen)
- 🚨 *Hart neu starten* → Container-Restart via Docker-API (15s Downtime)
- 🧹 *Konversation komprimieren* → Sessions weg + Restart (Auto-Trigger bei >140 Messages via `COMPACT_AFTER_MESSAGES`-Env in Bridge, schuetzt vor E2BIG-Crash)
- **Cache-Cleanup**: Beim App-Start werden orphane TTS-WAVs aus dem Cache geraeumt. Plus Settings-Buttons "TTS-Cache leeren", "Update-Cache leeren", "Anhang-Cache leeren"
### Wake-Word (openWakeWord, on-device)
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 10006
versionName "0.1.0.6"
versionCode 10201
versionName "0.1.2.1"
// Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general'
}
@@ -7,7 +7,7 @@ import com.facebook.react.uimanager.ViewManager
class ApkInstallerPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
return listOf(ApkInstallerModule(reactContext))
return listOf(ApkInstallerModule(reactContext), FileOpenerModule(reactContext))
}
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
@@ -0,0 +1,55 @@
package com.ariacockpit
import android.content.Intent
import android.net.Uri
import android.os.Build
import androidx.core.content.FileProvider
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import java.io.File
/**
* Oeffnet eine beliebige Datei (PDF, Bild, Office-Doc, ...) mit der vom User
* gewaehlten App via Android-Intent-Picker. Nutzt FileProvider damit auch
* Android 7+ (content:// statt file://) das URI lesen darf.
*
* MIME-Type wird vom Caller bestimmt — App-Auswahl ist davon abhaengig (PDF
* geht an PDF-Viewer, image/jpeg an Galerie, etc.).
*/
class FileOpenerModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
override fun getName() = "FileOpener"
@ReactMethod
fun open(filePath: String, mimeType: String, promise: Promise) {
try {
val cleanPath = filePath.removePrefix("file://")
val file = File(cleanPath)
if (!file.exists()) {
promise.reject("FILE_NOT_FOUND", "Datei nicht gefunden: $cleanPath")
return
}
val context = reactApplicationContext
val uri: Uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
} else {
Uri.fromFile(file)
}
val safeMime = if (mimeType.isBlank()) "application/octet-stream" else mimeType
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, safeMime)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
// Chooser zeigt Android-Auswahl falls mehrere Apps das MIME oeffnen koennen.
val chooser = Intent.createChooser(intent, "Oeffnen mit").apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(chooser)
promise.resolve(true)
} catch (e: Exception) {
promise.reject("OPEN_ERROR", e.message, e)
}
}
}
@@ -1,4 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="cache" path="." />
<files-path name="files" path="." />
<external-path name="external" path="." />
<external-files-path name="external_files" path="." />
<external-cache-path name="external_cache" path="." />
</paths>
+18 -17
View File
@@ -1,6 +1,6 @@
{
"name": "aria-cockpit",
"version": "0.1.0.6",
"version": "0.1.2.1",
"private": true,
"scripts": {
"android": "react-native run-android",
@@ -10,31 +10,32 @@
"build:apk": "cd android && ./gradlew assembleRelease"
},
"dependencies": {
"@react-native-async-storage/async-storage": "^1.21.0",
"@react-native-community/geolocation": "^3.2.1",
"@react-navigation/bottom-tabs": "^6.5.11",
"@react-navigation/native": "^6.1.9",
"react": "18.2.0",
"react-native": "0.73.4",
"@react-navigation/native": "^6.1.9",
"@react-navigation/bottom-tabs": "^6.5.11",
"react-native-screens": "3.27.0",
"react-native-safe-area-context": "^4.8.2",
"react-native-audio-recorder-player": "^3.6.7",
"react-native-camera-kit": "^13.0.0",
"react-native-document-picker": "^9.1.1",
"react-native-sound": "^0.11.2",
"@react-native-community/geolocation": "^3.2.1",
"react-native-fs": "^2.20.0",
"react-native-image-picker": "^7.1.0",
"react-native-permissions": "^4.1.4",
"react-native-camera-kit": "^13.0.0",
"@react-native-async-storage/async-storage": "^1.21.0",
"react-native-fs": "^2.20.0",
"react-native-audio-recorder-player": "^3.6.7"
"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"
},
"devDependencies": {
"typescript": "^5.3.3",
"@react-native/eslint-config": "^0.73.2",
"@react-native/metro-config": "^0.73.5",
"@react-native/typescript-config": "^0.73.1",
"@types/jest": "^29.5.11",
"@types/react": "^18.2.48",
"@types/react-native": "^0.73.0",
"@react-native/eslint-config": "^0.73.2",
"@react-native/typescript-config": "^0.73.1",
"@react-native/metro-config": "^0.73.5",
"metro-react-native-babel-preset": "^0.77.0",
"jest": "^29.7.0",
"@types/jest": "^29.5.11"
"metro-react-native-babel-preset": "^0.77.0",
"typescript": "^5.3.3"
}
}
+67 -5
View File
@@ -1,26 +1,88 @@
/**
* MessageText — selektierbarer Chat-Text mit Android-Auto-Linkifizierung.
* MessageText — selektierbarer Chat-Text mit Android-Auto-Linkifizierung,
* plus Inline-Image-Rendering wenn der Text Bild-URLs enthaelt.
*
* Wir nutzen Androids dataDetectorType="all" (System macht Phone/URL/Email
* - Markdown-Syntax `![alt](url)` und plain `https://...image.png` werden
* erkannt — die URL bleibt im Text sichtbar (klickbar via Linkify),
* zusaetzlich wird das Bild als <Image> oder <SvgUri> drunter gerendert.
* - Wir nutzen Androids dataDetectorType="all" (System macht Phone/URL/Email
* automatisch klickbar) und ein einzelnes <Text selectable> ohne nested
* <Text> mit eigenem onPress. Nested Text mit onPress fingen die Long-Press-
* <Text> mit eigenem onPress Nested Text mit onPress fing die Long-Press-
* Geste ab, damit war Markieren+Kopieren defekt.
*/
import React from 'react';
import { Text, TextStyle, StyleProp } from 'react-native';
import React, { useEffect, useState } from 'react';
import { View, Text, Image, TextStyle, StyleProp } from 'react-native';
import { SvgUri } from 'react-native-svg';
interface Props {
text: string;
style?: StyleProp<TextStyle>;
}
// Bild-URL-Pattern: http(s)://... endend auf gaengige Bild-Endungen.
const IMG_URL_RE = /https?:\/\/[^\s)<"']+\.(?:jpe?g|png|gif|webp|bmp|ico|svg)(?:\?[^\s)<"']*)?/gi;
function extractImageUrls(text: string): string[] {
const urls = new Set<string>();
const matches = text.match(IMG_URL_RE);
if (matches) matches.forEach(u => urls.add(u));
return Array.from(urls);
}
const SVG_RE = /\.svg(?:\?|$)/i;
/** Image mit dynamischer Aspect-Ratio aus echten Bilddimensionen.
* SVGs werden ueber react-native-svg gerendert (kein Image.getSize). */
const InlineImage: React.FC<{ uri: string }> = ({ uri }) => {
const isSvg = SVG_RE.test(uri);
const [aspectRatio, setAspectRatio] = useState<number>(1);
const [failed, setFailed] = useState(false);
useEffect(() => {
if (isSvg) return; // Image.getSize geht fuer SVG nicht
let cancelled = false;
Image.getSize(
uri,
(w, h) => { if (!cancelled && w > 0 && h > 0) setAspectRatio(Math.max(0.5, Math.min(2.5, w / h))); },
() => { if (!cancelled) setFailed(true); },
);
return () => { cancelled = true; };
}, [uri, isSvg]);
if (failed) return null;
if (isSvg) {
return (
<View style={{ marginTop: 8, width: 260, height: 260, backgroundColor: '#0D0D1A', borderRadius: 8, alignItems: 'center', justifyContent: 'center' }}>
<SvgUri uri={uri} width="100%" height="100%" onError={() => setFailed(true)} />
</View>
);
}
return (
<Image
source={{ uri }}
style={{ width: 260, aspectRatio, borderRadius: 8, marginTop: 8, backgroundColor: '#0D0D1A' }}
resizeMode="cover"
onError={() => setFailed(true)}
/>
);
};
const MessageText: React.FC<Props> = ({ text, style }) => {
const imageUrls = extractImageUrls(text || '');
if (imageUrls.length === 0) {
return (
<Text style={style} selectable dataDetectorType="all">
{text}
</Text>
);
}
return (
<View>
<Text style={style} selectable dataDetectorType="all">
{text}
</Text>
{imageUrls.map(u => <InlineImage key={u} uri={u} />)}
</View>
);
};
export default MessageText;
+142
View File
@@ -0,0 +1,142 @@
/**
* ZoomableImage — Pinch-to-Zoom + Pan fuer das Vollbild-Modal.
*
* Reine React-Native-Implementation ohne externe Lib:
* - 1 Finger: Pan wenn schon gezoomt
* - 2 Finger: Pinch fuer Zoom + Pan
* - Doppel-Tap: Toggle 1x ↔ 2.5x Zoom
*
* Scale wird auf [1, 5] gecapped, Translation auf das verfuegbare
* Image-Volumen (kein Out-of-bounds-Pan).
*/
import React, { useRef } from 'react';
import { Animated, PanResponder, View, StyleSheet, ImageStyle, StyleProp } from 'react-native';
interface Props {
uri: string;
containerWidth: number;
containerHeight: number;
style?: StyleProp<ImageStyle>;
}
const ZoomableImage: React.FC<Props> = ({ uri, containerWidth, containerHeight, style }) => {
const scale = useRef(new Animated.Value(1)).current;
const translateX = useRef(new Animated.Value(0)).current;
const translateY = useRef(new Animated.Value(0)).current;
// Aktuelle Werte (Animated.Value lesen ist async, wir tracken parallel)
const current = useRef({ scale: 1, x: 0, y: 0 }).current;
// State beim Geste-Start (touchStart-Snapshot)
const start = useRef({ scale: 1, x: 0, y: 0, distance: 0, focalX: 0, focalY: 0 }).current;
// Doppel-Tap-Erkennung
const lastTapAt = useRef(0);
const distance = (touches: any[]) => {
const [a, b] = touches;
return Math.hypot(a.pageX - b.pageX, a.pageY - b.pageY);
};
const focal = (touches: any[]) => {
const [a, b] = touches;
return { x: (a.pageX + b.pageX) / 2, y: (a.pageY + b.pageY) / 2 };
};
const clamp = (v: number, min: number, max: number) => Math.max(min, Math.min(max, v));
const applyAndClamp = (newScale: number, newX: number, newY: number) => {
const s = clamp(newScale, 1, 5);
// Maximal-Translation: (imgSize * scale - imgSize) / 2
const maxX = Math.max(0, (containerWidth * s - containerWidth) / 2);
const maxY = Math.max(0, (containerHeight * s - containerHeight) / 2);
const x = clamp(newX, -maxX, maxX);
const y = clamp(newY, -maxY, maxY);
current.scale = s;
current.x = x;
current.y = y;
scale.setValue(s);
translateX.setValue(x);
translateY.setValue(y);
};
const responder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: () => true,
onPanResponderGrant: (_e, gestureState) => {
const touches = gestureState as any;
const t = touches.numberActiveTouches || 1;
// Doppel-Tap-Erkennung (nur bei 1 Finger)
if (t === 1) {
const now = Date.now();
if (now - lastTapAt.current < 280) {
// Doppel-Tap → Zoom-Toggle
if (current.scale > 1.1) {
applyAndClamp(1, 0, 0);
} else {
applyAndClamp(2.5, 0, 0);
}
lastTapAt.current = 0;
return;
}
lastTapAt.current = now;
}
start.scale = current.scale;
start.x = current.x;
start.y = current.y;
},
onPanResponderMove: (e, gestureState) => {
const touches = e.nativeEvent.touches;
if (touches.length >= 2) {
// Pinch + Pan
if (start.distance === 0) {
// Initialisiere die Pinch-Referenz beim Uebergang 1→2 Finger
start.distance = distance(touches);
const f = focal(touches);
start.focalX = f.x;
start.focalY = f.y;
start.scale = current.scale;
start.x = current.x;
start.y = current.y;
return;
}
const newDistance = distance(touches);
const newFocal = focal(touches);
const scaleFactor = newDistance / start.distance;
const newScale = clamp(start.scale * scaleFactor, 1, 5);
// Pan-Anteil aus Focal-Bewegung
const newX = start.x + (newFocal.x - start.focalX);
const newY = start.y + (newFocal.y - start.focalY);
applyAndClamp(newScale, newX, newY);
} else if (touches.length === 1 && current.scale > 1.05) {
// Single-Finger-Pan nur wenn gezoomt
start.distance = 0; // Reset Pinch-Tracking
applyAndClamp(current.scale, start.x + gestureState.dx, start.y + gestureState.dy);
}
},
onPanResponderRelease: () => { start.distance = 0; },
onPanResponderTerminate: () => { start.distance = 0; },
}),
).current;
return (
<View style={StyleSheet.absoluteFill} {...responder.panHandlers}>
<Animated.Image
source={{ uri }}
style={[
style,
{
transform: [
{ translateX },
{ translateY },
{ scale },
],
},
]}
resizeMode="contain"
/>
</View>
);
};
export default ZoomableImage;
+124 -15
View File
@@ -20,9 +20,14 @@ import {
Modal,
ToastAndroid,
AppState,
NativeModules,
Alert,
} from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import RNFS from 'react-native-fs';
import { SvgUri } from 'react-native-svg';
import { Dimensions } from 'react-native';
import ZoomableImage from '../components/ZoomableImage';
import rvs, { RVSMessage, ConnectionState } from '../services/rvs';
import audioService from '../services/audio';
import wakeWordService from '../services/wakeword';
@@ -80,6 +85,23 @@ const capMessages = (msgs: ChatMessage[]): ChatMessage[] =>
const DEFAULT_ATTACHMENT_DIR = `${RNFS.DocumentDirectoryPath}/chat_attachments`;
const STORAGE_PATH_KEY = 'aria_attachment_storage_path';
const { FileOpener } = NativeModules as {
FileOpener?: { open: (filePath: string, mimeType: string) => Promise<boolean> };
};
/** Datei mit Android-Intent-Picker oeffnen (System waehlt App nach MIME). */
async function openFileWithIntent(filePath: string, mimeType: string): Promise<void> {
if (!FileOpener) {
ToastAndroid.show('FileOpener Native Module fehlt', ToastAndroid.SHORT);
return;
}
try {
await FileOpener.open(filePath, mimeType || 'application/octet-stream');
} catch (err: any) {
ToastAndroid.show(`Oeffnen fehlgeschlagen: ${err?.message || err}`, ToastAndroid.LONG);
}
}
/** Image-Vorschau in der Chat-Bubble. Misst die echte Bild-Dimension via
* Image.getSize + setzt aspectRatio dynamisch — dadurch passt sich die
* Bubble ans Bild an (kein "Strich" mehr bei breiten oder hohen Bildern). */
@@ -95,7 +117,9 @@ const ChatImage: React.FC<{
onError: () => void;
}> = ({ uri, onPress, onError }) => {
const [aspectRatio, setAspectRatio] = useState<number>(4 / 3);
const isSvg = /\.svg(?:\?|$)/i.test(uri);
useEffect(() => {
if (isSvg) return; // SvgUri hat kein getSize
let cancelled = false;
Image.getSize(uri, (w, h) => {
if (!cancelled && w > 0 && h > 0) {
@@ -106,7 +130,16 @@ const ChatImage: React.FC<{
}
}, () => {});
return () => { cancelled = true; };
}, [uri]);
}, [uri, isSvg]);
if (isSvg) {
return (
<TouchableOpacity onPress={onPress} activeOpacity={0.8}>
<View style={[CHAT_IMAGE_STYLE, { height: 260, alignItems: 'center', justifyContent: 'center' }]}>
<SvgUri uri={uri} width="100%" height="100%" onError={onError} />
</View>
</TouchableOpacity>
);
}
return (
<TouchableOpacity onPress={onPress} activeOpacity={0.8}>
<Image
@@ -179,6 +212,10 @@ const ChatScreen: React.FC = () => {
const flatListRef = useRef<FlatList>(null);
const messageIdCounter = useRef(0);
// ServerPaths fuer die der User auf "oeffnen" geklickt hat — beim
// file_response wird die Datei nach dem Speichern direkt mit dem System-
// Intent geoeffnet (PDF-Viewer, Galerie, etc.).
const autoOpenPaths = useRef<Set<string>>(new Set());
// Eindeutige Message-ID generieren
const nextId = (): string => {
@@ -349,11 +386,32 @@ const ChatScreen: React.FC = () => {
return;
}
// file_from_aria: ARIA hat eine Datei rausgegeben → als ARIA-Bubble anzeigen
if (message.type === 'file_from_aria') {
const p = message.payload || {};
const ariaMsg: ChatMessage = {
id: nextId(),
sender: 'aria',
text: '',
timestamp: Date.now(),
attachments: [{
type: (typeof p.mimeType === 'string' && p.mimeType.startsWith('image/')) ? 'image' : 'file',
name: (p.name as string) || 'datei',
size: (p.size as number) || 0,
mimeType: (p.mimeType as string) || '',
serverPath: (p.serverPath as string) || '',
}],
};
setMessages(prev => capMessages([...prev, ariaMsg]));
return;
}
// file_response: Re-Download von Server — lokal speichern
if (message.type === 'file_response') {
const reqId = (message.payload.requestId as string) || '';
const b64 = (message.payload.base64 as string) || '';
const serverPath = (message.payload.serverPath as string) || '';
const mimeType = (message.payload.mimeType as string) || '';
if (b64 && reqId) {
const fileName = (message.payload.name as string) || 'download';
persistAttachment(b64, reqId, fileName).then(filePath => {
@@ -363,6 +421,11 @@ const ChatScreen: React.FC = () => {
a.serverPath === serverPath ? { ...a, uri: filePath } : a
),
})));
// Wenn der User dieses File explizit oeffnen wollte → Intent-Picker
if (serverPath && autoOpenPaths.current.has(serverPath)) {
autoOpenPaths.current.delete(serverPath);
openFileWithIntent(filePath.replace(/^file:\/\//, ''), mimeType);
}
}).catch(() => {});
}
return;
@@ -1008,7 +1071,22 @@ const ChatScreen: React.FC = () => {
</Text>
</TouchableOpacity>
) : (
<View style={styles.attachmentFile}>
<TouchableOpacity
style={styles.attachmentFile}
onPress={() => {
// Lokal vorhanden \u2192 direkt mit System-Intent oeffnen
if (att.uri) {
openFileWithIntent(att.uri.replace(/^file:\/\//, ''), att.mimeType || '');
return;
}
// Sonst: file_request \u2192 bei file_response wird die Datei
// gespeichert UND geoeffnet (autoOpenPaths-Tracking).
if (att.serverPath) {
autoOpenPaths.current.add(att.serverPath);
rvs.send('file_request' as any, { serverPath: att.serverPath, requestId: item.id });
}
}}
>
<Text style={styles.attachmentFileIcon}>
{att.mimeType?.includes('pdf') ? '\uD83D\uDCC4' :
att.mimeType?.includes('word') || att.mimeType?.includes('document') ? '\uD83D\uDCC3' :
@@ -1018,12 +1096,10 @@ const ChatScreen: React.FC = () => {
<Text style={styles.attachmentFileName} numberOfLines={1}>{att.name}</Text>
{att.size ? <Text style={styles.attachmentFileSize}>{Math.round(att.size / 1024)}KB</Text> : null}
{!att.uri && att.serverPath && (
<TouchableOpacity onPress={() => rvs.send('file_request' as any, { serverPath: att.serverPath, requestId: item.id })}>
<Text style={[styles.attachmentFileSize, {color: '#0096FF'}]}>(laden)</Text>
</TouchableOpacity>
<Text style={[styles.attachmentFileSize, {color: '#0096FF'}]}>(tippen zum oeffnen)</Text>
)}
{!att.uri && !att.serverPath && <Text style={styles.attachmentFileSize}>(nicht verfuegbar)</Text>}
</View>
</TouchableOpacity>
)}
</View>
))}
@@ -1176,10 +1252,30 @@ const ChatScreen: React.FC = () => {
? '\u270D\uFE0F ARIA schreibt...'
: '\uD83D\uDCAD ARIA denkt...'}
</Text>
<View style={{flexDirection: 'row', gap: 6}}>
<TouchableOpacity style={[styles.thinkingCancel, {borderColor: '#FF9500'}]} onPress={() => rvs.send('doctor_fix' as any, {})}>
<Text style={[styles.thinkingCancelText, {color: '#FF9500'}]}>{'🔧'}</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.thinkingCancel, {borderColor: '#FF3B30'}]}
onPress={() => {
Alert.alert(
'ARIA hart neu starten?',
'Container-Restart (~15s). Laufende Anfragen gehen verloren.',
[
{ text: 'Abbrechen', style: 'cancel' },
{ text: 'Neu starten', style: 'destructive', onPress: () => rvs.send('aria_restart' as any, {}) },
],
);
}}
>
<Text style={[styles.thinkingCancelText, {color: '#FF3B30'}]}>{'🚨'}</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.thinkingCancel} onPress={cancelRequest}>
<Text style={styles.thinkingCancelText}>Abbrechen</Text>
</TouchableOpacity>
</View>
</View>
)}
{/* Pending Anhaenge Vorschau */}
@@ -1284,19 +1380,32 @@ const ChatScreen: React.FC = () => {
{/* Bild-Vollbild Modal */}
<Modal visible={!!fullscreenImage} transparent animationType="fade" onRequestClose={() => setFullscreenImage(null)}>
<View style={styles.fullscreenOverlay}>
{fullscreenImage && (
/\.svg(?:\?|$)/i.test(fullscreenImage) ? (
// SVG: bisher keine Pinch-Zoom — Tap zum Schliessen
<TouchableOpacity style={styles.fullscreenImage} activeOpacity={1} onPress={() => setFullscreenImage(null)}>
<SvgUri uri={fullscreenImage} width="100%" height="100%" preserveAspectRatio="xMidYMid meet" />
</TouchableOpacity>
) : (
// Pixel-Bild: Pinch-Zoom + Pan ueber ZoomableImage
<ZoomableImage
uri={fullscreenImage}
containerWidth={Dimensions.get('window').width}
containerHeight={Dimensions.get('window').height}
style={styles.fullscreenImage}
/>
)
)}
{/* Close-Button oben rechts — die TouchableOpacity-uebergreifend funktioniert
wegen ZoomableImage-PanResponder nicht zuverlaessig fuer Tap-to-Close */}
<TouchableOpacity
style={styles.fullscreenOverlay}
activeOpacity={1}
style={{ position: 'absolute', top: 32, right: 16, padding: 12, backgroundColor: 'rgba(0,0,0,0.5)', borderRadius: 24 }}
onPress={() => setFullscreenImage(null)}
>
{fullscreenImage && (
<Image
source={{ uri: fullscreenImage }}
style={styles.fullscreenImage}
resizeMode="contain"
/>
)}
<Text style={{ color: '#FFF', fontSize: 22 }}>{'✕'}</Text>
</TouchableOpacity>
</View>
</Modal>
{/* Datei-Upload Modal */}
+70 -1
View File
@@ -1288,6 +1288,74 @@ const SettingsScreen: React.FC = () => {
</TouchableOpacity>
</View>
{/* === ARIA Reparatur === */}
<Text style={[styles.sectionTitle, {marginTop: 16}]}>Reparatur</Text>
<View style={styles.card}>
<Text style={styles.toggleHint}>
Wenn ARIA gar nicht mehr antwortet oder auf jede Anfrage mit
"Antwort ohne Text" zurueckkommt meistens ein steckengebliebener
Run im aria-core. Dieser Button fuehrt {'“'}openclaw doctor --fix{'”'}
aus und macht ARIA wieder ansprechbar.
</Text>
<TouchableOpacity
style={[styles.clearButton, {marginTop: 8, backgroundColor: 'rgba(255,149,0,0.15)'}]}
onPress={() => {
rvs.send('doctor_fix' as any, {});
ToastAndroid.show('Reparatur-Befehl gesendet — Antwort kommt gleich', ToastAndroid.SHORT);
}}
>
<Text style={[styles.clearButtonText, {color: '#FF9500'}]}>{'🔧 ARIA reparieren'}</Text>
</TouchableOpacity>
<Text style={[styles.toggleHint, {marginTop: 12}]}>
Wenn auch Reparieren nicht hilft Container hart neu starten.
ARIA ist dann ~15 Sekunden weg und kommt mit frischem State zurueck.
Laufende Anfragen gehen verloren.
</Text>
<TouchableOpacity
style={[styles.clearButton, {marginTop: 8, backgroundColor: 'rgba(255,59,48,0.15)'}]}
onPress={() => {
Alert.alert(
'ARIA hart neu starten?',
'Container-Restart (~15s). Laufende Anfragen gehen verloren.',
[
{ text: 'Abbrechen', style: 'cancel' },
{ text: 'Neu starten', style: 'destructive', onPress: () => {
rvs.send('aria_restart' as any, {});
ToastAndroid.show('Container-Restart angestossen…', ToastAndroid.LONG);
}},
],
);
}}
>
<Text style={[styles.clearButtonText, {color: '#FF3B30'}]}>{'🚨 ARIA hart neu starten'}</Text>
</TouchableOpacity>
<Text style={[styles.toggleHint, {marginTop: 12}]}>
Konversation komplett zuruecksetzen alle bisherigen Nachrichten
aus ARIA's Session loeschen + Container neu. Anders als der harte
Restart wird hier auch ARIA's Erinnerung an die laufende
Konversation gewipt. Geschieht automatisch alle 140 Nachrichten
(Bridge-Setting COMPACT_AFTER_MESSAGES).
</Text>
<TouchableOpacity
style={[styles.clearButton, {marginTop: 8, backgroundColor: 'rgba(255,149,0,0.15)'}]}
onPress={() => {
Alert.alert(
'Konversation komprimieren?',
'Alle Nachrichten in ARIAs aktueller Session werden geloescht und der Container neu gestartet. ARIA vergisst den bisherigen Gespraechsverlauf.',
[
{ text: 'Abbrechen', style: 'cancel' },
{ text: 'Komprimieren', style: 'destructive', onPress: () => {
rvs.send('aria_session_reset' as any, {});
ToastAndroid.show('Session wird zurueckgesetzt…', ToastAndroid.LONG);
}},
],
);
}}
>
<Text style={[styles.clearButtonText, {color: '#FF9500'}]}>{'🧹 Konversation komprimieren'}</Text>
</TouchableOpacity>
</View>
</>)}
{/* === Logs === */}
@@ -1401,7 +1469,8 @@ const SettingsScreen: React.FC = () => {
<Text style={styles.aboutTitle}>ARIA Cockpit</Text>
<Text style={styles.aboutVersion}>Version {require('../../package.json').version}</Text>
<Text style={styles.aboutInfo}>
Stefans Kommandozentrale f{'\u00FC'}r ARIA.{'\n'}
ARIA \u2014 Autonomous Reasoning & Intelligence Assistant.{'\n'}
Stefans Kommandozentrale.{'\n'}
Gebaut mit React Native + TypeScript.
</Text>
<TouchableOpacity
+34 -12
View File
@@ -868,11 +868,16 @@ class AudioService {
final?: boolean;
silent?: boolean;
}): Promise<string> {
// _stoppedMessageId: User hat diese Antwort mid-Wiedergabe gestoppt
// (Mute geklickt). Auch wenn Mute jetzt wieder aus ist, soll diese
// Antwort nicht weiterspielen. Erst eine neue messageId resetted das.
const incomingMsgId = payload.messageId || '';
const stoppedByUser = !!this._stoppedMessageId && incomingMsgId === this._stoppedMessageId;
// Globaler Mute-Flag uebersteuert das per-Call silent — verhindert
// Race-Conditions wenn der User zwischen Chunks den Mute-Knopf drueckt.
// _pausedForCall: AudioTrack ist gestoppt waehrend Anruf — Chunks weiter
// sammeln (fuer WAV-Cache), aber NICHT in den Player schicken.
const silent = !!payload.silent || this._muted || this._pausedForCall;
const silent = !!payload.silent || this._muted || this._pausedForCall || stoppedByUser;
if (!silent && !PcmStreamPlayer) {
console.warn('[Audio] PcmStreamPlayer Native Module nicht verfuegbar');
return '';
@@ -913,6 +918,13 @@ class AudioService {
this.pausedMessageId = '';
this.pausedPosition = 0;
}
// Stop-Marker zuruecksetzen wenn neue messageId — neue Antwort darf
// wieder normal abspielen, egal ob Mute zwischendurch aktiv war.
if (this._stoppedMessageId && this._stoppedMessageId !== messageId) {
console.log('[Audio] Neue Antwort (msgId=%s) — Stop-Marker fuer %s zurueckgesetzt',
messageId, this._stoppedMessageId);
this._stoppedMessageId = '';
}
this.pcmStreamActive = true;
this.pcmMessageId = messageId;
this.pcmSampleRate = sampleRate;
@@ -1181,11 +1193,26 @@ class AudioService {
* abgespielt. Wird in pauseForCall gesetzt, in endCallPause/resumeFrom-
* Interruption zurueckgenommen. */
private _pausedForCall: boolean = false;
/** Wenn der User mid-Wiedergabe Mute drueckt: messageId der ABGEBROCHENEN
* Antwort merken. Folge-Chunks dieser msgId werden silent ignoriert, auch
* wenn der User Mute wieder ausschaltet — kein "Resume mid-Antwort". Eine
* NEUE messageId resetted das, dann spielt's wieder normal. */
private _stoppedMessageId: string = '';
setMuted(muted: boolean): void {
console.log('[Audio] setMuted: %s (currentSound=%s pcmStreamActive=%s)',
muted, this.currentSound ? 'aktiv' : 'null', this.pcmStreamActive);
this._muted = muted;
if (muted) this.stopPlayback();
if (muted) {
// Aktuell laufende Antwort als "verworfen" markieren — nachfolgende
// chunks dieser msgId werden silent gehalten auch wenn der User Mute
// gleich wieder ausschaltet. Erst eine NEUE Antwort darf wieder reden.
const activeMsgId = this.pcmMessageId || this.currentPlaybackMsgId;
if (activeMsgId) {
this._stoppedMessageId = activeMsgId;
console.log('[Audio] Antwort %s als gestoppt markiert', activeMsgId);
}
this.stopPlayback();
}
}
isMuted(): boolean { return this._muted; }
@@ -1204,10 +1231,6 @@ class AudioService {
stopBackgroundAudio().catch(() => {});
this.audioQueue = [];
this.isPlaying = false;
// Merken: war ein react-native-sound-Sound aktiv? Dann muessen wir nach
// release() den Focus-Stack aufmischen (RNSound-Bug: stop+release laesst
// den AudioFocusRequest haengen, Spotify resumed sonst nicht).
const hadRnSound = !!(this.currentSound || this.resumeSound || this.preloadedSound);
if (this.currentSound) {
this.currentSound.stop();
this.currentSound.release();
@@ -1233,14 +1256,13 @@ class AudioService {
this.pcmBuffer = [];
this.pcmBytesCollected = 0;
this.pcmMessageId = '';
// Audio-Focus sofort freigeben — User hat explizit abgebrochen
// Audio-Focus sofort freigeben — User hat explizit abgebrochen.
// Unser Focus war TRANSIENT, Spotify resumed darum automatisch beim
// Abandon. Den frueheren kickReleaseMedia haben wir entfernt: er
// requestete USAGE_MEDIA mit GAIN (permanent), was Spotify als
// "user-action stopp" interpretierte und Auto-Resume verhinderte.
this._cancelDeferredFocusRelease();
AudioFocus?.release().catch(() => {});
if (hadRnSound) {
// RNSound's haengender USAGE_MEDIA-Focus aufloesen — sonst bleibt
// Spotify pausiert obwohl unser Focus released ist.
AudioFocus?.kickReleaseMedia?.().catch(() => {});
}
}
// --- Status & Callbacks ---
+53
View File
@@ -52,6 +52,59 @@ Fuer Web-Anfragen: **WebFetch** oder **Bash mit curl**. Niemals sagen "ich habe
4. **Regelmaessig committen** — mit sinnvollen Commit-Messages.
5. **Tageslog fuehren** — was wurde getan, was ist offen.
## Dateien an Stefan zurueckgeben — KRITISCH
**Das ist die EINZIGE Methode wie Stefan an Dateien rankommt. Ohne
diese Schritte sieht und bekommt er die Datei NICHT.**
### Regel 1 — Speicher-Ort
Dateien fuer Stefan AUSSCHLIESSLICH unter `/shared/uploads/` speichern.
NIEMALS in:
- `/home/node/.openclaw/workspace/...` (das ist NUR dein Arbeitsverzeichnis,
Stefan hat keinen Zugriff darauf)
- `/tmp/...`, `/root/...`, oder sonst irgendwo
Dateinamen mit `aria_`-Prefix damit Cleanup-Scripts sie zuordnen koennen:
```
/shared/uploads/aria_<beschreibender_name>.<ext>
```
Beispiele: `aria_termin_zusage.pdf`, `aria_einkaufsliste.md`,
`aria_logs_2026-05-10.zip`.
### Regel 2 — Marker im Antworttext
Am Ende deiner Antwort EINMALIG den Marker setzen:
```
[FILE: /shared/uploads/aria_<name>.<ext>]
```
OHNE diesen Marker erscheint die Datei NICHT in der App / Diagnostic.
Mehrere Dateien: mehrere `[FILE: ...]`-Marker am Ende, jeder in
eigener Zeile.
### Beispiel — kompletter Workflow
User: "Schreib mir ein Lasagne-Rezept als md-Datei"
1. Du schreibst die Datei: `Write` Tool mit Pfad `/shared/uploads/aria_lasagne.md`
2. Antwort an Stefan:
```
Hier dein Lasagne-Rezept — Ragu am Vortag, echter Parmesan,
Ruhezeit nicht skippen. Beim Schichten Bechamel auf jede Lage.
[FILE: /shared/uploads/aria_lasagne.md]
```
Der Marker wird automatisch aus dem sichtbaren Text entfernt und
als Anhang-Bubble angezeigt. Stefan tippt drauf → oeffnet die Datei.
## Stimme
TTS laeuft ueber F5-TTS (Voice Cloning, Gaming-PC). Stefan kann eigene
+91
View File
@@ -78,6 +78,97 @@ Wenn ein Tool nicht klappt, probiere die Alternative. Niemals sagen "ich habe ke
- Destruktive Operationen (Dateien loeschen, Datenbanken droppen)
- Push auf main
## Dateien an Stefan zurueckgeben — KRITISCH
**Das ist die EINZIGE Methode wie Stefan an Dateien rankommt. Ohne diese
Schritte sieht und bekommt er die Datei NICHT.**
### Regel 1 — Speicher-Ort
Dateien fuer Stefan AUSSCHLIESSLICH unter `/shared/uploads/` speichern.
NIEMALS in:
- `/home/node/.openclaw/workspace/...` (NUR dein Arbeitsverzeichnis,
Stefan hat keinen Zugriff)
- `/tmp/...`, `/root/...`, oder sonst irgendwo
Dateinamen mit `aria_`-Prefix:
```
/shared/uploads/aria_<beschreibender_name>.<ext>
```
Beispiele: `aria_termin_zusage.pdf`, `aria_einkaufsliste.md`,
`aria_logs_2026-05-10.zip`.
### Regel 2 — Marker im Antworttext
Am Ende deiner Antwort EINMALIG den Marker setzen:
```
[FILE: /shared/uploads/aria_<name>.<ext>]
```
OHNE diesen Marker erscheint die Datei NICHT in der App / Diagnostic.
Mehrere Dateien: mehrere `[FILE: ...]`-Marker am Ende, jeder in
eigener Zeile.
**WICHTIG — Datei MUSS existieren bevor du den Marker setzt.**
Marker fuer nicht-existente Pfade werden silent gefiltert + Stefan
bekommt einen Hinweis dass du eine Datei versprochen aber nicht
erstellt hast. Wenn du z.B. eine MIDI-Datei nicht generieren kannst,
sag das offen statt nur den Marker zu setzen. Verifiziere zur Not
mit `Bash` + `ls -la /shared/uploads/aria_<name>.<ext>` dass die
Datei wirklich da ist.
### Beispiel — kompletter Workflow
User: "Schreib mir ein Lasagne-Rezept als md-Datei"
1. Du schreibst: `Write` Tool mit Pfad `/shared/uploads/aria_lasagne.md`
2. Antwort an Stefan:
```
Hier dein Lasagne-Rezept — Ragu am Vortag, echter Parmesan,
Ruhezeit nicht skippen. Beim Schichten Bechamel auf jede Lage.
[FILE: /shared/uploads/aria_lasagne.md]
```
Der Marker wird automatisch aus dem sichtbaren Text entfernt und
als Anhang-Bubble angezeigt. Stefan tippt drauf → oeffnet die Datei
im jeweiligen Standard-Programm.
### Externe Bilder/Dateien — IMMER runterladen, nicht nur verlinken
Wenn Stefan ein Bild oder eine Datei aus dem Netz haben will (Wikipedia,
Wiki Commons, ein Beispiel-PDF, etc.):
NICHT NUR die URL in die Antwort schreiben — das Bild ist dann nur
solange sichtbar wie der externe Server lebt.
STATTDESSEN:
1. Mit `Bash` + curl/wget herunterladen nach `/shared/uploads/aria_<name>.<ext>`
2. Mit `[FILE: ...]`-Marker als Anhang ausspielen
Beispiel — User: "Zeig mir ein Bild von Micky Maus"
```bash
curl -sL "https://upload.wikimedia.org/wikipedia/commons/7/7f/Mickey_Mouse.svg" \
-o /shared/uploads/aria_mickey_mouse.svg
```
Antwort:
```
Hier Micky Maus — offizielles SVG von Wikimedia Commons (Public Domain).
[FILE: /shared/uploads/aria_mickey_mouse.svg]
```
So bleibt das Bild permanent im Chat-Verlauf, auch wenn die Wiki-URL
spaeter offline geht oder umgezogen wird.
## Stimme
TTS laeuft ueber F5-TTS auf der Gamebox (Voice Cloning). Stefan kann
@@ -1,10 +1,10 @@
# Stefan — Benutzer-Praeferenzen
# <Username> — Benutzer-Praeferenzen
## Allgemein
- **Sprache:** Deutsch
- **Kommunikation:** Direkt, kein Bullshit, Humor willkommen
- **Rolle:** Chef, Auftraggeber, Entwickler bei HackerSoft Oldenburg
- **Sprache:** <z.B. Deutsch>
- **Kommunikation:** <z.B. Direkt, kein Bullshit, Humor willkommen>
- **Rolle:** <z.B. Chef, Auftraggeber, Entwickler bei XYZ>
## Bestaetigung erforderlich fuer
@@ -12,7 +12,6 @@
- Push auf main
- Aenderungen an Kundensystemen
- Server-Befehle die nicht rueckgaengig gemacht werden koennen
- Windows neu installieren (erst Daten sichern!)
## Autonomes Arbeiten OK fuer
@@ -28,8 +27,10 @@
| Tool | Zweck |
|------|-------|
| **Proxmox** | VM-Infrastruktur (ARIAs Zuhause) |
| **Gitea** | Code-Hosting (gitea.hackersoft.de) |
| **OpenCRM** | Kundenverwaltung |
| **STARFACE** | Telefonie |
| **RustDesk** | Remote IT-Support bei Kunden |
| **<Beispiel-Tool>** | <Zweck> |
<!--
Diese Datei ist eine Vorlage. Lokal als USER.md kopieren und mit
eigenen Praeferenzen + Tool-Stack fuellen. USER.md selbst ist via
.gitignore vom Repo ausgeschlossen.
-->
+182
View File
@@ -16,7 +16,9 @@ import asyncio
import base64
import json
import logging
import mimetypes
import os
import re
import signal
import ssl
import sys
@@ -547,6 +549,12 @@ 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
# User-Message-Counter fuer Auto-Compact. Bei zu langer Konversation
# sprengt die argv-Liste beim Claude-Subprocess-Spawn (E2BIG). Bei
# COMPACT_AFTER erreicht → Sessions reset + Container restart.
# Counter ueberlebt Bridge-Restart nicht (frischer Zaehler beim Start ok).
self._user_message_count: int = 0
self._compact_after = int(os.getenv("COMPACT_AFTER_MESSAGES", "140"))
# Pending Files: wenn die App ein Bild + Text gleichzeitig schickt, kommen
# zwei separate RVS-Events ('file' und 'chat') — wir buffern die Files
# kurz und mergen sie mit dem nachfolgenden Chat-Text zu einer einzigen
@@ -882,6 +890,51 @@ class ARIABridge:
pass
return payload.get("text", "")
# File-Marker-Pattern: `[FILE: /pfad/zur/datei.ext]` (Pfad kann Spaces
# enthalten, Endung beliebig). Mehrfach im Text moeglich.
_FILE_MARKER_RE = re.compile(r"\[FILE:\s*(/shared/uploads/[^\]]+?)\s*\]", re.IGNORECASE)
def _extract_file_markers(self, text: str) -> tuple[str, list[dict], list[str]]:
"""Sucht [FILE: /shared/uploads/...]-Marker.
Returns (cleaned_text, valid_files, missing_paths)."""
files: list[dict] = []
missing: list[str] = []
for m in self._FILE_MARKER_RE.finditer(text):
path = m.group(1).strip()
if not path.startswith("/shared/uploads/"):
logger.warning("[core] FILE-Marker mit unerlaubtem Pfad ignoriert: %s", path)
continue
if not os.path.isfile(path):
logger.warning("[core] FILE-Marker zeigt auf nicht existente Datei: %s", path)
missing.append(path)
continue
name = os.path.basename(path)
mime, _ = mimetypes.guess_type(path)
size = os.path.getsize(path)
files.append({
"serverPath": path,
"name": name,
"mimeType": mime or "application/octet-stream",
"size": size,
})
cleaned = self._FILE_MARKER_RE.sub("", text).strip()
# Zwei aufeinanderfolgende Leerzeilen → eine
cleaned = re.sub(r"\n{3,}", "\n\n", cleaned)
return cleaned, files, missing
async def _broadcast_aria_file(self, file_info: dict) -> None:
"""ARIA hat eine Datei fuer den User erstellt — App+Diagnostic informieren."""
logger.info("[rvs] ARIA-Datei rausgeben: %s (%s, %dKB)",
file_info["name"], file_info["mimeType"], file_info["size"] // 1024)
try:
await self._send_to_rvs({
"type": "file_from_aria",
"payload": file_info,
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
except Exception as e:
logger.warning("[rvs] file_from_aria broadcast fehlgeschlagen: %s", e)
async def _process_core_response(self, text: str, payload: dict) -> None:
"""Verarbeitet eine fertige Antwort von aria-core.
@@ -896,6 +949,22 @@ class ARIABridge:
logger.info("[core] NO_REPLY empfangen — Antwort still verworfen")
return
# File-Marker `[FILE: /shared/uploads/aria_xyz.pdf]` extrahieren —
# ARIA legt damit Dateien fuer den User bereit (Bilder, PDFs, etc.).
# Der Marker wird aus dem Antworttext entfernt (TTS soll ihn nicht
# vorlesen) und parallel als file_from_aria-Event geschickt.
text, aria_files, missing_files = self._extract_file_markers(text)
for f in aria_files:
await self._broadcast_aria_file(f)
# Bei fehlenden Files: User informieren (sonst sieht er nur stille
# Verluste — ARIA hat den Marker hingeschrieben aber das File nicht
# tatsaechlich angelegt).
if missing_files:
missing_list = "\n".join(f"{os.path.basename(p)}" for p in missing_files)
text = (text + "\n\n[Hinweis] Folgende Dateien hat ARIA zwar erwaehnt "
f"aber nicht erstellt:\n{missing_list}\n"
"Bitte ARIA bitten, sie wirklich zu schreiben.").strip()
metadata = payload.get("metadata", {})
is_critical = metadata.get("critical", False)
requested_voice = metadata.get("voice")
@@ -1118,12 +1187,53 @@ class ARIABridge:
await self.send_to_core(text, source="app-file+chat")
return True
async def _trigger_session_reset(self) -> None:
"""Sessions loeschen + Container restart via Diagnostic HTTP-API."""
try:
req = urllib.request.Request(
"http://localhost:3001/api/aria-session-reset",
data=b"{}",
method="POST",
headers={"Content-Type": "application/json"},
)
def _do_reset():
try:
with urllib.request.urlopen(req, timeout=45) as resp:
return resp.status
except Exception as e:
return f"err:{e}"
result = await asyncio.get_event_loop().run_in_executor(None, _do_reset)
logger.info("[core] Session-Reset Result: %s", result)
except Exception as e:
logger.warning("[core] Session-Reset Trigger fehlgeschlagen: %s", e)
async def send_to_core(self, text: str, source: str = "bridge") -> None:
"""Sendet Text an aria-core (OpenClaw chat.send Protokoll)."""
if self.ws_core is None:
logger.error("[core] Nicht verbunden — Nachricht verworfen: '%s'", text[:60])
return
# Auto-Compact: bei zu vielen User-Messages laeuft argv beim Subprocess-
# Spawn ueber (E2BIG). Vor send pruefen, ggf. Sessions resetten.
if source.startswith("app") and self._compact_after > 0:
self._user_message_count += 1
if self._user_message_count >= self._compact_after:
logger.warning("[core] Auto-Compact: %d Messages erreicht — Session-Reset",
self._user_message_count)
self._user_message_count = 0
# Reset triggern via Diagnostic (asynchron, blockiert send nicht)
asyncio.create_task(self._trigger_session_reset())
# User informieren — der naechste Request kommt erst nach Restart durch
await self._send_to_rvs({
"type": "chat",
"payload": {
"text": f"[Compact] Konversation war lang ({self._compact_after} Nachrichten) — Session wurde geleert, ARIA startet frisch. Deine letzte Nachricht bitte gleich nochmal senden.",
"sender": "aria",
},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
return
# Aktive Session vom Diagnostic holen
self._fetch_active_session()
@@ -1528,6 +1638,76 @@ class ARIABridge:
except Exception as e:
logger.warning("[rvs] file_saved konnte nicht an App gesendet werden: %s", e)
elif msg_type == "aria_session_reset":
# Manueller Compact-Trigger: Sessions weg + Restart
logger.warning("[rvs] aria_session_reset Request von App")
self._user_message_count = 0
asyncio.create_task(self._trigger_session_reset())
return
elif msg_type == "aria_restart":
# App-Button "ARIA hart neu starten" → docker restart aria-core
# via Diagnostic (der hat den Docker-Socket gemountet).
logger.warning("[rvs] aria_restart Request von App — harter Container-Restart")
try:
req = urllib.request.Request(
"http://localhost:3001/api/aria-restart",
data=b"{}",
method="POST",
headers={"Content-Type": "application/json"},
)
def _do_restart():
try:
with urllib.request.urlopen(req, timeout=45) as resp:
return resp.status, resp.read().decode("utf-8", errors="ignore")
except Exception as e:
return None, str(e)
status, body = await asyncio.get_event_loop().run_in_executor(None, _do_restart)
logger.info("[rvs] aria_restart Result: status=%s", status)
# Note: bei erfolgreichem Restart ist die RVS-Verbindung sehr
# wahrscheinlich kurz weg (aria-bridge ist im service:aria-Network).
# Die Antwort kommt evtl. nicht mehr durch — egal.
except Exception as e:
logger.warning("[rvs] aria_restart Weiterleitung fehlgeschlagen: %s", e)
return
elif msg_type == "doctor_fix":
# App-Button "ARIA reparieren" → openclaw doctor --fix anstossen.
# Bridge erreicht aria-core nicht via docker (kein docker-socket
# gemountet), aber der Diagnostic-Server hat den Socket. HTTP-Call
# an http://localhost:3001/api/doctor-fix.
logger.info("[rvs] doctor_fix Request von App — leite an Diagnostic weiter")
try:
req = urllib.request.Request(
"http://localhost:3001/api/doctor-fix",
data=b"{}",
method="POST",
headers={"Content-Type": "application/json"},
)
# Blocking call ist OK weil openclaw doctor schnell durchlaeuft.
# In Executor laufen lassen damit der asyncio-Loop nicht blockt.
def _do_fix():
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return resp.status, resp.read().decode("utf-8", errors="ignore")
except Exception as e:
return None, str(e)
status, body = await asyncio.get_event_loop().run_in_executor(None, _do_fix)
ok = status == 200
logger.info("[rvs] doctor_fix Result: status=%s ok=%s", status, ok)
await self._send_to_rvs({
"type": "chat",
"payload": {
"text": "[Reparatur] ARIA wurde durchgecheckt — sollte wieder antworten." if ok
else f"[Reparatur] Fehlgeschlagen: {body[:200]}",
"sender": "aria",
},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
except Exception as e:
logger.warning("[rvs] doctor_fix Weiterleitung fehlgeschlagen: %s", e)
return
elif msg_type == "file_request":
# App fordert eine Datei an (Re-Download nach Cache-Leerung)
server_path = payload.get("serverPath", "")
@@ -1545,6 +1725,7 @@ class ARIABridge:
return
with open(server_path, "rb") as f:
file_b64 = base64.b64encode(f.read()).decode("ascii")
mime, _ = mimetypes.guess_type(server_path)
logger.info("[rvs] Re-Download: %s (%dKB)", server_path, len(file_b64) // 1365)
await self._send_to_rvs({
"type": "file_response",
@@ -1553,6 +1734,7 @@ class ARIABridge:
"serverPath": server_path,
"base64": file_b64,
"name": os.path.basename(server_path),
"mimeType": mime or "application/octet-stream",
},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
+130 -83
View File
@@ -57,10 +57,10 @@
.log-entry.warn { color: #FFD60A; }
.log-entry.info { color: #AAB; }
.log-entry.debug { color: #555570; }
.log-entry.pipeline-step { color: #0096FF; border-left: 2px solid #0096FF; padding-left: 6px; margin: 2px 0; }
.log-entry.pipeline-ok { color: #34C759; border-left: 2px solid #34C759; padding-left: 6px; margin: 2px 0; }
.log-entry.pipeline-err { color: #FF3B30; border-left: 2px solid #FF3B30; padding-left: 6px; margin: 2px 0; }
.log-entry.pipeline-sep { color: #333; margin: 6px 0 2px; }
.log-entry.trace-step { color: #0096FF; border-left: 2px solid #0096FF; padding-left: 6px; margin: 2px 0; }
.log-entry.trace-ok { color: #34C759; border-left: 2px solid #34C759; padding-left: 6px; margin: 2px 0; }
.log-entry.trace-err { color: #FF3B30; border-left: 2px solid #FF3B30; padding-left: 6px; margin: 2px 0; }
.log-entry.trace-sep { color: #333; margin: 6px 0 2px; }
.chat-box { background: #080810; border: 1px solid #1E1E2E; border-radius: 6px;
min-height: 120px; max-height: 250px; overflow-y: auto;
@@ -288,8 +288,13 @@
<div class="chat-box" id="chat-box"></div>
<div id="thinking-indicator" style="display:none;padding:6px 10px;font-size:12px;color:#FFD60A;background:#1E1E2E;border-radius:0 0 6px 6px;margin-top:-8px;margin-bottom:8px;align-items:center;justify-content:space-between;">
<span><span style="animation:pulse 1s infinite;">&#x1F4AD;</span> <span id="thinking-text">ARIA denkt...</span></span>
<div style="display:flex;gap:6px;">
<button class="btn secondary" onclick="doctorFix()" style="padding:2px 10px;font-size:11px;color:#FF9500;border-color:#FF9500;" title="ARIA reparieren — openclaw doctor --fix">&#x1F527; Reparieren</button>
<button class="btn secondary" onclick="ariaSessionReset()" style="padding:2px 10px;font-size:11px;color:#FF9500;border-color:#FF9500;" title="Konversation komprimieren — Sessions weg + Restart">&#x1F9F9; Compact</button>
<button class="btn secondary" onclick="ariaRestart()" style="padding:2px 10px;font-size:11px;color:#FF3B30;border-color:#FF3B30;" title="Container hart neu starten (~15s)">&#x1F6A8; Hart neu</button>
<button class="btn secondary" onclick="cancelRequest()" style="padding:2px 10px;font-size:11px;color:#FF3B30;border-color:#FF3B30;">Abbrechen</button>
</div>
</div>
<div id="diag-pending-attachments" style="display:none;padding:6px 10px;background:#1E1E2E;border-radius:6px 6px 0 0;margin-bottom:-4px;display:flex;gap:6px;flex-wrap:wrap;align-items:center;">
</div>
<div class="input-row">
@@ -374,8 +379,7 @@
<button class="tab-btn" data-tab="proxy" onclick="switchTab('proxy')">Proxy <span class="tab-count" id="count-proxy">0</span></button>
<button class="tab-btn" data-tab="bridge" onclick="switchTab('bridge')">Bridge <span class="tab-count" id="count-bridge">0</span></button>
<button class="tab-btn" data-tab="server" onclick="switchTab('server')">Server <span class="tab-count" id="count-server">0</span></button>
<button class="tab-btn" data-tab="pipeline" onclick="switchTab('pipeline')" style="margin-left:auto;border-color:#0096FF44;color:#0096FF">Pipeline <span class="tab-count" id="count-pipeline">0</span></button>
<button class="tab-btn" data-tab="tts" onclick="switchTab('tts')" style="border-color:#34C75944;color:#34C759">TTS</button>
<button class="tab-btn" data-tab="trace" onclick="switchTab('trace')" style="margin-left:auto;border-color:#0096FF44;color:#0096FF" title="End-to-End-Mitschnitt einer einzelnen Anfrage mit Zeitstempeln">Trace <span class="tab-count" id="count-trace">0</span></button>
</div>
</div>
<div class="log-panel">
@@ -394,28 +398,7 @@
<div class="log-box hidden" id="log-proxy"></div>
<div class="log-box hidden" id="log-bridge"></div>
<div class="log-box hidden" id="log-server"></div>
<div class="log-box hidden" id="log-pipeline"></div>
<div class="log-box hidden" id="log-tts" style="padding:12px;">
<h3 style="color:#34C759;margin:0 0 12px;">TTS Diagnose (XTTS)</h3>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:12px;">
<div style="background:#1E1E2E;padding:8px;border-radius:6px;">
<div style="color:#8888AA;font-size:10px;text-transform:uppercase;">Status</div>
<div style="font-size:14px;margin-top:4px;" id="tts-status">Unbekannt</div>
</div>
<div style="background:#1E1E2E;padding:8px;border-radius:6px;">
<div style="color:#8888AA;font-size:10px;text-transform:uppercase;">Letzter Fehler</div>
<div style="color:#FF6B6B;font-size:12px;margin-top:4px;word-break:break-all;" id="tts-last-error">-</div>
</div>
</div>
<div style="margin-bottom:8px;">
<input type="text" id="tts-test-text" value="Hallo Stefan, ich bin ARIA." placeholder="Test-Text..." style="background:#1E1E2E;border:1px solid #2A2A3E;border-radius:6px;padding:8px;color:#fff;font-size:13px;width:100%;box-sizing:border-box;">
</div>
<div style="display:flex;gap:8px;">
<button class="btn" onclick="testTTS('')" style="flex:1;">XTTS testen</button>
<button class="btn secondary" onclick="checkTTSStatus()" style="flex:1;">Status pruefen</button>
</div>
<div id="tts-log" style="margin-top:12px;max-height:200px;overflow-y:auto;font-size:11px;font-family:monospace;color:#8888AA;"></div>
</div>
<div class="log-box hidden" id="log-trace"></div>
</div>
</div>
@@ -454,6 +437,14 @@
<!-- ══════ TAB: Einstellungen ══════ -->
<div id="tab-settings" class="main-tab">
<!-- Was ist ARIA? -->
<div class="settings-section">
<div class="card" style="max-width:700px;font-size:13px;color:#AAA;border-left:3px solid #0096FF;">
<strong style="color:#0096FF;">ARIA</strong> — Autonomous Reasoning &amp; Intelligence Assistant.
Selbst gehosteter JARVIS-artiger KI-Assistent, gebaut von Stefan / HackerSoft Oldenburg.
</div>
</div>
<!-- Betriebsmodus -->
<div class="settings-section">
<h2>Betriebsmodus</h2>
@@ -738,8 +729,8 @@
let ws;
let activeTab = 'all';
const DOCKER_TABS = ['gateway', 'proxy', 'bridge'];
const autoScroll = { all: true, gateway: true, rvs: true, proxy: true, bridge: true, server: true, pipeline: true };
const logCounts = { all: 0, gateway: 0, rvs: 0, proxy: 0, bridge: 0, server: 0, pipeline: 0 };
const autoScroll = { all: true, gateway: true, rvs: true, proxy: true, bridge: true, server: true, trace: true };
const logCounts = { all: 0, gateway: 0, rvs: 0, proxy: 0, bridge: 0, server: 0, trace: 0 };
const logBoxes = {
all: document.getElementById('log-all'),
@@ -748,8 +739,7 @@
proxy: document.getElementById('log-proxy'),
bridge: document.getElementById('log-bridge'),
server: document.getElementById('log-server'),
pipeline: document.getElementById('log-pipeline'),
tts: document.getElementById('log-tts'),
trace: document.getElementById('log-trace'),
};
// Scroll-Pause pro aktivem Tab
@@ -803,7 +793,7 @@
if (source === 'proxy') return 'proxy';
if (source === 'bridge') return 'bridge';
if (source === 'server' || source === 'browser') return 'server';
if (source === 'pipeline') return 'pipeline';
if (source === 'trace') return 'trace';
return null;
}
@@ -843,27 +833,6 @@
if (msg.type === 'state') { updateState(msg.state); return; }
if (msg.type === 'log') { addLog(msg.entry.level, msg.entry.source, msg.entry.message, msg.entry.ts); return; }
if (msg.type === 'tts_result') {
if (msg.ok) {
ttsLog(`\u2705 ${msg.voice}: ${msg.duration}ms, ${msg.size} bytes`);
document.getElementById('tts-status').textContent = 'OK';
document.getElementById('tts-status').style.color = '#34C759';
} else {
ttsLog(`\u274C Fehler: ${msg.error}`);
document.getElementById('tts-status').textContent = 'Fehler';
document.getElementById('tts-status').style.color = '#FF3B30';
document.getElementById('tts-last-error').textContent = msg.error;
}
return;
}
if (msg.type === 'tts_status') {
document.getElementById('tts-status').textContent = msg.ok ? 'OK' : 'Fehler';
document.getElementById('tts-status').style.color = msg.ok ? '#34C759' : '#FF3B30';
if (msg.error) { document.getElementById('tts-last-error').textContent = msg.error; ttsLog(`Fehler: ${msg.error}`); }
else { document.getElementById('tts-last-error').textContent = '-'; ttsLog('TTS OK'); }
return;
}
if (msg.type === 'agent_activity') {
updateThinkingIndicator(msg);
return;
@@ -993,7 +962,12 @@
}
if (msg.type === 'chat_final') {
addChat('received', msg.text, 'chat:final');
addChat('received', msg.text || '', 'chat:final');
return;
}
if (msg.type === 'file_from_aria') {
const p = msg.payload || {};
addAriaFile(p);
return;
}
if (msg.type === 'chat_delta') { return; }
@@ -1080,10 +1054,23 @@
chatBox.innerHTML = '';
if (msg.messages && msg.messages.length > 0) {
for (const m of msg.messages) {
if (m.type === 'aria_file') {
// ARIA-Datei-Bubble rekonstruieren (statt addAriaFile damit
// kein Auto-Scroll-Race waehrend des Bulk-Loads)
addAriaFile({ serverPath: m.serverPath, name: m.name, mimeType: m.mimeType, size: m.size });
continue;
}
const el = document.createElement('div');
el.className = `chat-msg ${m.type}`;
const escaped = escapeHtml(m.text);
const linked = linkifyText(escaped);
// [FILE: ...]-Marker rausfiltern (gleicher Filter wie addChat)
const cleaned = (m.text || '').replace(/\[FILE:\s*\/shared\/uploads\/[^\]]+\]/gi, '').replace(/\n{3,}/g, '\n\n').trim();
const escaped = escapeHtml(cleaned);
let linked = linkifyText(escaped);
// /shared/uploads/-Bildpfade auch im History inline rendern
// (gleicher Replace wie in addChat — sonst sieht man nach F5 nur Text-Pfade)
linked = linked.replace(/\/shared\/uploads\/[^\s<"]+\.(jpg|jpeg|png|gif|webp|svg|bmp)/gi, (match) => {
return `<a href="${match}" target="_blank">${match}</a><img src="${match}" class="chat-media" onclick="openLightbox('image','${match}')" onerror="this.style.display='none'">`;
});
const time = m.ts ? new Date(m.ts).toLocaleTimeString('de-DE') : '?';
el.innerHTML = `${linked}<div class="meta">${escapeHtml(m.meta)} — ${time}</div>`;
chatBox.appendChild(el);
@@ -1345,12 +1332,12 @@
const time = ts ? new Date(ts).toLocaleTimeString('de-DE') : new Date().toLocaleTimeString('de-DE');
const line = `${time} [${source}] ${message}`;
// Pipeline-Eintraege nur in Pipeline-Tab (nicht in Alle)
if (source === 'pipeline') {
const pipeLevel = level === 'error' ? 'pipeline-err' : level === 'info' && message.includes('>>>') ? 'pipeline-ok' : 'pipeline-step';
appendToLog('pipeline', pipeLevel, `${time} ${message}`);
logCounts.pipeline++;
document.getElementById('count-pipeline').textContent = logCounts.pipeline;
// Trace-Eintraege nur in Trace-Tab (nicht in Alle)
if (source === 'trace') {
const pipeLevel = level === 'error' ? 'trace-err' : level === 'info' && message.includes('>>>') ? 'trace-ok' : 'trace-step';
appendToLog('trace', pipeLevel, `${time} ${message}`);
logCounts.trace++;
document.getElementById('count-trace').textContent = logCounts.trace;
return;
}
@@ -1434,6 +1421,10 @@
}
function addChat(type, text, meta, options) {
// [FILE: /shared/uploads/aria_xxx.ext]-Marker aus dem Antworttext entfernen —
// die Datei kommt separat via file_from_aria-Event als eigene Bubble.
// /gi entfernt mehrere Marker, falls ARIA mehrere Dateien in einer Antwort liefert.
if (text) text = text.replace(/\[FILE:\s*\/shared\/uploads\/[^\]]+\]/gi, '').replace(/\n{3,}/g, '\n\n').trim();
const escaped = escapeHtml(text);
let linked = linkifyText(escaped);
// /shared/uploads/ Pfade als Inline-Bilder anzeigen
@@ -1475,6 +1466,41 @@
}
}
/** ARIA hat eine Datei rausgegeben — als eigene Bubble mit Klick-Handler. */
function addAriaFile(p) {
const name = p.name || 'datei';
const serverPath = p.serverPath || '';
const mimeType = p.mimeType || '';
const sizeKB = p.size ? Math.round(p.size / 1024) : 0;
const isImage = mimeType.startsWith('image/');
const isPdf = mimeType === 'application/pdf';
const url = serverPath; // Diagnostic-Server liefert /shared/* aus
const sizeStr = sizeKB > 1024 ? `${(sizeKB/1024).toFixed(1)}MB` : `${sizeKB}KB`;
const icon = isImage ? '🖼️' : isPdf ? '📄' : '📎';
// PDFs/Bilder: target=_blank → neuer Tab. Andere: download-Attribut.
const linkAttrs = (isImage || isPdf)
? `href="${url}" target="_blank" rel="noopener"`
: `href="${url}" download="${escapeHtml(name)}"`;
let preview = '';
if (isImage) {
preview = `<img src="${url}" class="chat-media" onclick="openLightbox('image','${url}')" onerror="this.style.display='none'" style="margin-top:6px;">`;
}
const html = `<div style="font-weight:bold;">${icon} ARIA hat eine Datei erstellt</div>` +
`<a ${linkAttrs} style="color:#0096FF;text-decoration:underline;">${escapeHtml(name)}</a>` +
` <span style="color:#888;font-size:11px;">(${escapeHtml(mimeType)}, ${sizeStr})</span>` +
preview +
`<div style="margin-top:4px;font-size:10px;color:#666;font-family:monospace;">${escapeHtml(serverPath)}</div>` +
`<div class="meta">ARIA-Datei — ${new Date().toLocaleTimeString('de-DE')}</div>`;
for (const box of [chatBox, document.getElementById('chat-box-fs')]) {
if (!box) continue;
const el = document.createElement('div');
el.className = 'chat-msg received';
el.innerHTML = html;
box.appendChild(el);
box.scrollTop = box.scrollHeight;
}
}
let chatFullscreen = false;
function toggleChatFullscreen() {
const modal = document.getElementById('chat-fullscreen');
@@ -1813,6 +1839,47 @@
renderDiagPending();
}
// ── Reparieren — openclaw doctor --fix ──────
function doctorFix() {
fetch('/api/doctor-fix', { method: 'POST' })
.then(r => r.json())
.then(data => {
if (data.ok) {
addLog('info', 'server', 'Reparatur ausgefuehrt: ' + (data.output || 'OK').slice(0, 200));
} else {
addLog('error', 'server', 'Reparatur fehlgeschlagen: ' + (data.error || ''));
}
})
.catch(err => addLog('error', 'server', 'Reparatur Request fehlgeschlagen: ' + err.message));
}
// ── Hard-Restart — docker restart aria-core ──────
function ariaRestart() {
if (!confirm('ARIA wird hart neu gestartet (Container-Restart, ~15s).\n\nLaufende Anfragen gehen verloren. Sicher?')) return;
fetch('/api/aria-restart', { method: 'POST' })
.then(r => r.json())
.then(data => {
if (data.ok) {
addLog('info', 'server', 'ARIA neu gestartet — wartet auf Reconnect');
} else {
addLog('error', 'server', 'Restart fehlgeschlagen: ' + (data.error || ''));
}
})
.catch(err => addLog('error', 'server', 'Restart Request fehlgeschlagen: ' + err.message));
}
// ── Compact / Session-Reset ──────
function ariaSessionReset() {
if (!confirm('Konversation komprimieren: alle Nachrichten in ARIAs aktueller Session werden geloescht und der Container neu gestartet. ARIA vergisst den bisherigen Gespraechsverlauf. Sicher?')) return;
fetch('/api/aria-session-reset', { method: 'POST' })
.then(r => r.json())
.then(data => {
if (data.ok) addLog('info', 'server', 'Session geleert, ARIA neu gestartet');
else addLog('error', 'server', 'Reset fehlgeschlagen: ' + (data.error || ''));
})
.catch(err => addLog('error', 'server', 'Reset Request fehlgeschlagen: ' + err.message));
}
// ── Abbrechen ──────────────────────────────
function cancelRequest() {
send({ action: 'cancel_request' });
@@ -1978,26 +2045,6 @@
send({ action: 'set_mode', mode });
}
// ── TTS Diagnose ─────────────────────────────
function ttsLog(msg) {
const el = document.getElementById('tts-log');
const time = new Date().toLocaleTimeString('de-DE');
el.innerHTML += `<div>[${time}] ${escapeHtml(msg)}</div>`;
el.scrollTop = el.scrollHeight;
}
function testTTS(voice) {
const text = document.getElementById('tts-test-text').value.trim();
if (!text) return;
ttsLog(`Teste ${voice}: "${text}"...`);
send({ action: 'test_tts', voice, text });
}
function checkTTSStatus() {
ttsLog('Pruefe TTS-Status...');
send({ action: 'check_tts' });
}
function openLightbox(mediaType, url) {
const lb = document.getElementById('lightbox');
if (mediaType === 'video') {
+167 -77
View File
@@ -113,9 +113,9 @@ let rvsWs = null;
let reqIdCounter = 0;
const browserClients = new Set();
// ── Pipeline Tracking ──────────────────────────────────
let pipelineActive = false;
let pipelineStartTime = 0;
// ── Trace-Tracking (End-to-End-Mitschnitt einer Anfrage) ──────────────────────────────────
let traceActive = false;
let traceStartTime = 0;
// Nach chat:final kommen oft noch Trailing Agent-Events. Waehrend dieses
// Fensters unterdruecken wir agent_activity-Broadcasts, damit der
@@ -124,40 +124,40 @@ let lastChatFinalAt = 0;
const SETTLED_WINDOW_MS = 3000;
function plog(message, level) {
const elapsed = pipelineActive ? `+${Date.now() - pipelineStartTime}ms` : "";
const entry = { ts: new Date().toISOString(), level: level || "info", source: "pipeline", message: `${elapsed ? `[${elapsed}] ` : ""}${message}` };
const elapsed = traceActive ? `+${Date.now() - traceStartTime}ms` : "";
const entry = { ts: new Date().toISOString(), level: level || "info", source: "trace", message: `${elapsed ? `[${elapsed}] ` : ""}${message}` };
logs.push(entry);
if (logs.length > 500) logs.shift();
console.log(`[PIPELINE] ${entry.message}`);
console.log(`[TRACE] ${entry.message}`);
broadcast({ type: "log", entry });
}
let pipelineTimeout = null;
let traceTimeout = null;
function pipelineStart(method, text) {
// Falls noch eine Pipeline laeuft, beenden
if (pipelineActive) pipelineEnd(false, "Abgebrochen (neue Nachricht)");
pipelineActive = true;
pipelineStartTime = Date.now();
if (pipelineTimeout) clearTimeout(pipelineTimeout);
pipelineTimeout = setTimeout(() => {
if (pipelineActive) pipelineEnd(false, "Timeout — keine Antwort nach 10min");
function traceStart(method, text) {
// Falls noch ein Trace laeuft, beenden
if (traceActive) traceEnd(false, "Abgebrochen (neue Nachricht)");
traceActive = true;
traceStartTime = Date.now();
if (traceTimeout) clearTimeout(traceTimeout);
traceTimeout = setTimeout(() => {
if (traceActive) traceEnd(false, "Timeout — keine Antwort nach 10min");
}, 600000);
plog(`━━━ Pipeline Start: ${method} ━━━`);
plog(`━━━ Trace Start: ${method} ━━━`);
plog(`Nachricht: "${text}"`);
}
function pipelineEnd(ok, detail) {
if (!pipelineActive) return;
if (pipelineTimeout) { clearTimeout(pipelineTimeout); pipelineTimeout = null; }
const elapsed = Date.now() - pipelineStartTime;
function traceEnd(ok, detail) {
if (!traceActive) return;
if (traceTimeout) { clearTimeout(traceTimeout); traceTimeout = null; }
const elapsed = Date.now() - traceStartTime;
if (ok) {
plog(`>>> Fertig (${elapsed}ms): ${detail}`);
} else {
plog(`>>> FEHLER (${elapsed}ms): ${detail}`, "error");
}
plog(`━━━ Pipeline Ende ━━━`);
pipelineActive = false;
plog(`━━━ Trace Ende ━━━`);
traceActive = false;
// Thinking-Indikator IMMER zuruecksetzen — auch bei Timeout/Fehler/Abbruch
broadcast({ type: "agent_activity", activity: "idle" });
pendingMessageTime = 0;
@@ -327,8 +327,8 @@ async function connectGateway() {
state.gateway.handshakeOk = false;
gatewayWs = null;
broadcastState();
// Stuck "ARIA denkt..." vermeiden, falls Gateway waehrend Pipeline abkackt
if (pipelineActive) pipelineEnd(false, `Gateway-Verbindung verloren (${code})`);
// Stuck "ARIA denkt..." vermeiden, falls Gateway waehrend Trace abkackt
if (traceActive) traceEnd(false, `Gateway-Verbindung verloren (${code})`);
else broadcast({ type: "agent_activity", activity: "idle" });
checkGatewayHealth();
setTimeout(connectGateway, 5000);
@@ -375,7 +375,7 @@ function handleGatewayMessage(msg) {
if (msg.type === "res") {
const status = msg.ok ? "OK" : `FEHLER: ${JSON.stringify(msg.error).slice(0, 100)}`;
log("info", "gateway", `Response [${msg.id}]: ${status}`);
if (pipelineActive) {
if (traceActive) {
if (msg.ok) plog(`Gateway ACK [${msg.id}] — Nachricht angenommen`);
else plog(`Gateway NACK [${msg.id}] — ${JSON.stringify(msg.error).slice(0, 100)}`, "error");
}
@@ -427,12 +427,12 @@ function handleGatewayMessage(msg) {
if (runId && seenFinalRuns.has(runId)) return; // Duplikat
if (runId) { seenFinalRuns.add(runId); setTimeout(() => seenFinalRuns.delete(runId), 60000); }
// NO_REPLY → ARIA signalisiert "nicht antworten", Pipeline beenden aber nichts zeigen
// NO_REPLY → ARIA signalisiert "nicht antworten", Trace beenden aber nichts zeigen
const trimmed = (text || "").trim().replace(/^["'`*.\s]+|["'`*.\s]+$/g, "").toUpperCase();
if (trimmed === "NO_REPLY" || trimmed.startsWith("NO_REPLY")) {
log("info", "gateway", "NO_REPLY empfangen — still verworfen");
lastChatFinalAt = Date.now();
if (pipelineActive) pipelineEnd(true, "NO_REPLY (stumm)");
if (traceActive) traceEnd(true, "NO_REPLY (stumm)");
broadcast({ type: "agent_activity", activity: "idle" });
pendingMessageTime = 0;
updateAgentActivity();
@@ -441,7 +441,7 @@ function handleGatewayMessage(msg) {
log("info", "gateway", `ANTWORT: "${text.slice(0, 200)}"`);
lastChatFinalAt = Date.now();
if (pipelineActive) pipelineEnd(true, `"${text.slice(0, 120)}"`);
if (traceActive) traceEnd(true, `"${text.slice(0, 120)}"`);
broadcast({ type: "chat_final", text, payload });
broadcast({ type: "agent_activity", activity: "idle" });
pendingMessageTime = 0; // Watchdog: Antwort erhalten
@@ -462,7 +462,7 @@ function handleGatewayMessage(msg) {
if (state === "error") {
const error = payload.error || text || "Unbekannt";
log("error", "gateway", `Chat-Fehler: ${error}`);
if (pipelineActive) pipelineEnd(false, error);
if (traceActive) traceEnd(false, error);
else broadcast({ type: "agent_activity", activity: "idle" });
broadcast({ type: "chat_error", error, payload });
return;
@@ -485,7 +485,7 @@ function handleGatewayMessage(msg) {
const text = extractChatText(payload) || payload.text || "";
log("info", "gateway", `ANTWORT: "${text.slice(0, 200)}"`);
lastChatFinalAt = Date.now();
if (pipelineActive) pipelineEnd(true, `"${text.slice(0, 120)}"`);
if (traceActive) traceEnd(true, `"${text.slice(0, 120)}"`);
else broadcast({ type: "agent_activity", activity: "idle" });
broadcast({ type: "chat_final", text, payload });
return;
@@ -493,7 +493,7 @@ function handleGatewayMessage(msg) {
if (event === "chat:error") {
const error = payload.error || payload.message || "Unbekannt";
log("error", "gateway", `Chat-Fehler: ${error}`);
if (pipelineActive) pipelineEnd(false, error);
if (traceActive) traceEnd(false, error);
else broadcast({ type: "agent_activity", activity: "idle" });
broadcast({ type: "chat_error", error, payload });
return;
@@ -505,10 +505,10 @@ function handleGatewayMessage(msg) {
}
}
function sendToGateway(text, isPipeline) {
function sendToGateway(text, isTrace) {
if (!gatewayWs || gatewayWs.readyState !== WebSocket.OPEN) {
log("error", "gateway", "Nicht verbunden — kann nicht senden");
if (isPipeline) pipelineEnd(false, "Gateway nicht verbunden");
if (isTrace) traceEnd(false, "Gateway nicht verbunden");
return false;
}
@@ -535,7 +535,7 @@ function sendToGateway(text, isPipeline) {
fs.appendFileSync("/shared/config/chat_backup.jsonl", entry);
} catch {}
log("info", "gateway", `chat.send [${reqId}]: "${text}"`);
if (isPipeline) plog(`chat.send [${reqId}] an Gateway gesendet — warte auf ACK...`);
if (isTrace) plog(`chat.send [${reqId}] an Gateway gesendet — warte auf ACK...`);
// Gateway-Nachrichten NICHT an RVS senden (sonst doppelter ARIA-Request via Bridge)
return true;
@@ -605,8 +605,8 @@ function connectRVS(forcePlain) {
// Eigene Nachrichten ignorieren (Echo)
if (sender === "diagnostic") return;
log("info", "rvs", `Chat von ${sender}: "${(msg.payload.text || "").slice(0, 100)}"`);
if (pipelineActive) {
pipelineEnd(true, `Antwort via RVS von ${sender}: "${(msg.payload.text || "").slice(0, 120)}"`);
if (traceActive) {
traceEnd(true, `Antwort via RVS von ${sender}: "${(msg.payload.text || "").slice(0, 120)}"`);
}
broadcast({ type: "rvs_chat", msg });
} else if (msg.type === "file_saved" && msg.payload) {
@@ -620,6 +620,11 @@ function connectRVS(forcePlain) {
type: "chat",
payload: { text: `Anhang: ${name}\n${serverPath}`, sender: "user" }
}});
} else if (msg.type === "file_from_aria" && msg.payload) {
// ARIA hat eine Datei fuer den User erstellt — im Chat als Anhang anzeigen
const p = msg.payload;
log("info", "rvs", `ARIA-Datei: ${p.name} (${p.mimeType}, ${(p.size||0)/1024|0}KB)`);
broadcast({ type: "file_from_aria", payload: p });
} else if (msg.type === "heartbeat") {
// ignorieren
} else if (msg.type === "mode") {
@@ -739,14 +744,14 @@ function sendToRVS_raw(msgObj) {
freshWs.on("error", () => {});
}
function sendToRVS(text, isPipeline) {
function sendToRVS(text, isTrace) {
// Ueber Gateway senden (zuverlaessig) UND an RVS fuer App-Sichtbarkeit
// Die Bridge empfaengt RVS-Nachrichten von der App zuverlaessig,
// aber die Diagnostic→RVS→Bridge Route hat Zombie-Probleme.
// Deshalb: Gateway fuer ARIA, RVS nur fuer App-Anzeige.
// 1. An Gateway senden (damit ARIA antwortet)
const gatewayOk = sendToGateway(text, isPipeline);
const gatewayOk = sendToGateway(text, isTrace);
// 2. An RVS senden (damit die App die Nachricht sieht)
sendToRVS_raw({
@@ -1332,11 +1337,102 @@ const server = http.createServer((req, res) => {
pendingMessageTime = 0;
watchdogWarned = false;
watchdogFixAttempted = false;
if (pipelineActive) pipelineEnd(false, "Vom Benutzer abgebrochen (App)");
if (traceActive) traceEnd(false, "Vom Benutzer abgebrochen (App)");
else broadcast({ type: "agent_activity", activity: "idle" });
dockerExec("aria-core", "openclaw doctor --fix 2>/dev/null || true").catch(() => {});
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true }));
} else if (req.url === "/api/doctor-fix" && req.method === "POST") {
// Manueller "ARIA reparieren"-Button — stuck OpenClaw-Runs aufloesen.
log("info", "server", "HTTP /api/doctor-fix — manueller Reparatur-Trigger");
dockerExec("aria-core", "openclaw doctor --fix 2>&1")
.then(out => {
const summary = (out || "").split("\n").filter(l => l.trim()).slice(-3).join(" | ");
broadcast({ type: "watchdog", status: "fixed", message: `Reparatur ausgefuehrt: ${summary || "OK"}` });
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, output: out }));
})
.catch(err => {
broadcast({ type: "watchdog", status: "error", message: `Reparatur fehlgeschlagen: ${err.message}` });
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: err.message }));
});
return;
} else if (req.url === "/api/aria-session-reset" && req.method === "POST") {
// Sessions weg + Container neu — fuer Compact-After-N-Messages.
// E2BIG bei zu langen Sessions: argv beim Subprocess-spawn ueberschritten.
log("warn", "server", "HTTP /api/aria-session-reset — Sessions loeschen + Restart");
broadcast({ type: "watchdog", status: "fixing", message: "Sessions werden geleert — ARIA bekommt frischen Start" });
dockerExec("aria-core", "rm -f /home/node/.openclaw/agents/main/sessions/*.jsonl /home/node/.openclaw/agents/main/sessions/*.lock 2>&1 && echo '{}' > /home/node/.openclaw/agents/main/sessions/sessions.json")
.then(() => {
// Restart via Docker-API (gleicher Pfad wie /api/aria-restart)
const restartReq = http.request({
socketPath: "/var/run/docker.sock",
path: "/containers/aria-core/restart?t=10",
method: "POST",
headers: { "Content-Length": 0 },
timeout: 30000,
}, (dRes) => {
if (dRes.statusCode === 204) {
log("info", "server", "aria-session-reset OK");
broadcast({ type: "watchdog", status: "fixed", message: "Sessions geleert, ARIA neu gestartet" });
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true }));
} else {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: `Docker-API ${dRes.statusCode}` }));
}
});
restartReq.on("error", (err) => {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: err.message }));
});
restartReq.end();
})
.catch((err) => {
log("error", "server", `aria-session-reset Cleanup fehlgeschlagen: ${err.message}`);
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: err.message }));
});
return;
} else if (req.url === "/api/aria-restart" && req.method === "POST") {
// Harter Restart — fuer Faelle wo doctor --fix nicht reicht (alive aber
// haengender Run). Geht ueber Docker-API (Socket), kein CLI noetig.
// POST /containers/aria-core/restart?t=10 → SIGTERM, dann nach 10s SIGKILL.
log("warn", "server", "HTTP /api/aria-restart — harter Container-Restart");
broadcast({ type: "watchdog", status: "fixing", message: "ARIA wird hart neu gestartet (~15s)" });
const restartReq = http.request({
socketPath: "/var/run/docker.sock",
path: "/containers/aria-core/restart?t=10",
method: "POST",
headers: { "Content-Length": 0 },
timeout: 30000,
}, (dRes) => {
let body = "";
dRes.on("data", (c) => body += c);
dRes.on("end", () => {
// Docker-API: 204 = OK, 404 = container nicht da, 500 = anderer Fehler
if (dRes.statusCode === 204) {
log("info", "server", "aria-restart OK (Docker-API)");
broadcast({ type: "watchdog", status: "fixed", message: "ARIA wurde neu gestartet — sollte gleich antworten" });
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true }));
} else {
log("error", "server", `aria-restart Docker-API ${dRes.statusCode}: ${body.slice(0, 200)}`);
broadcast({ type: "watchdog", status: "error", message: `Restart fehlgeschlagen: HTTP ${dRes.statusCode}` });
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: `Docker-API ${dRes.statusCode}: ${body}` }));
}
});
});
restartReq.on("error", (err) => {
log("error", "server", `aria-restart Socket-Fehler: ${err.message}`);
broadcast({ type: "watchdog", status: "error", message: `Restart fehlgeschlagen: ${err.message}` });
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: err.message }));
});
restartReq.end();
return;
} else if (req.url.startsWith("/shared/")) {
// Dateien aus Shared Volume ausliefern (Bilder, Uploads)
const filePath = decodeURIComponent(req.url);
@@ -1379,10 +1475,10 @@ wss.on("connection", (ws) => {
const msg = JSON.parse(raw.toString());
if (msg.action === "test_gateway") {
pipelineStart("Gateway", msg.text || "aria lebst du noch?");
traceStart("Gateway", msg.text || "aria lebst du noch?");
sendToGateway(msg.text || "aria lebst du noch?", true);
} else if (msg.action === "test_rvs") {
pipelineStart("RVS", msg.text || "aria lebst du noch?");
traceStart("RVS", msg.text || "aria lebst du noch?");
sendToRVS(msg.text || "aria lebst du noch?", true);
} else if (msg.action === "reconnect_gateway") {
connectGateway();
@@ -1425,7 +1521,7 @@ wss.on("connection", (ws) => {
pendingMessageTime = 0;
watchdogWarned = false;
watchdogFixAttempted = false;
if (pipelineActive) pipelineEnd(false, "Vom Benutzer abgebrochen");
if (traceActive) traceEnd(false, "Vom Benutzer abgebrochen");
broadcast({ type: "agent_activity", activity: "idle" });
dockerExec("aria-core", "openclaw doctor --fix 2>/dev/null || true").catch(() => {});
} else if (msg.action === "voice_upload") {
@@ -1475,12 +1571,8 @@ wss.on("connection", (ws) => {
} catch {}
sendToRVS_raw({ type: "config", payload: voiceConfig, timestamp: Date.now() });
log("info", "server", `Voice-Config gespeichert: xttsVoice=${voiceConfig.xttsVoice || "default"}, whisper=${voiceConfig.whisperModel || "-"}`);
} else if (msg.action === "test_tts") {
handleTestTTS(ws, msg.text || "Test");
} else if (msg.action === "preview_voice") {
handleVoicePreview(ws, msg.voice || "", msg.text || "Hallo.", msg.speed);
} else if (msg.action === "check_tts") {
handleCheckTTS(ws);
} else if (msg.action === "check_desktop") {
checkDesktopAvailable(ws);
} else if (msg.action === "load_chat_history") {
@@ -1718,36 +1810,6 @@ async function handleVoicePreview(clientWs, voice, text, speed) {
}
}
async function handleTestTTS(clientWs, text) {
try {
log("info", "server", `TTS-Test via XTTS: "${text}"`);
// Via RVS an die XTTS-Bridge: xtts_request mit Test-Text
const requestId = crypto.randomUUID();
sendToRVS_raw({
type: "xtts_request",
payload: { text, language: "de", requestId, voice: "" },
timestamp: Date.now(),
});
clientWs.send(JSON.stringify({ type: "tts_result", ok: true, duration: "pending", size: "?" }));
} catch (err) {
clientWs.send(JSON.stringify({ type: "tts_result", ok: false, error: err.message }));
}
}
async function handleCheckTTS(clientWs) {
try {
// XTTS-Status ueber RVS abfragen (xtts_list_voices)
sendToRVS_raw({ type: "xtts_list_voices", payload: {}, timestamp: Date.now() });
clientWs.send(JSON.stringify({
type: "tts_status",
ok: true,
error: null,
}));
} catch (err) {
clientWs.send(JSON.stringify({ type: "tts_status", ok: false, error: err.message }));
}
}
function checkDesktopAvailable(clientWs) {
// Pruefen ob VNC auf der VM laeuft (Port 5900/5901)
const checkSock = net.connect({ host: "host.docker.internal", port: 5901 }, () => {
@@ -2135,7 +2197,35 @@ async function handleLoadChatHistory(clientWs) {
} else if (role === "assistant") {
// Reply-Prefix entfernen: "[[reply_to_current]] "
text = text.replace(/^\[\[reply_to_\w+\]\]\s*/g, "").trim();
if (text) chatMessages.push({ type: "received", text, meta: "chat:final", ts: msg.timestamp || obj.timestamp || 0 });
const ts = msg.timestamp || obj.timestamp || 0;
// ARIA-File-Marker aus dem Text parsen — pro existierender Datei
// eine separate file_from_aria-aehnliche Message einfuegen damit die
// Anhang-Bubble nach Browser-Refresh wieder erscheint.
const fileRe = /\[FILE:\s*(\/shared\/uploads\/[^\]]+?)\s*\]/gi;
let m;
while ((m = fileRe.exec(text)) !== null) {
const p = m[1].trim();
try {
if (fs.existsSync(p)) {
const st = fs.statSync(p);
const ext = path.extname(p).toLowerCase();
const mimeMap = { ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".gif": "image/gif",
".webp": "image/webp", ".svg": "image/svg+xml", ".pdf": "application/pdf",
".mp3": "audio/mpeg", ".mid": "audio/midi", ".midi": "audio/midi",
".wav": "audio/wav", ".txt": "text/plain", ".md": "text/markdown",
".json": "application/json", ".zip": "application/zip" };
chatMessages.push({
type: "aria_file",
serverPath: p,
name: path.basename(p),
mimeType: mimeMap[ext] || "application/octet-stream",
size: st.size,
ts,
});
}
} catch {}
}
if (text) chatMessages.push({ type: "received", text, meta: "chat:final", ts });
}
} catch {}
}
+1
View File
@@ -87,6 +87,7 @@ services:
- RVS_TLS=${RVS_TLS:-true}
- RVS_TLS_FALLBACK=${RVS_TLS_FALLBACK:-true}
- RVS_TOKEN=${RVS_TOKEN:-}
- COMPACT_AFTER_MESSAGES=${COMPACT_AFTER_MESSAGES:-140}
restart: unless-stopped
# ─── Diagnostic (Selbstcheck-UI und Einstellungen) ────
Executable
+43
View File
@@ -0,0 +1,43 @@
#!/bin/bash
# ════════════════════════════════════════════════════════════
# ARIA — Setup-Script
#
# Materialisiert Config-Dateien aus *.example-Vorlagen wenn
# das Original fehlt. Wird einmalig nach git clone und nach
# jedem git pull empfohlen — schadet auch sonst nichts (idempotent,
# ueberschreibt nichts Bestehendes).
#
# Beispiele:
# aria-data/config/USER.md.example → USER.md (wenn nicht vorhanden)
# aria-data/config/aria.env.example → aria.env (wenn nicht vorhanden)
#
# Diese Files sind via .gitignore vom Repo ausgeschlossen — die
# Vorlagen liegen aber im Repo damit ein frisches Setup ohne lange
# Anleitung lauffaehig ist.
# ════════════════════════════════════════════════════════════
set -e
cd "$(dirname "$0")"
created=0
skipped=0
for example in aria-data/config/*.example; do
[ -f "$example" ] || continue
target="${example%.example}"
if [ -e "$target" ]; then
skipped=$((skipped + 1))
else
cp "$example" "$target"
echo "$target erstellt aus $(basename "$example")"
created=$((created + 1))
fi
done
if [ $created -eq 0 ]; then
echo "Alle Config-Dateien vorhanden ($skipped uebersprungen)."
else
echo ""
echo "$created Datei(en) angelegt, $skipped uebersprungen."
echo "Falls noetig anpassen: aria-data/config/"
fi
+26
View File
@@ -89,6 +89,32 @@ Wichtige Mechanismen:
- [x] **Mute-Button stoppt jetzt auch laufenden PCM-Stream**`pcmStreamActive` wurde beim isFinal-Chunk schon false gesetzt, der AudioTrack spielte aber noch sekundenlang aus seinem Buffer. `stopPlayback()` uebersprang darum `PcmStreamPlayer.stop()`. Fix: stop() immer rufen (ist idempotent), kein Flag-Check mehr
- [x] **GPS-Permission im Manifest + Runtime-Request** beim Settings-Toggle — vorher fehlten ACCESS_COARSE_LOCATION / ACCESS_FINE_LOCATION komplett. `Geolocation.getCurrentPosition` schlug lautlos fehl, App sendete nie ein location-Feld
- [x] **GPS-Position auch im STT-Payload an Diagnostic** — die App sendet location einmal im audio-Payload. Die Bridge nutzte sie zwar (ging in aria-core's Kontext rein), reichte sie aber nicht im STT-broadcast an Diagnostic durch. Diagnostic zeigte darum bei Spracheingaben nie den GPS-Block, obwohl der "GPS einblenden"-Toggle aktiv war
- [x] **Auto-Resume nach Anruf — pcmBuffer bleibt erhalten**: `haltAllPlayback` leerte den pcmBuffer mid-Anruf, isFinal schrieb dann eine leere WAV. Neue `pauseForCall`-Methode statt `haltAllPlayback`: AudioTrack stoppt + Focus released, `pcmBuffer` und `pcmMessageId` bleiben — chunks werden weiter gesammelt damit isFinal die WAV schreibt und resumeFromInterruption sie findet. Plus `captureInterruption` idempotent gemacht (ringing → offhook ueberschreibt nicht)
- [x] **Replay-Resume nach Anruf**: `_firePlaybackStarted` ueberschrieb `currentPlaybackMsgId` mit leerem pcmMessageId — captureInterruption hatte nichts zu merken. Plus Regex `[0-9a-f-]+\.wav` matchte nicht alle Dateinamen. Plus `_playFromPathAtPosition` aktualisiert jetzt das Tracking damit ein zweiter Anruf in derselben Antwort auch funktioniert
- [x] **`pauseForCall` setzt `isPlaying` zurueck**: vorher haengten weitere Play-Button-Klicks nach Anruf, weil `playAudio` bei `isPlaying=true` den `_playNext`-Pfad ueberspringt
- [x] **Play-Button rendert neu wenn Cache-Datei weg ist**: vorher checkte der Button nur `if (item.audioPath)` — auf eine geloeschte Cache-Datei zeigte das aber stillschweigend ins Leere. Jetzt RNFS.exists-Check mit Fallback auf `tts_request` an die Bridge → F5-TTS rendert neu, WAV wandert zurueck in den Cache
- [x] **Bridge WebSocket max_size 50 MB**: Python `websockets.connect` hat 1 MiB Default — Stefan's 4MB JPEG (5.78 MB Base64) sprengte das, Bridge-Connection wurde silent gedroppt. f5tts/whisper-bridges hatten max_size schon, nur aria_bridge war vergessen
- [x] **Bridge resized Bilder >2 MB serverseitig auf 1568px**: Claude-Vision-API hat ~5 MB Base64-Limit. Galerie-Bilder via `react-native-image-picker` sind clientseitig schon klein, Buroklammer/DocumentPicker reichte das rohe File durch — Claude lieferte leere Antwort. Pillow im Bridge-Container, nur fuer JPEG/PNG/WebP/GIF (PDFs/ZIPs/SVGs unangetastet)
- [x] **Bridge `chat:error` liest auch `errorMessage`**: OpenClaw legt bei state=error den Text dort statt in `error` ab → Bridge meldete generisches "[Fehler] Unbekannt", echter Fehler nur in Container-Logs. Plus: `chat:final` ohne text wird jetzt mit Hinweis-Bubble an die App gemeldet (statt stumm), z.B. wenn Vision das Bild silent ablehnt
- [x] **Cache-Cleanup beim App-Start** — orphane `aria_tts_*.wav` Files (>5 min) im CachesDirectoryPath werden weggeraeumt, sammeln sich sonst an wenn Sound mid-playback gestoppt wird (Anruf, Mute, Barge-In) und der completion-Callback nicht feuert. Plus neuer Settings-Button "TTS-Cache leeren" mit Live-Groessenanzeige
- [x] **Verbose-Logging-Toggle in Settings → Protokoll**: `console.log` global stummschaltbar (warn/error bleiben aktiv) — spart adb-logcat-Speicher wenn alles laeuft
- [x] **800 ms-Delay vor Anruf-Auto-Resume**: ARIA's neuer Focus-Request kollidierte sonst mit Spotify's Auto-Resume nach Anruf-Ende. System haengt noch im IN_CALL→NORMAL-Mode-Uebergang, Spotify sieht Loss → Loss und bleibt pausiert. Mit Delay schafft Spotify den Resume-Schritt, dann pausiert ARIA wieder ordnungsgemaess
- [x] **Mute-Button = Stop fuer aktuelle Antwort**: vorher startete eine NEUE PCM-Chunk-Sequenz nach Mute-aus die alte Antwort weiter wo sie war (funktionierte 2x, dann nicht mehr weil isFinal schon kam). Jetzt mit `_stoppedMessageId`-Tracking: bei Mute wird die aktive msgId gemerkt, alle weiteren chunks dieser msgId bleiben silent — auch wenn Mute zurueckgenommen wird. Reset bei neuer msgId, neue Antworten spielen normal
- [x] **Spotify resumed nach Mute-Stop**: `stopPlayback` released seinen TRANSIENT-Focus (USAGE_ASSISTANT) sauber → Spotify bekommt GAIN-Event und resumed automatisch. Ein zwischenzeitlich eingebauter `kickReleaseMedia` (USAGE_MEDIA + GAIN) verhinderte das Auto-Resume sogar (Spotify interpretierte es als "user-action stopp") — wieder rausgenommen
- [x] **ARIA kann Dateien an User zurueckgeben** (PDFs, Bilder, Office-Docs, Markdown, ZIPs, ...): ARIA setzt am Antwort-Ende `[FILE: /shared/uploads/aria_<name>.<ext>]` Marker, Bridge parsed sie raus (TTS liest's nicht vor) und sendet `file_from_aria`-Event ueber RVS. App zeigt Anhang-Bubble + Klick oeffnet via Android-Intent-Picker (`FileOpenerModule`, FileProvider), Diagnostic zeigt Bubble + PDFs/Bilder neuer Tab, andere als Download. Mehrere Marker = mehrere Bubbles, nicht-existente Marker werden mit Hinweis an User gemeldet (statt silent gedroppt)
- [x] **External Bilder/Dateien werden serverseitig persistiert**: ARIA laed externe URLs (Wikipedia, Wiki Commons) mit curl runter und gibt sie via `[FILE: ...]`-Marker zurueck — bleibt permanent im Chat auch wenn die Online-Quelle stirbt. System-Prompt instruiert sie das Pattern zu nutzen
- [x] **ARIA-Datei-Bubbles ueberleben Browser-Refresh**: Diagnostic-Server parsed beim `load_chat_history` die Marker aus dem OpenClaw-Session-File und schickt `aria_file`-Eintraege mit, sodass die Anhang-Bubbles nach F5 wiederhergestellt werden. Plus: `/shared/uploads/`-Bildpfade werden im History-Render auch als Inline-Image gerendert (vorher nur in live-Bubbles)
- [x] **"ARIA reparieren"-Button** in App + Diagnostic: triggert `openclaw doctor --fix` ueber RVS → Bridge → Diagnostic HTTP-API. Fix fuer stuck Runs ohne SSH
- [x] **"ARIA hart neu starten"-Button**: docker compose-Restart ueber Docker-Socket-API im Diagnostic-Server. Mit Confirmation in der App, fuer Faelle wo doctor nicht reicht (alive aber haengender Run)
- [x] **Auto-Compact nach N Messages**: bei zu langer Session wirft Linux beim Subprocess-spawn E2BIG (Argument list too long, ~128KB-2MB Limit). Bridge zaehlt User-Messages; bei `COMPACT_AFTER_MESSAGES` (env, default 140) werden Sessions geleert + Container neu gestartet, User bekommt Hinweis-Bubble. Plus manueller "🧹 Konversation komprimieren"-Button in App-Settings und Diagnostic
- [x] **`[FILE: ...]`-Marker-Filter ueberall in Diagnostic**: Filter direkt in `addChat` damit er fuer alle Code-Pfade greift (chat_final, proxy_result, History-Load, ...) — vorher rutschten Marker als Text durch wenn sie nicht ueber chat_final kamen
- [x] **Mehrere `[FILE: ...]`-Marker in einer Antwort**: Bridge zerlegt sauber in mehrere file_from_aria-Events, ARIA muss nicht selbst zwei Antworten posten. Bei nicht-existenten Files erscheint ein User-Hinweis statt silent skip
- [x] **Inline-Bilder in Chat-Nachrichten** (App): `![alt](url)`- und plain-`https://image.png`-URLs werden als Image-Vorschau unter dem Text gerendert. Mit `react-native-svg` auch SVG-URLs inline
- [x] **SVG-Anhaenge** werden korrekt gerendert: ChatImage-Komponente erkennt `.svg`-Endung und nutzt SvgUri statt Image (RN-Image kann SVG nicht). Vollbild-Modal genauso, mit `preserveAspectRatio="xMidYMid meet"` damit SVGs nicht gestreckt werden
- [x] **Pinch-Zoom + Pan im Vollbild-Modal** (App): neue `ZoomableImage`-Komponente, reine RN-Implementation mit PanResponder+Animated, ohne externe Lib. 2-Finger-Pinch 1x..5x, 1-Finger-Pan wenn gezoomt, Doppel-Tap toggelt 1x↔2.5x. Plus ✕-Close-Button damit Tap-to-Close nicht mit Pan-Gesten kollidiert
- [x] **ARIA-Abkuerzung ausgeschrieben**: in App → Einstellungen → Ueber und Diagnostic → Einstellungen ist jetzt erklaert: "ARIA — Autonomous Reasoning & Intelligence Assistant"
- [x] **`init.sh`** legt fehlende Config-Dateien aus *.example-Vorlagen an — frischer Clone laeuft ohne Anleitung an
- [x] **`USER.md` privat**: aus dem Repo genommen (enthielt interne Tool-Liste mit Gitea-URL etc.). Vorlage als `USER.md.example` checked-in, lokales File via `.gitignore` ausgeschlossen
### App Features
+4
View File
@@ -18,6 +18,10 @@ const ALLOWED_TYPES = new Set([
"update_check", "update_available", "update_download", "update_data",
"agent_activity", "cancel_request",
"audio_pcm",
"file_from_aria",
"doctor_fix",
"aria_restart",
"aria_session_reset",
"xtts_delete_voice",
"voice_preload", "voice_ready",
"stt_request", "stt_response",