Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 24a91887ef | |||
| 4e62b2919f | |||
| fa774156fe | |||
| 3b19f05c5b | |||
| fc3ecaacca | |||
| 08857093b5 | |||
| 62018b3e51 | |||
| 89e3a195a3 | |||
| f023ba0ac5 | |||
| a0570ef8f7 | |||
| facde1fef7 | |||
| 38106a2096 | |||
| a476afb311 | |||
| db4c7b9b72 | |||
| 3bc490b485 | |||
| dd6d70c46e | |||
| b1eaf42fef | |||
| fb9e5dcd10 | |||
| f95e71463f | |||
| 1088bff43d |
@@ -13,6 +13,10 @@ aria-data/config/*.env
|
|||||||
!aria-data/config/*.env.example
|
!aria-data/config/*.env.example
|
||||||
!aria-data/config/openclaw.env
|
!aria-data/config/openclaw.env
|
||||||
|
|
||||||
|
# Privater User-Profile-Snippet (Tool-Stack, interne URLs)
|
||||||
|
aria-data/config/USER.md
|
||||||
|
!aria-data/config/USER.md.example
|
||||||
|
|
||||||
# ── ARIAs Gedächtnis (nur per tar gesichert) ────
|
# ── ARIAs Gedächtnis (nur per tar gesichert) ────
|
||||||
aria-data/brain/
|
aria-data/brain/
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
|||||||
import ChatScreen from './src/screens/ChatScreen';
|
import ChatScreen from './src/screens/ChatScreen';
|
||||||
import SettingsScreen from './src/screens/SettingsScreen';
|
import SettingsScreen from './src/screens/SettingsScreen';
|
||||||
import rvs from './src/services/rvs';
|
import rvs from './src/services/rvs';
|
||||||
|
import { initLogger } from './src/services/logger';
|
||||||
|
|
||||||
// --- Navigation ---
|
// --- Navigation ---
|
||||||
|
|
||||||
@@ -44,6 +45,10 @@ const TAB_ICONS: Record<string, { active: string; inactive: string }> = {
|
|||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
// Beim Start: gespeicherte RVS-Konfiguration laden und verbinden
|
// Beim Start: gespeicherte RVS-Konfiguration laden und verbinden
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Verbose-Logging-Setting laden BEVOR andere Module loslegen.
|
||||||
|
// initLogger ist async aber blockt nichts — solange er noch laueft,
|
||||||
|
// loggen wir normal (Default an), danach respektiert console.log das Setting.
|
||||||
|
initLogger().catch(() => {});
|
||||||
const initConnection = async () => {
|
const initConnection = async () => {
|
||||||
const config = await rvs.loadConfig();
|
const config = await rvs.loadConfig();
|
||||||
if (config) {
|
if (config) {
|
||||||
|
|||||||
@@ -79,8 +79,8 @@ android {
|
|||||||
applicationId "com.ariacockpit"
|
applicationId "com.ariacockpit"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 10002
|
versionCode 10100
|
||||||
versionName "0.1.0.2"
|
versionName "0.1.1.0"
|
||||||
// Fallback fuer Libraries mit Product Flavors
|
// Fallback fuer Libraries mit Product Flavors
|
||||||
missingDimensionStrategy 'react-native-camera', 'general'
|
missingDimensionStrategy 'react-native-camera', 'general'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import com.facebook.react.uimanager.ViewManager
|
|||||||
|
|
||||||
class ApkInstallerPackage : ReactPackage {
|
class ApkInstallerPackage : ReactPackage {
|
||||||
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
|
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
|
||||||
return listOf(ApkInstallerModule(reactContext))
|
return listOf(ApkInstallerModule(reactContext), FileOpenerModule(reactContext))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
|
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
|
||||||
|
|||||||
@@ -131,6 +131,58 @@ class AudioFocusModule(reactContext: ReactApplicationContext) : ReactContextBase
|
|||||||
promise.resolve(true)
|
promise.resolve(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Den USAGE_MEDIA-Focus-Stack im System aufmischen, damit Spotify/YouTube
|
||||||
|
* resumen wenn ein anderer Player (z.B. react-native-sound) seinen Focus
|
||||||
|
* nicht ordnungsgemaess released hat. Strategie: kurz selbst USAGE_MEDIA
|
||||||
|
* GAIN beanspruchen — das System invalidiert dabei den haengenden Stack-
|
||||||
|
* Eintrag des anderen Players — und sofort wieder abandonen. Spotify
|
||||||
|
* bekommt den Focus-Gain und resumed.
|
||||||
|
*
|
||||||
|
* Workaround fuer das react-native-sound-Bug: Sound.stop()/release()
|
||||||
|
* laesst den AudioFocusRequest haengen.
|
||||||
|
*/
|
||||||
|
@ReactMethod
|
||||||
|
fun kickReleaseMedia(promise: Promise) {
|
||||||
|
val am = audioManager()
|
||||||
|
if (am == null) {
|
||||||
|
promise.resolve(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Async laufen lassen — wir wollen einen request, Pause, dann abandon.
|
||||||
|
// Ohne Pause merkt das System (und damit Spotify) die kurze Owner-
|
||||||
|
// Wechsel oft gar nicht. 250ms reicht erfahrungsgemaess fuer den
|
||||||
|
// Focus-Stack-Refresh.
|
||||||
|
Thread {
|
||||||
|
try {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val attrs = AudioAttributes.Builder()
|
||||||
|
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||||
|
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
|
||||||
|
.build()
|
||||||
|
val kickListener = AudioManager.OnAudioFocusChangeListener { /* ignorieren */ }
|
||||||
|
val kickReq = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
|
||||||
|
.setAudioAttributes(attrs)
|
||||||
|
.setOnAudioFocusChangeListener(kickListener)
|
||||||
|
.build()
|
||||||
|
am.requestAudioFocus(kickReq)
|
||||||
|
Thread.sleep(250)
|
||||||
|
am.abandonAudioFocusRequest(kickReq)
|
||||||
|
} else {
|
||||||
|
val kickListener = AudioManager.OnAudioFocusChangeListener { /* ignorieren */ }
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
am.requestAudioFocus(kickListener, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN)
|
||||||
|
Thread.sleep(250)
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
am.abandonAudioFocus(kickListener)
|
||||||
|
}
|
||||||
|
Log.i(TAG, "kickReleaseMedia: USAGE_MEDIA-Stack aufgemischt (250ms Pause)")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "kickReleaseMedia failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}.start()
|
||||||
|
promise.resolve(true)
|
||||||
|
}
|
||||||
|
|
||||||
private fun release() {
|
private fun release() {
|
||||||
val am = audioManager() ?: return
|
val am = audioManager() ?: return
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package com.ariacockpit
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import com.facebook.react.bridge.Promise
|
||||||
|
import com.facebook.react.bridge.ReactApplicationContext
|
||||||
|
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||||
|
import com.facebook.react.bridge.ReactMethod
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Oeffnet eine beliebige Datei (PDF, Bild, Office-Doc, ...) mit der vom User
|
||||||
|
* gewaehlten App via Android-Intent-Picker. Nutzt FileProvider damit auch
|
||||||
|
* Android 7+ (content:// statt file://) das URI lesen darf.
|
||||||
|
*
|
||||||
|
* MIME-Type wird vom Caller bestimmt — App-Auswahl ist davon abhaengig (PDF
|
||||||
|
* geht an PDF-Viewer, image/jpeg an Galerie, etc.).
|
||||||
|
*/
|
||||||
|
class FileOpenerModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
|
||||||
|
override fun getName() = "FileOpener"
|
||||||
|
|
||||||
|
@ReactMethod
|
||||||
|
fun open(filePath: String, mimeType: String, promise: Promise) {
|
||||||
|
try {
|
||||||
|
val cleanPath = filePath.removePrefix("file://")
|
||||||
|
val file = File(cleanPath)
|
||||||
|
if (!file.exists()) {
|
||||||
|
promise.reject("FILE_NOT_FOUND", "Datei nicht gefunden: $cleanPath")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val context = reactApplicationContext
|
||||||
|
val uri: Uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
|
FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||||
|
} else {
|
||||||
|
Uri.fromFile(file)
|
||||||
|
}
|
||||||
|
val safeMime = if (mimeType.isBlank()) "application/octet-stream" else mimeType
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||||
|
setDataAndType(uri, safeMime)
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
}
|
||||||
|
// Chooser zeigt Android-Auswahl falls mehrere Apps das MIME oeffnen koennen.
|
||||||
|
val chooser = Intent.createChooser(intent, "Oeffnen mit").apply {
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
context.startActivity(chooser)
|
||||||
|
promise.resolve(true)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
promise.reject("OPEN_ERROR", e.message, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<paths>
|
<paths>
|
||||||
<cache-path name="cache" path="." />
|
<cache-path name="cache" path="." />
|
||||||
|
<files-path name="files" path="." />
|
||||||
|
<external-path name="external" path="." />
|
||||||
|
<external-files-path name="external_files" path="." />
|
||||||
|
<external-cache-path name="external_cache" path="." />
|
||||||
</paths>
|
</paths>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aria-cockpit",
|
"name": "aria-cockpit",
|
||||||
"version": "0.1.0.2",
|
"version": "0.1.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"android": "react-native run-android",
|
"android": "react-native run-android",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
Modal,
|
Modal,
|
||||||
ToastAndroid,
|
ToastAndroid,
|
||||||
AppState,
|
AppState,
|
||||||
|
NativeModules,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import RNFS from 'react-native-fs';
|
import RNFS from 'react-native-fs';
|
||||||
@@ -80,6 +81,23 @@ const capMessages = (msgs: ChatMessage[]): ChatMessage[] =>
|
|||||||
const DEFAULT_ATTACHMENT_DIR = `${RNFS.DocumentDirectoryPath}/chat_attachments`;
|
const DEFAULT_ATTACHMENT_DIR = `${RNFS.DocumentDirectoryPath}/chat_attachments`;
|
||||||
const STORAGE_PATH_KEY = 'aria_attachment_storage_path';
|
const STORAGE_PATH_KEY = 'aria_attachment_storage_path';
|
||||||
|
|
||||||
|
const { FileOpener } = NativeModules as {
|
||||||
|
FileOpener?: { open: (filePath: string, mimeType: string) => Promise<boolean> };
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Datei mit Android-Intent-Picker oeffnen (System waehlt App nach MIME). */
|
||||||
|
async function openFileWithIntent(filePath: string, mimeType: string): Promise<void> {
|
||||||
|
if (!FileOpener) {
|
||||||
|
ToastAndroid.show('FileOpener Native Module fehlt', ToastAndroid.SHORT);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await FileOpener.open(filePath, mimeType || 'application/octet-stream');
|
||||||
|
} catch (err: any) {
|
||||||
|
ToastAndroid.show(`Oeffnen fehlgeschlagen: ${err?.message || err}`, ToastAndroid.LONG);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Image-Vorschau in der Chat-Bubble. Misst die echte Bild-Dimension via
|
/** Image-Vorschau in der Chat-Bubble. Misst die echte Bild-Dimension via
|
||||||
* Image.getSize + setzt aspectRatio dynamisch — dadurch passt sich die
|
* Image.getSize + setzt aspectRatio dynamisch — dadurch passt sich die
|
||||||
* Bubble ans Bild an (kein "Strich" mehr bei breiten oder hohen Bildern). */
|
* Bubble ans Bild an (kein "Strich" mehr bei breiten oder hohen Bildern). */
|
||||||
@@ -179,6 +197,10 @@ const ChatScreen: React.FC = () => {
|
|||||||
|
|
||||||
const flatListRef = useRef<FlatList>(null);
|
const flatListRef = useRef<FlatList>(null);
|
||||||
const messageIdCounter = useRef(0);
|
const messageIdCounter = useRef(0);
|
||||||
|
// ServerPaths fuer die der User auf "oeffnen" geklickt hat — beim
|
||||||
|
// file_response wird die Datei nach dem Speichern direkt mit dem System-
|
||||||
|
// Intent geoeffnet (PDF-Viewer, Galerie, etc.).
|
||||||
|
const autoOpenPaths = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
// Eindeutige Message-ID generieren
|
// Eindeutige Message-ID generieren
|
||||||
const nextId = (): string => {
|
const nextId = (): string => {
|
||||||
@@ -349,11 +371,32 @@ const ChatScreen: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// file_from_aria: ARIA hat eine Datei rausgegeben → als ARIA-Bubble anzeigen
|
||||||
|
if (message.type === 'file_from_aria') {
|
||||||
|
const p = message.payload || {};
|
||||||
|
const ariaMsg: ChatMessage = {
|
||||||
|
id: nextId(),
|
||||||
|
sender: 'aria',
|
||||||
|
text: '',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
attachments: [{
|
||||||
|
type: (typeof p.mimeType === 'string' && p.mimeType.startsWith('image/')) ? 'image' : 'file',
|
||||||
|
name: (p.name as string) || 'datei',
|
||||||
|
size: (p.size as number) || 0,
|
||||||
|
mimeType: (p.mimeType as string) || '',
|
||||||
|
serverPath: (p.serverPath as string) || '',
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
setMessages(prev => capMessages([...prev, ariaMsg]));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// file_response: Re-Download von Server — lokal speichern
|
// file_response: Re-Download von Server — lokal speichern
|
||||||
if (message.type === 'file_response') {
|
if (message.type === 'file_response') {
|
||||||
const reqId = (message.payload.requestId as string) || '';
|
const reqId = (message.payload.requestId as string) || '';
|
||||||
const b64 = (message.payload.base64 as string) || '';
|
const b64 = (message.payload.base64 as string) || '';
|
||||||
const serverPath = (message.payload.serverPath as string) || '';
|
const serverPath = (message.payload.serverPath as string) || '';
|
||||||
|
const mimeType = (message.payload.mimeType as string) || '';
|
||||||
if (b64 && reqId) {
|
if (b64 && reqId) {
|
||||||
const fileName = (message.payload.name as string) || 'download';
|
const fileName = (message.payload.name as string) || 'download';
|
||||||
persistAttachment(b64, reqId, fileName).then(filePath => {
|
persistAttachment(b64, reqId, fileName).then(filePath => {
|
||||||
@@ -363,6 +406,11 @@ const ChatScreen: React.FC = () => {
|
|||||||
a.serverPath === serverPath ? { ...a, uri: filePath } : a
|
a.serverPath === serverPath ? { ...a, uri: filePath } : a
|
||||||
),
|
),
|
||||||
})));
|
})));
|
||||||
|
// Wenn der User dieses File explizit oeffnen wollte → Intent-Picker
|
||||||
|
if (serverPath && autoOpenPaths.current.has(serverPath)) {
|
||||||
|
autoOpenPaths.current.delete(serverPath);
|
||||||
|
openFileWithIntent(filePath.replace(/^file:\/\//, ''), mimeType);
|
||||||
|
}
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -1008,7 +1056,22 @@ const ChatScreen: React.FC = () => {
|
|||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.attachmentFile}>
|
<TouchableOpacity
|
||||||
|
style={styles.attachmentFile}
|
||||||
|
onPress={() => {
|
||||||
|
// Lokal vorhanden \u2192 direkt mit System-Intent oeffnen
|
||||||
|
if (att.uri) {
|
||||||
|
openFileWithIntent(att.uri.replace(/^file:\/\//, ''), att.mimeType || '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Sonst: file_request \u2192 bei file_response wird die Datei
|
||||||
|
// gespeichert UND geoeffnet (autoOpenPaths-Tracking).
|
||||||
|
if (att.serverPath) {
|
||||||
|
autoOpenPaths.current.add(att.serverPath);
|
||||||
|
rvs.send('file_request' as any, { serverPath: att.serverPath, requestId: item.id });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Text style={styles.attachmentFileIcon}>
|
<Text style={styles.attachmentFileIcon}>
|
||||||
{att.mimeType?.includes('pdf') ? '\uD83D\uDCC4' :
|
{att.mimeType?.includes('pdf') ? '\uD83D\uDCC4' :
|
||||||
att.mimeType?.includes('word') || att.mimeType?.includes('document') ? '\uD83D\uDCC3' :
|
att.mimeType?.includes('word') || att.mimeType?.includes('document') ? '\uD83D\uDCC3' :
|
||||||
@@ -1018,12 +1081,10 @@ const ChatScreen: React.FC = () => {
|
|||||||
<Text style={styles.attachmentFileName} numberOfLines={1}>{att.name}</Text>
|
<Text style={styles.attachmentFileName} numberOfLines={1}>{att.name}</Text>
|
||||||
{att.size ? <Text style={styles.attachmentFileSize}>{Math.round(att.size / 1024)}KB</Text> : null}
|
{att.size ? <Text style={styles.attachmentFileSize}>{Math.round(att.size / 1024)}KB</Text> : null}
|
||||||
{!att.uri && att.serverPath && (
|
{!att.uri && att.serverPath && (
|
||||||
<TouchableOpacity onPress={() => rvs.send('file_request' as any, { serverPath: att.serverPath, requestId: item.id })}>
|
<Text style={[styles.attachmentFileSize, {color: '#0096FF'}]}>(tippen zum oeffnen)</Text>
|
||||||
<Text style={[styles.attachmentFileSize, {color: '#0096FF'}]}>(laden)</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
)}
|
||||||
{!att.uri && !att.serverPath && <Text style={styles.attachmentFileSize}>(nicht verfuegbar)</Text>}
|
{!att.uri && !att.serverPath && <Text style={styles.attachmentFileSize}>(nicht verfuegbar)</Text>}
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
@@ -1038,19 +1099,24 @@ const ChatScreen: React.FC = () => {
|
|||||||
{!isUser && item.text.length > 0 && (
|
{!isUser && item.text.length > 0 && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.playButton}
|
style={styles.playButton}
|
||||||
onPress={() => {
|
onPress={async () => {
|
||||||
if (item.audioPath) {
|
// Erst lokalen Cache pruefen — audioPath kann auf eine geloeschte
|
||||||
audioService.playFromPath(item.audioPath);
|
// Datei zeigen (TTS-Cache geleert oder Auto-Cleanup). In dem Fall
|
||||||
} else {
|
// ueber RVS neu rendern lassen statt stumm zu bleiben.
|
||||||
// messageId mitschicken damit die Bridge das generierte Audio
|
const cachePath = item.audioPath?.replace(/^file:\/\//, '') || '';
|
||||||
// wieder mit der Nachricht verknuepft (fuer den naechsten Replay aus Cache)
|
const cached = cachePath ? await RNFS.exists(cachePath).catch(() => false) : false;
|
||||||
rvs.send('tts_request' as any, {
|
if (cached) {
|
||||||
text: item.text,
|
audioService.playFromPath(item.audioPath!);
|
||||||
voice: localXttsVoiceRef.current,
|
return;
|
||||||
speed: ttsSpeedRef.current,
|
|
||||||
messageId: item.messageId || '',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
// messageId mitschicken damit die Bridge das generierte Audio
|
||||||
|
// wieder mit der Nachricht verknuepft (fuer den naechsten Replay aus Cache)
|
||||||
|
rvs.send('tts_request' as any, {
|
||||||
|
text: item.text,
|
||||||
|
voice: localXttsVoiceRef.current,
|
||||||
|
speed: ttsSpeedRef.current,
|
||||||
|
messageId: item.messageId || '',
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text style={styles.playButtonText}>{'\uD83D\uDD0A'}</Text>
|
<Text style={styles.playButtonText}>{'\uD83D\uDD0A'}</Text>
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import {
|
|||||||
TTS_SPEED_STORAGE_KEY,
|
TTS_SPEED_STORAGE_KEY,
|
||||||
} from '../services/audio';
|
} from '../services/audio';
|
||||||
import audioService from '../services/audio';
|
import audioService from '../services/audio';
|
||||||
|
import { isVerboseLogging, setVerboseLogging } from '../services/logger';
|
||||||
import {
|
import {
|
||||||
isWakeReadySoundEnabled,
|
isWakeReadySoundEnabled,
|
||||||
setWakeReadySoundEnabled,
|
setWakeReadySoundEnabled,
|
||||||
@@ -137,6 +138,7 @@ const SettingsScreen: React.FC = () => {
|
|||||||
const [showVadInfo, setShowVadInfo] = useState(false);
|
const [showVadInfo, setShowVadInfo] = useState(false);
|
||||||
const [apkCacheInfo, setApkCacheInfo] = useState<{count: number, totalMB: number} | null>(null);
|
const [apkCacheInfo, setApkCacheInfo] = useState<{count: number, totalMB: number} | null>(null);
|
||||||
const [ttsCacheInfo, setTtsCacheInfo] = useState<{count: number, totalMB: number} | null>(null);
|
const [ttsCacheInfo, setTtsCacheInfo] = useState<{count: number, totalMB: number} | null>(null);
|
||||||
|
const [verboseLogging, setVerboseLoggingState] = useState<boolean>(isVerboseLogging());
|
||||||
const [ttsSpeed, setTtsSpeed] = useState<number>(TTS_SPEED_DEFAULT);
|
const [ttsSpeed, setTtsSpeed] = useState<number>(TTS_SPEED_DEFAULT);
|
||||||
const [wakeKeyword, setWakeKeyword] = useState<string>(DEFAULT_KEYWORD);
|
const [wakeKeyword, setWakeKeyword] = useState<string>(DEFAULT_KEYWORD);
|
||||||
const [wakeStatus, setWakeStatus] = useState<string>('');
|
const [wakeStatus, setWakeStatus] = useState<string>('');
|
||||||
@@ -1291,6 +1293,28 @@ const SettingsScreen: React.FC = () => {
|
|||||||
{/* === Logs === */}
|
{/* === Logs === */}
|
||||||
{currentSection === 'protocol' && (<>
|
{currentSection === 'protocol' && (<>
|
||||||
<Text style={styles.sectionTitle}>Protokoll</Text>
|
<Text style={styles.sectionTitle}>Protokoll</Text>
|
||||||
|
|
||||||
|
{/* Verbose-Logging-Toggle */}
|
||||||
|
<View style={styles.card}>
|
||||||
|
<View style={styles.toggleRow}>
|
||||||
|
<Text style={styles.toggleLabel}>Verbose Logging</Text>
|
||||||
|
<Switch
|
||||||
|
value={verboseLogging}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
setVerboseLogging(v);
|
||||||
|
setVerboseLoggingState(v);
|
||||||
|
}}
|
||||||
|
trackColor={{ false: '#3A3A52', true: '#0096FF' }}
|
||||||
|
thumbColor={verboseLogging ? '#FFFFFF' : '#666680'}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.toggleHint}>
|
||||||
|
Wenn aus: console.log wird global stummgeschaltet (Speicher schonen).
|
||||||
|
Warnungen und Fehler bleiben immer aktiv. Bei Bedarf einschalten zum
|
||||||
|
Debuggen via adb logcat.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
<View style={styles.card}>
|
<View style={styles.card}>
|
||||||
{/* Tab-Umschalter */}
|
{/* Tab-Umschalter */}
|
||||||
<View style={styles.tabRow}>
|
<View style={styles.tabRow}>
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ const { AudioFocus, PcmStreamPlayer } = NativeModules as {
|
|||||||
requestDuck: () => Promise<boolean>;
|
requestDuck: () => Promise<boolean>;
|
||||||
requestExclusive: () => Promise<boolean>;
|
requestExclusive: () => Promise<boolean>;
|
||||||
release: () => Promise<boolean>;
|
release: () => Promise<boolean>;
|
||||||
|
kickReleaseMedia: () => Promise<boolean>;
|
||||||
|
getMode?: () => Promise<number>;
|
||||||
};
|
};
|
||||||
PcmStreamPlayer?: {
|
PcmStreamPlayer?: {
|
||||||
start: (sampleRate: number, channels: number, prerollSeconds: number) => Promise<boolean>;
|
start: (sampleRate: number, channels: number, prerollSeconds: number) => Promise<boolean>;
|
||||||
@@ -316,13 +318,19 @@ class AudioService {
|
|||||||
* unterdrueckt — der Focus bleibt fuer die ganze Konversation gehalten. */
|
* unterdrueckt — der Focus bleibt fuer die ganze Konversation gehalten. */
|
||||||
private _releaseFocusDeferred(): void {
|
private _releaseFocusDeferred(): void {
|
||||||
if (this._conversationFocusActive) {
|
if (this._conversationFocusActive) {
|
||||||
|
console.log('[Audio] _releaseFocusDeferred: Conversation aktiv → kein Release');
|
||||||
this._cancelDeferredFocusRelease();
|
this._cancelDeferredFocusRelease();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this._cancelDeferredFocusRelease();
|
this._cancelDeferredFocusRelease();
|
||||||
|
console.log('[Audio] _releaseFocusDeferred: in %dms', this.FOCUS_RELEASE_DELAY_MS);
|
||||||
this.focusReleaseTimer = setTimeout(() => {
|
this.focusReleaseTimer = setTimeout(() => {
|
||||||
this.focusReleaseTimer = null;
|
this.focusReleaseTimer = null;
|
||||||
if (this._conversationFocusActive) return;
|
if (this._conversationFocusActive) {
|
||||||
|
console.log('[Audio] Focus-Release abgebrochen (Conversation jetzt aktiv)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log('[Audio] AudioFocus jetzt released');
|
||||||
AudioFocus?.release().catch(() => {});
|
AudioFocus?.release().catch(() => {});
|
||||||
}, this.FOCUS_RELEASE_DELAY_MS);
|
}, this.FOCUS_RELEASE_DELAY_MS);
|
||||||
}
|
}
|
||||||
@@ -860,11 +868,16 @@ class AudioService {
|
|||||||
final?: boolean;
|
final?: boolean;
|
||||||
silent?: boolean;
|
silent?: boolean;
|
||||||
}): Promise<string> {
|
}): Promise<string> {
|
||||||
|
// _stoppedMessageId: User hat diese Antwort mid-Wiedergabe gestoppt
|
||||||
|
// (Mute geklickt). Auch wenn Mute jetzt wieder aus ist, soll diese
|
||||||
|
// Antwort nicht weiterspielen. Erst eine neue messageId resetted das.
|
||||||
|
const incomingMsgId = payload.messageId || '';
|
||||||
|
const stoppedByUser = !!this._stoppedMessageId && incomingMsgId === this._stoppedMessageId;
|
||||||
// Globaler Mute-Flag uebersteuert das per-Call silent — verhindert
|
// Globaler Mute-Flag uebersteuert das per-Call silent — verhindert
|
||||||
// Race-Conditions wenn der User zwischen Chunks den Mute-Knopf drueckt.
|
// Race-Conditions wenn der User zwischen Chunks den Mute-Knopf drueckt.
|
||||||
// _pausedForCall: AudioTrack ist gestoppt waehrend Anruf — Chunks weiter
|
// _pausedForCall: AudioTrack ist gestoppt waehrend Anruf — Chunks weiter
|
||||||
// sammeln (fuer WAV-Cache), aber NICHT in den Player schicken.
|
// sammeln (fuer WAV-Cache), aber NICHT in den Player schicken.
|
||||||
const silent = !!payload.silent || this._muted || this._pausedForCall;
|
const silent = !!payload.silent || this._muted || this._pausedForCall || stoppedByUser;
|
||||||
if (!silent && !PcmStreamPlayer) {
|
if (!silent && !PcmStreamPlayer) {
|
||||||
console.warn('[Audio] PcmStreamPlayer Native Module nicht verfuegbar');
|
console.warn('[Audio] PcmStreamPlayer Native Module nicht verfuegbar');
|
||||||
return '';
|
return '';
|
||||||
@@ -905,6 +918,13 @@ class AudioService {
|
|||||||
this.pausedMessageId = '';
|
this.pausedMessageId = '';
|
||||||
this.pausedPosition = 0;
|
this.pausedPosition = 0;
|
||||||
}
|
}
|
||||||
|
// Stop-Marker zuruecksetzen wenn neue messageId — neue Antwort darf
|
||||||
|
// wieder normal abspielen, egal ob Mute zwischendurch aktiv war.
|
||||||
|
if (this._stoppedMessageId && this._stoppedMessageId !== messageId) {
|
||||||
|
console.log('[Audio] Neue Antwort (msgId=%s) — Stop-Marker fuer %s zurueckgesetzt',
|
||||||
|
messageId, this._stoppedMessageId);
|
||||||
|
this._stoppedMessageId = '';
|
||||||
|
}
|
||||||
this.pcmStreamActive = true;
|
this.pcmStreamActive = true;
|
||||||
this.pcmMessageId = messageId;
|
this.pcmMessageId = messageId;
|
||||||
this.pcmSampleRate = sampleRate;
|
this.pcmSampleRate = sampleRate;
|
||||||
@@ -1131,11 +1151,13 @@ class AudioService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.currentSound = sound;
|
this.currentSound = sound;
|
||||||
|
console.log('[Audio] Sound.play startet (path=%s)', soundPath);
|
||||||
|
|
||||||
// Naechstes Audio schon vorbereiten waehrend dieses abspielt
|
// Naechstes Audio schon vorbereiten waehrend dieses abspielt
|
||||||
this._preloadNext();
|
this._preloadNext();
|
||||||
|
|
||||||
sound.play((success) => {
|
sound.play((success) => {
|
||||||
|
console.log('[Audio] Sound.play callback: success=%s queue=%d', success, this.audioQueue.length);
|
||||||
if (!success) console.warn('[Audio] Wiedergabe fehlgeschlagen');
|
if (!success) console.warn('[Audio] Wiedergabe fehlgeschlagen');
|
||||||
sound.release();
|
sound.release();
|
||||||
this.currentSound = null;
|
this.currentSound = null;
|
||||||
@@ -1171,16 +1193,37 @@ class AudioService {
|
|||||||
* abgespielt. Wird in pauseForCall gesetzt, in endCallPause/resumeFrom-
|
* abgespielt. Wird in pauseForCall gesetzt, in endCallPause/resumeFrom-
|
||||||
* Interruption zurueckgenommen. */
|
* Interruption zurueckgenommen. */
|
||||||
private _pausedForCall: boolean = false;
|
private _pausedForCall: boolean = false;
|
||||||
|
/** Wenn der User mid-Wiedergabe Mute drueckt: messageId der ABGEBROCHENEN
|
||||||
|
* Antwort merken. Folge-Chunks dieser msgId werden silent ignoriert, auch
|
||||||
|
* wenn der User Mute wieder ausschaltet — kein "Resume mid-Antwort". Eine
|
||||||
|
* NEUE messageId resetted das, dann spielt's wieder normal. */
|
||||||
|
private _stoppedMessageId: string = '';
|
||||||
setMuted(muted: boolean): void {
|
setMuted(muted: boolean): void {
|
||||||
console.log('[Audio] setMuted: %s (currentSound=%s pcmStreamActive=%s)',
|
console.log('[Audio] setMuted: %s (currentSound=%s pcmStreamActive=%s)',
|
||||||
muted, this.currentSound ? 'aktiv' : 'null', this.pcmStreamActive);
|
muted, this.currentSound ? 'aktiv' : 'null', this.pcmStreamActive);
|
||||||
this._muted = muted;
|
this._muted = muted;
|
||||||
if (muted) this.stopPlayback();
|
if (muted) {
|
||||||
|
// Aktuell laufende Antwort als "verworfen" markieren — nachfolgende
|
||||||
|
// chunks dieser msgId werden silent gehalten auch wenn der User Mute
|
||||||
|
// gleich wieder ausschaltet. Erst eine NEUE Antwort darf wieder reden.
|
||||||
|
const activeMsgId = this.pcmMessageId || this.currentPlaybackMsgId;
|
||||||
|
if (activeMsgId) {
|
||||||
|
this._stoppedMessageId = activeMsgId;
|
||||||
|
console.log('[Audio] Antwort %s als gestoppt markiert', activeMsgId);
|
||||||
|
}
|
||||||
|
this.stopPlayback();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
isMuted(): boolean { return this._muted; }
|
isMuted(): boolean { return this._muted; }
|
||||||
|
|
||||||
/** Laufende Wiedergabe stoppen + Queue leeren */
|
/** Laufende Wiedergabe stoppen + Queue leeren */
|
||||||
stopPlayback(): void {
|
stopPlayback(): void {
|
||||||
|
// Idempotent: wenn nichts mehr aktiv ist, NICHT noch einen Focus-Release/
|
||||||
|
// Kick-Cycle anstossen — Re-Renders triggern setMuted oft mehrfach hinter-
|
||||||
|
// einander, und jeder weitere Kick lässt Spotify nochmal kurz pausieren.
|
||||||
|
const hasAnything = !!(this.currentSound || this.resumeSound || this.preloadedSound
|
||||||
|
|| this.pcmStreamActive || this.audioQueue.length || this.isPlaying);
|
||||||
|
if (!hasAnything) return;
|
||||||
console.log('[Audio] stopPlayback: currentSound=%s queue=%d pcm=%s',
|
console.log('[Audio] stopPlayback: currentSound=%s queue=%d pcm=%s',
|
||||||
this.currentSound ? 'aktiv' : 'null', this.audioQueue.length, this.pcmStreamActive);
|
this.currentSound ? 'aktiv' : 'null', this.audioQueue.length, this.pcmStreamActive);
|
||||||
// Foreground-Service auch stoppen — sonst bleibt die Notification haengen
|
// Foreground-Service auch stoppen — sonst bleibt die Notification haengen
|
||||||
@@ -1213,7 +1256,11 @@ class AudioService {
|
|||||||
this.pcmBuffer = [];
|
this.pcmBuffer = [];
|
||||||
this.pcmBytesCollected = 0;
|
this.pcmBytesCollected = 0;
|
||||||
this.pcmMessageId = '';
|
this.pcmMessageId = '';
|
||||||
// Audio-Focus sofort freigeben — User hat explizit abgebrochen
|
// Audio-Focus sofort freigeben — User hat explizit abgebrochen.
|
||||||
|
// Unser Focus war TRANSIENT, Spotify resumed darum automatisch beim
|
||||||
|
// Abandon. Den frueheren kickReleaseMedia haben wir entfernt: er
|
||||||
|
// requestete USAGE_MEDIA mit GAIN (permanent), was Spotify als
|
||||||
|
// "user-action stopp" interpretierte und Auto-Resume verhinderte.
|
||||||
this._cancelDeferredFocusRelease();
|
this._cancelDeferredFocusRelease();
|
||||||
AudioFocus?.release().catch(() => {});
|
AudioFocus?.release().catch(() => {});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(() => {});
|
||||||
|
}
|
||||||
@@ -52,6 +52,36 @@ Fuer Web-Anfragen: **WebFetch** oder **Bash mit curl**. Niemals sagen "ich habe
|
|||||||
4. **Regelmaessig committen** — mit sinnvollen Commit-Messages.
|
4. **Regelmaessig committen** — mit sinnvollen Commit-Messages.
|
||||||
5. **Tageslog fuehren** — was wurde getan, was ist offen.
|
5. **Tageslog fuehren** — was wurde getan, was ist offen.
|
||||||
|
|
||||||
|
## Dateien an Stefan zurueckgeben
|
||||||
|
|
||||||
|
Wenn du eine Datei fuer Stefan erstellt hast (PDF, Bild, ausgefuelltes
|
||||||
|
Formular, Markdown, CSV, ZIP, ...), speichere sie unter `/shared/uploads/`
|
||||||
|
mit `aria_`-Prefix:
|
||||||
|
|
||||||
|
```
|
||||||
|
/shared/uploads/aria_<beschreibender_name>.<ext>
|
||||||
|
```
|
||||||
|
|
||||||
|
Beispiele: `aria_termin_zusage.pdf`, `aria_einkaufsliste.md`,
|
||||||
|
`aria_logs_2026-05-10.zip`.
|
||||||
|
|
||||||
|
Haenge dann am Ende deiner Antwort EINMALIG den Marker an:
|
||||||
|
|
||||||
|
```
|
||||||
|
[FILE: /shared/uploads/aria_<name>.<ext>]
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Marker wird automatisch aus dem sichtbaren Antworttext entfernt
|
||||||
|
(TTS liest ihn nicht vor) und als Datei-Anhang in der App und im
|
||||||
|
Diagnostic-Chat angezeigt — Stefan kann die Datei mit einem Klick
|
||||||
|
oeffnen (PDF-Viewer, Bildbetrachter, Standard-App per MIME-Type).
|
||||||
|
|
||||||
|
Mehrere Dateien in einer Antwort: einfach mehrere `[FILE: ...]`-Marker
|
||||||
|
am Ende, jeder in einer eigenen Zeile.
|
||||||
|
|
||||||
|
Pfad muss zwingend mit `/shared/uploads/` beginnen — andere Pfade
|
||||||
|
werden ignoriert.
|
||||||
|
|
||||||
## Stimme
|
## Stimme
|
||||||
|
|
||||||
TTS laeuft ueber F5-TTS (Voice Cloning, Gaming-PC). Stefan kann eigene
|
TTS laeuft ueber F5-TTS (Voice Cloning, Gaming-PC). Stefan kann eigene
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# Stefan — Benutzer-Praeferenzen
|
# <Username> — Benutzer-Praeferenzen
|
||||||
|
|
||||||
## Allgemein
|
## Allgemein
|
||||||
|
|
||||||
- **Sprache:** Deutsch
|
- **Sprache:** <z.B. Deutsch>
|
||||||
- **Kommunikation:** Direkt, kein Bullshit, Humor willkommen
|
- **Kommunikation:** <z.B. Direkt, kein Bullshit, Humor willkommen>
|
||||||
- **Rolle:** Chef, Auftraggeber, Entwickler bei HackerSoft Oldenburg
|
- **Rolle:** <z.B. Chef, Auftraggeber, Entwickler bei XYZ>
|
||||||
|
|
||||||
## Bestaetigung erforderlich fuer
|
## Bestaetigung erforderlich fuer
|
||||||
|
|
||||||
@@ -12,7 +12,6 @@
|
|||||||
- Push auf main
|
- Push auf main
|
||||||
- Aenderungen an Kundensystemen
|
- Aenderungen an Kundensystemen
|
||||||
- Server-Befehle die nicht rueckgaengig gemacht werden koennen
|
- Server-Befehle die nicht rueckgaengig gemacht werden koennen
|
||||||
- Windows neu installieren (erst Daten sichern!)
|
|
||||||
|
|
||||||
## Autonomes Arbeiten OK fuer
|
## Autonomes Arbeiten OK fuer
|
||||||
|
|
||||||
@@ -28,8 +27,10 @@
|
|||||||
|
|
||||||
| Tool | Zweck |
|
| Tool | Zweck |
|
||||||
|------|-------|
|
|------|-------|
|
||||||
| **Proxmox** | VM-Infrastruktur (ARIAs Zuhause) |
|
| **<Beispiel-Tool>** | <Zweck> |
|
||||||
| **Gitea** | Code-Hosting (gitea.hackersoft.de) |
|
|
||||||
| **OpenCRM** | Kundenverwaltung |
|
<!--
|
||||||
| **STARFACE** | Telefonie |
|
Diese Datei ist eine Vorlage. Lokal als USER.md kopieren und mit
|
||||||
| **RustDesk** | Remote IT-Support bei Kunden |
|
eigenen Praeferenzen + Tool-Stack fuellen. USER.md selbst ist via
|
||||||
|
.gitignore vom Repo ausgeschlossen.
|
||||||
|
-->
|
||||||
@@ -16,7 +16,9 @@ import asyncio
|
|||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import signal
|
import signal
|
||||||
import ssl
|
import ssl
|
||||||
import sys
|
import sys
|
||||||
@@ -882,6 +884,48 @@ class ARIABridge:
|
|||||||
pass
|
pass
|
||||||
return payload.get("text", "")
|
return payload.get("text", "")
|
||||||
|
|
||||||
|
# File-Marker-Pattern: `[FILE: /pfad/zur/datei.ext]` (Pfad kann Spaces
|
||||||
|
# enthalten, Endung beliebig). Mehrfach im Text moeglich.
|
||||||
|
_FILE_MARKER_RE = re.compile(r"\[FILE:\s*(/shared/uploads/[^\]]+?)\s*\]", re.IGNORECASE)
|
||||||
|
|
||||||
|
def _extract_file_markers(self, text: str) -> tuple[str, list[dict]]:
|
||||||
|
"""Sucht [FILE: /shared/uploads/...]-Marker, gibt (cleaned_text, file_list) zurueck."""
|
||||||
|
files: list[dict] = []
|
||||||
|
for m in self._FILE_MARKER_RE.finditer(text):
|
||||||
|
path = m.group(1).strip()
|
||||||
|
if not path.startswith("/shared/uploads/"):
|
||||||
|
logger.warning("[core] FILE-Marker mit unerlaubtem Pfad ignoriert: %s", path)
|
||||||
|
continue
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
logger.warning("[core] FILE-Marker zeigt auf nicht existente Datei: %s", path)
|
||||||
|
continue
|
||||||
|
name = os.path.basename(path)
|
||||||
|
mime, _ = mimetypes.guess_type(path)
|
||||||
|
size = os.path.getsize(path)
|
||||||
|
files.append({
|
||||||
|
"serverPath": path,
|
||||||
|
"name": name,
|
||||||
|
"mimeType": mime or "application/octet-stream",
|
||||||
|
"size": size,
|
||||||
|
})
|
||||||
|
cleaned = self._FILE_MARKER_RE.sub("", text).strip()
|
||||||
|
# Zwei aufeinanderfolgende Leerzeilen → eine
|
||||||
|
cleaned = re.sub(r"\n{3,}", "\n\n", cleaned)
|
||||||
|
return cleaned, files
|
||||||
|
|
||||||
|
async def _broadcast_aria_file(self, file_info: dict) -> None:
|
||||||
|
"""ARIA hat eine Datei fuer den User erstellt — App+Diagnostic informieren."""
|
||||||
|
logger.info("[rvs] ARIA-Datei rausgeben: %s (%s, %dKB)",
|
||||||
|
file_info["name"], file_info["mimeType"], file_info["size"] // 1024)
|
||||||
|
try:
|
||||||
|
await self._send_to_rvs({
|
||||||
|
"type": "file_from_aria",
|
||||||
|
"payload": file_info,
|
||||||
|
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("[rvs] file_from_aria broadcast fehlgeschlagen: %s", e)
|
||||||
|
|
||||||
async def _process_core_response(self, text: str, payload: dict) -> None:
|
async def _process_core_response(self, text: str, payload: dict) -> None:
|
||||||
"""Verarbeitet eine fertige Antwort von aria-core.
|
"""Verarbeitet eine fertige Antwort von aria-core.
|
||||||
|
|
||||||
@@ -896,6 +940,14 @@ class ARIABridge:
|
|||||||
logger.info("[core] NO_REPLY empfangen — Antwort still verworfen")
|
logger.info("[core] NO_REPLY empfangen — Antwort still verworfen")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# File-Marker `[FILE: /shared/uploads/aria_xyz.pdf]` extrahieren —
|
||||||
|
# ARIA legt damit Dateien fuer den User bereit (Bilder, PDFs, etc.).
|
||||||
|
# Der Marker wird aus dem Antworttext entfernt (TTS soll ihn nicht
|
||||||
|
# vorlesen) und parallel als file_from_aria-Event geschickt.
|
||||||
|
text, aria_files = self._extract_file_markers(text)
|
||||||
|
for f in aria_files:
|
||||||
|
await self._broadcast_aria_file(f)
|
||||||
|
|
||||||
metadata = payload.get("metadata", {})
|
metadata = payload.get("metadata", {})
|
||||||
is_critical = metadata.get("critical", False)
|
is_critical = metadata.get("critical", False)
|
||||||
requested_voice = metadata.get("voice")
|
requested_voice = metadata.get("voice")
|
||||||
@@ -1545,6 +1597,7 @@ class ARIABridge:
|
|||||||
return
|
return
|
||||||
with open(server_path, "rb") as f:
|
with open(server_path, "rb") as f:
|
||||||
file_b64 = base64.b64encode(f.read()).decode("ascii")
|
file_b64 = base64.b64encode(f.read()).decode("ascii")
|
||||||
|
mime, _ = mimetypes.guess_type(server_path)
|
||||||
logger.info("[rvs] Re-Download: %s (%dKB)", server_path, len(file_b64) // 1365)
|
logger.info("[rvs] Re-Download: %s (%dKB)", server_path, len(file_b64) // 1365)
|
||||||
await self._send_to_rvs({
|
await self._send_to_rvs({
|
||||||
"type": "file_response",
|
"type": "file_response",
|
||||||
@@ -1553,6 +1606,7 @@ class ARIABridge:
|
|||||||
"serverPath": server_path,
|
"serverPath": server_path,
|
||||||
"base64": file_b64,
|
"base64": file_b64,
|
||||||
"name": os.path.basename(server_path),
|
"name": os.path.basename(server_path),
|
||||||
|
"mimeType": mime or "application/octet-stream",
|
||||||
},
|
},
|
||||||
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -996,6 +996,11 @@
|
|||||||
addChat('received', msg.text, 'chat:final');
|
addChat('received', msg.text, 'chat:final');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (msg.type === 'file_from_aria') {
|
||||||
|
const p = msg.payload || {};
|
||||||
|
addAriaFile(p);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (msg.type === 'chat_delta') { return; }
|
if (msg.type === 'chat_delta') { return; }
|
||||||
if (msg.type === 'chat_error') {
|
if (msg.type === 'chat_error') {
|
||||||
addChat('error', msg.error, 'chat:error');
|
addChat('error', msg.error, 'chat:error');
|
||||||
@@ -1475,6 +1480,40 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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 class="meta">ARIA-Datei — ${new Date().toLocaleTimeString('de-DE')}</div>`;
|
||||||
|
for (const box of [chatBox, document.getElementById('chat-box-fs')]) {
|
||||||
|
if (!box) continue;
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'chat-msg received';
|
||||||
|
el.innerHTML = html;
|
||||||
|
box.appendChild(el);
|
||||||
|
box.scrollTop = box.scrollHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let chatFullscreen = false;
|
let chatFullscreen = false;
|
||||||
function toggleChatFullscreen() {
|
function toggleChatFullscreen() {
|
||||||
const modal = document.getElementById('chat-fullscreen');
|
const modal = document.getElementById('chat-fullscreen');
|
||||||
|
|||||||
@@ -620,6 +620,11 @@ function connectRVS(forcePlain) {
|
|||||||
type: "chat",
|
type: "chat",
|
||||||
payload: { text: `Anhang: ${name}\n${serverPath}`, sender: "user" }
|
payload: { text: `Anhang: ${name}\n${serverPath}`, sender: "user" }
|
||||||
}});
|
}});
|
||||||
|
} else if (msg.type === "file_from_aria" && msg.payload) {
|
||||||
|
// ARIA hat eine Datei fuer den User erstellt — im Chat als Anhang anzeigen
|
||||||
|
const p = msg.payload;
|
||||||
|
log("info", "rvs", `ARIA-Datei: ${p.name} (${p.mimeType}, ${(p.size||0)/1024|0}KB)`);
|
||||||
|
broadcast({ type: "file_from_aria", payload: p });
|
||||||
} else if (msg.type === "heartbeat") {
|
} else if (msg.type === "heartbeat") {
|
||||||
// ignorieren
|
// ignorieren
|
||||||
} else if (msg.type === "mode") {
|
} else if (msg.type === "mode") {
|
||||||
|
|||||||
@@ -89,6 +89,18 @@ Wichtige Mechanismen:
|
|||||||
- [x] **Mute-Button stoppt jetzt auch laufenden PCM-Stream** — `pcmStreamActive` wurde beim isFinal-Chunk schon false gesetzt, der AudioTrack spielte aber noch sekundenlang aus seinem Buffer. `stopPlayback()` uebersprang darum `PcmStreamPlayer.stop()`. Fix: stop() immer rufen (ist idempotent), kein Flag-Check mehr
|
- [x] **Mute-Button stoppt jetzt auch laufenden PCM-Stream** — `pcmStreamActive` wurde beim isFinal-Chunk schon false gesetzt, der AudioTrack spielte aber noch sekundenlang aus seinem Buffer. `stopPlayback()` uebersprang darum `PcmStreamPlayer.stop()`. Fix: stop() immer rufen (ist idempotent), kein Flag-Check mehr
|
||||||
- [x] **GPS-Permission im Manifest + Runtime-Request** beim Settings-Toggle — vorher fehlten ACCESS_COARSE_LOCATION / ACCESS_FINE_LOCATION komplett. `Geolocation.getCurrentPosition` schlug lautlos fehl, App sendete nie ein location-Feld
|
- [x] **GPS-Permission im Manifest + Runtime-Request** beim Settings-Toggle — vorher fehlten ACCESS_COARSE_LOCATION / ACCESS_FINE_LOCATION komplett. `Geolocation.getCurrentPosition` schlug lautlos fehl, App sendete nie ein location-Feld
|
||||||
- [x] **GPS-Position auch im STT-Payload an Diagnostic** — die App sendet location einmal im audio-Payload. Die Bridge nutzte sie zwar (ging in aria-core's Kontext rein), reichte sie aber nicht im STT-broadcast an Diagnostic durch. Diagnostic zeigte darum bei Spracheingaben nie den GPS-Block, obwohl der "GPS einblenden"-Toggle aktiv war
|
- [x] **GPS-Position auch im STT-Payload an Diagnostic** — die App sendet location einmal im audio-Payload. Die Bridge nutzte sie zwar (ging in aria-core's Kontext rein), reichte sie aber nicht im STT-broadcast an Diagnostic durch. Diagnostic zeigte darum bei Spracheingaben nie den GPS-Block, obwohl der "GPS einblenden"-Toggle aktiv war
|
||||||
|
- [x] **Auto-Resume nach Anruf — pcmBuffer bleibt erhalten**: `haltAllPlayback` leerte den pcmBuffer mid-Anruf, isFinal schrieb dann eine leere WAV. Neue `pauseForCall`-Methode statt `haltAllPlayback`: AudioTrack stoppt + Focus released, `pcmBuffer` und `pcmMessageId` bleiben — chunks werden weiter gesammelt damit isFinal die WAV schreibt und resumeFromInterruption sie findet. Plus `captureInterruption` idempotent gemacht (ringing → offhook ueberschreibt nicht)
|
||||||
|
- [x] **Replay-Resume nach Anruf**: `_firePlaybackStarted` ueberschrieb `currentPlaybackMsgId` mit leerem pcmMessageId — captureInterruption hatte nichts zu merken. Plus Regex `[0-9a-f-]+\.wav` matchte nicht alle Dateinamen. Plus `_playFromPathAtPosition` aktualisiert jetzt das Tracking damit ein zweiter Anruf in derselben Antwort auch funktioniert
|
||||||
|
- [x] **`pauseForCall` setzt `isPlaying` zurueck**: vorher haengten weitere Play-Button-Klicks nach Anruf, weil `playAudio` bei `isPlaying=true` den `_playNext`-Pfad ueberspringt
|
||||||
|
- [x] **Play-Button rendert neu wenn Cache-Datei weg ist**: vorher checkte der Button nur `if (item.audioPath)` — auf eine geloeschte Cache-Datei zeigte das aber stillschweigend ins Leere. Jetzt RNFS.exists-Check mit Fallback auf `tts_request` an die Bridge → F5-TTS rendert neu, WAV wandert zurueck in den Cache
|
||||||
|
- [x] **Bridge WebSocket max_size 50 MB**: Python `websockets.connect` hat 1 MiB Default — Stefan's 4MB JPEG (5.78 MB Base64) sprengte das, Bridge-Connection wurde silent gedroppt. f5tts/whisper-bridges hatten max_size schon, nur aria_bridge war vergessen
|
||||||
|
- [x] **Bridge resized Bilder >2 MB serverseitig auf 1568px**: Claude-Vision-API hat ~5 MB Base64-Limit. Galerie-Bilder via `react-native-image-picker` sind clientseitig schon klein, Buroklammer/DocumentPicker reichte das rohe File durch — Claude lieferte leere Antwort. Pillow im Bridge-Container, nur fuer JPEG/PNG/WebP/GIF (PDFs/ZIPs/SVGs unangetastet)
|
||||||
|
- [x] **Bridge `chat:error` liest auch `errorMessage`**: OpenClaw legt bei state=error den Text dort statt in `error` ab → Bridge meldete generisches "[Fehler] Unbekannt", echter Fehler nur in Container-Logs. Plus: `chat:final` ohne text wird jetzt mit Hinweis-Bubble an die App gemeldet (statt stumm), z.B. wenn Vision das Bild silent ablehnt
|
||||||
|
- [x] **Cache-Cleanup beim App-Start** — orphane `aria_tts_*.wav` Files (>5 min) im CachesDirectoryPath werden weggeraeumt, sammeln sich sonst an wenn Sound mid-playback gestoppt wird (Anruf, Mute, Barge-In) und der completion-Callback nicht feuert. Plus neuer Settings-Button "TTS-Cache leeren" mit Live-Groessenanzeige
|
||||||
|
- [x] **Verbose-Logging-Toggle in Settings → Protokoll**: `console.log` global stummschaltbar (warn/error bleiben aktiv) — spart adb-logcat-Speicher wenn alles laeuft
|
||||||
|
- [x] **800 ms-Delay vor Anruf-Auto-Resume**: ARIA's neuer Focus-Request kollidierte sonst mit Spotify's Auto-Resume nach Anruf-Ende. System haengt noch im IN_CALL→NORMAL-Mode-Uebergang, Spotify sieht Loss → Loss und bleibt pausiert. Mit Delay schafft Spotify den Resume-Schritt, dann pausiert ARIA wieder ordnungsgemaess
|
||||||
|
- [x] **Mute-Button = Stop fuer aktuelle Antwort**: vorher startete eine NEUE PCM-Chunk-Sequenz nach Mute-aus die alte Antwort weiter wo sie war (funktionierte 2x, dann nicht mehr weil isFinal schon kam). Jetzt mit `_stoppedMessageId`-Tracking: bei Mute wird die aktive msgId gemerkt, alle weiteren chunks dieser msgId bleiben silent — auch wenn Mute zurueckgenommen wird. Reset bei neuer msgId, neue Antworten spielen normal
|
||||||
|
- [x] **Spotify resumed nach Mute-Stop**: `stopPlayback` released seinen TRANSIENT-Focus (USAGE_ASSISTANT) sauber → Spotify bekommt GAIN-Event und resumed automatisch. Ein zwischenzeitlich eingebauter `kickReleaseMedia` (USAGE_MEDIA + GAIN) verhinderte das Auto-Resume sogar (Spotify interpretierte es als "user-action stopp") — wieder rausgenommen
|
||||||
|
|
||||||
### App Features
|
### App Features
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const ALLOWED_TYPES = new Set([
|
|||||||
"update_check", "update_available", "update_download", "update_data",
|
"update_check", "update_available", "update_download", "update_data",
|
||||||
"agent_activity", "cancel_request",
|
"agent_activity", "cancel_request",
|
||||||
"audio_pcm",
|
"audio_pcm",
|
||||||
|
"file_from_aria",
|
||||||
"xtts_delete_voice",
|
"xtts_delete_voice",
|
||||||
"voice_preload", "voice_ready",
|
"voice_preload", "voice_ready",
|
||||||
"stt_request", "stt_response",
|
"stt_request", "stt_response",
|
||||||
|
|||||||
Reference in New Issue
Block a user