Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 03fc465057 | |||
| b696b47feb | |||
| 6aae565541 | |||
| 214bd218a0 | |||
| 2afeee29ee | |||
| c8dee4c416 | |||
| f49f3c3b08 | |||
| c4bbb06710 | |||
| 4411cc4fff | |||
| 24a91887ef | |||
| 4e62b2919f | |||
| fa774156fe | |||
| 3b19f05c5b | |||
| fc3ecaacca | |||
| 08857093b5 | |||
| 62018b3e51 | |||
| 89e3a195a3 | |||
| f023ba0ac5 | |||
| a0570ef8f7 | |||
| facde1fef7 | |||
| 38106a2096 | |||
| a476afb311 | |||
| db4c7b9b72 | |||
| 3bc490b485 | |||
| dd6d70c46e | |||
| b1eaf42fef | |||
| fb9e5dcd10 | |||
| f95e71463f | |||
| 1088bff43d | |||
| cad68db2a2 | |||
| 50b10c8ac0 | |||
| a8b586ec92 | |||
| 632e1e4fa1 | |||
| 7e12816ebd | |||
| 8f64f8fb30 | |||
| b3ff3991c4 | |||
| a4ea387c98 | |||
| 68fbf74a23 | |||
| b857f778e9 | |||
| 31aa82b68c | |||
| de8eeb69e2 | |||
| f5970ce700 | |||
| ef1a4436ca | |||
| 981779cd9e | |||
| 3dcd2ae0b4 |
@@ -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/
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -79,8 +79,8 @@ android {
|
||||
applicationId "com.ariacockpit"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 906
|
||||
versionName "0.0.9.6"
|
||||
versionCode 10101
|
||||
versionName "0.1.1.1"
|
||||
// Fallback fuer Libraries mit Product Flavors
|
||||
missingDimensionStrategy 'react-native-camera', 'general'
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import com.facebook.react.uimanager.ViewManager
|
||||
|
||||
class ApkInstallerPackage : ReactPackage {
|
||||
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
|
||||
return listOf(ApkInstallerModule(reactContext))
|
||||
return listOf(ApkInstallerModule(reactContext), FileOpenerModule(reactContext))
|
||||
}
|
||||
|
||||
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aria-cockpit",
|
||||
"version": "0.0.9.6",
|
||||
"version": "0.1.1.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"android": "react-native run-android",
|
||||
@@ -10,31 +10,32 @@
|
||||
"build:apk": "cd android && ./gradlew assembleRelease"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "^1.21.0",
|
||||
"@react-native-community/geolocation": "^3.2.1",
|
||||
"@react-navigation/bottom-tabs": "^6.5.11",
|
||||
"@react-navigation/native": "^6.1.9",
|
||||
"react": "18.2.0",
|
||||
"react-native": "0.73.4",
|
||||
"@react-navigation/native": "^6.1.9",
|
||||
"@react-navigation/bottom-tabs": "^6.5.11",
|
||||
"react-native-screens": "3.27.0",
|
||||
"react-native-safe-area-context": "^4.8.2",
|
||||
"react-native-audio-recorder-player": "^3.6.7",
|
||||
"react-native-camera-kit": "^13.0.0",
|
||||
"react-native-document-picker": "^9.1.1",
|
||||
"react-native-sound": "^0.11.2",
|
||||
"@react-native-community/geolocation": "^3.2.1",
|
||||
"react-native-fs": "^2.20.0",
|
||||
"react-native-image-picker": "^7.1.0",
|
||||
"react-native-permissions": "^4.1.4",
|
||||
"react-native-camera-kit": "^13.0.0",
|
||||
"@react-native-async-storage/async-storage": "^1.21.0",
|
||||
"react-native-fs": "^2.20.0",
|
||||
"react-native-audio-recorder-player": "^3.6.7"
|
||||
"react-native-safe-area-context": "^4.8.2",
|
||||
"react-native-screens": "3.27.0",
|
||||
"react-native-sound": "^0.11.2",
|
||||
"react-native-svg": "^14.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.3.3",
|
||||
"@react-native/eslint-config": "^0.73.2",
|
||||
"@react-native/metro-config": "^0.73.5",
|
||||
"@react-native/typescript-config": "^0.73.1",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/react": "^18.2.48",
|
||||
"@types/react-native": "^0.73.0",
|
||||
"@react-native/eslint-config": "^0.73.2",
|
||||
"@react-native/typescript-config": "^0.73.1",
|
||||
"@react-native/metro-config": "^0.73.5",
|
||||
"metro-react-native-babel-preset": "^0.77.0",
|
||||
"jest": "^29.7.0",
|
||||
"@types/jest": "^29.5.11"
|
||||
"metro-react-native-babel-preset": "^0.77.0",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 `` 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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Modal,
|
||||
ToastAndroid,
|
||||
AppState,
|
||||
NativeModules,
|
||||
} from 'react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import RNFS from 'react-native-fs';
|
||||
@@ -80,6 +81,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). */
|
||||
@@ -179,6 +197,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 +371,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 +406,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 +1056,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 +1081,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 +1099,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>
|
||||
|
||||
@@ -50,6 +50,8 @@ import {
|
||||
TTS_SPEED_MAX,
|
||||
TTS_SPEED_STORAGE_KEY,
|
||||
} from '../services/audio';
|
||||
import audioService from '../services/audio';
|
||||
import { isVerboseLogging, setVerboseLogging } from '../services/logger';
|
||||
import {
|
||||
isWakeReadySoundEnabled,
|
||||
setWakeReadySoundEnabled,
|
||||
@@ -135,6 +137,8 @@ const SettingsScreen: React.FC = () => {
|
||||
const [vadSilenceDb, setVadSilenceDb] = useState<number | null>(null);
|
||||
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>('');
|
||||
@@ -224,6 +228,7 @@ const SettingsScreen: React.FC = () => {
|
||||
});
|
||||
isWakeReadySoundEnabled().then(setWakeReadySound);
|
||||
updateService.getApkCacheSize().then(setApkCacheInfo).catch(() => {});
|
||||
audioService.getTtsCacheSize().then(setTtsCacheInfo).catch(() => {});
|
||||
AsyncStorage.getItem('aria_xtts_voice').then(saved => {
|
||||
if (saved) setXttsVoice(saved);
|
||||
});
|
||||
@@ -1251,11 +1256,65 @@ const SettingsScreen: React.FC = () => {
|
||||
</TouchableOpacity>
|
||||
</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 === */}
|
||||
{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}>
|
||||
|
||||
+142
-16
@@ -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>;
|
||||
@@ -301,6 +303,12 @@ class AudioService {
|
||||
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
|
||||
@@ -310,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);
|
||||
}
|
||||
@@ -363,6 +377,10 @@ class AudioService {
|
||||
console.log('[Audio] pauseForCall: %s', reason || '(no reason)');
|
||||
this._conversationFocusActive = false;
|
||||
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
|
||||
stopBackgroundAudio().catch(() => {});
|
||||
// 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
|
||||
// captureInterruption bei einem zweiten Anruf die Position nicht
|
||||
// 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];
|
||||
// Virtuelle Start-Zeit so setzen, dass captureInterruption (das den
|
||||
// Leading-Silence-Offset wieder abzieht) die korrekte Position liefert.
|
||||
@@ -778,8 +796,13 @@ class AudioService {
|
||||
if (!base64Data) return;
|
||||
// Mute-Flag respektieren — robust gegen Race-Conditions zwischen User-
|
||||
// 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);
|
||||
console.log('[Audio] playAudio: queued (queue=%d isPlaying=%s pausedForCall=%s)',
|
||||
this.audioQueue.length, this.isPlaying, this._pausedForCall);
|
||||
if (!this.isPlaying) {
|
||||
this._playNext();
|
||||
}
|
||||
@@ -845,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 '';
|
||||
@@ -890,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;
|
||||
@@ -1019,10 +1054,12 @@ class AudioService {
|
||||
console.warn('[Audio] Cache-Datei existiert nicht mehr:', cleanPath);
|
||||
return;
|
||||
}
|
||||
const msgIdMatch = cleanPath.match(/([0-9a-f-]+)\.wav$/i);
|
||||
if (msgIdMatch) {
|
||||
this.currentPlaybackMsgId = msgIdMatch[1];
|
||||
// Start-Zeit so setzen dass elapsed = LEADING (≈ 0 nach captureInterruption-Berechnung)
|
||||
// Dateiname ohne .wav als messageId nehmen (egal ob UUID oder andere ID)
|
||||
const fileMatch = cleanPath.match(/([^/\\]+)\.wav$/i);
|
||||
const msgId = fileMatch ? fileMatch[1] : '';
|
||||
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;
|
||||
}
|
||||
const b64 = await RNFS.readFile(cleanPath, 'base64');
|
||||
@@ -1053,9 +1090,15 @@ class AudioService {
|
||||
}
|
||||
|
||||
private _firePlaybackStarted(): void {
|
||||
// Tracking fuer Auto-Resume nach Anruf-Pause
|
||||
this.playbackStartTime = Date.now();
|
||||
this.currentPlaybackMsgId = this.pcmMessageId || '';
|
||||
// Tracking fuer Auto-Resume nach Anruf-Pause: NUR setzen wenn ein
|
||||
// PCM-Stream laeuft (Live-TTS). Bei Play-Button / Resume-Sound hat der
|
||||
// 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 => {
|
||||
try { cb(); } catch (e) { console.warn('[Audio] playbackStarted listener err:', e); }
|
||||
});
|
||||
@@ -1108,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;
|
||||
@@ -1148,14 +1193,39 @@ 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
|
||||
// wenn Wiedergabe abgebrochen wird (Anruf, Cancel, Barge-In).
|
||||
stopBackgroundAudio().catch(() => {});
|
||||
@@ -1166,6 +1236,11 @@ class AudioService {
|
||||
this.currentSound.release();
|
||||
this.currentSound = null;
|
||||
}
|
||||
if (this.resumeSound) {
|
||||
this.resumeSound.stop();
|
||||
this.resumeSound.release();
|
||||
this.resumeSound = null;
|
||||
}
|
||||
if (this.preloadedSound) {
|
||||
this.preloadedSound.release();
|
||||
this.preloadedSound = null;
|
||||
@@ -1181,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(() => {});
|
||||
}
|
||||
@@ -1223,19 +1302,29 @@ class AudioService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Alte Aufnahme- und TTS-Files aus dem Cache loeschen (>30s alt). */
|
||||
private async _cleanupStaleCacheFiles(): Promise<void> {
|
||||
/** Alte Aufnahme- und TTS-Files aus dem Cache loeschen.
|
||||
* 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 {
|
||||
const files = await RNFS.readDir(RNFS.CachesDirectoryPath);
|
||||
const now = Date.now();
|
||||
let removed = 0;
|
||||
let freedBytes = 0;
|
||||
for (const f of files) {
|
||||
if (!f.isFile()) continue;
|
||||
if (!f.name.startsWith('aria_recording_') && !f.name.startsWith('aria_tts_')) continue;
|
||||
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(() => {});
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
if (removed > 0) {
|
||||
console.log('[Audio] Cache-Cleanup: %d Files entfernt, %.1fMB freigegeben',
|
||||
removed, freedBytes / 1024 / 1024);
|
||||
}
|
||||
} catch {
|
||||
// silent — cleanup ist best-effort
|
||||
}
|
||||
@@ -1262,6 +1351,43 @@ class AudioService {
|
||||
// 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
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
@@ -202,14 +202,19 @@ class PhoneCallService {
|
||||
audioService.endCallPause();
|
||||
wakeWordService.resumeFromCall().catch(() => {});
|
||||
ToastAndroid.show(toast, ToastAndroid.SHORT);
|
||||
// Auto-Resume: ab gemerkter Position weiterspielen wenn ARIA vor dem
|
||||
// Anruf gerade redete. Wartet bis zu 30s auf den WAV-Cache (falls
|
||||
// final-Marker erst nach dem Anruf-Ende kam).
|
||||
audioService.resumeFromInterruption(30000).then(ok => {
|
||||
if (ok) {
|
||||
console.log('[PhoneCall] Auto-Resume von gemerkter Position gestartet');
|
||||
}
|
||||
}).catch(() => {});
|
||||
// 800ms warten bevor Auto-Resume — sonst kollidiert ARIA's neuer Focus-
|
||||
// Request mit Spotify's Auto-Resume nach Anruf-Ende. System haengt nach
|
||||
// dem Auflegen noch im IN_CALL-Mode-Uebergang, Spotify schaut auf Focus-
|
||||
// Gain und wuerde sofort wieder LOSS sehen → bleibt pausiert.
|
||||
// Mit Delay: Spotify resumed kurz, dann pausiert ARIA wieder ordnungs-
|
||||
// gemaess. Wenn ARIA nichts pending hat, bleibt Spotify einfach an.
|
||||
setTimeout(() => {
|
||||
audioService.resumeFromInterruption(30000).then(ok => {
|
||||
if (ok) {
|
||||
console.log('[PhoneCall] Auto-Resume von gemerkter Position gestartet');
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, 800);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
-->
|
||||
+108
-4
@@ -16,7 +16,9 @@ import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import ssl
|
||||
import sys
|
||||
@@ -677,7 +679,10 @@ class ARIABridge:
|
||||
while self.running:
|
||||
try:
|
||||
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
|
||||
if not await self._openclaw_handshake(ws):
|
||||
logger.error("[core] Handshake fehlgeschlagen — Reconnect")
|
||||
@@ -783,13 +788,29 @@ class ARIABridge:
|
||||
await self._emit_activity("idle", "")
|
||||
if not text:
|
||||
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
|
||||
logger.info("[core] Antwort: '%s'", text[:80])
|
||||
await self._process_core_response(text, payload)
|
||||
return
|
||||
|
||||
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)
|
||||
self._last_chat_final_at = asyncio.get_event_loop().time()
|
||||
await self._emit_activity("idle", "")
|
||||
@@ -825,7 +846,12 @@ class ARIABridge:
|
||||
return
|
||||
|
||||
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)
|
||||
await self._send_to_rvs({
|
||||
"type": "chat",
|
||||
@@ -858,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.
|
||||
|
||||
@@ -872,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")
|
||||
@@ -1141,7 +1217,8 @@ class ARIABridge:
|
||||
try:
|
||||
url = f"{current_url}?token={self.rvs_token}"
|
||||
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
|
||||
retry_delay = 2
|
||||
logger.info("[rvs] Verbunden — warte auf App-Nachrichten")
|
||||
@@ -1461,6 +1538,31 @@ class ARIABridge:
|
||||
size_kb = len(file_b64) // 1365
|
||||
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)
|
||||
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():
|
||||
@@ -1495,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",
|
||||
@@ -1503,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),
|
||||
})
|
||||
|
||||
@@ -16,3 +16,6 @@ sounddevice
|
||||
|
||||
# Wake-Word Erkennung
|
||||
openwakeword
|
||||
|
||||
# Bild-Resizing (zu grosse Pixel-Bilder shrinken bevor Claude-Vision sie sieht — 5MB-Limit)
|
||||
Pillow
|
||||
|
||||
+46
-1
@@ -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');
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user