Compare commits

..

45 Commits

Author SHA1 Message Date
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
duffyduck 38106a2096 release: bump version to 0.1.0.6 2026-05-10 17:07:53 +02:00
duffyduck a476afb311 fix(audio): kickReleaseMedia mit 250ms Pause zwischen request+abandon — Spotify kriegt den Focus-Wechsel sonst gar nicht mit
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:07:03 +02:00
duffyduck db4c7b9b72 release: bump version to 0.1.0.5 2026-05-10 17:02:56 +02:00
duffyduck 3bc490b485 fix(audio): stopPlayback idempotent — kein doppelter Focus-Kick
Re-Renders / setInterval(loadSettings) triggern setMuted(true) oft
mehrfach hintereinander → jeder weitere stopPlayback rief erneut
kickReleaseMedia, Spotify pausierte+resumte mehrfach (Stefan: "spielt
kurz und pausiert dann wieder").

Fix: stopPlayback returnt sofort wenn nichts mehr aktiv ist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:02:01 +02:00
duffyduck dd6d70c46e release: bump version to 0.1.0.4 2026-05-10 16:59:15 +02:00
duffyduck b1eaf42fef fix(audio): Spotify resumed nach Mute — RNSound's haengenden Focus loesen
Logs zeigten: react-native-sound requestet beim Sound.play() einen
EIGENEN AudioFocus mit USAGE_MEDIA, released den aber bei Sound.stop()/
release() NICHT (bekanntes RN-sound-Bug). Spotify sieht den haengenden
Media-Focus → bleibt pausiert.

Workaround: Native-Methode kickReleaseMedia() macht einen request+abandon-
Cycle mit USAGE_MEDIA, das System raeumt damit den Focus-Stack auf und
Spotify bekommt sauberen GAIN-Event. stopPlayback ruft das jetzt nach
Sound.release() wenn vorher ein RNSound aktiv war.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:57:52 +02:00
duffyduck fb9e5dcd10 feat(logger): Verbose-Logging-Toggle in Settings → Protokoll
console.log wird global stummgeschaltet wenn aus — spart adb-logcat-
Speicher wenn alles laeuft. console.warn/error bleiben immer aktiv.
Default an.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:52:25 +02:00
duffyduck f95e71463f release: bump version to 0.1.0.3 2026-05-10 16:43:37 +02:00
duffyduck 1088bff43d fix(chat): Play-Button rendert neu wenn Cache-Datei weg
Vorher: Button checkte nur ob audioPath gesetzt ist — auf eine geloeschte
Cache-Datei hat aber nichts geprueft. playFromPath warntete nur und
returnte stumm. Jetzt wird VOR playFromPath die Existenz geprueft, sonst
geht's ueber tts_request an die Bridge zum Neu-Rendern.

Plus: Logs in Sound.play-Callback und _releaseFocusDeferred fuer den
"Spotify resumed nicht nach Replay"-Bug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:42:38 +02:00
duffyduck cad68db2a2 release: bump version to 0.1.0.2 2026-05-10 16:38:00 +02:00
duffyduck 50b10c8ac0 feat(audio): Cache-Cleanup beim App-Start + TTS-Cache-Settings-Button
- App-Start raeumt orphane aria_tts_*.wav (>5min) aus dem Cache —
  Wiedergaben die durch Anruf/Mute/Barge-In abgebrochen wurden
  hinterliessen sonst Files, weil der completion-Callback nicht feuert.
- Neuer Settings-Button "TTS-Cache leeren" mit Live-Groessenanzeige —
  parallel zum bestehenden "Update-Cache leeren".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:36:01 +02:00
duffyduck a8b586ec92 release: bump version to 0.1.0.1 2026-05-10 16:30:14 +02:00
duffyduck 632e1e4fa1 fix(audio): pauseForCall setzt isPlaying zurueck — Playback nach Anruf nicht mehr tot
pauseForCall stoppte zwar currentSound + setzte ihn auf null, hat aber
isPlaying=true gelassen. Folge: nach dem Anruf war jeder weitere Play-
Button-Klick wirkungslos, weil playAudio bei isPlaying=true den
_playNext-Pfad ueberspringt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:28:42 +02:00
duffyduck 7e12816ebd release: bump version to 0.1.0.0 2026-05-10 16:22:08 +02:00
duffyduck 8f64f8fb30 fix(phone): 800ms-Delay vor Auto-Resume — Spotify kommt zum Atmen
Wenn ARIA's Resume-Pfad direkt nach Anruf-Ende den AudioFocus requestet,
kollidiert das mit Spotify's eigenem Auto-Resume. System haengt noch im
IN_CALL-Mode-Uebergang, Spotify sieht "Loss → Loss" und bleibt pausiert
statt kurz zu resumen.

Mit 800ms-Delay: Spotify schafft den Resume-Schritt, dann pausiert ARIA
wieder ordnungsgemaess. Wenn ARIA nichts pending hatte, bleibt Spotify
einfach weiter an.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:20:48 +02:00
duffyduck b3ff3991c4 feat(bridge): Bilder >2MB serverseitig auf 1568px verkleinern
Claude-Vision-API hat ~5MB Base64-Limit. Stefan's 4MB Foto via
Buroklammer (DocumentPicker) sprengte das, Claude lieferte leere
Antwort zurueck. Galerie-Pfad ging weil react-native-image-picker
schon clientseitig komprimiert.

Bridge resized jetzt JPEG/PNG/WebP/GIF >2MB auf max 1568px lange
Seite (Anthropic-Empfehlung), JPEG q=85. SVG, PDF, ZIP, Office-Docs
bleiben unangetastet — die laufen ueber Tools, nicht Vision.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:15:05 +02:00
duffyduck a4ea387c98 fix(bridge): "chat final ohne Text" wird sichtbar an App gemeldet
Wenn Claude-Vision das Bild silent ablehnt (z.B. zu gross), kommt
phase=end ohne Crash, aber chat:final ohne text. Bridge ignorierte das
nur mit Warning — App wartete ewig auf Antwort. Jetzt kommt eine
Hinweis-Bubble damit der User weiss dass was schief lief.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:11:14 +02:00
duffyduck 68fbf74a23 fix(bridge): chat:error liest auch errorMessage — kein "Unbekannt" mehr
OpenClaw legt bei state=error den Text in errorMessage statt error.
Bridge ignorierte das und meldete generisches "[Fehler] Unbekannt" an
App + Diagnostic — der echte Text ("Process exited with code 1" etc)
ging nur in die Container-Logs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:07:20 +02:00
duffyduck b857f778e9 release: bump version to 0.0.9.9 2026-05-10 15:56:53 +02:00
duffyduck 31aa82b68c debug+fix(audio): Mute-Logs + resumeSound auch in stopPlayback stoppen
stopPlayback stoppte bisher nur currentSound, nicht resumeSound — wenn
nach einem Anruf der Auto-Resume laeuft und der User Mute drueckt, bleibt
der Resume-Sound weiter spielen.

Plus Logs in setMuted/stopPlayback um zu sehen warum Stefans Mute beim
Replay nicht greift.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:55:51 +02:00
duffyduck de8eeb69e2 release: bump version to 0.0.9.8 2026-05-10 15:46:36 +02:00
duffyduck f5970ce700 fix(audio): _firePlaybackStarted ueberschrieb playFromPath-Tracking mit leerem pcmMessageId
Logs zeigten: playFromPath setzt currentPlaybackMsgId='db710ff3-...', 9s
spaeter beim Anruf war captureInterruption msgId=(leer). Ursache:
_firePlaybackStarted setzt currentPlaybackMsgId blind aus pcmMessageId —
das ist beim Play-Button leer.

Jetzt nur noch setzen wenn ein PCM-Stream laeuft. Play-Button und Resume-
Sound setzen ihr Tracking selber im Caller.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:44:28 +02:00
duffyduck ef1a4436ca fix(bridge): WebSocket max_size auf 50MB — grosse Bilder/Uploads gehen wieder
Python websockets Default-Limit ist nur 1 MiB. Stefan's 4MB JPEG (5.8MB als
Base64) sprengte das, Bridge-Verbindung wurde silent gedroppt. App sah
nichts, Diagnostic kriegte kein file_saved, ARIA reagierte nicht — Kamera-
Bilder waren klein genug (<1MB) und gingen darum durch.

