Compare commits

..

31 Commits

Author SHA1 Message Date
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
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
22 changed files with 734 additions and 64 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/
+5
View File
@@ -13,6 +13,7 @@ import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import ChatScreen from './src/screens/ChatScreen';
import SettingsScreen from './src/screens/SettingsScreen';
import rvs from './src/services/rvs';
import { initLogger } from './src/services/logger';
// --- Navigation ---
@@ -44,6 +45,10 @@ const TAB_ICONS: Record<string, { active: string; inactive: string }> = {
const App: React.FC = () => {
// Beim Start: gespeicherte RVS-Konfiguration laden und verbinden
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 config = await rvs.loadConfig();
if (config) {
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 10002
versionName "0.1.0.2"
versionCode 10102
versionName "0.1.1.2"
// 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<*, *>> {
@@ -131,6 +131,58 @@ class AudioFocusModule(reactContext: ReactApplicationContext) : ReactContextBase
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() {
val am = audioManager() ?: return
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"?>
<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.2",
"version": "0.1.1.2",
"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"
}
}
+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
* automatisch klickbar) und ein einzelnes <Text selectable> ohne nested
* <Text> mit eigenem onPress. Nested Text mit onPress fingen die Long-Press-
* Geste ab, damit war Markieren+Kopieren defekt.
* - 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 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>;
}
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 (
<Text style={style} selectable dataDetectorType="all">
{text}
</Text>
<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>
);
};
+96 -18
View File
@@ -20,9 +20,11 @@ import {
Modal,
ToastAndroid,
AppState,
NativeModules,
} 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 rvs, { RVSMessage, ConnectionState } from '../services/rvs';
import audioService from '../services/audio';
import wakeWordService from '../services/wakeword';
@@ -80,6 +82,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 +114,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 +127,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 +209,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 +383,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 +418,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 +1068,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 +1093,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>
))}
@@ -1038,19 +1111,24 @@ const ChatScreen: React.FC = () => {
{!isUser && item.text.length > 0 && (
<TouchableOpacity
style={styles.playButton}
onPress={() => {
if (item.audioPath) {
audioService.playFromPath(item.audioPath);
} else {
// 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 || '',
});
onPress={async () => {
// Erst lokalen Cache pruefen — audioPath kann auf eine geloeschte
// Datei zeigen (TTS-Cache geleert oder Auto-Cleanup). In dem Fall
// ueber RVS neu rendern lassen statt stumm zu bleiben.
const cachePath = item.audioPath?.replace(/^file:\/\//, '') || '';
const cached = cachePath ? await RNFS.exists(cachePath).catch(() => false) : false;
if (cached) {
audioService.playFromPath(item.audioPath!);
return;
}
// 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>
+24
View File
@@ -51,6 +51,7 @@ import {
TTS_SPEED_STORAGE_KEY,
} from '../services/audio';
import audioService from '../services/audio';
import { isVerboseLogging, setVerboseLogging } from '../services/logger';
import {
isWakeReadySoundEnabled,
setWakeReadySoundEnabled,
@@ -137,6 +138,7 @@ const SettingsScreen: React.FC = () => {
const [showVadInfo, setShowVadInfo] = useState(false);
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 [wakeKeyword, setWakeKeyword] = useState<string>(DEFAULT_KEYWORD);
const [wakeStatus, setWakeStatus] = useState<string>('');
@@ -1291,6 +1293,28 @@ const SettingsScreen: React.FC = () => {
{/* === Logs === */}
{currentSection === 'protocol' && (<>
<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}>
{/* Tab-Umschalter */}
<View style={styles.tabRow}>
+51 -4
View File
@@ -41,6 +41,8 @@ const { AudioFocus, PcmStreamPlayer } = NativeModules as {
requestDuck: () => Promise<boolean>;
requestExclusive: () => Promise<boolean>;
release: () => Promise<boolean>;
kickReleaseMedia: () => Promise<boolean>;
getMode?: () => Promise<number>;
};
PcmStreamPlayer?: {
start: (sampleRate: number, channels: number, prerollSeconds: number) => Promise<boolean>;
@@ -316,13 +318,19 @@ class AudioService {
* unterdrueckt — der Focus bleibt fuer die ganze Konversation gehalten. */
private _releaseFocusDeferred(): void {
if (this._conversationFocusActive) {
console.log('[Audio] _releaseFocusDeferred: Conversation aktiv → kein Release');
this._cancelDeferredFocusRelease();
return;
}
this._cancelDeferredFocusRelease();
console.log('[Audio] _releaseFocusDeferred: in %dms', this.FOCUS_RELEASE_DELAY_MS);
this.focusReleaseTimer = setTimeout(() => {
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(() => {});
}, this.FOCUS_RELEASE_DELAY_MS);
}
@@ -860,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 '';
@@ -905,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;
@@ -1131,11 +1151,13 @@ class AudioService {
}
this.currentSound = sound;
console.log('[Audio] Sound.play startet (path=%s)', soundPath);
// Naechstes Audio schon vorbereiten waehrend dieses abspielt
this._preloadNext();
sound.play((success) => {
console.log('[Audio] Sound.play callback: success=%s queue=%d', success, this.audioQueue.length);
if (!success) console.warn('[Audio] Wiedergabe fehlgeschlagen');
sound.release();
this.currentSound = null;
@@ -1171,16 +1193,37 @@ 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; }
/** Laufende Wiedergabe stoppen + Queue leeren */
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
@@ -1213,7 +1256,11 @@ 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(() => {});
}
+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(() => {});
}
+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
+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)
- 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
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.
-->
+54
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
@@ -882,6 +884,48 @@ 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]]:
"""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:
"""Verarbeitet eine fertige Antwort von aria-core.
@@ -896,6 +940,14 @@ 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 = self._extract_file_markers(text)
for f in aria_files:
await self._broadcast_aria_file(f)
metadata = payload.get("metadata", {})
is_critical = metadata.get("critical", False)
requested_voice = metadata.get("voice")
@@ -1545,6 +1597,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 +1606,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),
})
+46 -1
View File
@@ -993,7 +993,17 @@
}
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;
}
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;
function toggleChatFullscreen() {
const modal = document.getElementById('chat-fullscreen');
+5
View File
@@ -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") {
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] **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
### App Features
+1
View File
@@ -18,6 +18,7 @@ const ALLOWED_TYPES = new Set([
"update_check", "update_available", "update_download", "update_data",
"agent_activity", "cancel_request",
"audio_pcm",
"file_from_aria",
"xtts_delete_voice",
"voice_preload", "voice_ready",
"stt_request", "stt_response",