f5tts/whisper-bridges hatten max_size=50MB schon drin, nur aria_bridge
hatte's an beiden websockets.connect-Stellen vergessen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:42:48 +02:00
duffyduck 981779cd9e release: bump version to 0.0.9.7 2026-05-10 15:37:45 +02:00
duffyduck 3dcd2ae0b4 fix(audio): msgId-Regex liberaler — auch nicht-UUID-Dateinamen werden erkannt
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:36:43 +02:00
24 changed files with 917 additions and 87 deletions
+4
View File
@@ -13,6 +13,10 @@ aria-data/config/*.env
!aria-data/config/*.env.example !aria-data/config/*.env.example
!aria-data/config/openclaw.env !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) ──── # ── ARIAs Gedächtnis (nur per tar gesichert) ────
aria-data/brain/ aria-data/brain/
+5
View File
@@ -13,6 +13,7 @@ import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import ChatScreen from './src/screens/ChatScreen'; import ChatScreen from './src/screens/ChatScreen';
import SettingsScreen from './src/screens/SettingsScreen'; import SettingsScreen from './src/screens/SettingsScreen';
import rvs from './src/services/rvs'; import rvs from './src/services/rvs';
import { initLogger } from './src/services/logger';
// --- Navigation --- // --- Navigation ---
@@ -44,6 +45,10 @@ const TAB_ICONS: Record<string, { active: string; inactive: string }> = {
const App: React.FC = () => { const App: React.FC = () => {
// Beim Start: gespeicherte RVS-Konfiguration laden und verbinden // Beim Start: gespeicherte RVS-Konfiguration laden und verbinden
useEffect(() => { useEffect(() => {
// Verbose-Logging-Setting laden BEVOR andere Module loslegen.
// initLogger ist async aber blockt nichts — solange er noch laueft,
// loggen wir normal (Default an), danach respektiert console.log das Setting.
initLogger().catch(() => {});
const initConnection = async () => { const initConnection = async () => {
const config = await rvs.loadConfig(); const config = await rvs.loadConfig();
if (config) { if (config) {
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit" applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 906 versionCode 10101
versionName "0.0.9.6" versionName "0.1.1.1"
// Fallback fuer Libraries mit Product Flavors // Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general' missingDimensionStrategy 'react-native-camera', 'general'
} }
@@ -7,7 +7,7 @@ import com.facebook.react.uimanager.ViewManager
class ApkInstallerPackage : ReactPackage { class ApkInstallerPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> { override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
return listOf(ApkInstallerModule(reactContext)) return listOf(ApkInstallerModule(reactContext), FileOpenerModule(reactContext))
} }
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> { override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
@@ -131,6 +131,58 @@ class AudioFocusModule(reactContext: ReactApplicationContext) : ReactContextBase
promise.resolve(true) promise.resolve(true)
} }
/** Den USAGE_MEDIA-Focus-Stack im System aufmischen, damit Spotify/YouTube
* resumen wenn ein anderer Player (z.B. react-native-sound) seinen Focus
* nicht ordnungsgemaess released hat. Strategie: kurz selbst USAGE_MEDIA
* GAIN beanspruchen — das System invalidiert dabei den haengenden Stack-
* Eintrag des anderen Players — und sofort wieder abandonen. Spotify
* bekommt den Focus-Gain und resumed.
*
* Workaround fuer das react-native-sound-Bug: Sound.stop()/release()
* laesst den AudioFocusRequest haengen.
*/
@ReactMethod
fun kickReleaseMedia(promise: Promise) {
val am = audioManager()
if (am == null) {
promise.resolve(false)
return
}
// Async laufen lassen — wir wollen einen request, Pause, dann abandon.
// Ohne Pause merkt das System (und damit Spotify) die kurze Owner-
// Wechsel oft gar nicht. 250ms reicht erfahrungsgemaess fuer den
// Focus-Stack-Refresh.
Thread {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val attrs = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build()
val kickListener = AudioManager.OnAudioFocusChangeListener { /* ignorieren */ }
val kickReq = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes(attrs)
.setOnAudioFocusChangeListener(kickListener)
.build()
am.requestAudioFocus(kickReq)
Thread.sleep(250)
am.abandonAudioFocusRequest(kickReq)
} else {
val kickListener = AudioManager.OnAudioFocusChangeListener { /* ignorieren */ }
@Suppress("DEPRECATION")
am.requestAudioFocus(kickListener, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN)
Thread.sleep(250)
@Suppress("DEPRECATION")
am.abandonAudioFocus(kickListener)
}
Log.i(TAG, "kickReleaseMedia: USAGE_MEDIA-Stack aufgemischt (250ms Pause)")
} catch (e: Exception) {
Log.w(TAG, "kickReleaseMedia failed: ${e.message}")
}
}.start()
promise.resolve(true)
}
private fun release() { private fun release() {
val am = audioManager() ?: return val am = audioManager() ?: return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@@ -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"?> <?xml version="1.0" encoding="utf-8"?>
<paths> <paths>
<cache-path name="cache" path="." /> <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> </paths>
+18 -17
View File
@@ -1,6 +1,6 @@
{ {
"name": "aria-cockpit", "name": "aria-cockpit",
"version": "0.0.9.6", "version": "0.1.1.1",
"private": true, "private": true,
"scripts": { "scripts": {
"android": "react-native run-android", "android": "react-native run-android",
@@ -10,31 +10,32 @@
"build:apk": "cd android && ./gradlew assembleRelease" "build:apk": "cd android && ./gradlew assembleRelease"
}, },
"dependencies": { "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": "18.2.0",
"react-native": "0.73.4", "react-native": "0.73.4",
"@react-navigation/native": "^6.1.9", "react-native-audio-recorder-player": "^3.6.7",
"@react-navigation/bottom-tabs": "^6.5.11", "react-native-camera-kit": "^13.0.0",
"react-native-screens": "3.27.0",
"react-native-safe-area-context": "^4.8.2",
"react-native-document-picker": "^9.1.1", "react-native-document-picker": "^9.1.1",
"react-native-sound": "^0.11.2", "react-native-fs": "^2.20.0",
"@react-native-community/geolocation": "^3.2.1",
"react-native-image-picker": "^7.1.0", "react-native-image-picker": "^7.1.0",
"react-native-permissions": "^4.1.4", "react-native-permissions": "^4.1.4",
"react-native-camera-kit": "^13.0.0", "react-native-safe-area-context": "^4.8.2",
"@react-native-async-storage/async-storage": "^1.21.0", "react-native-screens": "3.27.0",
"react-native-fs": "^2.20.0", "react-native-sound": "^0.11.2",
"react-native-audio-recorder-player": "^3.6.7" "react-native-svg": "^14.1.0"
}, },
"devDependencies": { "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": "^18.2.48",
"@types/react-native": "^0.73.0", "@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", "jest": "^29.7.0",
"@types/jest": "^29.5.11" "metro-react-native-babel-preset": "^0.77.0",
"typescript": "^5.3.3"
} }
} }
+73 -11
View File
@@ -1,25 +1,87 @@
/** /**
* 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
* automatisch klickbar) und ein einzelnes <Text selectable> ohne nested * erkannt — die URL bleibt im Text sichtbar (klickbar via Linkify),
* <Text> mit eigenem onPress. Nested Text mit onPress fingen die Long-Press- * zusaetzlich wird das Bild als <Image> oder <SvgUri> drunter gerendert.
* Geste ab, damit war Markieren+Kopieren defekt. * - 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 fing die Long-Press-
* Geste ab, damit war Markieren+Kopieren defekt.
*/ */
import React from 'react'; import React, { useEffect, useState } from 'react';
import { Text, TextStyle, StyleProp } from 'react-native'; import { View, Text, Image, TextStyle, StyleProp } from 'react-native';
import { SvgUri } from 'react-native-svg';
interface Props { interface Props {
text: string; text: string;
style?: StyleProp<TextStyle>; style?: StyleProp<TextStyle>;
} }
const MessageText: React.FC<Props> = ({ text, style }) => { // 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 ( return (
<Text style={style} selectable dataDetectorType="all"> <Image
{text} source={{ uri }}
</Text> 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>
); );
}; };
+83 -17
View File
@@ -20,6 +20,7 @@ import {
Modal, Modal,
ToastAndroid, ToastAndroid,
AppState, AppState,
NativeModules,
} from 'react-native'; } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import RNFS from 'react-native-fs'; import RNFS from 'react-native-fs';
@@ -80,6 +81,23 @@ const capMessages = (msgs: ChatMessage[]): ChatMessage[] =>
const DEFAULT_ATTACHMENT_DIR = `${RNFS.DocumentDirectoryPath}/chat_attachments`; const DEFAULT_ATTACHMENT_DIR = `${RNFS.DocumentDirectoryPath}/chat_attachments`;
const STORAGE_PATH_KEY = 'aria_attachment_storage_path'; 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-Vorschau in der Chat-Bubble. Misst die echte Bild-Dimension via
* Image.getSize + setzt aspectRatio dynamisch — dadurch passt sich die * Image.getSize + setzt aspectRatio dynamisch — dadurch passt sich die
* Bubble ans Bild an (kein "Strich" mehr bei breiten oder hohen Bildern). */ * Bubble ans Bild an (kein "Strich" mehr bei breiten oder hohen Bildern). */
@@ -179,6 +197,10 @@ const ChatScreen: React.FC = () => {
const flatListRef = useRef<FlatList>(null); const flatListRef = useRef<FlatList>(null);
const messageIdCounter = useRef(0); 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 // Eindeutige Message-ID generieren
const nextId = (): string => { const nextId = (): string => {
@@ -349,11 +371,32 @@ const ChatScreen: React.FC = () => {
return; 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 // file_response: Re-Download von Server — lokal speichern
if (message.type === 'file_response') { if (message.type === 'file_response') {
const reqId = (message.payload.requestId as string) || ''; const reqId = (message.payload.requestId as string) || '';
const b64 = (message.payload.base64 as string) || ''; const b64 = (message.payload.base64 as string) || '';
const serverPath = (message.payload.serverPath as string) || ''; const serverPath = (message.payload.serverPath as string) || '';
const mimeType = (message.payload.mimeType as string) || '';
if (b64 && reqId) { if (b64 && reqId) {
const fileName = (message.payload.name as string) || 'download'; const fileName = (message.payload.name as string) || 'download';
persistAttachment(b64, reqId, fileName).then(filePath => { persistAttachment(b64, reqId, fileName).then(filePath => {
@@ -363,6 +406,11 @@ const ChatScreen: React.FC = () => {
a.serverPath === serverPath ? { ...a, uri: filePath } : a 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(() => {}); }).catch(() => {});
} }
return; return;
@@ -1008,7 +1056,22 @@ const ChatScreen: React.FC = () => {
</Text> </Text>
</TouchableOpacity> </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}> <Text style={styles.attachmentFileIcon}>
{att.mimeType?.includes('pdf') ? '\uD83D\uDCC4' : {att.mimeType?.includes('pdf') ? '\uD83D\uDCC4' :
att.mimeType?.includes('word') || att.mimeType?.includes('document') ? '\uD83D\uDCC3' : att.mimeType?.includes('word') || att.mimeType?.includes('document') ? '\uD83D\uDCC3' :
@@ -1018,12 +1081,10 @@ const ChatScreen: React.FC = () => {
<Text style={styles.attachmentFileName} numberOfLines={1}>{att.name}</Text> <Text style={styles.attachmentFileName} numberOfLines={1}>{att.name}</Text>
{att.size ? <Text style={styles.attachmentFileSize}>{Math.round(att.size / 1024)}KB</Text> : null} {att.size ? <Text style={styles.attachmentFileSize}>{Math.round(att.size / 1024)}KB</Text> : null}
{!att.uri && att.serverPath && ( {!att.uri && att.serverPath && (
<TouchableOpacity onPress={() => rvs.send('file_request' as any, { serverPath: att.serverPath, requestId: item.id })}> <Text style={[styles.attachmentFileSize, {color: '#0096FF'}]}>(tippen zum oeffnen)</Text>
<Text style={[styles.attachmentFileSize, {color: '#0096FF'}]}>(laden)</Text>
</TouchableOpacity>
)} )}
{!att.uri && !att.serverPath && <Text style={styles.attachmentFileSize}>(nicht verfuegbar)</Text>} {!att.uri && !att.serverPath && <Text style={styles.attachmentFileSize}>(nicht verfuegbar)</Text>}
</View> </TouchableOpacity>
)} )}
</View> </View>
))} ))}
@@ -1038,19 +1099,24 @@ const ChatScreen: React.FC = () => {
{!isUser && item.text.length > 0 && ( {!isUser && item.text.length > 0 && (
<TouchableOpacity <TouchableOpacity
style={styles.playButton} style={styles.playButton}
onPress={() => { onPress={async () => {
if (item.audioPath) { // Erst lokalen Cache pruefen — audioPath kann auf eine geloeschte
audioService.playFromPath(item.audioPath); // Datei zeigen (TTS-Cache geleert oder Auto-Cleanup). In dem Fall
} else { // ueber RVS neu rendern lassen statt stumm zu bleiben.
// messageId mitschicken damit die Bridge das generierte Audio const cachePath = item.audioPath?.replace(/^file:\/\//, '') || '';
// wieder mit der Nachricht verknuepft (fuer den naechsten Replay aus Cache) const cached = cachePath ? await RNFS.exists(cachePath).catch(() => false) : false;
rvs.send('tts_request' as any, { if (cached) {
text: item.text, audioService.playFromPath(item.audioPath!);
voice: localXttsVoiceRef.current, return;
speed: ttsSpeedRef.current,
messageId: item.messageId || '',
});
} }
// messageId mitschicken damit die Bridge das generierte Audio
// wieder mit der Nachricht verknuepft (fuer den naechsten Replay aus Cache)
rvs.send('tts_request' as any, {
text: item.text,
voice: localXttsVoiceRef.current,
speed: ttsSpeedRef.current,
messageId: item.messageId || '',
});
}} }}
> >
<Text style={styles.playButtonText}>{'\uD83D\uDD0A'}</Text> <Text style={styles.playButtonText}>{'\uD83D\uDD0A'}</Text>
+59
View File
@@ -50,6 +50,8 @@ import {
TTS_SPEED_MAX, TTS_SPEED_MAX,
TTS_SPEED_STORAGE_KEY, TTS_SPEED_STORAGE_KEY,
} from '../services/audio'; } from '../services/audio';
import audioService from '../services/audio';
import { isVerboseLogging, setVerboseLogging } from '../services/logger';
import { import {
isWakeReadySoundEnabled, isWakeReadySoundEnabled,
setWakeReadySoundEnabled, setWakeReadySoundEnabled,
@@ -135,6 +137,8 @@ const SettingsScreen: React.FC = () => {
const [vadSilenceDb, setVadSilenceDb] = useState<number | null>(null); const [vadSilenceDb, setVadSilenceDb] = useState<number | null>(null);
const [showVadInfo, setShowVadInfo] = useState(false); const [showVadInfo, setShowVadInfo] = useState(false);
const [apkCacheInfo, setApkCacheInfo] = useState<{count: number, totalMB: number} | null>(null); const [apkCacheInfo, setApkCacheInfo] = useState<{count: number, totalMB: number} | null>(null);
const [ttsCacheInfo, setTtsCacheInfo] = useState<{count: number, totalMB: number} | null>(null);
const [verboseLogging, setVerboseLoggingState] = useState<boolean>(isVerboseLogging());
const [ttsSpeed, setTtsSpeed] = useState<number>(TTS_SPEED_DEFAULT); const [ttsSpeed, setTtsSpeed] = useState<number>(TTS_SPEED_DEFAULT);
const [wakeKeyword, setWakeKeyword] = useState<string>(DEFAULT_KEYWORD); const [wakeKeyword, setWakeKeyword] = useState<string>(DEFAULT_KEYWORD);
const [wakeStatus, setWakeStatus] = useState<string>(''); const [wakeStatus, setWakeStatus] = useState<string>('');
@@ -224,6 +228,7 @@ const SettingsScreen: React.FC = () => {
}); });
isWakeReadySoundEnabled().then(setWakeReadySound); isWakeReadySoundEnabled().then(setWakeReadySound);
updateService.getApkCacheSize().then(setApkCacheInfo).catch(() => {}); updateService.getApkCacheSize().then(setApkCacheInfo).catch(() => {});
audioService.getTtsCacheSize().then(setTtsCacheInfo).catch(() => {});
AsyncStorage.getItem('aria_xtts_voice').then(saved => { AsyncStorage.getItem('aria_xtts_voice').then(saved => {
if (saved) setXttsVoice(saved); if (saved) setXttsVoice(saved);
}); });
@@ -1251,11 +1256,65 @@ const SettingsScreen: React.FC = () => {
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{/* === TTS-Cache === */}
<Text style={[styles.sectionTitle, {marginTop: 16}]}>TTS-Cache</Text>
<View style={styles.card}>
<Text style={styles.toggleHint}>
Gespeicherte Sprachausgaben (WAV pro Antwort) werden fuer den
Play-Button und Auto-Resume nach Anrufen genutzt. Loeschen
unterbricht keine laufende Wiedergabe, alte Antworten lassen sich
danach nur nicht mehr abspielen.
</Text>
<Text style={[styles.storageSizeText, {marginTop: 8}]}>
{ttsCacheInfo === null ? '...' :
ttsCacheInfo.count === 0 ? 'leer' :
`${ttsCacheInfo.count} WAV${ttsCacheInfo.count === 1 ? '' : 's'} · ${ttsCacheInfo.totalMB.toFixed(1)}MB`}
</Text>
<TouchableOpacity
style={[styles.clearButton, {marginTop: 8, backgroundColor: 'rgba(255,59,48,0.15)'}]}
onPress={async () => {
const res = await audioService.clearTtsCache();
ToastAndroid.show(
res.removed === 0
? 'TTS-Cache war schon leer'
: `${res.removed} WAV${res.removed === 1 ? '' : 's'} geloescht (${res.freedMB.toFixed(1)}MB frei)`,
ToastAndroid.SHORT,
);
const info = await audioService.getTtsCacheSize();
setTtsCacheInfo(info);
}}
>
<Text style={[styles.clearButtonText, {color: '#FF3B30'}]}>TTS-Cache leeren</Text>
</TouchableOpacity>
</View>
</>)} </>)}
{/* === Logs === */} {/* === Logs === */}
{currentSection === 'protocol' && (<> {currentSection === 'protocol' && (<>
<Text style={styles.sectionTitle}>Protokoll</Text> <Text style={styles.sectionTitle}>Protokoll</Text>
{/* Verbose-Logging-Toggle */}
<View style={styles.card}>
<View style={styles.toggleRow}>
<Text style={styles.toggleLabel}>Verbose Logging</Text>
<Switch
value={verboseLogging}
onValueChange={(v) => {
setVerboseLogging(v);
setVerboseLoggingState(v);
}}
trackColor={{ false: '#3A3A52', true: '#0096FF' }}
thumbColor={verboseLogging ? '#FFFFFF' : '#666680'}
/>
</View>
<Text style={styles.toggleHint}>
Wenn aus: console.log wird global stummgeschaltet (Speicher schonen).
Warnungen und Fehler bleiben immer aktiv. Bei Bedarf einschalten zum
Debuggen via adb logcat.
</Text>
</View>
<View style={styles.card}> <View style={styles.card}>
{/* Tab-Umschalter */} {/* Tab-Umschalter */}
<View style={styles.tabRow}> <View style={styles.tabRow}>
+142 -16
View File
@@ -41,6 +41,8 @@ const { AudioFocus, PcmStreamPlayer } = NativeModules as {
requestDuck: () => Promise<boolean>; requestDuck: () => Promise<boolean>;
requestExclusive: () => Promise<boolean>; requestExclusive: () => Promise<boolean>;
release: () => Promise<boolean>; release: () => Promise<boolean>;
kickReleaseMedia: () => Promise<boolean>;
getMode?: () => Promise<number>;
}; };
PcmStreamPlayer?: { PcmStreamPlayer?: {
start: (sampleRate: number, channels: number, prerollSeconds: number) => Promise<boolean>; start: (sampleRate: number, channels: number, prerollSeconds: number) => Promise<boolean>;
@@ -301,6 +303,12 @@ class AudioService {
console.warn('[Audio] PcmPlaybackFinished-Subscription fehlgeschlagen:', err); console.warn('[Audio] PcmPlaybackFinished-Subscription fehlgeschlagen:', err);
} }
} }
// App-Start: orphaned aria_tts_*.wav / aria_recording_*.mp4 aus dem Cache
// wegraeumen. Sammeln sich an wenn Sound mid-playback gestoppt wird (Anruf,
// Mute, Barge-In) — der completion-callback feuert dann nicht und die Datei
// bleibt liegen. 5min-Threshold damit gerade aktiv geschriebene Files sicher
// sind. cleanupOnStartup ist async, blockt den Constructor nicht.
this._cleanupStaleCacheFiles(5 * 60 * 1000).catch(() => {});
} }
/** AudioFocus mit kleiner Verzoegerung freigeben — Spotify/YouTube /** AudioFocus mit kleiner Verzoegerung freigeben — Spotify/YouTube
@@ -310,13 +318,19 @@ class AudioService {
* unterdrueckt — der Focus bleibt fuer die ganze Konversation gehalten. */ * unterdrueckt — der Focus bleibt fuer die ganze Konversation gehalten. */
private _releaseFocusDeferred(): void { private _releaseFocusDeferred(): void {
if (this._conversationFocusActive) { if (this._conversationFocusActive) {
console.log('[Audio] _releaseFocusDeferred: Conversation aktiv → kein Release');
this._cancelDeferredFocusRelease(); this._cancelDeferredFocusRelease();
return; return;
} }
this._cancelDeferredFocusRelease(); this._cancelDeferredFocusRelease();
console.log('[Audio] _releaseFocusDeferred: in %dms', this.FOCUS_RELEASE_DELAY_MS);
this.focusReleaseTimer = setTimeout(() => { this.focusReleaseTimer = setTimeout(() => {
this.focusReleaseTimer = null; this.focusReleaseTimer = null;
if (this._conversationFocusActive) return; if (this._conversationFocusActive) {
console.log('[Audio] Focus-Release abgebrochen (Conversation jetzt aktiv)');
return;
}
console.log('[Audio] AudioFocus jetzt released');
AudioFocus?.release().catch(() => {}); AudioFocus?.release().catch(() => {});
}, this.FOCUS_RELEASE_DELAY_MS); }, this.FOCUS_RELEASE_DELAY_MS);
} }
@@ -363,6 +377,10 @@ class AudioService {
console.log('[Audio] pauseForCall: %s', reason || '(no reason)'); console.log('[Audio] pauseForCall: %s', reason || '(no reason)');
this._conversationFocusActive = false; this._conversationFocusActive = false;
this._pausedForCall = true; this._pausedForCall = true;
// Queue + isPlaying ruecksetzen — sonst klemmt der naechste Play-Button
// (playAudio sieht isPlaying=true und ruft _playNext nicht mehr auf).
this.audioQueue = [];
this.isPlaying = false;
// Foreground-Service stoppen — Notification waere sonst irrefuehrend // Foreground-Service stoppen — Notification waere sonst irrefuehrend
stopBackgroundAudio().catch(() => {}); stopBackgroundAudio().catch(() => {});
// SoundPool/RNSound (Resume-Sound, Play-Button) stoppen — nicht relevant fuer Auto-Resume // SoundPool/RNSound (Resume-Sound, Play-Button) stoppen — nicht relevant fuer Auto-Resume
@@ -467,7 +485,7 @@ class AudioService {
// Tracking auch fuer den Resume-Sound aktualisieren — sonst kann // Tracking auch fuer den Resume-Sound aktualisieren — sonst kann
// captureInterruption bei einem zweiten Anruf die Position nicht // captureInterruption bei einem zweiten Anruf die Position nicht
// mehr ermitteln (playbackStartTime waere von der ersten Wiedergabe). // mehr ermitteln (playbackStartTime waere von der ersten Wiedergabe).
const msgIdMatch = path.match(/([0-9a-f-]+)\.wav$/i); const msgIdMatch = path.match(/([^/\\]+)\.wav$/i);
if (msgIdMatch) this.currentPlaybackMsgId = msgIdMatch[1]; if (msgIdMatch) this.currentPlaybackMsgId = msgIdMatch[1];
// Virtuelle Start-Zeit so setzen, dass captureInterruption (das den // Virtuelle Start-Zeit so setzen, dass captureInterruption (das den
// Leading-Silence-Offset wieder abzieht) die korrekte Position liefert. // Leading-Silence-Offset wieder abzieht) die korrekte Position liefert.
@@ -778,8 +796,13 @@ class AudioService {
if (!base64Data) return; if (!base64Data) return;
// Mute-Flag respektieren — robust gegen Race-Conditions zwischen User- // Mute-Flag respektieren — robust gegen Race-Conditions zwischen User-
// Klick auf Mute und einem TTS-Chunk der im selben Tick eintrifft. // Klick auf Mute und einem TTS-Chunk der im selben Tick eintrifft.
if (this._muted) return; if (this._muted) {
console.log('[Audio] playAudio: muted=true → skip');
return;
}
this.audioQueue.push(base64Data); this.audioQueue.push(base64Data);
console.log('[Audio] playAudio: queued (queue=%d isPlaying=%s pausedForCall=%s)',
this.audioQueue.length, this.isPlaying, this._pausedForCall);
if (!this.isPlaying) { if (!this.isPlaying) {
this._playNext(); this._playNext();
} }
@@ -845,11 +868,16 @@ class AudioService {
final?: boolean; final?: boolean;
silent?: boolean; silent?: boolean;
}): Promise<string> { }): 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 // Globaler Mute-Flag uebersteuert das per-Call silent — verhindert
// Race-Conditions wenn der User zwischen Chunks den Mute-Knopf drueckt. // Race-Conditions wenn der User zwischen Chunks den Mute-Knopf drueckt.
// _pausedForCall: AudioTrack ist gestoppt waehrend Anruf — Chunks weiter // _pausedForCall: AudioTrack ist gestoppt waehrend Anruf — Chunks weiter
// sammeln (fuer WAV-Cache), aber NICHT in den Player schicken. // 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) { if (!silent && !PcmStreamPlayer) {
console.warn('[Audio] PcmStreamPlayer Native Module nicht verfuegbar'); console.warn('[Audio] PcmStreamPlayer Native Module nicht verfuegbar');
return ''; return '';
@@ -890,6 +918,13 @@ class AudioService {
this.pausedMessageId = ''; this.pausedMessageId = '';
this.pausedPosition = 0; 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.pcmStreamActive = true;
this.pcmMessageId = messageId; this.pcmMessageId = messageId;
this.pcmSampleRate = sampleRate; this.pcmSampleRate = sampleRate;
@@ -1019,10 +1054,12 @@ class AudioService {
console.warn('[Audio] Cache-Datei existiert nicht mehr:', cleanPath); console.warn('[Audio] Cache-Datei existiert nicht mehr:', cleanPath);
return; return;
} }
const msgIdMatch = cleanPath.match(/([0-9a-f-]+)\.wav$/i); // Dateiname ohne .wav als messageId nehmen (egal ob UUID oder andere ID)
if (msgIdMatch) { const fileMatch = cleanPath.match(/([^/\\]+)\.wav$/i);
this.currentPlaybackMsgId = msgIdMatch[1]; const msgId = fileMatch ? fileMatch[1] : '';
// Start-Zeit so setzen dass elapsed = LEADING (≈ 0 nach captureInterruption-Berechnung) console.log('[Audio] playFromPath: cleanPath=%s → msgId=%s', cleanPath, msgId || '(leer)');
if (msgId) {
this.currentPlaybackMsgId = msgId;
this.playbackStartTime = Date.now() - this.LEADING_SILENCE_SEC * 1000; this.playbackStartTime = Date.now() - this.LEADING_SILENCE_SEC * 1000;
} }
const b64 = await RNFS.readFile(cleanPath, 'base64'); const b64 = await RNFS.readFile(cleanPath, 'base64');
@@ -1053,9 +1090,15 @@ class AudioService {
} }
private _firePlaybackStarted(): void { private _firePlaybackStarted(): void {
// Tracking fuer Auto-Resume nach Anruf-Pause // Tracking fuer Auto-Resume nach Anruf-Pause: NUR setzen wenn ein
this.playbackStartTime = Date.now(); // PCM-Stream laeuft (Live-TTS). Bei Play-Button / Resume-Sound hat der
this.currentPlaybackMsgId = this.pcmMessageId || ''; // Caller (playFromPath / _playFromPathAtPosition) das Tracking schon
// korrekt mit der msgId aus dem Pfad gesetzt — sonst wuerden wir hier
// mit leerem pcmMessageId ueberschreiben.
if (this.pcmMessageId) {
this.playbackStartTime = Date.now();
this.currentPlaybackMsgId = this.pcmMessageId;
}
this.playbackStartedListeners.forEach(cb => { this.playbackStartedListeners.forEach(cb => {
try { cb(); } catch (e) { console.warn('[Audio] playbackStarted listener err:', e); } try { cb(); } catch (e) { console.warn('[Audio] playbackStarted listener err:', e); }
}); });
@@ -1108,11 +1151,13 @@ class AudioService {
} }
this.currentSound = sound; this.currentSound = sound;
console.log('[Audio] Sound.play startet (path=%s)', soundPath);
// Naechstes Audio schon vorbereiten waehrend dieses abspielt // Naechstes Audio schon vorbereiten waehrend dieses abspielt
this._preloadNext(); this._preloadNext();
sound.play((success) => { sound.play((success) => {
console.log('[Audio] Sound.play callback: success=%s queue=%d', success, this.audioQueue.length);
if (!success) console.warn('[Audio] Wiedergabe fehlgeschlagen'); if (!success) console.warn('[Audio] Wiedergabe fehlgeschlagen');
sound.release(); sound.release();
this.currentSound = null; this.currentSound = null;
@@ -1148,14 +1193,39 @@ class AudioService {
* abgespielt. Wird in pauseForCall gesetzt, in endCallPause/resumeFrom- * abgespielt. Wird in pauseForCall gesetzt, in endCallPause/resumeFrom-
* Interruption zurueckgenommen. */ * Interruption zurueckgenommen. */
private _pausedForCall: boolean = false; 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 { setMuted(muted: boolean): void {
console.log('[Audio] setMuted: %s (currentSound=%s pcmStreamActive=%s)',
muted, this.currentSound ? 'aktiv' : 'null', this.pcmStreamActive);
this._muted = muted; 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; } isMuted(): boolean { return this._muted; }
/** Laufende Wiedergabe stoppen + Queue leeren */ /** Laufende Wiedergabe stoppen + Queue leeren */
stopPlayback(): void { stopPlayback(): void {
// Idempotent: wenn nichts mehr aktiv ist, NICHT noch einen Focus-Release/
// Kick-Cycle anstossen — Re-Renders triggern setMuted oft mehrfach hinter-
// einander, und jeder weitere Kick lässt Spotify nochmal kurz pausieren.
const hasAnything = !!(this.currentSound || this.resumeSound || this.preloadedSound
|| this.pcmStreamActive || this.audioQueue.length || this.isPlaying);
if (!hasAnything) return;
console.log('[Audio] stopPlayback: currentSound=%s queue=%d pcm=%s',
this.currentSound ? 'aktiv' : 'null', this.audioQueue.length, this.pcmStreamActive);
// Foreground-Service auch stoppen — sonst bleibt die Notification haengen // Foreground-Service auch stoppen — sonst bleibt die Notification haengen
// wenn Wiedergabe abgebrochen wird (Anruf, Cancel, Barge-In). // wenn Wiedergabe abgebrochen wird (Anruf, Cancel, Barge-In).
stopBackgroundAudio().catch(() => {}); stopBackgroundAudio().catch(() => {});
@@ -1166,6 +1236,11 @@ class AudioService {
this.currentSound.release(); this.currentSound.release();
this.currentSound = null; this.currentSound = null;
} }
if (this.resumeSound) {
this.resumeSound.stop();
this.resumeSound.release();
this.resumeSound = null;
}
if (this.preloadedSound) { if (this.preloadedSound) {
this.preloadedSound.release(); this.preloadedSound.release();
this.preloadedSound = null; this.preloadedSound = null;
@@ -1181,7 +1256,11 @@ class AudioService {
this.pcmBuffer = []; this.pcmBuffer = [];
this.pcmBytesCollected = 0; this.pcmBytesCollected = 0;
this.pcmMessageId = ''; 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(); this._cancelDeferredFocusRelease();
AudioFocus?.release().catch(() => {}); AudioFocus?.release().catch(() => {});
} }
@@ -1223,19 +1302,29 @@ class AudioService {
} }
} }
/** Alte Aufnahme- und TTS-Files aus dem Cache loeschen (>30s alt). */ /** Alte Aufnahme- und TTS-Files aus dem Cache loeschen.
private async _cleanupStaleCacheFiles(): Promise<void> { * Default 30s — verwendet beim Mikro-Start (kurze Lebensdauer reicht).
* App-Start nutzt 5min damit gerade aktive Files nicht erwischt werden. */
private async _cleanupStaleCacheFiles(maxAgeMs: number = 30000): Promise<void> {
try { try {
const files = await RNFS.readDir(RNFS.CachesDirectoryPath); const files = await RNFS.readDir(RNFS.CachesDirectoryPath);
const now = Date.now(); const now = Date.now();
let removed = 0;
let freedBytes = 0;
for (const f of files) { for (const f of files) {
if (!f.isFile()) continue; if (!f.isFile()) continue;
if (!f.name.startsWith('aria_recording_') && !f.name.startsWith('aria_tts_')) continue; if (!f.name.startsWith('aria_recording_') && !f.name.startsWith('aria_tts_')) continue;
const age = now - (f.mtime ? f.mtime.getTime() : 0); const age = now - (f.mtime ? f.mtime.getTime() : 0);
if (age > 30000) { if (age > maxAgeMs) {
freedBytes += parseInt(f.size as any, 10) || 0;
await RNFS.unlink(f.path).catch(() => {}); await RNFS.unlink(f.path).catch(() => {});
removed += 1;
} }
} }
if (removed > 0) {
console.log('[Audio] Cache-Cleanup: %d Files entfernt, %.1fMB freigegeben',
removed, freedBytes / 1024 / 1024);
}
} catch { } catch {
// silent — cleanup ist best-effort // silent — cleanup ist best-effort
} }
@@ -1262,6 +1351,43 @@ class AudioService {
// silent // silent
} }
} }
/** Aktuelle Groesse des TTS-Caches. */
async getTtsCacheSize(): Promise<{ count: number; totalMB: number }> {
let count = 0;
let total = 0;
try {
const dir = `${RNFS.DocumentDirectoryPath}/tts_cache`;
if (await RNFS.exists(dir)) {
const files = await RNFS.readDir(dir);
for (const f of files) {
if (!f.isFile() || !f.name.endsWith('.wav')) continue;
count += 1;
total += parseInt(f.size as any, 10) || 0;
}
}
} catch {}
return { count, totalMB: total / 1024 / 1024 };
}
/** TTS-Cache komplett leeren (Settings-Button). */
async clearTtsCache(): Promise<{ removed: number; freedMB: number }> {
let removed = 0;
let freed = 0;
try {
const dir = `${RNFS.DocumentDirectoryPath}/tts_cache`;
if (!(await RNFS.exists(dir))) return { removed: 0, freedMB: 0 };
const files = await RNFS.readDir(dir);
for (const f of files) {
if (!f.isFile() || !f.name.endsWith('.wav')) continue;
const size = parseInt(f.size as any, 10) || 0;
await RNFS.unlink(f.path).catch(() => {});
removed += 1;
freed += size;
}
} catch {}
return { removed, freedMB: freed / 1024 / 1024 };
}
} }
// Singleton // Singleton
+41
View File
@@ -0,0 +1,41 @@
/**
* Verbose-Logging-Toggle: console.log laesst sich global stummschalten.
* console.warn/console.error bleiben immer an — Fehler will man immer sehen.
*
* Default: an (true). Toggle ueber Settings → Protokoll → Verbose Logging.
* Beim Start wird der gespeicherte Wert geladen, vorher loggen wir normal.
*/
import AsyncStorage from '@react-native-async-storage/async-storage';
export const VERBOSE_LOGGING_KEY = 'aria_verbose_logging';
// Original-console.log retten, damit wir die Wrapper jederzeit wieder
// "scharf" stellen koennen (sonst waere ein Toggle-an nach -aus tot).
const originalLog = console.log.bind(console);
const noop = () => {};
let _verbose = true;
function applyState(): void {
console.log = _verbose ? originalLog : noop;
}
/** Wert aus AsyncStorage laden und anwenden. Beim App-Start aufrufen. */
export async function initLogger(): Promise<void> {
try {
const v = await AsyncStorage.getItem(VERBOSE_LOGGING_KEY);
_verbose = v !== 'false'; // default: true
} catch {}
applyState();
}
export function isVerboseLogging(): boolean {
return _verbose;
}
export function setVerboseLogging(verbose: boolean): void {
_verbose = verbose;
applyState();
AsyncStorage.setItem(VERBOSE_LOGGING_KEY, String(verbose)).catch(() => {});
}
+13 -8
View File
@@ -202,14 +202,19 @@ class PhoneCallService {
audioService.endCallPause(); audioService.endCallPause();
wakeWordService.resumeFromCall().catch(() => {}); wakeWordService.resumeFromCall().catch(() => {});
ToastAndroid.show(toast, ToastAndroid.SHORT); ToastAndroid.show(toast, ToastAndroid.SHORT);
// Auto-Resume: ab gemerkter Position weiterspielen wenn ARIA vor dem // 800ms warten bevor Auto-Resume — sonst kollidiert ARIA's neuer Focus-
// Anruf gerade redete. Wartet bis zu 30s auf den WAV-Cache (falls // Request mit Spotify's Auto-Resume nach Anruf-Ende. System haengt nach
// final-Marker erst nach dem Anruf-Ende kam). // dem Auflegen noch im IN_CALL-Mode-Uebergang, Spotify schaut auf Focus-
audioService.resumeFromInterruption(30000).then(ok => { // Gain und wuerde sofort wieder LOSS sehen → bleibt pausiert.
if (ok) { // Mit Delay: Spotify resumed kurz, dann pausiert ARIA wieder ordnungs-
console.log('[PhoneCall] Auto-Resume von gemerkter Position gestartet'); // gemaess. Wenn ARIA nichts pending hat, bleibt Spotify einfach an.
} setTimeout(() => {
}).catch(() => {}); audioService.resumeFromInterruption(30000).then(ok => {
if (ok) {
console.log('[PhoneCall] Auto-Resume von gemerkter Position gestartet');
}
}).catch(() => {});
}, 800);
} }
} }
+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. 4. **Regelmaessig committen** — mit sinnvollen Commit-Messages.
5. **Tageslog fuehren** — was wurde getan, was ist offen. 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 ## Stimme
TTS laeuft ueber F5-TTS (Voice Cloning, Gaming-PC). Stefan kann eigene TTS laeuft ueber F5-TTS (Voice Cloning, Gaming-PC). Stefan kann eigene
+83
View File
@@ -78,6 +78,89 @@ Wenn ein Tool nicht klappt, probiere die Alternative. Niemals sagen "ich habe ke
- Destruktive Operationen (Dateien loeschen, Datenbanken droppen) - Destruktive Operationen (Dateien loeschen, Datenbanken droppen)
- Push auf main - 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.
### 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 ## Stimme
TTS laeuft ueber F5-TTS auf der Gamebox (Voice Cloning). Stefan kann TTS laeuft ueber F5-TTS auf der Gamebox (Voice Cloning). Stefan kann
@@ -1,10 +1,10 @@
# Stefan — Benutzer-Praeferenzen # <Username> — Benutzer-Praeferenzen
## Allgemein ## Allgemein
- **Sprache:** Deutsch - **Sprache:** <z.B. Deutsch>
- **Kommunikation:** Direkt, kein Bullshit, Humor willkommen - **Kommunikation:** <z.B. Direkt, kein Bullshit, Humor willkommen>
- **Rolle:** Chef, Auftraggeber, Entwickler bei HackerSoft Oldenburg - **Rolle:** <z.B. Chef, Auftraggeber, Entwickler bei XYZ>
## Bestaetigung erforderlich fuer ## Bestaetigung erforderlich fuer
@@ -12,7 +12,6 @@
- Push auf main - Push auf main
- Aenderungen an Kundensystemen - Aenderungen an Kundensystemen
- Server-Befehle die nicht rueckgaengig gemacht werden koennen - Server-Befehle die nicht rueckgaengig gemacht werden koennen
- Windows neu installieren (erst Daten sichern!)
## Autonomes Arbeiten OK fuer ## Autonomes Arbeiten OK fuer
@@ -28,8 +27,10 @@
| Tool | Zweck | | Tool | Zweck |
|------|-------| |------|-------|
| **Proxmox** | VM-Infrastruktur (ARIAs Zuhause) | | **<Beispiel-Tool>** | <Zweck> |
| **Gitea** | Code-Hosting (gitea.hackersoft.de) |
| **OpenCRM** | Kundenverwaltung | <!--
| **STARFACE** | Telefonie | Diese Datei ist eine Vorlage. Lokal als USER.md kopieren und mit
| **RustDesk** | Remote IT-Support bei Kunden | eigenen Praeferenzen + Tool-Stack fuellen. USER.md selbst ist via
.gitignore vom Repo ausgeschlossen.
-->
+108 -4
View File
@@ -16,7 +16,9 @@ import asyncio
import base64 import base64
import json import json
import logging import logging
import mimetypes
import os import os
import re
import signal import signal
import ssl import ssl
import sys import sys
@@ -677,7 +679,10 @@ class ARIABridge:
while self.running: while self.running:
try: try:
logger.info("[core] Verbinde: %s", self.ws_url) logger.info("[core] Verbinde: %s", self.ws_url)
async with websockets.connect(self.ws_url) as ws: # max_size=50MB damit grosse Bilder/Voice-Uploads durchgehen.
# Python-websockets Default ist nur 1 MiB → 5MB JPEG sprengt
# das Limit, Connection wird silent gedroppt.
async with websockets.connect(self.ws_url, max_size=50 * 1024 * 1024) as ws:
# OpenClaw Handshake durchfuehren # OpenClaw Handshake durchfuehren
if not await self._openclaw_handshake(ws): if not await self._openclaw_handshake(ws):
logger.error("[core] Handshake fehlgeschlagen — Reconnect") logger.error("[core] Handshake fehlgeschlagen — Reconnect")
@@ -783,13 +788,29 @@ class ARIABridge:
await self._emit_activity("idle", "") await self._emit_activity("idle", "")
if not text: if not text:
logger.warning("[core] chat final ohne Text: %s", json.dumps(payload)[:200]) logger.warning("[core] chat final ohne Text: %s", json.dumps(payload)[:200])
# App+Diagnostic informieren statt stumm — sonst wartet die
# UI ewig auf eine Antwort die nicht kommt. Passiert z.B.
# wenn Claude-Vision das Bild ablehnt (leere Antwort)
# oder die Antwort nur aus Tool-Calls ohne Final-Text bestand.
await self._send_to_rvs({
"type": "chat",
"payload": {
"text": "[Hinweis] Antwort ohne Text — moeglicherweise Bild zu gross fuer Vision-API oder reine Tool-Ausfuehrung.",
"sender": "aria",
},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
return return
logger.info("[core] Antwort: '%s'", text[:80]) logger.info("[core] Antwort: '%s'", text[:80])
await self._process_core_response(text, payload) await self._process_core_response(text, payload)
return return
if state == "error": if state == "error":
error = payload.get("error", "Unbekannt") # OpenClaw nutzt errorMessage statt error bei state=error.
error = (payload.get("error")
or payload.get("errorMessage")
or payload.get("message")
or "Unbekannt")
logger.error("[core] Chat-Fehler: %s", error) logger.error("[core] Chat-Fehler: %s", error)
self._last_chat_final_at = asyncio.get_event_loop().time() self._last_chat_final_at = asyncio.get_event_loop().time()
await self._emit_activity("idle", "") await self._emit_activity("idle", "")
@@ -825,7 +846,12 @@ class ARIABridge:
return return
if event_name == "chat:error": if event_name == "chat:error":
error = payload.get("error", payload.get("message", "Unbekannt")) # OpenClaw legt den echten Text manchmal in errorMessage ab
# (state=error). Vorher wurde nur error/message gechecked → "Unbekannt".
error = (payload.get("error")
or payload.get("errorMessage")
or payload.get("message")
or "Unbekannt")
logger.error("[core] Chat-Fehler (legacy): %s", error) logger.error("[core] Chat-Fehler (legacy): %s", error)
await self._send_to_rvs({ await self._send_to_rvs({
"type": "chat", "type": "chat",
@@ -858,6 +884,48 @@ class ARIABridge:
pass pass
return payload.get("text", "") 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]]:
"""Sucht [FILE: /shared/uploads/...]-Marker, gibt (cleaned_text, file_list) zurueck."""
files: list[dict] = []
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)
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
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: async def _process_core_response(self, text: str, payload: dict) -> None:
"""Verarbeitet eine fertige Antwort von aria-core. """Verarbeitet eine fertige Antwort von aria-core.
@@ -872,6 +940,14 @@ class ARIABridge:
logger.info("[core] NO_REPLY empfangen — Antwort still verworfen") logger.info("[core] NO_REPLY empfangen — Antwort still verworfen")
return 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 = self._extract_file_markers(text)
for f in aria_files:
await self._broadcast_aria_file(f)
metadata = payload.get("metadata", {}) metadata = payload.get("metadata", {})
is_critical = metadata.get("critical", False) is_critical = metadata.get("critical", False)
requested_voice = metadata.get("voice") requested_voice = metadata.get("voice")
@@ -1141,7 +1217,8 @@ class ARIABridge:
try: try:
url = f"{current_url}?token={self.rvs_token}" url = f"{current_url}?token={self.rvs_token}"
logger.info("[rvs] Verbinde: %s", current_url) logger.info("[rvs] Verbinde: %s", current_url)
async with websockets.connect(url) as ws: # max_size=50MB (siehe core-Connect oben — gleicher Grund).
async with websockets.connect(url, max_size=50 * 1024 * 1024) as ws:
self.ws_rvs = ws self.ws_rvs = ws
retry_delay = 2 retry_delay = 2
logger.info("[rvs] Verbunden — warte auf App-Nachrichten") logger.info("[rvs] Verbunden — warte auf App-Nachrichten")
@@ -1461,6 +1538,31 @@ class ARIABridge:
size_kb = len(file_b64) // 1365 size_kb = len(file_b64) // 1365
logger.info("[rvs] Datei gespeichert: %s (%dKB)", file_path, size_kb) logger.info("[rvs] Datei gespeichert: %s (%dKB)", file_path, size_kb)
# Pixel-Bilder fuer Claude-Vision shrinken wenn > 2 MB. SVG/PDF/ZIP
# bleiben unangetastet (Vision laeuft eh nur auf Raster-Formaten).
CLAUDE_VISION_FORMATS = ("image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif")
if file_type.lower() in CLAUDE_VISION_FORMATS:
file_size_bytes = os.path.getsize(file_path)
if file_size_bytes > 2 * 1024 * 1024:
try:
from PIL import Image
with Image.open(file_path) as img:
orig_w, orig_h = img.size
# Anthropic-Empfehlung: max 1568px lange Seite. RGB-Konvertierung
# falls RGBA/Palette (JPEG braucht RGB).
img.thumbnail((1568, 1568), Image.Resampling.LANCZOS)
if img.mode in ("RGBA", "P"):
img = img.convert("RGB")
img.save(file_path, "JPEG", quality=85, optimize=True)
new_size_bytes = os.path.getsize(file_path)
logger.info("[rvs] Bild verkleinert: %dx%d%dx%d, %.1fMB → %.1fMB",
orig_w, orig_h, img.size[0], img.size[1],
file_size_bytes / 1024 / 1024,
new_size_bytes / 1024 / 1024)
except Exception as e:
logger.warning("[rvs] Bild-Resize fehlgeschlagen (%s) — Original wird genutzt: %s",
file_name, e)
# In Pending-Queue + Flush-Timer (anti-spam Buffering) # In Pending-Queue + Flush-Timer (anti-spam Buffering)
self._pending_files.append((file_path, file_name, file_type, size_kb, int(width or 0), int(height or 0))) self._pending_files.append((file_path, file_name, file_type, size_kb, int(width or 0), int(height or 0)))
if self._pending_files_flush_task and not self._pending_files_flush_task.done(): if self._pending_files_flush_task and not self._pending_files_flush_task.done():
@@ -1495,6 +1597,7 @@ class ARIABridge:
return return
with open(server_path, "rb") as f: with open(server_path, "rb") as f:
file_b64 = base64.b64encode(f.read()).decode("ascii") 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) logger.info("[rvs] Re-Download: %s (%dKB)", server_path, len(file_b64) // 1365)
await self._send_to_rvs({ await self._send_to_rvs({
"type": "file_response", "type": "file_response",
@@ -1503,6 +1606,7 @@ class ARIABridge:
"serverPath": server_path, "serverPath": server_path,
"base64": file_b64, "base64": file_b64,
"name": os.path.basename(server_path), "name": os.path.basename(server_path),
"mimeType": mime or "application/octet-stream",
}, },
"timestamp": int(asyncio.get_event_loop().time() * 1000), "timestamp": int(asyncio.get_event_loop().time() * 1000),
}) })
+3
View File
@@ -16,3 +16,6 @@ sounddevice
# Wake-Word Erkennung # Wake-Word Erkennung
openwakeword openwakeword
# Bild-Resizing (zu grosse Pixel-Bilder shrinken bevor Claude-Vision sie sieht — 5MB-Limit)
Pillow
+46 -1
View File
@@ -993,7 +993,17 @@
} }
if (msg.type === 'chat_final') { if (msg.type === 'chat_final') {
addChat('received', msg.text, 'chat:final'); // [FILE: /shared/uploads/aria_xxx.ext]-Marker aus dem Antworttext
// entfernen — die Datei kommt separat via file_from_aria.
// (Diagnostic empfaengt chat_final direkt vom Gateway, Bridge
// hat darum nicht filtern koennen.)
const cleaned = (msg.text || '').replace(/\[FILE:\s*\/shared\/uploads\/[^\]]+\]/gi, '').replace(/\n{3,}/g, '\n\n').trim();
addChat('received', cleaned, 'chat:final');
return;
}
if (msg.type === 'file_from_aria') {
const p = msg.payload || {};
addAriaFile(p);
return; return;
} }
if (msg.type === 'chat_delta') { return; } if (msg.type === 'chat_delta') { return; }
@@ -1475,6 +1485,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; let chatFullscreen = false;
function toggleChatFullscreen() { function toggleChatFullscreen() {
const modal = document.getElementById('chat-fullscreen'); const modal = document.getElementById('chat-fullscreen');
+5
View File
@@ -620,6 +620,11 @@ function connectRVS(forcePlain) {
type: "chat", type: "chat",
payload: { text: `Anhang: ${name}\n${serverPath}`, sender: "user" } 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") { } else if (msg.type === "heartbeat") {
// ignorieren // ignorieren
} else if (msg.type === "mode") { } else if (msg.type === "mode") {
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
+12
View File
@@ -89,6 +89,18 @@ 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] **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-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] **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
### App Features ### App Features
+1
View File
@@ -18,6 +18,7 @@ const ALLOWED_TYPES = new Set([
"update_check", "update_available", "update_download", "update_data", "update_check", "update_available", "update_download", "update_data",
"agent_activity", "cancel_request", "agent_activity", "cancel_request",
"audio_pcm", "audio_pcm",
"file_from_aria",
"xtts_delete_voice", "xtts_delete_voice",
"voice_preload", "voice_ready", "voice_preload", "voice_ready",
"stt_request", "stt_response", "stt_request", "stt_response",