Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1fb1fdef9e | |||
| 593d26e0ff | |||
| 394abb58be | |||
| fc3bee6d05 | |||
| b203503fd8 | |||
| 8b0a72dc9b | |||
| 23add7a107 | |||
| caf84196fb | |||
| 099b9651a6 | |||
| 76d72a1eef | |||
| 87deede078 | |||
| 6fec8588c1 | |||
| aafdbcd57a | |||
| 08da28f475 | |||
| 8c1014d281 | |||
| 271fc4edf6 | |||
| cd390a4115 | |||
| a65ed579d2 | |||
| 2ad1f57382 | |||
| 58e3cfd3e6 | |||
| 7de4ee8f5b | |||
| 213edac3a7 | |||
| acc13aef6b | |||
| 4bbc6f7787 | |||
| 20f2ea1829 | |||
| 2d23f0668b | |||
| d6030a06b7 | |||
| 0df76e2af6 | |||
| f80fe1df93 | |||
| cff421bc53 | |||
| bca925d385 | |||
| 9abde89805 | |||
| ea4f639fcb | |||
| 64cd5f7d52 | |||
| 843ebe1d8f | |||
| 764619f076 | |||
| e3a0cfb55a | |||
| 2929749314 | |||
| 51b9512f4e | |||
| ffcfa44eef | |||
| 6363da97b1 | |||
| 07ed2cdcf6 | |||
| 5ad68b7dfc | |||
| 8a6ee018ea | |||
| b42590ff95 | |||
| 056b579c47 | |||
| 576e612cd0 | |||
| c2faa06a15 | |||
| d3ed3556eb | |||
| d960d125c0 | |||
| 89d5d7ec0a | |||
| ea0c13936b | |||
| 773c976822 | |||
| cd05ed2379 |
@@ -306,7 +306,8 @@ aria-core → Antwort → Gateway → Diagnostic → RVS → App
|
|||||||
### Features
|
### Features
|
||||||
|
|
||||||
- **STT**: faster-whisper (lokal, offline, 16kHz mono)
|
- **STT**: faster-whisper (lokal, offline, 16kHz mono)
|
||||||
- **TTS**: Piper (Ramona + Thorsten, offline)
|
- **TTS**: Piper (Ramona + Thorsten, offline) oder XTTS v2 (remote, GPU, Voice Cloning)
|
||||||
|
- **Markdown-Bereinigung**: Entfernt **fett**, *kursiv*, `code`, Links, Listen etc. vor TTS (natuerliche Sprache)
|
||||||
- **Wake-Word**: openwakeword (lokales Mikrofon auf der VM)
|
- **Wake-Word**: openwakeword (lokales Mikrofon auf der VM)
|
||||||
- **App-Audio**: Base64 Audio von App → FFmpeg → Whisper STT → Text an aria-core
|
- **App-Audio**: Base64 Audio von App → FFmpeg → Whisper STT → Text an aria-core
|
||||||
- **Modi**: Normal, Nicht stoeren, Fluestern, Hangar, Gaming
|
- **Modi**: Normal, Nicht stoeren, Fluestern, Hangar, Gaming
|
||||||
@@ -340,10 +341,10 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit aria-core.
|
|||||||
- **Chat-Test**: Nachrichten direkt an ARIA senden (Gateway oder via RVS), Vollbild-Modus
|
- **Chat-Test**: Nachrichten direkt an ARIA senden (Gateway oder via RVS), Vollbild-Modus
|
||||||
- **"ARIA denkt..." Indikator**: Zeigt live was ARIA gerade tut (Denken, Tool, Schreiben)
|
- **"ARIA denkt..." Indikator**: Zeigt live was ARIA gerade tut (Denken, Tool, Schreiben)
|
||||||
- **Abbrechen-Button**: Stoppt laufende Anfragen + doctor --fix
|
- **Abbrechen-Button**: Stoppt laufende Anfragen + doctor --fix
|
||||||
- **Session-Verwaltung**: Sessions auflisten, wechseln, erstellen, loeschen
|
- **Session-Verwaltung**: Sessions auflisten, wechseln, erstellen, loeschen, als Markdown exportieren (⬇ Button)
|
||||||
- **Chat-History**: Wird beim Laden und Session-Wechsel angezeigt (read-only aus JSONL)
|
- **Chat-History**: Wird beim Laden und Session-Wechsel angezeigt (read-only aus JSONL)
|
||||||
- **TTS-Diagnose Tab**: Stimmen testen, Status pruefen, Fehler anzeigen
|
- **TTS-Diagnose Tab**: Stimmen testen, Status pruefen, Fehler anzeigen
|
||||||
- **Einstellungen**: TTS-Engine (Piper/XTTS), Stimmen, Speed, Highlight-Trigger, Betriebsmodi
|
- **Einstellungen**: TTS-Engine (Piper/XTTS), Stimmen, Speed, Highlight-Trigger, Betriebsmodi, Whisper-Modell (tiny…large-v3, Hot-Reload)
|
||||||
- **XTTS Voice Cloning**: Audio-Samples hochladen, eigene Stimme erstellen
|
- **XTTS Voice Cloning**: Audio-Samples hochladen, eigene Stimme erstellen
|
||||||
- **Claude Login**: Browser-Terminal zum Einloggen in den Proxy
|
- **Claude Login**: Browser-Terminal zum Einloggen in den Proxy
|
||||||
- **Core Terminal**: Shell in aria-core (openclaw CLI)
|
- **Core Terminal**: Shell in aria-core (openclaw CLI)
|
||||||
@@ -367,15 +368,19 @@ API-Endpoint fuer andere Services: `GET http://localhost:3001/api/session`
|
|||||||
|
|
||||||
- Text-Chat mit ARIA
|
- Text-Chat mit ARIA
|
||||||
- **Sprachaufnahme**: Push-to-Talk (halten) oder Tap-to-Talk (tippen, Auto-Stop bei Stille)
|
- **Sprachaufnahme**: Push-to-Talk (halten) oder Tap-to-Talk (tippen, Auto-Stop bei Stille)
|
||||||
|
- **Gespraechsmodus** (Ohr-Button): Nach jeder ARIA-Antwort startet automatisch die Aufnahme — wie ein natuerliches Gespraech hin und her, ohne Buttons druecken
|
||||||
- **VAD (Voice Activity Detection)**: Erkennt 1.8s Stille und stoppt automatisch
|
- **VAD (Voice Activity Detection)**: Erkennt 1.8s Stille und stoppt automatisch
|
||||||
- **STT (Speech-to-Text)**: Audio wird in der Bridge per Whisper transkribiert, transkribierter Text erscheint im Chat
|
- **Speech Gate**: Aufnahme wird verworfen wenn keine Sprache erkannt (kein Rauschen an Whisper)
|
||||||
- **TTS-Wiedergabe**: ARIA antwortet per Lautsprecher (Piper oder XTTS v2)
|
- **STT (Speech-to-Text)**: Audio wird als 16kHz mono aufgenommen und in der Bridge per Whisper transkribiert, transkribierter Text erscheint im Chat
|
||||||
|
- **"ARIA denkt..." Indicator**: Zeigt live den Status vom Core (Denken, Tool, Schreiben) + Abbrechen-Button
|
||||||
|
- **TTS-Wiedergabe**: ARIA antwortet per Lautsprecher (Piper oder XTTS v2), Audio-Queue mit Preloading
|
||||||
- **Play-Button**: Jede ARIA-Nachricht kann nochmal vorgelesen werden
|
- **Play-Button**: Jede ARIA-Nachricht kann nochmal vorgelesen werden
|
||||||
- **Chat-Suche**: Lupe in der Statusleiste filtert Nachrichten live
|
- **Chat-Suche**: Lupe in der Statusleiste filtert Nachrichten live
|
||||||
- **Datei- und Bild-Upload**: Bilder inline im Chat (Vollbild-Tap), Dateien mit Icon + Name + Groesse
|
- **Mehrere Anhaenge**: Bilder + Dateien sammeln, Text hinzufuegen, dann zusammen senden
|
||||||
|
- **Paste-Support**: Bilder aus Zwischenablage einfuegen (Diagnostic)
|
||||||
- **Anhaenge**: Bridge speichert in Shared Volume, ARIA kann darauf zugreifen, Re-Download ueber RVS
|
- **Anhaenge**: Bridge speichert in Shared Volume, ARIA kann darauf zugreifen, Re-Download ueber RVS
|
||||||
- **Einstellungen**: TTS Engine, Stimmen, Speed pro Stimme, Speicherort, Auto-Download, GPS
|
- **Einstellungen**: TTS Engine, Stimmen, Speed pro Stimme, Speicherort, Auto-Download, GPS
|
||||||
- **Auto-Update**: Prueft beim Start auf neue Version, Download + Installation ueber RVS
|
- **Auto-Update**: Prueft beim Start + per Button auf neue Version, Download + Installation ueber RVS (FileProvider)
|
||||||
- GPS-Position (optional)
|
- GPS-Position (optional)
|
||||||
- QR-Code Scanner fuer Token-Pairing
|
- QR-Code Scanner fuer Token-Pairing
|
||||||
|
|
||||||
@@ -421,6 +426,17 @@ GITEA_USER=stefan
|
|||||||
RVS_UPDATE_HOST=root@aria-rvs # Optional: fuer Auto-Update
|
RVS_UPDATE_HOST=root@aria-rvs # Optional: fuer Auto-Update
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Docker-Cleanup
|
||||||
|
|
||||||
|
Das Bridge-Image zieht grosse ML-Deps (faster-whisper, ctranslate2, onnxruntime,
|
||||||
|
openwakeword, piper-tts) — bei jedem Rebuild waechst der Docker-Build-Cache. Wenn
|
||||||
|
die VM voll laeuft:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./cleanup.sh # sicher: Build-Cache + ungenutzte Images
|
||||||
|
./cleanup.sh --full # aggressiv: zusaetzlich ungenutzte Volumes (mit Rueckfrage)
|
||||||
|
```
|
||||||
|
|
||||||
### Auto-Update
|
### Auto-Update
|
||||||
|
|
||||||
Die App prueft beim Start ob eine neuere Version auf dem RVS liegt.
|
Die App prueft beim Start ob eine neuere Version auf dem RVS liegt.
|
||||||
@@ -709,6 +725,17 @@ docker exec aria-core ssh aria-wohnung hostname
|
|||||||
- [x] Auto-Update System (APK via RVS)
|
- [x] Auto-Update System (APK via RVS)
|
||||||
- [x] Chat-Suche, Play-Button, Abbrechen-Button
|
- [x] Chat-Suche, Play-Button, Abbrechen-Button
|
||||||
- [x] XTTS v2 Integration (GPU, Voice Cloning, remote ueber RVS)
|
- [x] XTTS v2 Integration (GPU, Voice Cloning, remote ueber RVS)
|
||||||
|
- [x] Gespraechsmodus (Ohr-Button, automatische Aufnahme nach ARIA-Antwort)
|
||||||
|
- [x] Mehrere Anhaenge + Text vor dem Senden + Paste-Support
|
||||||
|
- [x] Markdown-Bereinigung fuer TTS
|
||||||
|
- [x] Auto-Update mit FileProvider + Update-Check Button
|
||||||
|
- [x] Inverted FlatList (zuverlaessiges Scroll-to-Bottom)
|
||||||
|
- [x] Speech Gate (VAD verwirft Aufnahme ohne erkannte Sprache)
|
||||||
|
- [x] Session-Persistenz ueber Container-Restarts (sessionFromFile + atomic write)
|
||||||
|
- [x] Session-Export als Markdown-Datei (Download-Button pro Session)
|
||||||
|
- [x] "ARIA denkt..."-Indicator + Abbrechen-Button in App (via Bridge → RVS)
|
||||||
|
- [x] Whisper-Modell waehlbar in Diagnostic (tiny…large-v3, Hot-Reload)
|
||||||
|
- [x] App-Aufnahme explizit 16kHz mono (optimal fuer Whisper, kein Resample)
|
||||||
|
|
||||||
### Phase 2 — ARIA wird produktiv
|
### Phase 2 — ARIA wird produktiv
|
||||||
|
|
||||||
|
|||||||
@@ -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 208
|
versionCode 401
|
||||||
versionName "0.0.2.8"
|
versionName "0.0.4.1"
|
||||||
// Fallback fuer Libraries mit Product Flavors
|
// Fallback fuer Libraries mit Product Flavors
|
||||||
missingDimensionStrategy 'react-native-camera', 'general'
|
missingDimensionStrategy 'react-native-camera', 'general'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".MainApplication"
|
android:name=".MainApplication"
|
||||||
@@ -24,5 +25,15 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
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.ReactApplicationContext
|
||||||
|
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||||
|
import com.facebook.react.bridge.ReactMethod
|
||||||
|
import com.facebook.react.bridge.Promise
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class ApkInstallerModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
|
||||||
|
override fun getName() = "ApkInstaller"
|
||||||
|
|
||||||
|
@ReactMethod
|
||||||
|
fun install(filePath: String, promise: Promise) {
|
||||||
|
try {
|
||||||
|
val file = File(filePath)
|
||||||
|
if (!file.exists()) {
|
||||||
|
promise.reject("FILE_NOT_FOUND", "APK nicht gefunden: $filePath")
|
||||||
|
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 intent = Intent(Intent.ACTION_VIEW).apply {
|
||||||
|
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
}
|
||||||
|
|
||||||
|
context.startActivity(intent)
|
||||||
|
promise.resolve(true)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
promise.reject("INSTALL_ERROR", e.message, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.ariacockpit
|
||||||
|
|
||||||
|
import com.facebook.react.ReactPackage
|
||||||
|
import com.facebook.react.bridge.NativeModule
|
||||||
|
import com.facebook.react.bridge.ReactApplicationContext
|
||||||
|
import com.facebook.react.uimanager.ViewManager
|
||||||
|
|
||||||
|
class ApkInstallerPackage : ReactPackage {
|
||||||
|
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
|
||||||
|
return listOf(ApkInstallerModule(reactContext))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package com.ariacockpit
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.media.AudioAttributes
|
||||||
|
import android.media.AudioFocusRequest
|
||||||
|
import android.media.AudioManager
|
||||||
|
import android.os.Build
|
||||||
|
import com.facebook.react.bridge.Promise
|
||||||
|
import com.facebook.react.bridge.ReactApplicationContext
|
||||||
|
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||||
|
import com.facebook.react.bridge.ReactMethod
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Steuert Audio-Focus fuer Ducking/Muten anderer Apps.
|
||||||
|
*
|
||||||
|
* - requestDuck() → andere Apps werden leiser (ARIA spricht TTS)
|
||||||
|
* - requestExclusive() → andere Apps werden pausiert (Mikrofon-Aufnahme)
|
||||||
|
* - release() → Focus abgeben, andere Apps duerfen wieder
|
||||||
|
*/
|
||||||
|
class AudioFocusModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
|
||||||
|
override fun getName() = "AudioFocus"
|
||||||
|
|
||||||
|
private var currentRequest: AudioFocusRequest? = null
|
||||||
|
|
||||||
|
private fun audioManager(): AudioManager? =
|
||||||
|
reactApplicationContext.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
|
||||||
|
|
||||||
|
private fun requestFocus(durationHint: Int, usage: Int, promise: Promise) {
|
||||||
|
val am = audioManager()
|
||||||
|
if (am == null) {
|
||||||
|
promise.reject("NO_AUDIO_MANAGER", "AudioManager nicht verfuegbar")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
release()
|
||||||
|
|
||||||
|
val result: Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val attrs = AudioAttributes.Builder()
|
||||||
|
.setUsage(usage)
|
||||||
|
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||||
|
.build()
|
||||||
|
val req = AudioFocusRequest.Builder(durationHint)
|
||||||
|
.setAudioAttributes(attrs)
|
||||||
|
.setOnAudioFocusChangeListener { /* kein Callback noetig */ }
|
||||||
|
.build()
|
||||||
|
currentRequest = req
|
||||||
|
am.requestAudioFocus(req)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
am.requestAudioFocus(null, AudioManager.STREAM_MUSIC, durationHint)
|
||||||
|
}
|
||||||
|
|
||||||
|
promise.resolve(result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Andere Apps werden leiser (TTS spricht). */
|
||||||
|
@ReactMethod
|
||||||
|
fun requestDuck(promise: Promise) {
|
||||||
|
requestFocus(
|
||||||
|
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK,
|
||||||
|
AudioAttributes.USAGE_ASSISTANT,
|
||||||
|
promise,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Andere Apps werden pausiert (Mikrofon-Aufnahme / Gespraech). */
|
||||||
|
@ReactMethod
|
||||||
|
fun requestExclusive(promise: Promise) {
|
||||||
|
requestFocus(
|
||||||
|
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE,
|
||||||
|
AudioAttributes.USAGE_VOICE_COMMUNICATION,
|
||||||
|
promise,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Focus abgeben — andere Apps duerfen wieder volle Lautstaerke. */
|
||||||
|
@ReactMethod
|
||||||
|
fun release(promise: Promise) {
|
||||||
|
release()
|
||||||
|
promise.resolve(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun release() {
|
||||||
|
val am = audioManager() ?: return
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
currentRequest?.let { am.abandonAudioFocusRequest(it) }
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
am.abandonAudioFocus(null)
|
||||||
|
}
|
||||||
|
currentRequest = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.ariacockpit
|
||||||
|
|
||||||
|
import com.facebook.react.ReactPackage
|
||||||
|
import com.facebook.react.bridge.NativeModule
|
||||||
|
import com.facebook.react.bridge.ReactApplicationContext
|
||||||
|
import com.facebook.react.uimanager.ViewManager
|
||||||
|
|
||||||
|
class AudioFocusPackage : ReactPackage {
|
||||||
|
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
|
||||||
|
return listOf(AudioFocusModule(reactContext))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,8 +18,8 @@ class MainApplication : Application(), ReactApplication {
|
|||||||
object : DefaultReactNativeHost(this) {
|
object : DefaultReactNativeHost(this) {
|
||||||
override fun getPackages(): List<ReactPackage> =
|
override fun getPackages(): List<ReactPackage> =
|
||||||
PackageList(this).packages.apply {
|
PackageList(this).packages.apply {
|
||||||
// Packages that cannot be autolinked yet can be added manually here, for example:
|
add(ApkInstallerPackage())
|
||||||
// add(MyReactNativePackage())
|
add(AudioFocusPackage())
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getJSMainModuleName(): String = "index"
|
override fun getJSMainModuleName(): String = "index"
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths>
|
||||||
|
<cache-path name="cache" path="." />
|
||||||
|
</paths>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aria-cockpit",
|
"name": "aria-cockpit",
|
||||||
"version": "0.0.2.8",
|
"version": "0.0.4.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"android": "react-native run-android",
|
"android": "react-native run-android",
|
||||||
|
|||||||
+255
-105
@@ -5,7 +5,7 @@
|
|||||||
* Datei- und Kamera-Upload.
|
* Datei- und Kamera-Upload.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
Platform,
|
Platform,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Image,
|
Image,
|
||||||
|
ScrollView,
|
||||||
Modal,
|
Modal,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
@@ -47,12 +48,22 @@ interface ChatMessage {
|
|||||||
text: string;
|
text: string;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
attachments?: Attachment[];
|
attachments?: Attachment[];
|
||||||
|
/** Bridge-Message-ID zur Zuordnung von TTS-Audio */
|
||||||
|
messageId?: string;
|
||||||
|
/** Lokaler Pfad zur gecachten TTS-Audio-Datei (file://...) */
|
||||||
|
audioPath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Konstanten ---
|
// --- Konstanten ---
|
||||||
|
|
||||||
const CHAT_STORAGE_KEY = 'aria_chat_messages';
|
const CHAT_STORAGE_KEY = 'aria_chat_messages';
|
||||||
const MAX_STORED_MESSAGES = 500;
|
const MAX_STORED_MESSAGES = 500;
|
||||||
|
const MAX_MEMORY_MESSAGES = 500;
|
||||||
|
|
||||||
|
// Hilfe: Messages-Array auf Max kappen (aelteste raus) — verhindert OOM
|
||||||
|
// im Gespraechsmodus bei sehr vielen Nachrichten.
|
||||||
|
const capMessages = (msgs: ChatMessage[]): ChatMessage[] =>
|
||||||
|
msgs.length > MAX_MEMORY_MESSAGES ? msgs.slice(-MAX_MEMORY_MESSAGES) : msgs;
|
||||||
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';
|
||||||
|
|
||||||
@@ -94,6 +105,8 @@ const ChatScreen: React.FC = () => {
|
|||||||
const [fullscreenImage, setFullscreenImage] = useState<string | null>(null);
|
const [fullscreenImage, setFullscreenImage] = useState<string | null>(null);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [searchVisible, setSearchVisible] = useState(false);
|
const [searchVisible, setSearchVisible] = useState(false);
|
||||||
|
const [pendingAttachments, setPendingAttachments] = useState<{file: any, isPhoto: boolean}[]>([]);
|
||||||
|
const [agentActivity, setAgentActivity] = useState<{activity: string, tool: string}>({activity: 'idle', tool: ''});
|
||||||
|
|
||||||
const flatListRef = useRef<FlatList>(null);
|
const flatListRef = useRef<FlatList>(null);
|
||||||
const messageIdCounter = useRef(0);
|
const messageIdCounter = useRef(0);
|
||||||
@@ -215,12 +228,12 @@ const ChatScreen: React.FC = () => {
|
|||||||
if (sender === 'diagnostic') {
|
if (sender === 'diagnostic') {
|
||||||
const diagText = (message.payload.text as string) || '';
|
const diagText = (message.payload.text as string) || '';
|
||||||
if (diagText) {
|
if (diagText) {
|
||||||
setMessages(prev => [...prev, {
|
setMessages(prev => capMessages([...prev, {
|
||||||
id: nextId(),
|
id: nextId(),
|
||||||
sender: 'user',
|
sender: 'user',
|
||||||
text: diagText,
|
text: diagText,
|
||||||
timestamp: message.timestamp,
|
timestamp: message.timestamp,
|
||||||
}]);
|
}]));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -239,14 +252,33 @@ const ChatScreen: React.FC = () => {
|
|||||||
text,
|
text,
|
||||||
timestamp: ts,
|
timestamp: ts,
|
||||||
attachments: message.payload.attachments as Attachment[] | undefined,
|
attachments: message.payload.attachments as Attachment[] | undefined,
|
||||||
|
messageId: (message.payload.messageId as string) || undefined,
|
||||||
};
|
};
|
||||||
return [...prev, ariaMsg];
|
return capMessages([...prev, ariaMsg]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// TTS-Audio abspielen wenn vorhanden
|
// TTS-Audio abspielen wenn vorhanden
|
||||||
if (message.type === 'audio' && message.payload.base64) {
|
if (message.type === 'audio' && message.payload.base64) {
|
||||||
audioService.playAudio(message.payload.base64 as string);
|
const b64 = message.payload.base64 as string;
|
||||||
|
const refId = (message.payload.messageId as string) || '';
|
||||||
|
audioService.playAudio(b64);
|
||||||
|
// Wenn messageId mitgeliefert wurde: Audio in Cache speichern + Pfad in Message eintragen
|
||||||
|
if (refId) {
|
||||||
|
audioService.cacheAudio(b64, refId).then(audioPath => {
|
||||||
|
if (!audioPath) return;
|
||||||
|
setMessages(prev => prev.map(m =>
|
||||||
|
m.messageId === refId ? { ...m, audioPath } : m
|
||||||
|
));
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thinking-Indicator Status von der Bridge
|
||||||
|
if (message.type === 'agent_activity') {
|
||||||
|
const activity = (message.payload.activity as string) || 'idle';
|
||||||
|
const tool = (message.payload.tool as string) || '';
|
||||||
|
setAgentActivity({ activity, tool });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -273,12 +305,20 @@ const ChatScreen: React.FC = () => {
|
|||||||
return () => { unsubUpdate(); clearTimeout(timer); };
|
return () => { unsubUpdate(); clearTimeout(timer); };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Wake Word: "ARIA" Erkennung → Auto-Aufnahme starten
|
// Gespraechsmodus: Nach TTS-Wiedergabe automatisch Aufnahme starten
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubPlayback = audioService.onPlaybackFinished(() => {
|
||||||
|
if (wakeWordService.isActive()) {
|
||||||
|
wakeWordService.resume();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => unsubPlayback();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Wake Word / Gespraechsmodus: Auto-Aufnahme starten
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubWake = wakeWordService.onWakeWord(async () => {
|
const unsubWake = wakeWordService.onWakeWord(async () => {
|
||||||
console.log('[Chat] Wake Word erkannt — starte Auto-Aufnahme');
|
console.log('[Chat] Gespraechsmodus — starte Auto-Aufnahme');
|
||||||
// TTS stoppen damit ARIA sich nicht selbst hoert
|
|
||||||
audioService.stopPlayback();
|
|
||||||
// Aufnahme mit Auto-Stop (VAD) starten
|
// Aufnahme mit Auto-Stop (VAD) starten
|
||||||
const started = await audioService.startRecording(true);
|
const started = await audioService.startRecording(true);
|
||||||
if (!started) {
|
if (!started) {
|
||||||
@@ -300,7 +340,7 @@ const ChatScreen: React.FC = () => {
|
|||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
attachments: [{ type: 'audio', name: 'Sprachaufnahme' }],
|
attachments: [{ type: 'audio', name: 'Sprachaufnahme' }],
|
||||||
};
|
};
|
||||||
setMessages(prev => [...prev, userMsg]);
|
setMessages(prev => capMessages([...prev, userMsg]));
|
||||||
rvs.send('audio', {
|
rvs.send('audio', {
|
||||||
base64: result.base64,
|
base64: result.base64,
|
||||||
durationMs: result.durationMs,
|
durationMs: result.durationMs,
|
||||||
@@ -359,22 +399,8 @@ const ChatScreen: React.FC = () => {
|
|||||||
return () => { if (saveTimer.current) clearTimeout(saveTimer.current); };
|
return () => { if (saveTimer.current) clearTimeout(saveTimer.current); };
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
// Auto-Scroll wird ueber onContentSizeChange der FlatList gesteuert
|
// Inverted FlatList: neueste Nachrichten unten, kein manuelles Scrollen noetig
|
||||||
const shouldAutoScroll = useRef(true);
|
const invertedMessages = useMemo(() => [...messages].reverse(), [messages]);
|
||||||
const handleContentSizeChange = useCallback(() => {
|
|
||||||
if (shouldAutoScroll.current) {
|
|
||||||
flatListRef.current?.scrollToEnd({ animated: false });
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
const handleScrollBeginDrag = useCallback(() => {
|
|
||||||
shouldAutoScroll.current = false;
|
|
||||||
}, []);
|
|
||||||
const handleScrollEndDrag = useCallback((e: any) => {
|
|
||||||
// Auto-Scroll wieder aktivieren wenn User ganz unten ist
|
|
||||||
const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent;
|
|
||||||
const isAtBottom = contentOffset.y + layoutMeasurement.height >= contentSize.height - 50;
|
|
||||||
shouldAutoScroll.current = isAtBottom;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// GPS-Position holen (optional)
|
// GPS-Position holen (optional)
|
||||||
const getCurrentLocation = useCallback((): Promise<{ lat: number; lon: number } | null> => {
|
const getCurrentLocation = useCallback((): Promise<{ lat: number; lon: number } | null> => {
|
||||||
@@ -400,6 +426,13 @@ const ChatScreen: React.FC = () => {
|
|||||||
|
|
||||||
const sendTextMessage = useCallback(async () => {
|
const sendTextMessage = useCallback(async () => {
|
||||||
const text = inputText.trim();
|
const text = inputText.trim();
|
||||||
|
|
||||||
|
// Wenn pending Anhaenge vorhanden → Anhaenge + Text zusammen senden
|
||||||
|
if (pendingAttachments.length > 0) {
|
||||||
|
sendPendingAttachments(text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
|
|
||||||
setInputText('');
|
setInputText('');
|
||||||
@@ -412,14 +445,20 @@ const ChatScreen: React.FC = () => {
|
|||||||
text,
|
text,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
};
|
};
|
||||||
setMessages(prev => [...prev, userMsg]);
|
setMessages(prev => capMessages([...prev, userMsg]));
|
||||||
|
|
||||||
// An RVS senden
|
// An RVS senden
|
||||||
rvs.send('chat', {
|
rvs.send('chat', {
|
||||||
text,
|
text,
|
||||||
...(location && { location }),
|
...(location && { location }),
|
||||||
});
|
});
|
||||||
}, [inputText, getCurrentLocation]);
|
}, [inputText, getCurrentLocation, pendingAttachments, sendPendingAttachments]);
|
||||||
|
|
||||||
|
// Anfrage abbrechen — sofort lokalen Indicator weg, Bridge triggert doctor --fix
|
||||||
|
const cancelRequest = useCallback(() => {
|
||||||
|
setAgentActivity({ activity: 'idle', tool: '' });
|
||||||
|
rvs.send('cancel_request' as any, {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Sprachaufnahme abgeschlossen
|
// Sprachaufnahme abgeschlossen
|
||||||
const handleVoiceRecording = useCallback(async (result: RecordingResult) => {
|
const handleVoiceRecording = useCallback(async (result: RecordingResult) => {
|
||||||
@@ -431,7 +470,7 @@ const ChatScreen: React.FC = () => {
|
|||||||
text: '🎙 Spracheingabe wird verarbeitet...',
|
text: '🎙 Spracheingabe wird verarbeitet...',
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
};
|
};
|
||||||
setMessages(prev => [...prev, userMsg]);
|
setMessages(prev => capMessages([...prev, userMsg]));
|
||||||
|
|
||||||
rvs.send('audio', {
|
rvs.send('audio', {
|
||||||
base64: result.base64,
|
base64: result.base64,
|
||||||
@@ -441,88 +480,91 @@ const ChatScreen: React.FC = () => {
|
|||||||
});
|
});
|
||||||
}, [getCurrentLocation]);
|
}, [getCurrentLocation]);
|
||||||
|
|
||||||
// Datei senden
|
// Datei auswaehlen → zur Pending-Liste hinzufuegen
|
||||||
const handleFileSelected = useCallback(async (file: FileData) => {
|
const handleFileSelected = useCallback(async (file: FileData) => {
|
||||||
setShowFileUpload(false);
|
setShowFileUpload(false);
|
||||||
const location = await getCurrentLocation();
|
setPendingAttachments(prev => [...prev, { file, isPhoto: false }]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const isImage = file.type.startsWith('image/');
|
// Foto auswaehlen → zur Pending-Liste hinzufuegen
|
||||||
const msgId = nextId();
|
|
||||||
let imageUri = isImage && file.base64 ? `data:${file.type};base64,${file.base64}` : file.uri;
|
|
||||||
|
|
||||||
const userMsg: ChatMessage = {
|
|
||||||
id: msgId,
|
|
||||||
sender: 'user',
|
|
||||||
text: 'Anhang empfangen',
|
|
||||||
timestamp: Date.now(),
|
|
||||||
attachments: [{
|
|
||||||
type: isImage ? 'image' : 'file',
|
|
||||||
name: file.name,
|
|
||||||
size: file.size,
|
|
||||||
uri: imageUri,
|
|
||||||
mimeType: file.type,
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
setMessages(prev => [...prev, userMsg]);
|
|
||||||
|
|
||||||
// Anhang auf Disk speichern fuer Persistenz
|
|
||||||
if (file.base64) {
|
|
||||||
persistAttachment(file.base64, msgId, file.name).then(filePath => {
|
|
||||||
setMessages(prev => prev.map(m =>
|
|
||||||
m.id === msgId ? { ...m, attachments: m.attachments?.map(a => ({ ...a, uri: filePath })) } : m
|
|
||||||
));
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
rvs.send('file', {
|
|
||||||
name: file.name,
|
|
||||||
type: file.type,
|
|
||||||
size: file.size,
|
|
||||||
base64: file.base64,
|
|
||||||
...(location && { location }),
|
|
||||||
});
|
|
||||||
}, [getCurrentLocation]);
|
|
||||||
|
|
||||||
// Foto senden
|
|
||||||
const handlePhotoSelected = useCallback(async (photo: PhotoData) => {
|
const handlePhotoSelected = useCallback(async (photo: PhotoData) => {
|
||||||
setShowCameraUpload(false);
|
setShowCameraUpload(false);
|
||||||
|
setPendingAttachments(prev => [...prev, { file: photo, isPhoto: true }]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Alle Pending Anhaenge + Text senden
|
||||||
|
const sendPendingAttachments = useCallback(async (messageText: string) => {
|
||||||
|
if (pendingAttachments.length === 0) return;
|
||||||
const location = await getCurrentLocation();
|
const location = await getCurrentLocation();
|
||||||
|
|
||||||
const msgId = nextId();
|
const msgId = nextId();
|
||||||
const dataUri = photo.base64 ? `data:${photo.type};base64,${photo.base64}` : undefined;
|
|
||||||
|
|
||||||
|
// Alle Attachments fuer die Chat-Nachricht sammeln
|
||||||
|
const attachments: Attachment[] = [];
|
||||||
|
for (const { file, isPhoto } of pendingAttachments) {
|
||||||
|
const isImage = isPhoto || (file.type && file.type.startsWith('image/'));
|
||||||
|
const name = isPhoto ? file.fileName : file.name;
|
||||||
|
const base64 = file.base64 || '';
|
||||||
|
const mimeType = file.type || '';
|
||||||
|
const imageUri = isImage && base64 ? `data:${mimeType};base64,${base64}` : file.uri;
|
||||||
|
|
||||||
|
attachments.push({
|
||||||
|
type: isImage ? 'image' : 'file',
|
||||||
|
name,
|
||||||
|
size: file.size,
|
||||||
|
uri: imageUri,
|
||||||
|
mimeType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chat-Nachricht mit allen Anhaengen
|
||||||
const userMsg: ChatMessage = {
|
const userMsg: ChatMessage = {
|
||||||
id: msgId,
|
id: msgId,
|
||||||
sender: 'user',
|
sender: 'user',
|
||||||
text: 'Anhang empfangen',
|
text: messageText || `${pendingAttachments.length} Anhang/Anhaenge`,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
attachments: [{
|
attachments,
|
||||||
type: 'image',
|
|
||||||
name: photo.fileName,
|
|
||||||
uri: dataUri,
|
|
||||||
mimeType: photo.type,
|
|
||||||
}],
|
|
||||||
};
|
};
|
||||||
setMessages(prev => [...prev, userMsg]);
|
setMessages(prev => capMessages([...prev, userMsg]));
|
||||||
|
|
||||||
// Foto auf Disk speichern fuer Persistenz
|
// Alle Dateien an RVS senden + auf Disk speichern
|
||||||
if (photo.base64) {
|
for (const { file, isPhoto } of pendingAttachments) {
|
||||||
persistAttachment(photo.base64, msgId, photo.fileName).then(filePath => {
|
const name = isPhoto ? file.fileName : file.name;
|
||||||
setMessages(prev => prev.map(m =>
|
const base64 = file.base64 || '';
|
||||||
m.id === msgId ? { ...m, attachments: m.attachments?.map(a => ({ ...a, uri: filePath })) } : m
|
const mimeType = file.type || '';
|
||||||
));
|
|
||||||
}).catch(() => {});
|
// Auf Disk speichern
|
||||||
|
if (base64) {
|
||||||
|
persistAttachment(base64, msgId + '_' + name, name).then(filePath => {
|
||||||
|
setMessages(prev => prev.map(m =>
|
||||||
|
m.id === msgId ? { ...m, attachments: m.attachments?.map(a =>
|
||||||
|
a.name === name && !a.uri?.startsWith('file://') ? { ...a, uri: filePath } : a
|
||||||
|
)} : m
|
||||||
|
));
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// An RVS senden
|
||||||
|
rvs.send('file', {
|
||||||
|
name,
|
||||||
|
type: mimeType,
|
||||||
|
size: file.size,
|
||||||
|
base64,
|
||||||
|
...(isPhoto && file.width && { width: file.width, height: file.height }),
|
||||||
|
...(location && { location }),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
rvs.send('file', {
|
// Text als separate Nachricht (damit ARIA weiss was zu tun ist)
|
||||||
name: photo.fileName,
|
if (messageText) {
|
||||||
type: photo.type,
|
rvs.send('chat', {
|
||||||
base64: photo.base64,
|
text: messageText,
|
||||||
width: photo.width,
|
...(location && { location }),
|
||||||
height: photo.height,
|
});
|
||||||
...(location && { location }),
|
}
|
||||||
});
|
|
||||||
}, [getCurrentLocation]);
|
setPendingAttachments([]);
|
||||||
|
setInputText('');
|
||||||
|
}, [pendingAttachments, getCurrentLocation]);
|
||||||
|
|
||||||
// --- Rendering ---
|
// --- Rendering ---
|
||||||
|
|
||||||
@@ -594,16 +636,19 @@ const ChatScreen: React.FC = () => {
|
|||||||
{item.text}
|
{item.text}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{/* Play-Button fuer ARIA-Nachrichten */}
|
{/* Play-Button fuer ARIA-Nachrichten — Cache bevorzugt, sonst Regenerierung */}
|
||||||
{!isUser && item.text.length > 0 && (
|
{!isUser && item.text.length > 0 && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.playButton}
|
style={styles.playButton}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
// TTS-Request an Bridge senden
|
if (item.audioPath) {
|
||||||
rvs.send('tts_request' as any, { text: item.text, voice: '' });
|
audioService.playFromPath(item.audioPath);
|
||||||
|
} else {
|
||||||
|
rvs.send('tts_request' as any, { text: item.text, voice: '' });
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text style={styles.playButtonText}>{'\uD83D\uDD0A'}</Text>
|
<Text style={styles.playButtonText}>{item.audioPath ? '\uD83D\uDD0A' : '\uD83D\uDD0A'}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
<Text style={styles.timestamp}>{time}</Text>
|
<Text style={styles.timestamp}>{time}</Text>
|
||||||
@@ -653,14 +698,12 @@ const ChatScreen: React.FC = () => {
|
|||||||
{/* Nachrichtenliste */}
|
{/* Nachrichtenliste */}
|
||||||
<FlatList
|
<FlatList
|
||||||
ref={flatListRef}
|
ref={flatListRef}
|
||||||
data={searchQuery ? messages.filter(m => m.text.toLowerCase().includes(searchQuery.toLowerCase())) : messages}
|
inverted
|
||||||
|
data={searchQuery ? messages.filter(m => m.text.toLowerCase().includes(searchQuery.toLowerCase())).reverse() : invertedMessages}
|
||||||
keyExtractor={item => item.id}
|
keyExtractor={item => item.id}
|
||||||
renderItem={renderMessage}
|
renderItem={renderMessage}
|
||||||
contentContainerStyle={styles.messageList}
|
contentContainerStyle={styles.messageList}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
onContentSizeChange={handleContentSizeChange}
|
|
||||||
onScrollBeginDrag={handleScrollBeginDrag}
|
|
||||||
onScrollEndDrag={handleScrollEndDrag}
|
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
<View style={styles.emptyContainer}>
|
<View style={styles.emptyContainer}>
|
||||||
<Text style={styles.emptyIcon}>{'\uD83E\uDD16'}</Text>
|
<Text style={styles.emptyIcon}>{'\uD83E\uDD16'}</Text>
|
||||||
@@ -670,6 +713,56 @@ const ChatScreen: React.FC = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Thinking-Indicator */}
|
||||||
|
{agentActivity.activity !== 'idle' && (
|
||||||
|
<View style={styles.thinkingBar}>
|
||||||
|
<Text style={styles.thinkingText}>
|
||||||
|
{agentActivity.activity === 'tool' && agentActivity.tool
|
||||||
|
? `\uD83D\uDD27 ${agentActivity.tool}`
|
||||||
|
: agentActivity.activity === 'assistant'
|
||||||
|
? '\u270D\uFE0F ARIA schreibt...'
|
||||||
|
: '\uD83D\uDCAD ARIA denkt...'}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity style={styles.thinkingCancel} onPress={cancelRequest}>
|
||||||
|
<Text style={styles.thinkingCancelText}>Abbrechen</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pending Anhaenge Vorschau */}
|
||||||
|
{pendingAttachments.length > 0 && (
|
||||||
|
<View style={styles.pendingBar}>
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={{flex: 1}}>
|
||||||
|
{pendingAttachments.map((att, idx) => (
|
||||||
|
<View key={idx} style={styles.pendingItem}>
|
||||||
|
{att.file.type?.startsWith('image/') || att.isPhoto ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: att.file.base64
|
||||||
|
? `data:${att.file.type};base64,${att.file.base64}`
|
||||||
|
: att.file.uri }}
|
||||||
|
style={styles.pendingThumb}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={[styles.pendingThumb, {justifyContent: 'center', alignItems: 'center'}]}>
|
||||||
|
<Text style={{fontSize: 20}}>{'\uD83D\uDCC4'}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.pendingRemove}
|
||||||
|
onPress={() => setPendingAttachments(prev => prev.filter((_, i) => i !== idx))}
|
||||||
|
>
|
||||||
|
<Text style={{color: '#fff', fontSize: 10, fontWeight: 'bold'}}>X</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
<Text style={{color: '#8888AA', fontSize: 11, marginLeft: 8}}>{pendingAttachments.length}</Text>
|
||||||
|
<TouchableOpacity onPress={() => setPendingAttachments([])}>
|
||||||
|
<Text style={{color: '#FF3B30', fontSize: 14, paddingHorizontal: 8}}>Alle X</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Eingabebereich */}
|
{/* Eingabebereich */}
|
||||||
<View style={styles.inputContainer}>
|
<View style={styles.inputContainer}>
|
||||||
{/* Datei-Buttons */}
|
{/* Datei-Buttons */}
|
||||||
@@ -692,7 +785,7 @@ const ChatScreen: React.FC = () => {
|
|||||||
style={styles.textInput}
|
style={styles.textInput}
|
||||||
value={inputText}
|
value={inputText}
|
||||||
onChangeText={setInputText}
|
onChangeText={setInputText}
|
||||||
placeholder="Nachricht an ARIA..."
|
placeholder={pendingAttachments.length > 0 ? "Text zu den Anhaengen (optional)..." : "Nachricht an ARIA..."}
|
||||||
placeholderTextColor="#555570"
|
placeholderTextColor="#555570"
|
||||||
multiline
|
multiline
|
||||||
maxLength={4000}
|
maxLength={4000}
|
||||||
@@ -701,7 +794,7 @@ const ChatScreen: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Senden oder Sprache */}
|
{/* Senden oder Sprache */}
|
||||||
{inputText.trim() ? (
|
{inputText.trim() || pendingAttachments.length > 0 ? (
|
||||||
<TouchableOpacity style={styles.sendButton} onPress={sendTextMessage}>
|
<TouchableOpacity style={styles.sendButton} onPress={sendTextMessage}>
|
||||||
<Text style={styles.sendIcon}>{'\u2B06\uFE0F'}</Text>
|
<Text style={styles.sendIcon}>{'\u2B06\uFE0F'}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -932,6 +1025,63 @@ const styles = StyleSheet.create({
|
|||||||
wakeWordIcon: {
|
wakeWordIcon: {
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
},
|
},
|
||||||
|
thinkingBar: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
backgroundColor: '#1E1E2E',
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: '#2A2A3E',
|
||||||
|
},
|
||||||
|
thinkingText: {
|
||||||
|
color: '#FFD60A',
|
||||||
|
fontSize: 12,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
thinkingCancel: {
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#FF3B30',
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
thinkingCancelText: {
|
||||||
|
color: '#FF3B30',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
pendingBar: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#1E1E2E',
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: '#2A2A3E',
|
||||||
|
},
|
||||||
|
pendingItem: {
|
||||||
|
position: 'relative',
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
pendingThumb: {
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
borderRadius: 6,
|
||||||
|
backgroundColor: '#0D0D1A',
|
||||||
|
},
|
||||||
|
pendingRemove: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: -4,
|
||||||
|
right: -4,
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
borderRadius: 9,
|
||||||
|
backgroundColor: '#FF3B30',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
searchBar: {
|
searchBar: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
@@ -748,11 +748,21 @@ const SettingsScreen: React.FC = () => {
|
|||||||
<Text style={styles.sectionTitle}>{'\u00DC'}ber</Text>
|
<Text style={styles.sectionTitle}>{'\u00DC'}ber</Text>
|
||||||
<View style={styles.card}>
|
<View style={styles.card}>
|
||||||
<Text style={styles.aboutTitle}>ARIA Cockpit</Text>
|
<Text style={styles.aboutTitle}>ARIA Cockpit</Text>
|
||||||
<Text style={styles.aboutVersion}>Version 0.0.2.8 </Text>
|
<Text style={styles.aboutVersion}>Version {require('../../package.json').version}</Text>
|
||||||
<Text style={styles.aboutInfo}>
|
<Text style={styles.aboutInfo}>
|
||||||
Stefans Kommandozentrale f{'\u00FC'}r ARIA.{'\n'}
|
Stefans Kommandozentrale f{'\u00FC'}r ARIA.{'\n'}
|
||||||
Gebaut mit React Native + TypeScript.
|
Gebaut mit React Native + TypeScript.
|
||||||
</Text>
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.connectButton, {marginTop: 12}]}
|
||||||
|
onPress={() => {
|
||||||
|
const updateService = require('../services/updater').default;
|
||||||
|
updateService.checkForUpdate();
|
||||||
|
Alert.alert('Update-Check', 'Pruefe auf neue Version...');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={styles.connectButtonText}>Auf Updates pr{'\u00FC'}fen</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Platz am Ende */}
|
{/* Platz am Ende */}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* Nutzt react-native-audio-recorder-player fuer Aufnahme.
|
* Nutzt react-native-audio-recorder-player fuer Aufnahme.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Platform, PermissionsAndroid } from 'react-native';
|
import { Platform, PermissionsAndroid, NativeModules } from 'react-native';
|
||||||
import Sound from 'react-native-sound';
|
import Sound from 'react-native-sound';
|
||||||
import RNFS from 'react-native-fs';
|
import RNFS from 'react-native-fs';
|
||||||
import AudioRecorderPlayer, {
|
import AudioRecorderPlayer, {
|
||||||
@@ -16,6 +16,15 @@ import AudioRecorderPlayer, {
|
|||||||
OutputFormatAndroidType,
|
OutputFormatAndroidType,
|
||||||
} from 'react-native-audio-recorder-player';
|
} from 'react-native-audio-recorder-player';
|
||||||
|
|
||||||
|
// Native Module fuer Audio-Focus (Ducking/Muten anderer Apps)
|
||||||
|
const { AudioFocus } = NativeModules as {
|
||||||
|
AudioFocus?: {
|
||||||
|
requestDuck: () => Promise<boolean>;
|
||||||
|
requestExclusive: () => Promise<boolean>;
|
||||||
|
release: () => Promise<boolean>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// --- Typen ---
|
// --- Typen ---
|
||||||
|
|
||||||
export interface RecordingResult {
|
export interface RecordingResult {
|
||||||
@@ -42,6 +51,11 @@ const AUDIO_ENCODING = 'audio/wav';
|
|||||||
// VAD (Voice Activity Detection) — Stille-Erkennung
|
// VAD (Voice Activity Detection) — Stille-Erkennung
|
||||||
const VAD_SILENCE_THRESHOLD_DB = -45; // dB unter dem als "Stille" gilt
|
const VAD_SILENCE_THRESHOLD_DB = -45; // dB unter dem als "Stille" gilt
|
||||||
const VAD_SILENCE_DURATION_MS = 1800; // ms Stille bevor Auto-Stop
|
const VAD_SILENCE_DURATION_MS = 1800; // ms Stille bevor Auto-Stop
|
||||||
|
const VAD_SPEECH_THRESHOLD_DB = -28; // dB ueber dem als "Sprache" gilt (Sprach-Gate) — hoeher = weniger Umgebungsgeraeusche
|
||||||
|
const VAD_SPEECH_MIN_MS = 500; // ms Sprache bevor Aufnahme zaehlt — laenger = keine Huestler/Klopfer mehr
|
||||||
|
|
||||||
|
// Max-Dauer einer Aufnahme in Gespraechsmodus (Notbremse gegen Runaway-Loops)
|
||||||
|
const MAX_RECORDING_MS = 30000;
|
||||||
|
|
||||||
// --- Audio-Service ---
|
// --- Audio-Service ---
|
||||||
|
|
||||||
@@ -61,10 +75,15 @@ class AudioService {
|
|||||||
private preloadedSound: Sound | null = null;
|
private preloadedSound: Sound | null = null;
|
||||||
private preloadedPath: string = '';
|
private preloadedPath: string = '';
|
||||||
|
|
||||||
|
// Sprach-Gate: Aufnahme erst senden wenn tatsaechlich gesprochen wurde
|
||||||
|
private speechDetected: boolean = false;
|
||||||
|
private speechStartTime: number = 0;
|
||||||
|
|
||||||
// VAD State
|
// VAD State
|
||||||
private vadEnabled: boolean = false;
|
private vadEnabled: boolean = false;
|
||||||
private lastSpeechTime: number = 0;
|
private lastSpeechTime: number = 0;
|
||||||
private vadTimer: ReturnType<typeof setInterval> | null = null;
|
private vadTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private maxDurationTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.recorder = new AudioRecorderPlayer();
|
this.recorder = new AudioRecorderPlayer();
|
||||||
@@ -114,6 +133,10 @@ class AudioService {
|
|||||||
// Laufende Wiedergabe stoppen (damit ARIA sich nicht selbst hoert)
|
// Laufende Wiedergabe stoppen (damit ARIA sich nicht selbst hoert)
|
||||||
this.stopPlayback();
|
this.stopPlayback();
|
||||||
|
|
||||||
|
// Aufraeumen: Alte aria_recording_ und aria_tts_ Files loeschen
|
||||||
|
// (Schutz gegen Cache-Ueberlauf im Gespraechsmodus bei vielen Zyklen)
|
||||||
|
this._cleanupStaleCacheFiles().catch(() => {});
|
||||||
|
|
||||||
this.recordingPath = `${RNFS.CachesDirectoryPath}/aria_recording_${Date.now()}.mp4`;
|
this.recordingPath = `${RNFS.CachesDirectoryPath}/aria_recording_${Date.now()}.mp4`;
|
||||||
|
|
||||||
// Aufnahme mit Metering starten
|
// Aufnahme mit Metering starten
|
||||||
@@ -121,6 +144,8 @@ class AudioService {
|
|||||||
AudioEncoderAndroid: AudioEncoderAndroidType.AAC,
|
AudioEncoderAndroid: AudioEncoderAndroidType.AAC,
|
||||||
AudioSourceAndroid: AudioSourceAndroidType.MIC,
|
AudioSourceAndroid: AudioSourceAndroidType.MIC,
|
||||||
OutputFormatAndroid: OutputFormatAndroidType.MPEG_4,
|
OutputFormatAndroid: OutputFormatAndroidType.MPEG_4,
|
||||||
|
AudioSamplingRateAndroid: 16000,
|
||||||
|
AudioChannelsAndroid: 1,
|
||||||
}, true); // meteringEnabled = true
|
}, true); // meteringEnabled = true
|
||||||
|
|
||||||
// Metering-Callback
|
// Metering-Callback
|
||||||
@@ -128,7 +153,21 @@ class AudioService {
|
|||||||
const db = e.currentMetering ?? -160;
|
const db = e.currentMetering ?? -160;
|
||||||
this.meterListeners.forEach(cb => cb(db));
|
this.meterListeners.forEach(cb => cb(db));
|
||||||
|
|
||||||
// VAD: Stille erkennen
|
// Sprach-Gate: Erkennen ob tatsaechlich gesprochen wird
|
||||||
|
if (db > VAD_SPEECH_THRESHOLD_DB) {
|
||||||
|
if (!this.speechDetected && this.speechStartTime === 0) {
|
||||||
|
this.speechStartTime = Date.now();
|
||||||
|
}
|
||||||
|
if (this.speechStartTime > 0 && Date.now() - this.speechStartTime >= VAD_SPEECH_MIN_MS) {
|
||||||
|
this.speechDetected = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!this.speechDetected) {
|
||||||
|
this.speechStartTime = 0; // Reset wenn noch nicht als Sprache erkannt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// VAD: Stille erkennen (nur wenn Sprache erkannt wurde)
|
||||||
if (this.vadEnabled) {
|
if (this.vadEnabled) {
|
||||||
if (db > VAD_SILENCE_THRESHOLD_DB) {
|
if (db > VAD_SILENCE_THRESHOLD_DB) {
|
||||||
this.lastSpeechTime = Date.now();
|
this.lastSpeechTime = Date.now();
|
||||||
@@ -138,8 +177,13 @@ class AudioService {
|
|||||||
|
|
||||||
this.recordingStartTime = Date.now();
|
this.recordingStartTime = Date.now();
|
||||||
this.lastSpeechTime = Date.now();
|
this.lastSpeechTime = Date.now();
|
||||||
|
this.speechDetected = false;
|
||||||
|
this.speechStartTime = 0;
|
||||||
this.setState('recording');
|
this.setState('recording');
|
||||||
|
|
||||||
|
// Andere Apps waehrend der Aufnahme pausieren (Musik, Videos etc.)
|
||||||
|
AudioFocus?.requestExclusive().catch(() => {});
|
||||||
|
|
||||||
// VAD aktivieren
|
// VAD aktivieren
|
||||||
this.vadEnabled = autoStop;
|
this.vadEnabled = autoStop;
|
||||||
if (autoStop) {
|
if (autoStop) {
|
||||||
@@ -150,6 +194,11 @@ class AudioService {
|
|||||||
this.silenceListeners.forEach(cb => cb());
|
this.silenceListeners.forEach(cb => cb());
|
||||||
}
|
}
|
||||||
}, 200);
|
}, 200);
|
||||||
|
// Notbremse: Nach MAX_RECORDING_MS zwangsweise stoppen
|
||||||
|
this.maxDurationTimer = setTimeout(() => {
|
||||||
|
console.warn(`[Audio] Max-Dauer ${MAX_RECORDING_MS}ms erreicht — Zwangs-Stop`);
|
||||||
|
this.silenceListeners.forEach(cb => cb());
|
||||||
|
}, MAX_RECORDING_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[Audio] Aufnahme gestartet (autoStop: %s)', autoStop);
|
console.log('[Audio] Aufnahme gestartet (autoStop: %s)', autoStop);
|
||||||
@@ -174,12 +223,28 @@ class AudioService {
|
|||||||
clearInterval(this.vadTimer);
|
clearInterval(this.vadTimer);
|
||||||
this.vadTimer = null;
|
this.vadTimer = null;
|
||||||
}
|
}
|
||||||
|
if (this.maxDurationTimer) {
|
||||||
|
clearTimeout(this.maxDurationTimer);
|
||||||
|
this.maxDurationTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.recorder.stopRecorder();
|
await this.recorder.stopRecorder();
|
||||||
this.recorder.removeRecordBackListener();
|
this.recorder.removeRecordBackListener();
|
||||||
|
|
||||||
|
// Audio-Focus freigeben — andere Apps duerfen wieder
|
||||||
|
AudioFocus?.release().catch(() => {});
|
||||||
|
|
||||||
const durationMs = Date.now() - this.recordingStartTime;
|
const durationMs = Date.now() - this.recordingStartTime;
|
||||||
|
const hadSpeech = this.speechDetected;
|
||||||
|
|
||||||
|
// Sprach-Gate: Wenn keine Sprache erkannt → Aufnahme verwerfen
|
||||||
|
if (!hadSpeech) {
|
||||||
|
RNFS.unlink(this.recordingPath).catch(() => {});
|
||||||
|
this.setState('idle');
|
||||||
|
console.log('[Audio] Aufnahme verworfen — keine Sprache erkannt (nur Umgebungsgeraeusche)');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Audio-Datei als Base64 lesen
|
// Audio-Datei als Base64 lesen
|
||||||
const base64Data = await RNFS.readFile(this.recordingPath, 'base64');
|
const base64Data = await RNFS.readFile(this.recordingPath, 'base64');
|
||||||
@@ -188,7 +253,7 @@ class AudioService {
|
|||||||
RNFS.unlink(this.recordingPath).catch(() => {});
|
RNFS.unlink(this.recordingPath).catch(() => {});
|
||||||
|
|
||||||
this.setState('idle');
|
this.setState('idle');
|
||||||
console.log(`[Audio] Aufnahme beendet (${durationMs}ms, ${Math.round(base64Data.length / 1024)}KB)`);
|
console.log(`[Audio] Aufnahme beendet (${durationMs}ms, ${Math.round(base64Data.length / 1024)}KB, Sprache erkannt)`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
base64: base64Data,
|
base64: base64Data,
|
||||||
@@ -214,13 +279,71 @@ class AudioService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Base64-Audio persistent speichern. Gibt file:// Pfad zurueck (oder leer bei Fehler). */
|
||||||
|
async cacheAudio(base64Data: string, messageId: string): Promise<string> {
|
||||||
|
if (!base64Data || !messageId) return '';
|
||||||
|
try {
|
||||||
|
const dir = `${RNFS.DocumentDirectoryPath}/tts_cache`;
|
||||||
|
await RNFS.mkdir(dir).catch(() => {});
|
||||||
|
const path = `${dir}/${messageId}.wav`;
|
||||||
|
// Wenn Datei schon existiert (z.B. XTTS Chunks) → anhaengen statt ueberschreiben
|
||||||
|
const exists = await RNFS.exists(path);
|
||||||
|
if (exists) {
|
||||||
|
// Bestehende + neue Base64 laden, zusammenkleben (fuer jetzt: ueberschreiben)
|
||||||
|
// XTTS sendet mehrere Chunks — bei mehrfacher Ueberschreibung bleibt nur der letzte
|
||||||
|
// Fuer eine echte Konkatenation muesste WAV-Header gemerged werden
|
||||||
|
await RNFS.writeFile(path, base64Data, 'base64');
|
||||||
|
} else {
|
||||||
|
await RNFS.writeFile(path, base64Data, 'base64');
|
||||||
|
}
|
||||||
|
return `file://${path}`;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[Audio] cacheAudio fehlgeschlagen:', err);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Audio aus lokaler Datei (file:// Pfad) in die Queue und abspielen. */
|
||||||
|
async playFromPath(filePath: string): Promise<void> {
|
||||||
|
if (!filePath) return;
|
||||||
|
try {
|
||||||
|
const cleanPath = filePath.replace(/^file:\/\//, '');
|
||||||
|
if (!(await RNFS.exists(cleanPath))) {
|
||||||
|
console.warn('[Audio] Cache-Datei existiert nicht mehr:', cleanPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const b64 = await RNFS.readFile(cleanPath, 'base64');
|
||||||
|
this.playAudio(b64);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[Audio] playFromPath fehlgeschlagen:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Callback wenn alle Audio-Teile abgespielt sind
|
||||||
|
private playbackFinishedListeners: (() => void)[] = [];
|
||||||
|
|
||||||
|
onPlaybackFinished(callback: () => void): () => void {
|
||||||
|
this.playbackFinishedListeners.push(callback);
|
||||||
|
return () => {
|
||||||
|
this.playbackFinishedListeners = this.playbackFinishedListeners.filter(cb => cb !== callback);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Naechstes Audio aus der Queue abspielen */
|
/** Naechstes Audio aus der Queue abspielen */
|
||||||
private async _playNext(): Promise<void> {
|
private async _playNext(): Promise<void> {
|
||||||
if (this.audioQueue.length === 0) {
|
if (this.audioQueue.length === 0) {
|
||||||
this.isPlaying = false;
|
this.isPlaying = false;
|
||||||
|
// Audio-Focus abgeben → andere Apps volle Lautstaerke
|
||||||
|
AudioFocus?.release().catch(() => {});
|
||||||
|
// Alle Audio-Teile abgespielt → Listener benachrichtigen
|
||||||
|
this.playbackFinishedListeners.forEach(cb => cb());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Beim ersten Playback-Start: andere Apps ducken
|
||||||
|
if (!this.isPlaying) {
|
||||||
|
AudioFocus?.requestDuck().catch(() => {});
|
||||||
|
}
|
||||||
this.isPlaying = true;
|
this.isPlaying = true;
|
||||||
|
|
||||||
// Preloaded Sound verwenden wenn verfuegbar, sonst neu laden
|
// Preloaded Sound verwenden wenn verfuegbar, sonst neu laden
|
||||||
@@ -296,6 +419,8 @@ class AudioService {
|
|||||||
if (this.preloadedPath) RNFS.unlink(this.preloadedPath).catch(() => {});
|
if (this.preloadedPath) RNFS.unlink(this.preloadedPath).catch(() => {});
|
||||||
this.preloadedPath = '';
|
this.preloadedPath = '';
|
||||||
}
|
}
|
||||||
|
// Audio-Focus freigeben
|
||||||
|
AudioFocus?.release().catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Status & Callbacks ---
|
// --- Status & Callbacks ---
|
||||||
@@ -334,6 +459,46 @@ class AudioService {
|
|||||||
this.stateListeners.forEach(cb => cb(state));
|
this.stateListeners.forEach(cb => cb(state));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Alte Aufnahme- und TTS-Files aus dem Cache loeschen (>30s alt). */
|
||||||
|
private async _cleanupStaleCacheFiles(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const files = await RNFS.readDir(RNFS.CachesDirectoryPath);
|
||||||
|
const now = Date.now();
|
||||||
|
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) {
|
||||||
|
await RNFS.unlink(f.path).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// silent — cleanup ist best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alte TTS-Cache-Dateien loeschen die nicht mehr referenziert sind (>30 Tage). */
|
||||||
|
async cleanupOldTTSCache(keepMessageIds: Set<string>, maxAgeDays = 30): Promise<void> {
|
||||||
|
try {
|
||||||
|
const dir = `${RNFS.DocumentDirectoryPath}/tts_cache`;
|
||||||
|
if (!(await RNFS.exists(dir))) return;
|
||||||
|
const files = await RNFS.readDir(dir);
|
||||||
|
const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1000;
|
||||||
|
const now = Date.now();
|
||||||
|
for (const f of files) {
|
||||||
|
if (!f.isFile() || !f.name.endsWith('.wav')) continue;
|
||||||
|
const messageId = f.name.replace(/\.wav$/, '');
|
||||||
|
const age = now - (f.mtime ? f.mtime.getTime() : 0);
|
||||||
|
// Loeschen wenn: nicht mehr referenziert UND aelter als X Tage
|
||||||
|
if (!keepMessageIds.has(messageId) && age > maxAgeMs) {
|
||||||
|
await RNFS.unlink(f.path).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// silent
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Singleton
|
// Singleton
|
||||||
|
|||||||
@@ -7,12 +7,13 @@
|
|||||||
* 3. App zeigt Benachrichtigung → User bestaetigt → Download + Install
|
* 3. App zeigt Benachrichtigung → User bestaetigt → Download + Install
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Alert, Linking, Platform } from 'react-native';
|
import { Alert, Linking, Platform, NativeModules } from 'react-native';
|
||||||
import RNFS from 'react-native-fs';
|
import RNFS from 'react-native-fs';
|
||||||
import rvs, { RVSMessage } from './rvs';
|
import rvs, { RVSMessage } from './rvs';
|
||||||
|
|
||||||
// Aktuelle App-Version (aus package.json via Build)
|
// Version aus package.json (wird beim Build eingebettet)
|
||||||
const APP_VERSION = '0.0.2.3'; // TODO: aus nativer Build-Config lesen
|
const packageJson = require('../../package.json');
|
||||||
|
const APP_VERSION = packageJson.version || '0.0.0.0';
|
||||||
|
|
||||||
type UpdateCallback = (info: UpdateInfo) => void;
|
type UpdateCallback = (info: UpdateInfo) => void;
|
||||||
|
|
||||||
@@ -116,9 +117,17 @@ class UpdateService {
|
|||||||
const fileSize = await RNFS.stat(destPath);
|
const fileSize = await RNFS.stat(destPath);
|
||||||
console.log(`[Update] APK gespeichert: ${destPath} (${(parseInt(fileSize.size) / 1024 / 1024).toFixed(1)}MB)`);
|
console.log(`[Update] APK gespeichert: ${destPath} (${(parseInt(fileSize.size) / 1024 / 1024).toFixed(1)}MB)`);
|
||||||
|
|
||||||
// APK installieren (oeffnet Android-Installer)
|
// APK installieren via natives ApkInstaller Module (FileProvider + Intent)
|
||||||
if (Platform.OS === 'android') {
|
if (Platform.OS === 'android') {
|
||||||
await Linking.openURL(`file://${destPath}`);
|
try {
|
||||||
|
const { ApkInstaller } = NativeModules;
|
||||||
|
await ApkInstaller.install(destPath);
|
||||||
|
} catch (installErr: any) {
|
||||||
|
Alert.alert(
|
||||||
|
'APK heruntergeladen',
|
||||||
|
`Version ${info.version} gespeichert.\n\nBitte manuell installieren:\nDateimanager → ${apkData.fileName} antippen.\n\n(${installErr.message})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error(`[Update] Fehler: ${err.message}`);
|
console.error(`[Update] Fehler: ${err.message}`);
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Wake Word Service — "ARIA" Erkennung
|
* Gespraechsmodus — "Ohr-Button"
|
||||||
*
|
*
|
||||||
* Phase 1: Deaktiviert — react-native-live-audio-stream hat native Bridge-Probleme.
|
* Wenn aktiv: Nach jeder ARIA-Antwort (TTS fertig) startet automatisch die Aufnahme.
|
||||||
* Nutzt stattdessen Tap-to-Talk (VoiceButton) als primaeren Eingabemodus.
|
* Wie ein Walkie-Talkie / natuerliches Gespraech:
|
||||||
|
* ARIA spricht → Aufnahme startet → User spricht → VAD stoppt → ARIA antwortet → ...
|
||||||
*
|
*
|
||||||
* Phase 2: Porcupine on-device "ARIA" Keyword (geplant).
|
* Phase 2 (geplant): Porcupine "ARIA" Wake Word fuer passives Lauschen.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
type WakeWordCallback = () => void;
|
type WakeWordCallback = () => void;
|
||||||
@@ -17,30 +18,39 @@ class WakeWordService {
|
|||||||
private wakeCallbacks: WakeWordCallback[] = [];
|
private wakeCallbacks: WakeWordCallback[] = [];
|
||||||
private stateCallbacks: StateCallback[] = [];
|
private stateCallbacks: StateCallback[] = [];
|
||||||
|
|
||||||
/** Wake Word Erkennung starten */
|
/** Gespraechsmodus starten */
|
||||||
async start(): Promise<boolean> {
|
async start(): Promise<boolean> {
|
||||||
if (this.state === 'listening') return true;
|
if (this.state === 'listening') return true;
|
||||||
|
console.log('[WakeWord] Gespraechsmodus aktiviert — starte sofort Aufnahme');
|
||||||
try {
|
this.setState('listening');
|
||||||
// Phase 1: LiveAudioStream deaktiviert (native Bridge instabil)
|
// Sofort erste Aufnahme starten
|
||||||
// Stattdessen: Tap-to-Talk als primaerer Modus
|
setTimeout(() => {
|
||||||
console.log('[WakeWord] Wake Word ist in Phase 1 noch nicht verfuegbar — nutze Tap-to-Talk');
|
if (this.state === 'listening') {
|
||||||
this.setState('listening');
|
this.wakeCallbacks.forEach(cb => cb());
|
||||||
return true;
|
}
|
||||||
} catch (err) {
|
}, 500);
|
||||||
console.error('[WakeWord] Start fehlgeschlagen:', err);
|
return true;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Wake Word Erkennung stoppen */
|
/** Gespraechsmodus stoppen */
|
||||||
stop(): void {
|
stop(): void {
|
||||||
|
console.log('[WakeWord] Gespraechsmodus deaktiviert');
|
||||||
this.setState('off');
|
this.setState('off');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Nach Aufnahme erneut starten */
|
/** Nach ARIA-Antwort (TTS fertig): Aufnahme automatisch starten */
|
||||||
async resume(): Promise<void> {
|
async resume(): Promise<void> {
|
||||||
// Nichts zu tun in Phase 1
|
if (this.state !== 'listening') return;
|
||||||
|
// Kurze Pause damit TTS-Audio nicht ins Mikrofon geht
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 800));
|
||||||
|
if (this.state === 'listening') {
|
||||||
|
console.log('[WakeWord] TTS fertig — starte automatisch Aufnahme');
|
||||||
|
this.wakeCallbacks.forEach(cb => cb());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isActive(): boolean {
|
||||||
|
return this.state === 'listening';
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Callbacks ---
|
// --- Callbacks ---
|
||||||
|
|||||||
@@ -9,3 +9,10 @@ PIPER_THORSTEN=/voices/de_DE-thorsten-high.onnx
|
|||||||
|
|
||||||
# Wake-Word
|
# Wake-Word
|
||||||
WAKE_WORD=aria
|
WAKE_WORD=aria
|
||||||
|
|
||||||
|
# Whisper STT — wird zur Laufzeit in der Diagnostic (Sektion "Whisper") umgeschaltet
|
||||||
|
# und in /shared/config/voice_config.json gespeichert. Der Wert hier ist nur der
|
||||||
|
# Initial-Default beim ersten Start.
|
||||||
|
# Optionen: tiny | base | small | medium | large-v3
|
||||||
|
WHISPER_MODEL=medium
|
||||||
|
WHISPER_LANGUAGE=de
|
||||||
|
|||||||
+239
-12
@@ -63,7 +63,7 @@ RVS_TLS = os.getenv("RVS_TLS", "true") # true = wss://, false = ws://
|
|||||||
RVS_TLS_FALLBACK = os.getenv("RVS_TLS_FALLBACK", "true") # Bei TLS-Fehler ws:// versuchen
|
RVS_TLS_FALLBACK = os.getenv("RVS_TLS_FALLBACK", "true") # Bei TLS-Fehler ws:// versuchen
|
||||||
RVS_TOKEN = os.getenv("RVS_TOKEN", "") # Pairing-Token (gleich wie in der App)
|
RVS_TOKEN = os.getenv("RVS_TOKEN", "") # Pairing-Token (gleich wie in der App)
|
||||||
DIAGNOSTIC_URL = os.getenv("DIAGNOSTIC_URL", "http://127.0.0.1:3001") # Diagnostic API
|
DIAGNOSTIC_URL = os.getenv("DIAGNOSTIC_URL", "http://127.0.0.1:3001") # Diagnostic API
|
||||||
WHISPER_MODEL = os.getenv("WHISPER_MODEL", "small")
|
WHISPER_MODEL = os.getenv("WHISPER_MODEL", "medium")
|
||||||
WHISPER_LANGUAGE = os.getenv("WHISPER_LANGUAGE", "de")
|
WHISPER_LANGUAGE = os.getenv("WHISPER_LANGUAGE", "de")
|
||||||
|
|
||||||
# Audio-Parameter
|
# Audio-Parameter
|
||||||
@@ -105,7 +105,14 @@ EPIC_TRIGGERS = load_epic_triggers()
|
|||||||
|
|
||||||
|
|
||||||
def load_config() -> dict[str, str]:
|
def load_config() -> dict[str, str]:
|
||||||
"""Laedt Konfiguration aus /config/aria.env."""
|
"""Laedt Konfiguration.
|
||||||
|
|
||||||
|
Reihenfolge (hoechste Prioritaet zuletzt):
|
||||||
|
1. /config/aria.env (bind-mount)
|
||||||
|
2. /shared/config/runtime.json (zentral gepflegt ueber Diagnostic UI)
|
||||||
|
|
||||||
|
Werte aus runtime.json ueberschreiben die env-Datei.
|
||||||
|
"""
|
||||||
config: dict[str, str] = {}
|
config: dict[str, str] = {}
|
||||||
if CONFIG_PATH.exists():
|
if CONFIG_PATH.exists():
|
||||||
for line in CONFIG_PATH.read_text().splitlines():
|
for line in CONFIG_PATH.read_text().splitlines():
|
||||||
@@ -118,12 +125,115 @@ def load_config() -> dict[str, str]:
|
|||||||
logger.info("Konfiguration geladen aus %s", CONFIG_PATH)
|
logger.info("Konfiguration geladen aus %s", CONFIG_PATH)
|
||||||
else:
|
else:
|
||||||
logger.warning("Keine Konfiguration gefunden: %s", CONFIG_PATH)
|
logger.warning("Keine Konfiguration gefunden: %s", CONFIG_PATH)
|
||||||
|
|
||||||
|
# Runtime-Overrides aus zentralem Shared-Volume (Diagnostic UI)
|
||||||
|
runtime_path = Path("/shared/config/runtime.json")
|
||||||
|
if runtime_path.exists():
|
||||||
|
try:
|
||||||
|
runtime = json.loads(runtime_path.read_text())
|
||||||
|
overrides = {k: str(v) for k, v in runtime.items() if v not in (None, "")}
|
||||||
|
if overrides:
|
||||||
|
config.update(overrides)
|
||||||
|
logger.info("Runtime-Overrides geladen: %s", sorted(overrides.keys()))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("runtime.json konnte nicht gelesen werden: %s", e)
|
||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
||||||
# ── Voice Engine ─────────────────────────────────────────────
|
# ── Voice Engine ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
import re as _re_tts
|
||||||
|
|
||||||
|
_UNIT_WORDS = [
|
||||||
|
(r'\bTB\b', 'Terabyte'),
|
||||||
|
(r'\bGB\b', 'Gigabyte'),
|
||||||
|
(r'\bMB\b', 'Megabyte'),
|
||||||
|
(r'\bKB\b', 'Kilobyte'),
|
||||||
|
(r'\bkB\b', 'Kilobyte'),
|
||||||
|
(r'\bms\b', 'Millisekunden'),
|
||||||
|
(r'\bkm/h\b', 'Kilometer pro Stunde'),
|
||||||
|
(r'\bkm\b', 'Kilometer'),
|
||||||
|
(r'\bm/s\b', 'Meter pro Sekunde'),
|
||||||
|
(r'\bkg\b', 'Kilogramm'),
|
||||||
|
(r'\b°C\b', 'Grad Celsius'),
|
||||||
|
(r'°C', ' Grad Celsius'),
|
||||||
|
(r'\bMbps\b', 'Megabit pro Sekunde'),
|
||||||
|
(r'\bGbps\b', 'Gigabit pro Sekunde'),
|
||||||
|
(r'\bMhz\b|\bMHz\b', 'Megahertz'),
|
||||||
|
(r'\bGhz\b|\bGHz\b', 'Gigahertz'),
|
||||||
|
(r'%', ' Prozent'),
|
||||||
|
(r'\bCPU\b', 'C P U'),
|
||||||
|
(r'\bGPU\b', 'G P U'),
|
||||||
|
(r'\bRAM\b', 'R A M'),
|
||||||
|
(r'\bSSD\b', 'S S D'),
|
||||||
|
(r'\bHDD\b', 'H D D'),
|
||||||
|
(r'\bURL\b', 'U R L'),
|
||||||
|
(r'\bAPI\b', 'A P I'),
|
||||||
|
(r'\bRVS\b', 'R V S'),
|
||||||
|
(r'\bSSH\b', 'S S H'),
|
||||||
|
(r'\bVM\b', 'V M'),
|
||||||
|
(r'\bUI\b', 'U I'),
|
||||||
|
(r'\bTTS\b', 'T T S'),
|
||||||
|
(r'\bSTT\b', 'S T T'),
|
||||||
|
(r'\bTLS\b', 'T L S'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def clean_text_for_tts(text: str) -> str:
|
||||||
|
"""Bereitet Chat-Text fuer Sprachausgabe auf.
|
||||||
|
|
||||||
|
- `<voice>...</voice>` Tag: wenn vorhanden, NUR dieser Inhalt wird gelesen
|
||||||
|
- Code-Bloecke (```...``` und `...`) werden komplett entfernt
|
||||||
|
- Markdown (Fett, Kursiv, Links, Headings, Listen, Zitate) wird abgeraeumt
|
||||||
|
- Einheiten und gaengige Abkuerzungen werden ausgeschrieben (22GB → 22 Gigabyte)
|
||||||
|
- URLs werden durch "ein Link" ersetzt
|
||||||
|
- Mehrfach-Leerzeichen/Umbrueche normalisiert
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# <voice>...</voice> wenn vorhanden → nur das nehmen
|
||||||
|
voice_match = _re_tts.search(r'<voice>([\s\S]*?)</voice>', text, _re_tts.IGNORECASE)
|
||||||
|
if voice_match:
|
||||||
|
text = voice_match.group(1)
|
||||||
|
|
||||||
|
t = text
|
||||||
|
|
||||||
|
# Code-Bloecke komplett raus (Zeilenumbruch statt Platzhalter — sonst bricht Satzlogik)
|
||||||
|
t = _re_tts.sub(r'```[\s\S]*?```', '. ', t)
|
||||||
|
t = _re_tts.sub(r'`[^`]+`', '', t)
|
||||||
|
|
||||||
|
# Markdown
|
||||||
|
t = _re_tts.sub(r'\*\*([^*]+)\*\*', r'\1', t)
|
||||||
|
t = _re_tts.sub(r'\*([^*]+)\*', r'\1', t)
|
||||||
|
t = _re_tts.sub(r'__([^_]+)__', r'\1', t)
|
||||||
|
t = _re_tts.sub(r'\[([^\]]+)\]\((https?://[^)]+)\)', r'\1, ein Link', t)
|
||||||
|
t = _re_tts.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', t)
|
||||||
|
t = _re_tts.sub(r'https?://\S+', 'ein Link', t)
|
||||||
|
t = _re_tts.sub(r'^#{1,6}\s*', '', t, flags=_re_tts.MULTILINE)
|
||||||
|
t = _re_tts.sub(r'^>\s*', '', t, flags=_re_tts.MULTILINE)
|
||||||
|
t = _re_tts.sub(r'^[\-\*]\s+', '', t, flags=_re_tts.MULTILINE)
|
||||||
|
|
||||||
|
# Zahlen + Einheit: "22GB" → "22 Gigabyte" (Leerzeichen einfuegen)
|
||||||
|
t = _re_tts.sub(r'(\d+)([A-Za-z]{1,4})\b', r'\1 \2', t)
|
||||||
|
|
||||||
|
# Einheiten/Abkuerzungen ausschreiben
|
||||||
|
for pat, repl in _UNIT_WORDS:
|
||||||
|
t = _re_tts.sub(pat, repl, t)
|
||||||
|
|
||||||
|
# Anfuehrungszeichen
|
||||||
|
t = _re_tts.sub(r'["""„`]', '', t)
|
||||||
|
|
||||||
|
# Absaetze/Zeilenumbrueche normalisieren
|
||||||
|
t = _re_tts.sub(r'\n{2,}', '. ', t)
|
||||||
|
t = _re_tts.sub(r'\n', ', ', t)
|
||||||
|
t = _re_tts.sub(r'\s{2,}', ' ', t)
|
||||||
|
t = _re_tts.sub(r'\s*\.\s*\.\s*', '. ', t)
|
||||||
|
|
||||||
|
return t.strip()
|
||||||
|
|
||||||
|
|
||||||
class VoiceEngine:
|
class VoiceEngine:
|
||||||
"""Verwaltet Piper TTS mit zwei Stimmen: Ramona und Thorsten."""
|
"""Verwaltet Piper TTS mit zwei Stimmen: Ramona und Thorsten."""
|
||||||
|
|
||||||
@@ -201,11 +311,11 @@ class VoiceEngine:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Langen Text in Saetze aufteilen (Piper hat Limits bei langen Texten)
|
# Zentraler TTS-Cleanup (Markdown, Code, Einheiten, URLs)
|
||||||
import re
|
import re
|
||||||
sentences = re.split(r'(?<=[.!?])\s+', text.strip())
|
clean = clean_text_for_tts(text)
|
||||||
# Markdown-Formatierung entfernen
|
sentences = re.split(r'(?<=[.!?])\s+', clean)
|
||||||
sentences = [re.sub(r'\*\*([^*]+)\*\*', r'\1', s).strip() for s in sentences if s.strip()]
|
sentences = [s.strip() for s in sentences if s.strip()]
|
||||||
|
|
||||||
if not sentences:
|
if not sentences:
|
||||||
return None
|
return None
|
||||||
@@ -318,6 +428,25 @@ class STTEngine:
|
|||||||
self.model = WhisperModel(self.model_size, device="cpu", compute_type="int8")
|
self.model = WhisperModel(self.model_size, device="cpu", compute_type="int8")
|
||||||
logger.info("Whisper-Modell geladen")
|
logger.info("Whisper-Modell geladen")
|
||||||
|
|
||||||
|
def reload(self, model_size: str) -> bool:
|
||||||
|
"""Laedt ein anderes Whisper-Modell (bei Config-Aenderung)."""
|
||||||
|
if model_size == self.model_size and self.model is not None:
|
||||||
|
return False
|
||||||
|
allowed = {"tiny", "base", "small", "medium", "large-v3"}
|
||||||
|
if model_size not in allowed:
|
||||||
|
logger.warning("Ungueltiges Whisper-Modell: %s (erlaubt: %s)", model_size, allowed)
|
||||||
|
return False
|
||||||
|
logger.info("Lade Whisper-Modell neu: %s -> %s", self.model_size, model_size)
|
||||||
|
self.model_size = model_size
|
||||||
|
self.model = None
|
||||||
|
try:
|
||||||
|
self.model = WhisperModel(model_size, device="cpu", compute_type="int8")
|
||||||
|
logger.info("Whisper-Modell '%s' geladen", model_size)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Whisper-Modell '%s' konnte nicht geladen werden", model_size)
|
||||||
|
return False
|
||||||
|
|
||||||
def transcribe(self, audio_data: np.ndarray) -> str:
|
def transcribe(self, audio_data: np.ndarray) -> str:
|
||||||
"""Transkribiert Audio-Daten zu Text.
|
"""Transkribiert Audio-Daten zu Text.
|
||||||
|
|
||||||
@@ -490,6 +619,7 @@ class ARIABridge:
|
|||||||
# Komponenten
|
# Komponenten
|
||||||
self.voice_engine = VoiceEngine(VOICES_DIR)
|
self.voice_engine = VoiceEngine(VOICES_DIR)
|
||||||
self.tts_enabled = True
|
self.tts_enabled = True
|
||||||
|
vc: dict = {}
|
||||||
# Gespeicherte Voice-Config laden
|
# Gespeicherte Voice-Config laden
|
||||||
try:
|
try:
|
||||||
vc_path = "/shared/config/voice_config.json"
|
vc_path = "/shared/config/voice_config.json"
|
||||||
@@ -508,8 +638,10 @@ class ARIABridge:
|
|||||||
logger.info("Voice-Config geladen: %s", vc)
|
logger.info("Voice-Config geladen: %s", vc)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Voice-Config laden fehlgeschlagen: %s", e)
|
logger.warning("Voice-Config laden fehlgeschlagen: %s", e)
|
||||||
|
# Whisper-Modell: Config hat Vorrang, dann env/Default (medium)
|
||||||
|
whisper_model = vc.get("whisperModel") or self.config.get("WHISPER_MODEL", WHISPER_MODEL)
|
||||||
self.stt_engine = STTEngine(
|
self.stt_engine = STTEngine(
|
||||||
model_size=self.config.get("WHISPER_MODEL", WHISPER_MODEL),
|
model_size=whisper_model,
|
||||||
language=self.config.get("WHISPER_LANGUAGE", WHISPER_LANGUAGE),
|
language=self.config.get("WHISPER_LANGUAGE", WHISPER_LANGUAGE),
|
||||||
)
|
)
|
||||||
self.wake_word = WakeWordDetector()
|
self.wake_word = WakeWordDetector()
|
||||||
@@ -518,6 +650,12 @@ class ARIABridge:
|
|||||||
self.ws_core: Optional[websockets.WebSocketClientProtocol] = None
|
self.ws_core: Optional[websockets.WebSocketClientProtocol] = None
|
||||||
self.ws_rvs: Optional[websockets.WebSocketClientProtocol] = None
|
self.ws_rvs: Optional[websockets.WebSocketClientProtocol] = None
|
||||||
|
|
||||||
|
# Letzter gesendeter agent_activity-State (zum Entduplizieren)
|
||||||
|
self._last_activity_state: Optional[tuple] = None
|
||||||
|
# Zeitstempel des letzten chat:final — waehrend 3s danach werden
|
||||||
|
# trailing Agent-Events unterdrueckt (Core raeumt manchmal nach).
|
||||||
|
self._last_chat_final_at: float = 0.0
|
||||||
|
|
||||||
def initialize(self) -> None:
|
def initialize(self) -> None:
|
||||||
"""Initialisiert alle Komponenten.
|
"""Initialisiert alle Komponenten.
|
||||||
|
|
||||||
@@ -722,8 +860,18 @@ class ARIABridge:
|
|||||||
if event_name == "agent":
|
if event_name == "agent":
|
||||||
data = payload.get("data", {})
|
data = payload.get("data", {})
|
||||||
delta = data.get("delta", "")
|
delta = data.get("delta", "")
|
||||||
if delta and payload.get("stream") == "assistant":
|
stream = payload.get("stream", "")
|
||||||
|
if delta and stream == "assistant":
|
||||||
logger.debug("[core] Delta: '%s'", delta[:40])
|
logger.debug("[core] Delta: '%s'", delta[:40])
|
||||||
|
# Activity-Signal zur App (entdupliziert)
|
||||||
|
tool_name = data.get("name") or data.get("tool") or payload.get("tool") or ""
|
||||||
|
if stream == "tool_use" or data.get("type") == "tool_use":
|
||||||
|
activity = "tool"
|
||||||
|
elif stream == "assistant":
|
||||||
|
activity = "assistant"
|
||||||
|
else:
|
||||||
|
activity = "thinking"
|
||||||
|
await self._emit_activity(activity, tool_name)
|
||||||
return
|
return
|
||||||
|
|
||||||
# ── chat Events: Snapshots mit state=delta|final|error ──
|
# ── chat Events: Snapshots mit state=delta|final|error ──
|
||||||
@@ -732,6 +880,8 @@ class ARIABridge:
|
|||||||
|
|
||||||
if state == "final":
|
if state == "final":
|
||||||
text = self._extract_chat_text(payload)
|
text = self._extract_chat_text(payload)
|
||||||
|
self._last_chat_final_at = asyncio.get_event_loop().time()
|
||||||
|
await self._emit_activity("idle", "")
|
||||||
if not text:
|
if not text:
|
||||||
logger.warning("[core] chat final ohne Text: %s", json.dumps(payload)[:200])
|
logger.warning("[core] chat final ohne Text: %s", json.dumps(payload)[:200])
|
||||||
return
|
return
|
||||||
@@ -742,6 +892,8 @@ class ARIABridge:
|
|||||||
if state == "error":
|
if state == "error":
|
||||||
error = payload.get("error", "Unbekannt")
|
error = payload.get("error", "Unbekannt")
|
||||||
logger.error("[core] Chat-Fehler: %s", error)
|
logger.error("[core] Chat-Fehler: %s", error)
|
||||||
|
self._last_chat_final_at = asyncio.get_event_loop().time()
|
||||||
|
await self._emit_activity("idle", "")
|
||||||
await self._send_to_rvs({
|
await self._send_to_rvs({
|
||||||
"type": "chat",
|
"type": "chat",
|
||||||
"payload": {
|
"payload": {
|
||||||
@@ -813,6 +965,14 @@ class ARIABridge:
|
|||||||
- Leitet Antwort an die App weiter (via RVS)
|
- Leitet Antwort an die App weiter (via RVS)
|
||||||
- Sprachausgabe ueber TTS (wenn Modus erlaubt)
|
- Sprachausgabe ueber TTS (wenn Modus erlaubt)
|
||||||
"""
|
"""
|
||||||
|
# NO_REPLY Token: ARIA signalisiert explizit "nicht antworten"
|
||||||
|
# → komplett verwerfen (keine Chat-Nachricht, kein TTS)
|
||||||
|
# Toleranz fuer Variationen: "NO_REPLY", "no_reply", mit Punkt/Anfuehrungszeichen
|
||||||
|
stripped = text.strip().strip('."\'`*').upper()
|
||||||
|
if stripped == "NO_REPLY" or stripped.startswith("NO_REPLY"):
|
||||||
|
logger.info("[core] NO_REPLY empfangen — Antwort still verworfen")
|
||||||
|
return
|
||||||
|
|
||||||
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")
|
||||||
@@ -835,6 +995,9 @@ class ARIABridge:
|
|||||||
# Stimme auswaehlen
|
# Stimme auswaehlen
|
||||||
voice_name = requested_voice or self.voice_engine.select_voice(text)
|
voice_name = requested_voice or self.voice_engine.select_voice(text)
|
||||||
|
|
||||||
|
# Eindeutige Message-ID fuer Audio-Cache-Zuordnung
|
||||||
|
message_id = str(uuid.uuid4())
|
||||||
|
|
||||||
# Antwort an die App weiterleiten (als Chat-Nachricht)
|
# Antwort an die App weiterleiten (als Chat-Nachricht)
|
||||||
await self._send_to_rvs({
|
await self._send_to_rvs({
|
||||||
"type": "chat",
|
"type": "chat",
|
||||||
@@ -842,6 +1005,7 @@ class ARIABridge:
|
|||||||
"text": text,
|
"text": text,
|
||||||
"sender": "aria",
|
"sender": "aria",
|
||||||
"voice": voice_name,
|
"voice": voice_name,
|
||||||
|
"messageId": message_id,
|
||||||
},
|
},
|
||||||
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||||
})
|
})
|
||||||
@@ -851,20 +1015,24 @@ class ARIABridge:
|
|||||||
tts_engine = getattr(self, 'tts_engine_type', 'piper')
|
tts_engine = getattr(self, 'tts_engine_type', 'piper')
|
||||||
|
|
||||||
if tts_engine == "xtts":
|
if tts_engine == "xtts":
|
||||||
# XTTS: Ganzen Text senden, XTTS-Bridge teilt satzweise auf
|
# XTTS: aufbereiteter Text (Code-Bloecke raus, Einheiten ausgeschrieben)
|
||||||
xtts_voice = getattr(self, 'xtts_voice', '')
|
xtts_voice = getattr(self, 'xtts_voice', '')
|
||||||
|
tts_text = clean_text_for_tts(text)
|
||||||
|
if not tts_text:
|
||||||
|
logger.info("[core] TTS-Text leer nach Cleanup — XTTS uebersprungen")
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
await self._send_to_rvs({
|
await self._send_to_rvs({
|
||||||
"type": "xtts_request",
|
"type": "xtts_request",
|
||||||
"payload": {
|
"payload": {
|
||||||
"text": text,
|
"text": tts_text,
|
||||||
"voice": xtts_voice,
|
"voice": xtts_voice,
|
||||||
"language": "de",
|
"language": "de",
|
||||||
"requestId": str(uuid.uuid4()),
|
"requestId": str(uuid.uuid4()),
|
||||||
},
|
},
|
||||||
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||||
})
|
})
|
||||||
logger.info("[core] XTTS-Request gesendet (%s): '%s'", xtts_voice or "default", text[:60])
|
logger.info("[core] XTTS-Request gesendet (%s): '%s'", xtts_voice or "default", tts_text[:60])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("[core] XTTS-Request fehlgeschlagen: %s — Fallback auf Piper", e)
|
logger.warning("[core] XTTS-Request fehlgeschlagen: %s — Fallback auf Piper", e)
|
||||||
# Fallback auf Piper
|
# Fallback auf Piper
|
||||||
@@ -873,7 +1041,7 @@ class ARIABridge:
|
|||||||
audio_b64 = base64.b64encode(audio_data).decode("ascii")
|
audio_b64 = base64.b64encode(audio_data).decode("ascii")
|
||||||
await self._send_to_rvs({
|
await self._send_to_rvs({
|
||||||
"type": "audio",
|
"type": "audio",
|
||||||
"payload": {"base64": audio_b64, "mimeType": "audio/wav", "voice": voice_name},
|
"payload": {"base64": audio_b64, "mimeType": "audio/wav", "voice": voice_name, "messageId": message_id},
|
||||||
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
@@ -887,6 +1055,7 @@ class ARIABridge:
|
|||||||
"base64": audio_b64,
|
"base64": audio_b64,
|
||||||
"mimeType": "audio/wav",
|
"mimeType": "audio/wav",
|
||||||
"voice": voice_name,
|
"voice": voice_name,
|
||||||
|
"messageId": message_id,
|
||||||
},
|
},
|
||||||
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||||
})
|
})
|
||||||
@@ -1045,6 +1214,17 @@ class ARIABridge:
|
|||||||
sender = payload.get("sender", "")
|
sender = payload.get("sender", "")
|
||||||
if sender in ("aria", "stt"):
|
if sender in ("aria", "stt"):
|
||||||
return
|
return
|
||||||
|
text = payload.get("text", "")
|
||||||
|
if text:
|
||||||
|
logger.info("[rvs] App-Chat: '%s'", text[:80])
|
||||||
|
await self.send_to_core(text, source="app")
|
||||||
|
return
|
||||||
|
|
||||||
|
if msg_type == "cancel_request":
|
||||||
|
logger.info("[rvs] Cancel-Request von App — rufe Diagnostic /api/cancel auf")
|
||||||
|
await self._cancel_via_diagnostic()
|
||||||
|
await self._emit_activity("idle", "")
|
||||||
|
return
|
||||||
|
|
||||||
elif msg_type == "xtts_response":
|
elif msg_type == "xtts_response":
|
||||||
# XTTS-Audio vom Gaming-PC empfangen → an App weiterleiten
|
# XTTS-Audio vom Gaming-PC empfangen → an App weiterleiten
|
||||||
@@ -1125,6 +1305,15 @@ class ARIABridge:
|
|||||||
self.voice_engine.speech_speed["thorsten"] = max(0.3, min(2.0, float(payload["speedThorsten"])))
|
self.voice_engine.speech_speed["thorsten"] = max(0.3, min(2.0, float(payload["speedThorsten"])))
|
||||||
logger.info("[rvs] Speed Thorsten: %.1f", self.voice_engine.speech_speed["thorsten"])
|
logger.info("[rvs] Speed Thorsten: %.1f", self.voice_engine.speech_speed["thorsten"])
|
||||||
changed = True
|
changed = True
|
||||||
|
whisper_reloaded = False
|
||||||
|
if "whisperModel" in payload:
|
||||||
|
new_model = payload["whisperModel"]
|
||||||
|
if new_model and new_model != self.stt_engine.model_size:
|
||||||
|
logger.info("[rvs] Whisper-Modell Wechsel: %s -> %s (laedt...)", self.stt_engine.model_size, new_model)
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
whisper_reloaded = await loop.run_in_executor(None, self.stt_engine.reload, new_model)
|
||||||
|
if whisper_reloaded:
|
||||||
|
changed = True
|
||||||
# Persistent speichern in Shared Volume
|
# Persistent speichern in Shared Volume
|
||||||
if changed:
|
if changed:
|
||||||
try:
|
try:
|
||||||
@@ -1137,6 +1326,7 @@ class ARIABridge:
|
|||||||
"xttsVoice": getattr(self, "xtts_voice", ""),
|
"xttsVoice": getattr(self, "xtts_voice", ""),
|
||||||
"speedRamona": self.voice_engine.speech_speed.get("ramona", 1.0),
|
"speedRamona": self.voice_engine.speech_speed.get("ramona", 1.0),
|
||||||
"speedThorsten": self.voice_engine.speech_speed.get("thorsten", 1.0),
|
"speedThorsten": self.voice_engine.speech_speed.get("thorsten", 1.0),
|
||||||
|
"whisperModel": self.stt_engine.model_size,
|
||||||
}
|
}
|
||||||
with open("/shared/config/voice_config.json", "w") as f:
|
with open("/shared/config/voice_config.json", "w") as f:
|
||||||
json.dump(config_data, f, indent=2)
|
json.dump(config_data, f, indent=2)
|
||||||
@@ -1379,6 +1569,43 @@ class ARIABridge:
|
|||||||
|
|
||||||
# ── Log-Streaming an die App ─────────────────────────────
|
# ── Log-Streaming an die App ─────────────────────────────
|
||||||
|
|
||||||
|
async def _cancel_via_diagnostic(self) -> None:
|
||||||
|
"""Ruft das Diagnostic /api/cancel an — dort laeuft die volle Abbruch-Logik
|
||||||
|
(openclaw doctor --fix mit Docker-Socket)."""
|
||||||
|
def _do_request():
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{self._diagnostic_url}/api/cancel",
|
||||||
|
method="POST",
|
||||||
|
data=b"",
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||||
|
return resp.status
|
||||||
|
except Exception as e:
|
||||||
|
return f"error: {e}"
|
||||||
|
|
||||||
|
status = await asyncio.get_event_loop().run_in_executor(None, _do_request)
|
||||||
|
logger.info("[cancel] Diagnostic /api/cancel: %s", status)
|
||||||
|
|
||||||
|
async def _emit_activity(self, activity: str, tool: str = "") -> None:
|
||||||
|
"""Sendet agent_activity an die App — nur wenn sich der State geaendert hat.
|
||||||
|
|
||||||
|
Trailing Agent-Events nach chat:final werden 3s lang unterdrueckt
|
||||||
|
(nur 'idle' kommt immer durch)."""
|
||||||
|
if activity != "idle" and self._last_chat_final_at > 0:
|
||||||
|
since_final = asyncio.get_event_loop().time() - self._last_chat_final_at
|
||||||
|
if since_final < 3.0:
|
||||||
|
return
|
||||||
|
state = (activity, tool)
|
||||||
|
if state == self._last_activity_state:
|
||||||
|
return
|
||||||
|
self._last_activity_state = state
|
||||||
|
await self._send_to_rvs({
|
||||||
|
"type": "agent_activity",
|
||||||
|
"payload": {"activity": activity, "tool": tool},
|
||||||
|
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||||
|
})
|
||||||
|
|
||||||
async def send_log_to_app(self, source: str, message: str, level: str = "info") -> None:
|
async def send_log_to_app(self, source: str, message: str, level: str = "info") -> None:
|
||||||
"""Sendet einen Log-Eintrag an die App (erscheint im Log-Viewer)."""
|
"""Sendet einen Log-Eintrag an die App (erscheint im Log-Viewer)."""
|
||||||
await self._send_to_rvs({
|
await self._send_to_rvs({
|
||||||
|
|||||||
Executable
+44
@@ -0,0 +1,44 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ARIA Docker Cleanup
|
||||||
|
#
|
||||||
|
# Standard: docker builder prune + image prune (sicher, loescht keine Volumes)
|
||||||
|
# --full: Volle Reinigung inkl. --volumes (Vorsicht bei ungenutzten Volumes!)
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./cleanup.sh # sicherer Cleanup
|
||||||
|
# ./cleanup.sh --full # aggressiver Cleanup (inkl. Volumes)
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
FULL=0
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--full|-f) FULL=1 ;;
|
||||||
|
-h|--help)
|
||||||
|
grep '^#' "$0" | sed 's/^# \{0,1\}//'
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "── Docker Speicher VOR Cleanup ───────────────────"
|
||||||
|
docker system df
|
||||||
|
echo
|
||||||
|
|
||||||
|
if [ "$FULL" = "1" ]; then
|
||||||
|
echo ">>> VOLLE Reinigung (inkl. ungenutzter Volumes)"
|
||||||
|
read -p "Wirklich? [y/N] " -n 1 -r REPLY
|
||||||
|
echo
|
||||||
|
[[ ! $REPLY =~ ^[Yy]$ ]] && { echo "Abgebrochen."; exit 0; }
|
||||||
|
docker system prune -a --volumes -f
|
||||||
|
else
|
||||||
|
echo ">>> Sicherer Cleanup (Build-Cache + ungenutzte Images)"
|
||||||
|
docker builder prune -a -f
|
||||||
|
docker image prune -a -f
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "── Docker Speicher NACH Cleanup ──────────────────"
|
||||||
|
docker system df
|
||||||
|
echo
|
||||||
|
df -h / | head -2
|
||||||
+353
-24
@@ -201,12 +201,18 @@
|
|||||||
<button class="btn secondary" onclick="toggleChatFullscreen()" id="btn-chat-fs" style="padding:4px 10px;font-size:11px;">Vollbild</button>
|
<button class="btn secondary" onclick="toggleChatFullscreen()" id="btn-chat-fs" style="padding:4px 10px;font-size:11px;">Vollbild</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="chat-box" id="chat-box"></div>
|
<div class="chat-box" id="chat-box"></div>
|
||||||
<div id="thinking-indicator" style="display:none;padding:6px 10px;font-size:12px;color:#FFD60A;background:#1E1E2E;border-radius:0 0 6px 6px;margin-top:-8px;margin-bottom:8px;display:flex;align-items:center;justify-content:space-between;">
|
<div id="thinking-indicator" style="display:none;padding:6px 10px;font-size:12px;color:#FFD60A;background:#1E1E2E;border-radius:0 0 6px 6px;margin-top:-8px;margin-bottom:8px;align-items:center;justify-content:space-between;">
|
||||||
<span><span style="animation:pulse 1s infinite;">💭</span> <span id="thinking-text">ARIA denkt...</span></span>
|
<span><span style="animation:pulse 1s infinite;">💭</span> <span id="thinking-text">ARIA denkt...</span></span>
|
||||||
<button class="btn secondary" onclick="cancelRequest()" style="padding:2px 10px;font-size:11px;color:#FF3B30;border-color:#FF3B30;">Abbrechen</button>
|
<button class="btn secondary" onclick="cancelRequest()" style="padding:2px 10px;font-size:11px;color:#FF3B30;border-color:#FF3B30;">Abbrechen</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="diag-pending-attachments" style="display:none;padding:6px 10px;background:#1E1E2E;border-radius:6px 6px 0 0;margin-bottom:-4px;display:flex;gap:6px;flex-wrap:wrap;align-items:center;">
|
||||||
|
</div>
|
||||||
<div class="input-row">
|
<div class="input-row">
|
||||||
<input type="text" id="chat-input" placeholder="Nachricht an ARIA...">
|
<label class="btn secondary" style="padding:6px 10px;cursor:pointer;font-size:14px;" title="Datei anhaengen">
|
||||||
|
📎
|
||||||
|
<input type="file" id="diag-file-input" multiple accept="image/*,application/pdf,.doc,.docx,.txt" style="display:none;" onchange="handleDiagFileSelect(this.files)">
|
||||||
|
</label>
|
||||||
|
<input type="text" id="chat-input" placeholder="Nachricht an ARIA..." onpaste="handleDiagPaste(event)">
|
||||||
<button class="btn" id="btn-gw" onclick="testGateway()">Gateway senden</button>
|
<button class="btn" id="btn-gw" onclick="testGateway()">Gateway senden</button>
|
||||||
<button class="btn" id="btn-rvs" onclick="testRVS()">Via RVS senden</button>
|
<button class="btn" id="btn-rvs" onclick="testRVS()">Via RVS senden</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -493,6 +499,93 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Whisper (STT) -->
|
||||||
|
<div class="settings-section">
|
||||||
|
<h2>Whisper (Spracherkennung)</h2>
|
||||||
|
<div style="font-size:11px;color:#8888AA;margin-bottom:8px;">
|
||||||
|
Aenderungen werden sofort an die Bridge gesendet und das Modell neu geladen
|
||||||
|
(kann bei medium/large 10-30s dauern — waehrend dieser Zeit ist STT kurz pausiert).
|
||||||
|
</div>
|
||||||
|
<div class="card" style="max-width:500px;">
|
||||||
|
<div style="display:flex;align-items:center;gap:12px;margin-bottom:8px;">
|
||||||
|
<label style="color:#8888AA;font-size:12px;min-width:80px;">Modell:</label>
|
||||||
|
<select id="diag-whisper-model" onchange="sendVoiceConfig()" style="flex:1;background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:6px;padding:6px 10px;font-size:13px;">
|
||||||
|
<option value="tiny">tiny (39MB, schnell, niedrige Qualitaet)</option>
|
||||||
|
<option value="base">base (74MB, schnell, ok)</option>
|
||||||
|
<option value="small">small (244MB, mittel)</option>
|
||||||
|
<option value="medium" selected>medium (769MB, gut — Empfehlung)</option>
|
||||||
|
<option value="large-v3">large-v3 (1.5GB, beste Qualitaet, langsam auf CPU)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:10px;color:#555570;">
|
||||||
|
Tipp: <code>medium</code> ist der beste Kompromiss fuer CPU. <code>large-v3</code> nur bei GPU sinnvoll.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Runtime-Konfiguration (migriert von .env) -->
|
||||||
|
<div class="settings-section">
|
||||||
|
<h2>Runtime-Konfiguration</h2>
|
||||||
|
<div style="font-size:11px;color:#8888AA;margin-bottom:8px;">
|
||||||
|
Werte werden in <code>/shared/config/runtime.json</code> persistiert und
|
||||||
|
ueberschreiben die ENV-Variablen aus <code>aria.env</code>. Bridge liest
|
||||||
|
sie beim naechsten Start — nach Aenderung <b>Bridge-Container neu starten</b>
|
||||||
|
(Diagnostic-Container bleibt auf ENV).
|
||||||
|
</div>
|
||||||
|
<div class="card" style="max-width:600px;">
|
||||||
|
<div style="display:grid;grid-template-columns:140px 1fr;gap:8px 10px;align-items:center;font-size:13px;">
|
||||||
|
<label style="color:#8888AA;">RVS Host:</label>
|
||||||
|
<input type="text" id="rc-rvs-host" style="width:100%;box-sizing:border-box;background:#1E1E2E;border:1px solid #2A2A3E;border-radius:4px;padding:6px;color:#fff;">
|
||||||
|
<label style="color:#8888AA;">RVS Port:</label>
|
||||||
|
<input type="text" id="rc-rvs-port" style="width:100%;box-sizing:border-box;background:#1E1E2E;border:1px solid #2A2A3E;border-radius:4px;padding:6px;color:#fff;">
|
||||||
|
<label style="color:#8888AA;">RVS TLS:</label>
|
||||||
|
<select id="rc-rvs-tls" style="width:100%;box-sizing:border-box;background:#1E1E2E;border:1px solid #2A2A3E;border-radius:4px;padding:6px;color:#fff;">
|
||||||
|
<option value="true">true (wss://)</option>
|
||||||
|
<option value="false">false (ws://)</option>
|
||||||
|
</select>
|
||||||
|
<label style="color:#8888AA;">RVS Token:</label>
|
||||||
|
<div style="display:flex;gap:4px;min-width:0;">
|
||||||
|
<input type="password" id="rc-rvs-token" style="flex:1;min-width:0;box-sizing:border-box;background:#1E1E2E;border:1px solid #2A2A3E;border-radius:4px;padding:6px;color:#fff;font-family:monospace;">
|
||||||
|
<button type="button" class="btn secondary" onclick="toggleSecret('rc-rvs-token', this)" style="padding:4px 10px;flex-shrink:0;" title="Anzeigen/Verbergen">👁</button>
|
||||||
|
</div>
|
||||||
|
<label style="color:#8888AA;">Aria Auth Token:</label>
|
||||||
|
<div style="display:flex;gap:4px;min-width:0;">
|
||||||
|
<input type="password" id="rc-auth-token" style="flex:1;min-width:0;box-sizing:border-box;background:#1E1E2E;border:1px solid #2A2A3E;border-radius:4px;padding:6px;color:#fff;font-family:monospace;">
|
||||||
|
<button type="button" class="btn secondary" onclick="toggleSecret('rc-auth-token', this)" style="padding:4px 10px;flex-shrink:0;" title="Anzeigen/Verbergen">👁</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px;margin-top:12px;">
|
||||||
|
<button class="btn" onclick="saveRuntimeConfig()" style="flex:1;">Speichern</button>
|
||||||
|
<button class="btn secondary" onclick="loadRuntimeConfig()" style="flex:1;">Neu laden</button>
|
||||||
|
</div>
|
||||||
|
<div id="rc-status" style="font-size:11px;color:#555570;margin-top:6px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- App-Onboarding via QR-Code -->
|
||||||
|
<div class="settings-section">
|
||||||
|
<h2>App-Onboarding (QR-Code)</h2>
|
||||||
|
<div style="font-size:11px;color:#8888AA;margin-bottom:8px;">
|
||||||
|
RVS-Credentials als QR-Code — App scannt, keine manuelle Eingabe.
|
||||||
|
Enthaelt Host, Port, TLS-Flag und Token.
|
||||||
|
</div>
|
||||||
|
<div class="card" style="max-width:500px;">
|
||||||
|
<div style="display:flex;gap:12px;align-items:flex-start;">
|
||||||
|
<div id="onboarding-qr" style="width:220px;height:220px;flex-shrink:0;background:#1E1E2E;border-radius:6px;overflow:hidden;display:flex;align-items:center;justify-content:center;color:#555570;font-size:11px;text-align:center;">
|
||||||
|
QR-Code wird geladen...
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;font-size:11px;color:#8888AA;line-height:1.5;">
|
||||||
|
<div style="color:#FF9500;font-weight:bold;margin-bottom:4px;">Achtung</div>
|
||||||
|
Dieser QR enthaelt den RVS-Token im Klartext — zeige ihn niemandem,
|
||||||
|
speichere keine Screenshots davon in unsicheren Cloud-Diensten.
|
||||||
|
<button class="btn" onclick="loadOnboardingQR()" style="margin-top:10px;width:100%;">
|
||||||
|
QR neu generieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Highlight-Trigger -->
|
<!-- Highlight-Trigger -->
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h2>Highlight-Trigger</h2>
|
<h2>Highlight-Trigger</h2>
|
||||||
@@ -757,6 +850,11 @@
|
|||||||
}
|
}
|
||||||
xttsSelect.value = xttsVoice;
|
xttsSelect.value = xttsVoice;
|
||||||
toggleXTTSPanel();
|
toggleXTTSPanel();
|
||||||
|
// Whisper-Modell wiederherstellen (falls gesetzt)
|
||||||
|
if (msg.whisperModel) {
|
||||||
|
const wSel = document.getElementById('diag-whisper-model');
|
||||||
|
if (wSel) wSel.value = msg.whisperModel;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -885,6 +983,18 @@
|
|||||||
else alert('Loeschen fehlgeschlagen: ' + (msg.error || '?'));
|
else alert('Loeschen fehlgeschlagen: ' + (msg.error || '?'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (msg.type === 'session_export') {
|
||||||
|
if (!msg.ok) { alert('Export fehlgeschlagen: ' + (msg.error || '?')); return; }
|
||||||
|
const blob = new Blob([msg.markdown], { type: 'text/markdown;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = msg.filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (msg.type === 'active_session') {
|
if (msg.type === 'active_session') {
|
||||||
updateActiveSessionBar(msg.sessionKey);
|
updateActiveSessionBar(msg.sessionKey);
|
||||||
loadSessions(); // Tabelle neu rendern
|
loadSessions(); // Tabelle neu rendern
|
||||||
@@ -939,21 +1049,39 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sendDiagAttachments() {
|
||||||
|
// Alle pending Dateien an RVS senden
|
||||||
|
for (const f of diagPendingFiles) {
|
||||||
|
send({ action: 'send_file', name: f.name, type: f.type, size: f.size, base64: f.base64 });
|
||||||
|
}
|
||||||
|
if (diagPendingFiles.length > 0) {
|
||||||
|
addChat('sent', `${diagPendingFiles.length} Anhang/Anhaenge`, 'Datei');
|
||||||
|
}
|
||||||
|
diagPendingFiles = [];
|
||||||
|
renderDiagPending();
|
||||||
|
}
|
||||||
|
|
||||||
function testGateway() {
|
function testGateway() {
|
||||||
const input = document.getElementById('chat-input');
|
const input = document.getElementById('chat-input');
|
||||||
const text = input.value.trim();
|
const text = input.value.trim();
|
||||||
if (!text) return;
|
if (!text && diagPendingFiles.length === 0) return;
|
||||||
addChat('sent', text, 'Gateway direkt');
|
if (diagPendingFiles.length > 0) sendDiagAttachments();
|
||||||
send({ action: 'test_gateway', text });
|
if (text) {
|
||||||
|
addChat('sent', text, 'Gateway direkt');
|
||||||
|
send({ action: 'test_gateway', text });
|
||||||
|
}
|
||||||
input.value = '';
|
input.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function testRVS() {
|
function testRVS() {
|
||||||
const input = document.getElementById('chat-input');
|
const input = document.getElementById('chat-input');
|
||||||
const text = input.value.trim();
|
const text = input.value.trim();
|
||||||
if (!text) return;
|
if (!text && diagPendingFiles.length === 0) return;
|
||||||
addChat('sent', text, 'via RVS');
|
if (diagPendingFiles.length > 0) sendDiagAttachments();
|
||||||
send({ action: 'test_rvs', text });
|
if (text) {
|
||||||
|
addChat('sent', text, 'via RVS');
|
||||||
|
send({ action: 'test_rvs', text });
|
||||||
|
}
|
||||||
input.value = '';
|
input.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1239,7 +1367,11 @@
|
|||||||
label = 'ARIA schreibt...';
|
label = 'ARIA schreibt...';
|
||||||
}
|
}
|
||||||
|
|
||||||
indicators.forEach(el => { if (el) el.style.display = 'block'; });
|
indicators.forEach((el, i) => {
|
||||||
|
if (!el) return;
|
||||||
|
// Haupt-Indicator ist flex (Abbrechen-Button rechts), Vollbild-Variante block
|
||||||
|
el.style.display = i === 0 ? 'flex' : 'block';
|
||||||
|
});
|
||||||
texts.forEach(el => { if (el) el.textContent = label; });
|
texts.forEach(el => { if (el) el.textContent = label; });
|
||||||
|
|
||||||
// Auto-Hide nach 2min (falls idle Event verpasst wird — ARIA arbeitet max 15min)
|
// Auto-Hide nach 2min (falls idle Event verpasst wird — ARIA arbeitet max 15min)
|
||||||
@@ -1302,6 +1434,56 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Diagnostic Anhang-Handling ─────────────
|
||||||
|
let diagPendingFiles = [];
|
||||||
|
|
||||||
|
function handleDiagFileSelect(files) {
|
||||||
|
for (const file of files) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const base64 = reader.result.split(',')[1];
|
||||||
|
diagPendingFiles.push({ name: file.name, type: file.type, size: file.size, base64 });
|
||||||
|
renderDiagPending();
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDiagPaste(event) {
|
||||||
|
const items = event.clipboardData?.items;
|
||||||
|
if (!items) return;
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.kind === 'file') {
|
||||||
|
event.preventDefault();
|
||||||
|
const file = item.getAsFile();
|
||||||
|
if (file) handleDiagFileSelect([file]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDiagPending() {
|
||||||
|
const container = document.getElementById('diag-pending-attachments');
|
||||||
|
if (diagPendingFiles.length === 0) {
|
||||||
|
container.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.style.display = 'flex';
|
||||||
|
container.innerHTML = diagPendingFiles.map((f, i) => {
|
||||||
|
const isImage = f.type.startsWith('image/');
|
||||||
|
const preview = isImage ? `<img src="data:${f.type};base64,${f.base64}" style="width:40px;height:40px;border-radius:4px;object-fit:cover;">` : `<span style="font-size:20px;">📄</span>`;
|
||||||
|
return `<div style="position:relative;display:inline-block;">
|
||||||
|
${preview}
|
||||||
|
<span onclick="removeDiagPending(${i})" style="position:absolute;top:-4px;right:-4px;width:16px;height:16px;border-radius:8px;background:#FF3B30;color:#fff;font-size:10px;cursor:pointer;display:flex;align-items:center;justify-content:center;">X</span>
|
||||||
|
</div>`;
|
||||||
|
}).join('') + `<span style="color:#8888AA;font-size:11px;margin-left:4px;">${diagPendingFiles.length} Datei(en)</span>
|
||||||
|
<span onclick="diagPendingFiles=[];renderDiagPending();" style="color:#FF3B30;font-size:11px;cursor:pointer;margin-left:8px;">Alle X</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeDiagPending(idx) {
|
||||||
|
diagPendingFiles.splice(idx, 1);
|
||||||
|
renderDiagPending();
|
||||||
|
}
|
||||||
|
|
||||||
// ── Abbrechen ──────────────────────────────
|
// ── Abbrechen ──────────────────────────────
|
||||||
function cancelRequest() {
|
function cancelRequest() {
|
||||||
send({ action: 'cancel_request' });
|
send({ action: 'cancel_request' });
|
||||||
@@ -1318,7 +1500,120 @@
|
|||||||
const speedThorsten = parseFloat(document.getElementById('diag-speed-thorsten').value);
|
const speedThorsten = parseFloat(document.getElementById('diag-speed-thorsten').value);
|
||||||
const ttsEngine = document.getElementById('diag-tts-engine').value;
|
const ttsEngine = document.getElementById('diag-tts-engine').value;
|
||||||
const xttsVoice = document.getElementById('diag-xtts-voice').value;
|
const xttsVoice = document.getElementById('diag-xtts-voice').value;
|
||||||
send({ action: 'send_voice_config', defaultVoice, highlightVoice, ttsEnabled, speedRamona, speedThorsten, ttsEngine, xttsVoice });
|
const whisperModel = document.getElementById('diag-whisper-model').value;
|
||||||
|
send({ action: 'send_voice_config', defaultVoice, highlightVoice, ttsEnabled, speedRamona, speedThorsten, ttsEngine, xttsVoice, whisperModel });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Passwort-Feld Anzeigen/Verbergen ─────────────────────
|
||||||
|
function toggleSecret(inputId, btn) {
|
||||||
|
const el = document.getElementById(inputId);
|
||||||
|
if (!el) return;
|
||||||
|
if (el.type === 'password') {
|
||||||
|
el.type = 'text';
|
||||||
|
btn.innerHTML = '👀'; // 👀
|
||||||
|
btn.title = 'Verbergen';
|
||||||
|
} else {
|
||||||
|
el.type = 'password';
|
||||||
|
btn.innerHTML = '👁'; // 👁
|
||||||
|
btn.title = 'Anzeigen';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Runtime-Konfiguration ─────────────────────
|
||||||
|
async function loadRuntimeConfig() {
|
||||||
|
const statusEl = document.getElementById('rc-status');
|
||||||
|
statusEl.textContent = 'Lade...';
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/runtime-config');
|
||||||
|
const cfg = await resp.json();
|
||||||
|
document.getElementById('rc-rvs-host').value = cfg.RVS_HOST || '';
|
||||||
|
document.getElementById('rc-rvs-port').value = cfg.RVS_PORT || '443';
|
||||||
|
document.getElementById('rc-rvs-tls').value = String(cfg.RVS_TLS) === 'false' ? 'false' : 'true';
|
||||||
|
document.getElementById('rc-rvs-token').value = cfg.RVS_TOKEN || '';
|
||||||
|
document.getElementById('rc-auth-token').value = cfg.ARIA_AUTH_TOKEN || '';
|
||||||
|
statusEl.textContent = 'Geladen.';
|
||||||
|
statusEl.style.color = '#34C759';
|
||||||
|
loadOnboardingQR(); // QR bei Config-Wechsel neu generieren
|
||||||
|
} catch (e) {
|
||||||
|
statusEl.textContent = 'Fehler: ' + e.message;
|
||||||
|
statusEl.style.color = '#FF6B6B';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRuntimeConfig() {
|
||||||
|
const statusEl = document.getElementById('rc-status');
|
||||||
|
statusEl.textContent = 'Speichere...';
|
||||||
|
const patch = {
|
||||||
|
RVS_HOST: document.getElementById('rc-rvs-host').value.trim(),
|
||||||
|
RVS_PORT: document.getElementById('rc-rvs-port').value.trim(),
|
||||||
|
RVS_TLS: document.getElementById('rc-rvs-tls').value,
|
||||||
|
RVS_TOKEN: document.getElementById('rc-rvs-token').value.trim(),
|
||||||
|
ARIA_AUTH_TOKEN: document.getElementById('rc-auth-token').value.trim(),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/runtime-config', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.ok) {
|
||||||
|
statusEl.textContent = 'Gespeichert — Bridge-Container fuer Uebernahme neu starten.';
|
||||||
|
statusEl.style.color = '#FFD60A';
|
||||||
|
loadOnboardingQR(); // QR mit neuem Token
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error || 'Unbekannt');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
statusEl.textContent = 'Fehler: ' + e.message;
|
||||||
|
statusEl.style.color = '#FF6B6B';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── App-Onboarding QR-Code ────────────────────
|
||||||
|
let qrLibReady = false;
|
||||||
|
function ensureQRLib() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (qrLibReady || window.qrcode) { qrLibReady = true; resolve(); return; }
|
||||||
|
const s = document.createElement('script');
|
||||||
|
s.src = 'https://cdn.jsdelivr.net/npm/qrcode-generator@1.4.4/qrcode.min.js';
|
||||||
|
s.onload = () => { qrLibReady = true; resolve(); };
|
||||||
|
s.onerror = () => resolve(); // silent fail
|
||||||
|
document.head.appendChild(s);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOnboardingQR() {
|
||||||
|
const box = document.getElementById('onboarding-qr');
|
||||||
|
box.textContent = 'Lade...';
|
||||||
|
try {
|
||||||
|
await ensureQRLib();
|
||||||
|
if (!window.qrcode) throw new Error('QR-Library nicht geladen');
|
||||||
|
const resp = await fetch('/api/onboarding');
|
||||||
|
const cfg = await resp.json();
|
||||||
|
if (!cfg.rvsHost || !cfg.rvsToken) {
|
||||||
|
box.innerHTML = '<div style="color:#FF6B6B;">RVS nicht konfiguriert (ENV Variablen fehlen)</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Format kompatibel mit android/src/components/QRScanner.tsx parseQRData()
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
host: cfg.rvsHost,
|
||||||
|
port: Number(cfg.rvsPort) || 443,
|
||||||
|
tls: cfg.rvsTLS !== false,
|
||||||
|
token: cfg.rvsToken,
|
||||||
|
});
|
||||||
|
const qr = window.qrcode(0, 'M');
|
||||||
|
qr.addData(payload);
|
||||||
|
qr.make();
|
||||||
|
// Als SVG rendern — skaliert sauber auf Container-Groesse
|
||||||
|
box.innerHTML = qr.createSvgTag({ cellSize: 4, margin: 2, scalable: true });
|
||||||
|
const svg = box.querySelector('svg');
|
||||||
|
if (svg) {
|
||||||
|
svg.style.cssText = 'width:100%;height:100%;background:#fff;border-radius:4px;padding:6px;box-sizing:border-box;display:block;';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
box.innerHTML = `<div style="color:#FF6B6B;">Fehler: ${e.message}</div>`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Highlight-Trigger ────────────────────────
|
// ── Highlight-Trigger ────────────────────────
|
||||||
@@ -1583,32 +1878,60 @@
|
|||||||
: '<div style="color:#555570;padding:8px;text-align:center;">Keine Sessions gefunden</div>';
|
: '<div style="color:#555570;padding:8px;text-align:center;">Keine Sessions gefunden</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let html = '<table style="width:100%;border-collapse:collapse;">';
|
|
||||||
html += '<tr style="color:#8888AA;font-size:10px;text-align:left;border-bottom:1px solid #1E1E2E;">'
|
const active = data.sessions.filter(s => !s.archived);
|
||||||
|
const archives = data.sessions.filter(s => s.archived);
|
||||||
|
|
||||||
|
const headerRow = '<tr style="color:#8888AA;font-size:10px;text-align:left;border-bottom:1px solid #1E1E2E;">'
|
||||||
+ '<th style="padding:4px 6px;">Session</th>'
|
+ '<th style="padding:4px 6px;">Session</th>'
|
||||||
+ '<th style="padding:4px 6px;">Msgs</th>'
|
+ '<th style="padding:4px 6px;">Msgs</th>'
|
||||||
+ '<th style="padding:4px 6px;">Zuletzt</th>'
|
+ '<th style="padding:4px 6px;">Zuletzt</th>'
|
||||||
+ '<th style="padding:4px 6px;"></th></tr>';
|
+ '<th style="padding:4px 6px;"></th></tr>';
|
||||||
for (const s of data.sessions) {
|
|
||||||
|
const rowFor = (s, opts) => {
|
||||||
const date = s.modified ? new Date(s.modified * 1000).toLocaleString('de-DE', {day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}) : '?';
|
const date = s.modified ? new Date(s.modified * 1000).toLocaleString('de-DE', {day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}) : '?';
|
||||||
const key = escapeHtml(s.sessionKey || s.path.split('/').pop());
|
const key = escapeHtml(s.sessionKey || s.path.split('/').pop());
|
||||||
const orphanBadge = s.orphan ? ' <span style="background:#FF3B30;color:#fff;font-size:9px;padding:1px 4px;border-radius:3px;">verwaist</span>' : '';
|
const orphanBadge = s.orphan ? ' <span style="background:#FF3B30;color:#fff;font-size:9px;padding:1px 4px;border-radius:3px;">verwaist</span>' : '';
|
||||||
|
const archivedBadge = s.archived ? ' <span style="background:#555570;color:#fff;font-size:9px;padding:1px 4px;border-radius:3px;">archiv</span>' : '';
|
||||||
const modelBadge = s.model ? `<div style="font-size:9px;color:#555570;">${escapeHtml(s.model)}</div>` : '';
|
const modelBadge = s.model ? `<div style="font-size:9px;color:#555570;">${escapeHtml(s.model)}</div>` : '';
|
||||||
const isActive = (s.sessionKey === currentActiveSession);
|
const isActive = (s.sessionKey === currentActiveSession) && !s.archived;
|
||||||
const keyColor = isActive ? '#34C759' : (s.orphan ? '#555570' : '#E0E0F0');
|
const keyColor = isActive ? '#34C759' : (s.archived || s.orphan ? '#8888AA' : '#E0E0F0');
|
||||||
const activeBadge = isActive ? ' <span style="background:#34C759;color:#000;font-size:9px;padding:1px 4px;border-radius:3px;">aktiv</span>' : '';
|
const activeBadge = isActive ? ' <span style="background:#34C759;color:#000;font-size:9px;padding:1px 4px;border-radius:3px;">aktiv</span>' : '';
|
||||||
const rowBg = isActive ? 'background:rgba(52,199,89,0.08);' : '';
|
const rowBg = isActive ? 'background:rgba(52,199,89,0.08);' : (s.archived ? 'background:rgba(136,136,170,0.04);' : '');
|
||||||
html += `<tr style="border-bottom:1px solid #0D0D1A;cursor:pointer;${rowBg}" onmouseover="this.style.background='#1E1E2E'" onmouseout="this.style.background='${isActive ? 'rgba(52,199,89,0.08)' : ''}'">`
|
|
||||||
|
let actions = '';
|
||||||
|
if (s.archived) {
|
||||||
|
// Archive: nur Export + Loeschen (kein Aktivieren — wuerde aktive Session ueberschreiben)
|
||||||
|
actions = `<button class="btn secondary" onclick="event.stopPropagation();deleteSession('${escapeHtml(s.path)}')" style="padding:2px 6px;font-size:10px;color:#FF6B6B;margin-right:2px;" title="Archiv endgueltig loeschen">X</button>`
|
||||||
|
+ `<button class="btn secondary" onclick="event.stopPropagation();exportSession('${escapeHtml(s.path)}','${escapeHtml(s.sessionKey)}')" style="padding:2px 6px;font-size:10px;color:#8888AA;" title="Als Markdown exportieren">⬇</button>`;
|
||||||
|
} else {
|
||||||
|
actions = (isActive ? '' : `<button class="btn secondary" onclick="event.stopPropagation();activateSession('${escapeHtml(s.sessionKey)}')" style="padding:2px 6px;font-size:10px;color:#34C759;margin-right:2px;" title="Aktivieren">▶</button>`)
|
||||||
|
+ `<button class="btn secondary" onclick="event.stopPropagation();deleteSession('${escapeHtml(s.path)}')" style="padding:2px 6px;font-size:10px;color:#FF6B6B;margin-right:2px;" title="Loeschen">X</button>`
|
||||||
|
+ `<button class="btn secondary" onclick="event.stopPropagation();exportSession('${escapeHtml(s.path)}','${escapeHtml(s.sessionKey)}')" style="padding:2px 6px;font-size:10px;color:#8888AA;" title="Als Markdown exportieren">⬇</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<tr style="border-bottom:1px solid #0D0D1A;cursor:pointer;${rowBg}" onmouseover="this.style.background='#1E1E2E'" onmouseout="this.style.background='${isActive ? 'rgba(52,199,89,0.08)' : (s.archived ? 'rgba(136,136,170,0.04)' : '')}'">`
|
||||||
+ `<td style="padding:4px 6px;" onclick="viewSession('${escapeHtml(s.path)}')">`
|
+ `<td style="padding:4px 6px;" onclick="viewSession('${escapeHtml(s.path)}')">`
|
||||||
+ `<div style="color:${keyColor};">${key}${activeBadge}${orphanBadge}</div>${modelBadge}</td>`
|
+ `<div style="color:${keyColor};">${key}${activeBadge}${orphanBadge}${archivedBadge}</div>${modelBadge}</td>`
|
||||||
+ `<td style="padding:4px 6px;color:#8888AA;">${s.lines}</td>`
|
+ `<td style="padding:4px 6px;color:#8888AA;">${s.lines}</td>`
|
||||||
+ `<td style="padding:4px 6px;color:#8888AA;font-size:10px;">${date}</td>`
|
+ `<td style="padding:4px 6px;color:#8888AA;font-size:10px;">${date}</td>`
|
||||||
+ `<td style="padding:4px 6px;white-space:nowrap;">`
|
+ `<td style="padding:4px 6px;white-space:nowrap;">${actions}</td></tr>`;
|
||||||
+ (isActive ? '' : `<button class="btn secondary" onclick="event.stopPropagation();activateSession('${escapeHtml(s.sessionKey)}')" style="padding:2px 6px;font-size:10px;color:#34C759;margin-right:2px;" title="Aktivieren">▶</button>`)
|
};
|
||||||
+ `<button class="btn secondary" onclick="event.stopPropagation();deleteSession('${escapeHtml(s.path)}')" style="padding:2px 6px;font-size:10px;color:#FF6B6B;" title="Loeschen">X</button>`
|
|
||||||
+ `</td></tr>`;
|
let html = '<table style="width:100%;border-collapse:collapse;">' + headerRow;
|
||||||
}
|
for (const s of active) html += rowFor(s);
|
||||||
html += '</table>';
|
html += '</table>';
|
||||||
|
|
||||||
|
if (archives.length > 0) {
|
||||||
|
html += `<details style="margin-top:12px;" ${archives.length <= 5 ? 'open' : ''}>`
|
||||||
|
+ `<summary style="color:#8888AA;font-size:11px;cursor:pointer;padding:4px 0;">`
|
||||||
|
+ `Archivierte Versionen (${archives.length}) — von OpenClaw beim Session-Reset gesichert`
|
||||||
|
+ `</summary>`
|
||||||
|
+ `<table style="width:100%;border-collapse:collapse;margin-top:6px;">` + headerRow;
|
||||||
|
for (const s of archives) html += rowFor(s);
|
||||||
|
html += '</table></details>';
|
||||||
|
}
|
||||||
|
|
||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1669,6 +1992,10 @@
|
|||||||
send({ action: 'delete_session', sessionPath: path });
|
send({ action: 'delete_session', sessionPath: path });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function exportSession(path, sessionKey) {
|
||||||
|
send({ action: 'export_session', sessionPath: path, sessionKey });
|
||||||
|
}
|
||||||
|
|
||||||
function activateSession(sessionKey) {
|
function activateSession(sessionKey) {
|
||||||
send({ action: 'set_active_session', sessionKey });
|
send({ action: 'set_active_session', sessionKey });
|
||||||
}
|
}
|
||||||
@@ -1769,10 +2096,12 @@
|
|||||||
document.querySelectorAll('.main-nav-btn').forEach(b => {
|
document.querySelectorAll('.main-nav-btn').forEach(b => {
|
||||||
if (b.textContent.trim().toLowerCase().includes(tab === 'main' ? 'main' : 'einstellung')) b.classList.add('active');
|
if (b.textContent.trim().toLowerCase().includes(tab === 'main' ? 'main' : 'einstellung')) b.classList.add('active');
|
||||||
});
|
});
|
||||||
// Einstellungen: Config + Trigger laden
|
// Einstellungen: Config + Trigger + QR laden
|
||||||
if (tab === 'settings') {
|
if (tab === 'settings') {
|
||||||
loadHighlightTriggers();
|
loadHighlightTriggers();
|
||||||
send({ action: 'get_voice_config' });
|
send({ action: 'get_voice_config' });
|
||||||
|
loadRuntimeConfig();
|
||||||
|
loadOnboardingQR();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+269
-28
@@ -37,15 +37,76 @@ const state = {
|
|||||||
};
|
};
|
||||||
const SESSION_KEY_FILE = "/data/active-session";
|
const SESSION_KEY_FILE = "/data/active-session";
|
||||||
// /data Verzeichnis sicherstellen (Volume Mount)
|
// /data Verzeichnis sicherstellen (Volume Mount)
|
||||||
try { fs.mkdirSync("/data", { recursive: true }); } catch {}
|
try { fs.mkdirSync("/data", { recursive: true }); } catch (e) {
|
||||||
|
console.error(`[startup] /data mkdir fehlgeschlagen: ${e.message}`);
|
||||||
|
}
|
||||||
|
// sessionFromFile zeigt an, ob der aktive Key aus der Datei kam.
|
||||||
|
// Wenn true, darf resolveActiveSession NICHT mehr auto-picken (Wahl respektieren).
|
||||||
|
let sessionFromFile = false;
|
||||||
let activeSessionKey = (() => {
|
let activeSessionKey = (() => {
|
||||||
try {
|
try {
|
||||||
const saved = fs.readFileSync(SESSION_KEY_FILE, "utf-8").trim();
|
const saved = fs.readFileSync(SESSION_KEY_FILE, "utf-8").trim();
|
||||||
if (saved) { console.log(`[startup] Gespeicherte Session geladen: '${saved}'`); return saved; }
|
if (saved) {
|
||||||
} catch {}
|
console.log(`[startup] Gespeicherte Session geladen: '${saved}'`);
|
||||||
|
sessionFromFile = true;
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[startup] SESSION_KEY_FILE read: ${e.code || e.message}`);
|
||||||
|
}
|
||||||
console.log("[startup] Keine gespeicherte Session — Fallback 'main'");
|
console.log("[startup] Keine gespeicherte Session — Fallback 'main'");
|
||||||
return "main";
|
return "main";
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// ── Runtime-Config: /shared/config/runtime.json ─────────────
|
||||||
|
// ENV-Werte sind Defaults; Werte aus runtime.json haben Vorrang.
|
||||||
|
// Bridge und ggf. andere Komponenten lesen dieselbe Datei.
|
||||||
|
const RUNTIME_CONFIG_FILE = "/shared/config/runtime.json";
|
||||||
|
const RUNTIME_CONFIG_FIELDS = [
|
||||||
|
"RVS_HOST", "RVS_PORT", "RVS_TLS", "RVS_TOKEN",
|
||||||
|
"ARIA_AUTH_TOKEN", "WHISPER_MODEL", "WHISPER_LANGUAGE",
|
||||||
|
];
|
||||||
|
function readRuntimeConfig() {
|
||||||
|
const envDefaults = {
|
||||||
|
RVS_HOST, RVS_PORT, RVS_TLS, RVS_TOKEN,
|
||||||
|
ARIA_AUTH_TOKEN: process.env.ARIA_AUTH_TOKEN || "",
|
||||||
|
WHISPER_MODEL: process.env.WHISPER_MODEL || "medium",
|
||||||
|
WHISPER_LANGUAGE: process.env.WHISPER_LANGUAGE || "de",
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(RUNTIME_CONFIG_FILE, "utf-8");
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return { ...envDefaults, ...parsed };
|
||||||
|
} catch {
|
||||||
|
return envDefaults;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function writeRuntimeConfig(patch) {
|
||||||
|
let current = {};
|
||||||
|
try { current = JSON.parse(fs.readFileSync(RUNTIME_CONFIG_FILE, "utf-8")); } catch {}
|
||||||
|
for (const key of Object.keys(patch)) {
|
||||||
|
if (RUNTIME_CONFIG_FIELDS.includes(key)) current[key] = patch[key];
|
||||||
|
}
|
||||||
|
fs.mkdirSync("/shared/config", { recursive: true });
|
||||||
|
const tmp = RUNTIME_CONFIG_FILE + ".tmp";
|
||||||
|
fs.writeFileSync(tmp, JSON.stringify(current, null, 2));
|
||||||
|
fs.renameSync(tmp, RUNTIME_CONFIG_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomic write: temp-file + rename, laute Logs bei Fehler.
|
||||||
|
function persistActiveSession(key) {
|
||||||
|
try {
|
||||||
|
const tmp = SESSION_KEY_FILE + ".tmp";
|
||||||
|
fs.writeFileSync(tmp, key);
|
||||||
|
fs.renameSync(tmp, SESSION_KEY_FILE);
|
||||||
|
sessionFromFile = true;
|
||||||
|
console.log(`[session] Aktive Session persistiert: '${key}'`);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[session] FEHLER beim Persistieren von '${key}': ${e.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
const logs = [];
|
const logs = [];
|
||||||
let gatewayWs = null;
|
let gatewayWs = null;
|
||||||
let rvsWs = null;
|
let rvsWs = null;
|
||||||
@@ -56,6 +117,12 @@ const browserClients = new Set();
|
|||||||
let pipelineActive = false;
|
let pipelineActive = false;
|
||||||
let pipelineStartTime = 0;
|
let pipelineStartTime = 0;
|
||||||
|
|
||||||
|
// Nach chat:final kommen oft noch Trailing Agent-Events. Waehrend dieses
|
||||||
|
// Fensters unterdruecken wir agent_activity-Broadcasts, damit der
|
||||||
|
// Thinking-Indicator nicht wieder anspringt.
|
||||||
|
let lastChatFinalAt = 0;
|
||||||
|
const SETTLED_WINDOW_MS = 3000;
|
||||||
|
|
||||||
function plog(message, level) {
|
function plog(message, level) {
|
||||||
const elapsed = pipelineActive ? `+${Date.now() - pipelineStartTime}ms` : "";
|
const elapsed = pipelineActive ? `+${Date.now() - pipelineStartTime}ms` : "";
|
||||||
const entry = { ts: new Date().toISOString(), level: level || "info", source: "pipeline", message: `${elapsed ? `[${elapsed}] ` : ""}${message}` };
|
const entry = { ts: new Date().toISOString(), level: level || "info", source: "pipeline", message: `${elapsed ? `[${elapsed}] ` : ""}${message}` };
|
||||||
@@ -91,6 +158,9 @@ function pipelineEnd(ok, detail) {
|
|||||||
}
|
}
|
||||||
plog(`━━━ Pipeline Ende ━━━`);
|
plog(`━━━ Pipeline Ende ━━━`);
|
||||||
pipelineActive = false;
|
pipelineActive = false;
|
||||||
|
// Thinking-Indikator IMMER zuruecksetzen — auch bei Timeout/Fehler/Abbruch
|
||||||
|
broadcast({ type: "agent_activity", activity: "idle" });
|
||||||
|
pendingMessageTime = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Auto-Restart bei Netzwerk-Namespace-Verlust ──────
|
// ── Auto-Restart bei Netzwerk-Namespace-Verlust ──────
|
||||||
@@ -257,8 +327,10 @@ async function connectGateway() {
|
|||||||
state.gateway.handshakeOk = false;
|
state.gateway.handshakeOk = false;
|
||||||
gatewayWs = null;
|
gatewayWs = null;
|
||||||
broadcastState();
|
broadcastState();
|
||||||
|
// Stuck "ARIA denkt..." vermeiden, falls Gateway waehrend Pipeline abkackt
|
||||||
|
if (pipelineActive) pipelineEnd(false, `Gateway-Verbindung verloren (${code})`);
|
||||||
|
else broadcast({ type: "agent_activity", activity: "idle" });
|
||||||
checkGatewayHealth();
|
checkGatewayHealth();
|
||||||
// Auto-Reconnect nach 5s
|
|
||||||
setTimeout(connectGateway, 5000);
|
setTimeout(connectGateway, 5000);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -325,17 +397,22 @@ function handleGatewayMessage(msg) {
|
|||||||
broadcast({ type: "chat_delta", delta, payload });
|
broadcast({ type: "chat_delta", delta, payload });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Nach chat:final trickeln noch Aufraeum-Events rein — unterdruecken,
|
||||||
|
// damit der Thinking-Indicator nicht wieder anspringt.
|
||||||
|
const settled = lastChatFinalAt && (Date.now() - lastChatFinalAt) < SETTLED_WINDOW_MS;
|
||||||
|
|
||||||
// Tool-Nutzung erkennen und broadcasten
|
// Tool-Nutzung erkennen und broadcasten
|
||||||
if (stream === "tool_use" || data.type === "tool_use") {
|
if (stream === "tool_use" || data.type === "tool_use") {
|
||||||
const toolName = data.name || data.tool || payload.tool || "";
|
const toolName = data.name || data.tool || payload.tool || "";
|
||||||
if (toolName) {
|
if (toolName && !settled) {
|
||||||
broadcast({ type: "agent_activity", activity: "tool", tool: toolName, data });
|
broadcast({ type: "agent_activity", activity: "tool", tool: toolName, data });
|
||||||
log("info", "gateway", `Tool: ${toolName}`);
|
log("info", "gateway", `Tool: ${toolName}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Genereller Activity-Heartbeat (ARIA denkt)
|
if (!settled) {
|
||||||
broadcast({ type: "agent_activity", activity: stream || "thinking" });
|
broadcast({ type: "agent_activity", activity: stream || "thinking" });
|
||||||
|
}
|
||||||
updateAgentActivity();
|
updateAgentActivity();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -349,7 +426,21 @@ function handleGatewayMessage(msg) {
|
|||||||
const runId = payload.runId || "";
|
const runId = payload.runId || "";
|
||||||
if (runId && seenFinalRuns.has(runId)) return; // Duplikat
|
if (runId && seenFinalRuns.has(runId)) return; // Duplikat
|
||||||
if (runId) { seenFinalRuns.add(runId); setTimeout(() => seenFinalRuns.delete(runId), 60000); }
|
if (runId) { seenFinalRuns.add(runId); setTimeout(() => seenFinalRuns.delete(runId), 60000); }
|
||||||
|
|
||||||
|
// NO_REPLY → ARIA signalisiert "nicht antworten", Pipeline beenden aber nichts zeigen
|
||||||
|
const trimmed = (text || "").trim().replace(/^["'`*.\s]+|["'`*.\s]+$/g, "").toUpperCase();
|
||||||
|
if (trimmed === "NO_REPLY" || trimmed.startsWith("NO_REPLY")) {
|
||||||
|
log("info", "gateway", "NO_REPLY empfangen — still verworfen");
|
||||||
|
lastChatFinalAt = Date.now();
|
||||||
|
if (pipelineActive) pipelineEnd(true, "NO_REPLY (stumm)");
|
||||||
|
broadcast({ type: "agent_activity", activity: "idle" });
|
||||||
|
pendingMessageTime = 0;
|
||||||
|
updateAgentActivity();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
log("info", "gateway", `ANTWORT: "${text.slice(0, 200)}"`);
|
log("info", "gateway", `ANTWORT: "${text.slice(0, 200)}"`);
|
||||||
|
lastChatFinalAt = Date.now();
|
||||||
if (pipelineActive) pipelineEnd(true, `"${text.slice(0, 120)}"`);
|
if (pipelineActive) pipelineEnd(true, `"${text.slice(0, 120)}"`);
|
||||||
broadcast({ type: "chat_final", text, payload });
|
broadcast({ type: "chat_final", text, payload });
|
||||||
broadcast({ type: "agent_activity", activity: "idle" });
|
broadcast({ type: "agent_activity", activity: "idle" });
|
||||||
@@ -372,6 +463,7 @@ function handleGatewayMessage(msg) {
|
|||||||
const error = payload.error || text || "Unbekannt";
|
const error = payload.error || text || "Unbekannt";
|
||||||
log("error", "gateway", `Chat-Fehler: ${error}`);
|
log("error", "gateway", `Chat-Fehler: ${error}`);
|
||||||
if (pipelineActive) pipelineEnd(false, error);
|
if (pipelineActive) pipelineEnd(false, error);
|
||||||
|
else broadcast({ type: "agent_activity", activity: "idle" });
|
||||||
broadcast({ type: "chat_error", error, payload });
|
broadcast({ type: "chat_error", error, payload });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -392,7 +484,9 @@ function handleGatewayMessage(msg) {
|
|||||||
if (runId) { seenFinalRuns.add(runId); setTimeout(() => seenFinalRuns.delete(runId), 60000); }
|
if (runId) { seenFinalRuns.add(runId); setTimeout(() => seenFinalRuns.delete(runId), 60000); }
|
||||||
const text = extractChatText(payload) || payload.text || "";
|
const text = extractChatText(payload) || payload.text || "";
|
||||||
log("info", "gateway", `ANTWORT: "${text.slice(0, 200)}"`);
|
log("info", "gateway", `ANTWORT: "${text.slice(0, 200)}"`);
|
||||||
|
lastChatFinalAt = Date.now();
|
||||||
if (pipelineActive) pipelineEnd(true, `"${text.slice(0, 120)}"`);
|
if (pipelineActive) pipelineEnd(true, `"${text.slice(0, 120)}"`);
|
||||||
|
else broadcast({ type: "agent_activity", activity: "idle" });
|
||||||
broadcast({ type: "chat_final", text, payload });
|
broadcast({ type: "chat_final", text, payload });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -400,6 +494,7 @@ function handleGatewayMessage(msg) {
|
|||||||
const error = payload.error || payload.message || "Unbekannt";
|
const error = payload.error || payload.message || "Unbekannt";
|
||||||
log("error", "gateway", `Chat-Fehler: ${error}`);
|
log("error", "gateway", `Chat-Fehler: ${error}`);
|
||||||
if (pipelineActive) pipelineEnd(false, error);
|
if (pipelineActive) pipelineEnd(false, error);
|
||||||
|
else broadcast({ type: "agent_activity", activity: "idle" });
|
||||||
broadcast({ type: "chat_error", error, payload });
|
broadcast({ type: "chat_error", error, payload });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1109,6 +1204,45 @@ const server = http.createServer((req, res) => {
|
|||||||
} else if (req.url === "/api/session") {
|
} else if (req.url === "/api/session") {
|
||||||
res.writeHead(200, { "Content-Type": "application/json" });
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
res.end(JSON.stringify({ sessionKey: activeSessionKey }));
|
res.end(JSON.stringify({ sessionKey: activeSessionKey }));
|
||||||
|
} else if (req.url === "/api/runtime-config" && req.method === "GET") {
|
||||||
|
// Zentrale Runtime-Config (ENV + Override aus /shared/config/runtime.json)
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify(readRuntimeConfig()));
|
||||||
|
} else if (req.url === "/api/runtime-config" && req.method === "POST") {
|
||||||
|
let body = "";
|
||||||
|
req.on("data", chunk => { body += chunk; if (body.length > 32768) req.destroy(); });
|
||||||
|
req.on("end", () => {
|
||||||
|
try {
|
||||||
|
const patch = JSON.parse(body);
|
||||||
|
writeRuntimeConfig(patch);
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ ok: true, config: readRuntimeConfig() }));
|
||||||
|
log("info", "server", `Runtime-Config aktualisiert: ${Object.keys(patch).join(", ")}`);
|
||||||
|
} catch (err) {
|
||||||
|
res.writeHead(400, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ ok: false, error: err.message }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} else if (req.url === "/api/onboarding") {
|
||||||
|
// RVS-Credentials fuer QR-Code App-Onboarding
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
rvsHost: RVS_HOST,
|
||||||
|
rvsPort: RVS_PORT,
|
||||||
|
rvsTLS: RVS_TLS === "true" || RVS_TLS === true,
|
||||||
|
rvsToken: RVS_TOKEN,
|
||||||
|
}));
|
||||||
|
} else if (req.url === "/api/cancel" && req.method === "POST") {
|
||||||
|
log("warn", "server", "HTTP /api/cancel — Cancel-Request (von Bridge)");
|
||||||
|
pendingMessageTime = 0;
|
||||||
|
watchdogWarned = false;
|
||||||
|
watchdogFixAttempted = false;
|
||||||
|
if (pipelineActive) pipelineEnd(false, "Vom Benutzer abgebrochen (App)");
|
||||||
|
else broadcast({ type: "agent_activity", activity: "idle" });
|
||||||
|
dockerExec("aria-core", "openclaw doctor --fix 2>/dev/null || true").catch(() => {});
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ ok: true }));
|
||||||
} else if (req.url.startsWith("/shared/")) {
|
} else if (req.url.startsWith("/shared/")) {
|
||||||
// Dateien aus Shared Volume ausliefern (Bilder, Uploads)
|
// Dateien aus Shared Volume ausliefern (Bilder, Uploads)
|
||||||
const filePath = decodeURIComponent(req.url);
|
const filePath = decodeURIComponent(req.url);
|
||||||
@@ -1181,6 +1315,14 @@ wss.on("connection", (ws) => {
|
|||||||
if (ws._sshSock) ws._sshSock.write(msg.data);
|
if (ws._sshSock) ws._sshSock.write(msg.data);
|
||||||
} else if (msg.action === "live_ssh_close") {
|
} else if (msg.action === "live_ssh_close") {
|
||||||
if (ws._sshSock) { ws._sshSock.end(); ws._sshSock = null; }
|
if (ws._sshSock) { ws._sshSock.end(); ws._sshSock = null; }
|
||||||
|
} else if (msg.action === "send_file") {
|
||||||
|
// Datei von Diagnostic an Bridge via RVS senden
|
||||||
|
sendToRVS_raw({
|
||||||
|
type: "file",
|
||||||
|
payload: { name: msg.name, type: msg.type, size: msg.size, base64: msg.base64 },
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
log("info", "server", `Datei gesendet: ${msg.name} (${msg.type})`);
|
||||||
} else if (msg.action === "cancel_request") {
|
} else if (msg.action === "cancel_request") {
|
||||||
// Laufende Anfrage abbrechen — doctor --fix beendet stuck runs
|
// Laufende Anfrage abbrechen — doctor --fix beendet stuck runs
|
||||||
log("warn", "server", "Anfrage abgebrochen — fuehre doctor --fix aus");
|
log("warn", "server", "Anfrage abgebrochen — fuehre doctor --fix aus");
|
||||||
@@ -1201,7 +1343,11 @@ wss.on("connection", (ws) => {
|
|||||||
handleGetVoiceConfig(ws);
|
handleGetVoiceConfig(ws);
|
||||||
} else if (msg.action === "send_voice_config") {
|
} else if (msg.action === "send_voice_config") {
|
||||||
// Stimmen-Config persistent speichern + an Bridge via RVS senden
|
// Stimmen-Config persistent speichern + an Bridge via RVS senden
|
||||||
|
// Bestehende Config lesen um Felder zu mergen die dieser Call nicht setzt
|
||||||
|
let existing = {};
|
||||||
|
try { existing = JSON.parse(fs.readFileSync("/shared/config/voice_config.json", "utf-8")); } catch {}
|
||||||
const voiceConfig = {
|
const voiceConfig = {
|
||||||
|
...existing,
|
||||||
defaultVoice: msg.defaultVoice || "ramona",
|
defaultVoice: msg.defaultVoice || "ramona",
|
||||||
highlightVoice: msg.highlightVoice || "thorsten",
|
highlightVoice: msg.highlightVoice || "thorsten",
|
||||||
ttsEnabled: msg.ttsEnabled !== false,
|
ttsEnabled: msg.ttsEnabled !== false,
|
||||||
@@ -1210,12 +1356,13 @@ wss.on("connection", (ws) => {
|
|||||||
speedRamona: msg.speedRamona || 1.0,
|
speedRamona: msg.speedRamona || 1.0,
|
||||||
speedThorsten: msg.speedThorsten || 1.0,
|
speedThorsten: msg.speedThorsten || 1.0,
|
||||||
};
|
};
|
||||||
|
if (msg.whisperModel !== undefined) voiceConfig.whisperModel = msg.whisperModel;
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync("/shared/config", { recursive: true });
|
fs.mkdirSync("/shared/config", { recursive: true });
|
||||||
fs.writeFileSync("/shared/config/voice_config.json", JSON.stringify(voiceConfig, null, 2));
|
fs.writeFileSync("/shared/config/voice_config.json", JSON.stringify(voiceConfig, null, 2));
|
||||||
} catch {}
|
} catch {}
|
||||||
sendToRVS_raw({ type: "config", payload: voiceConfig, timestamp: Date.now() });
|
sendToRVS_raw({ type: "config", payload: voiceConfig, timestamp: Date.now() });
|
||||||
log("info", "server", `Voice-Config gespeichert+gesendet: default=${voiceConfig.defaultVoice}, highlight=${voiceConfig.highlightVoice}, tts=${voiceConfig.ttsEnabled}`);
|
log("info", "server", `Voice-Config gespeichert+gesendet: default=${voiceConfig.defaultVoice}, whisper=${voiceConfig.whisperModel || "-"}`);
|
||||||
} else if (msg.action === "get_triggers") {
|
} else if (msg.action === "get_triggers") {
|
||||||
handleGetTriggers(ws);
|
handleGetTriggers(ws);
|
||||||
} else if (msg.action === "save_triggers") {
|
} else if (msg.action === "save_triggers") {
|
||||||
@@ -1232,6 +1379,8 @@ wss.on("connection", (ws) => {
|
|||||||
handleListSessions(ws);
|
handleListSessions(ws);
|
||||||
} else if (msg.action === "read_session") {
|
} else if (msg.action === "read_session") {
|
||||||
handleReadSession(ws, msg.sessionPath);
|
handleReadSession(ws, msg.sessionPath);
|
||||||
|
} else if (msg.action === "export_session") {
|
||||||
|
handleExportSession(ws, msg.sessionPath, msg.sessionKey);
|
||||||
} else if (msg.action === "delete_session") {
|
} else if (msg.action === "delete_session") {
|
||||||
handleDeleteSession(ws, msg.sessionPath);
|
handleDeleteSession(ws, msg.sessionPath);
|
||||||
} else if (msg.action === "set_active_session") {
|
} else if (msg.action === "set_active_session") {
|
||||||
@@ -1503,17 +1652,17 @@ async function handleListSessions(clientWs) {
|
|||||||
try {
|
try {
|
||||||
log("info", "server", "Lade Sessions aus aria-core...");
|
log("info", "server", "Lade Sessions aus aria-core...");
|
||||||
|
|
||||||
// sessions.json als Index lesen + Datei-Details holen
|
// sessions.json als Index lesen + Datei-Details holen (inkl. .reset.* Archive)
|
||||||
const raw = await dockerExec("aria-core", `
|
const raw = await dockerExec("aria-core", `
|
||||||
cat ${SESSIONS_DIR}/sessions.json 2>/dev/null || echo '{}' &&
|
cat ${SESSIONS_DIR}/sessions.json 2>/dev/null || echo '{}' &&
|
||||||
echo '===FILE_DETAILS===' &&
|
echo '===FILE_DETAILS===' &&
|
||||||
for f in ${SESSIONS_DIR}/*.jsonl; do
|
for f in ${SESSIONS_DIR}/*.jsonl ${SESSIONS_DIR}/*.jsonl.reset.*; do
|
||||||
[ -f "$f" ] || continue
|
[ -f "$f" ] || continue
|
||||||
name=$(basename "$f")
|
name=$(basename "$f")
|
||||||
lines=$(wc -l < "$f" 2>/dev/null || echo 0)
|
msgs=$(grep -cE '"role":"(user|assistant)"' "$f" 2>/dev/null || echo 0)
|
||||||
size=$(du -h "$f" 2>/dev/null | cut -f1)
|
size=$(du -h "$f" 2>/dev/null | cut -f1)
|
||||||
modified=$(stat -c '%Y' "$f" 2>/dev/null || echo 0)
|
modified=$(stat -c '%Y' "$f" 2>/dev/null || echo 0)
|
||||||
echo "FILE:$name|LINES:$lines|SIZE:$size|MODIFIED:$modified"
|
echo "FILE:$name|LINES:$msgs|SIZE:$size|MODIFIED:$modified"
|
||||||
done
|
done
|
||||||
`.trim());
|
`.trim());
|
||||||
|
|
||||||
@@ -1568,8 +1717,29 @@ async function handleListSessions(clientWs) {
|
|||||||
delete fileDetails[filename];
|
delete fileDetails[filename];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dateien die nicht im Index stehen (Waisen / Reset-Files)
|
// Dateien die nicht im Index stehen (Waisen ODER Reset-Archive)
|
||||||
for (const [filename, details] of Object.entries(fileDetails)) {
|
for (const [filename, details] of Object.entries(fileDetails)) {
|
||||||
|
// .jsonl.reset.<ISO-Timestamp>Z → archivierte Session (OpenClaw-Reset)
|
||||||
|
// Format: 528f4d70-...jsonl.reset.2026-04-18T09-49-44.814Z
|
||||||
|
const resetMatch = filename.match(/^([a-f0-9-]+)\.jsonl\.reset\.(.+Z)$/);
|
||||||
|
if (resetMatch) {
|
||||||
|
const id = resetMatch[1];
|
||||||
|
// Timestamp ISO-8601 parsen: 2026-04-18T09-49-44.814Z → 2026-04-18T09:49:44.814Z
|
||||||
|
const tsStr = resetMatch[2].replace(/T(\d{2})-(\d{2})-(\d{2})/, "T$1:$2:$3");
|
||||||
|
const resetAt = Math.floor(new Date(tsStr).getTime() / 1000) || parseInt(details.MODIFIED) || 0;
|
||||||
|
sessions.push({
|
||||||
|
path: `${SESSIONS_DIR}/${filename}`,
|
||||||
|
sessionKey: id.slice(0, 8) + "… (archiv)",
|
||||||
|
sessionId: id,
|
||||||
|
lines: parseInt(details.LINES) || 0,
|
||||||
|
size: details.SIZE || "?",
|
||||||
|
modified: resetAt,
|
||||||
|
archived: true,
|
||||||
|
resetAt,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Echte Waisen (UUID.jsonl ohne Eintrag in sessions.json)
|
||||||
const id = filename.replace(".jsonl", "");
|
const id = filename.replace(".jsonl", "");
|
||||||
sessions.push({
|
sessions.push({
|
||||||
path: `${SESSIONS_DIR}/${filename}`,
|
path: `${SESSIONS_DIR}/${filename}`,
|
||||||
@@ -1614,6 +1784,68 @@ async function handleReadSession(clientWs, sessionPath) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleExportSession(clientWs, sessionPath, sessionKey) {
|
||||||
|
if (!sessionPath || sessionPath.includes("..") || !sessionPath.startsWith(SESSIONS_DIR)) {
|
||||||
|
clientWs.send(JSON.stringify({ type: "session_export", ok: false, error: "Ungueltiger Pfad" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const safePath = sessionPath.replace(/'/g, "");
|
||||||
|
const raw = await dockerExec("aria-core", `cat '${safePath}'`);
|
||||||
|
const lines = raw.split("\n").filter(l => l.trim());
|
||||||
|
|
||||||
|
const blocks = [];
|
||||||
|
for (const line of lines) {
|
||||||
|
let obj;
|
||||||
|
try { obj = JSON.parse(line); } catch { continue; }
|
||||||
|
if (obj.type !== "message" || !obj.message) continue;
|
||||||
|
const role = obj.message.role;
|
||||||
|
if (role !== "user" && role !== "assistant") continue;
|
||||||
|
|
||||||
|
let text = "";
|
||||||
|
const content = obj.message.content;
|
||||||
|
if (typeof content === "string") text = content;
|
||||||
|
else if (Array.isArray(content)) text = content.filter(c => c.type === "text").map(c => c.text || "").join("\n");
|
||||||
|
if (!text) continue;
|
||||||
|
|
||||||
|
if (role === "user") {
|
||||||
|
text = text.replace(/^Sender \(untrusted metadata\):[\s\S]*?```[\s\S]*?```\s*\n*/m, "").trim();
|
||||||
|
text = text.replace(/^\[.*?\]\s*/, "").trim();
|
||||||
|
} else {
|
||||||
|
text = text.replace(/^\[\[reply_to_\w+\]\]\s*/g, "").trim();
|
||||||
|
}
|
||||||
|
if (!text) continue;
|
||||||
|
|
||||||
|
const ts = obj.message.timestamp || obj.timestamp || 0;
|
||||||
|
const when = ts ? new Date(ts).toISOString().replace("T", " ").slice(0, 19) : "";
|
||||||
|
const heading = role === "user" ? "## 🧑 User" : "## 🤖 ARIA";
|
||||||
|
blocks.push(`${heading}${when ? ` — ${when}` : ""}\n\n${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportedAt = new Date().toISOString().replace("T", " ").slice(0, 19);
|
||||||
|
const title = sessionKey || sessionPath.split("/").pop().replace(".jsonl", "");
|
||||||
|
const markdown = [
|
||||||
|
`# Session: ${title}`,
|
||||||
|
``,
|
||||||
|
`Exportiert: ${exportedAt} `,
|
||||||
|
`Quelle: ${sessionPath}`,
|
||||||
|
``,
|
||||||
|
`---`,
|
||||||
|
``,
|
||||||
|
blocks.join("\n\n---\n\n"),
|
||||||
|
``,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const safeKey = (sessionKey || "session").replace(/[^a-zA-Z0-9_-]/g, "_");
|
||||||
|
const filename = `${exportedAt.slice(0, 10)}_${safeKey}.md`;
|
||||||
|
clientWs.send(JSON.stringify({ type: "session_export", ok: true, filename, markdown }));
|
||||||
|
log("info", "server", `Session exportiert: ${filename} (${blocks.length} Nachrichten)`);
|
||||||
|
} catch (err) {
|
||||||
|
log("error", "server", `Session-Export fehlgeschlagen: ${err.message}`);
|
||||||
|
clientWs.send(JSON.stringify({ type: "session_export", ok: false, error: err.message }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleDeleteSession(clientWs, sessionPath) {
|
async function handleDeleteSession(clientWs, sessionPath) {
|
||||||
if (!sessionPath || sessionPath.includes("..") || !sessionPath.startsWith(SESSIONS_DIR)) {
|
if (!sessionPath || sessionPath.includes("..") || !sessionPath.startsWith(SESSIONS_DIR)) {
|
||||||
clientWs.send(JSON.stringify({ type: "session_deleted", ok: false, error: "Ungueltiger Pfad" }));
|
clientWs.send(JSON.stringify({ type: "session_deleted", ok: false, error: "Ungueltiger Pfad" }));
|
||||||
@@ -1654,13 +1886,11 @@ async function handleDeleteSession(clientWs, sessionPath) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Session-Aufloesung: letzte aktive Session finden ────
|
// ── Session-Aufloesung: letzte aktive Session finden ────
|
||||||
|
// Wird nach Gateway-(Re-)Connect aufgerufen. Darf die explizit gewaehlte
|
||||||
|
// Session NIE ueberschreiben — nur beim absoluten Erststart auto-picken.
|
||||||
async function resolveActiveSession() {
|
async function resolveActiveSession() {
|
||||||
// Nur bei Fallback-Key "main" automatisch aufloesen — gespeicherte Wahl respektieren
|
if (sessionFromFile) {
|
||||||
const hasSavedSession = (() => {
|
log("info", "server", `Session '${activeSessionKey}' aus /data — keine Auto-Wahl`);
|
||||||
try { return !!fs.readFileSync(SESSION_KEY_FILE, "utf-8").trim(); } catch { return false; }
|
|
||||||
})();
|
|
||||||
if (hasSavedSession && activeSessionKey !== "main") {
|
|
||||||
log("info", "server", `Gespeicherte Session '${activeSessionKey}' wird beibehalten`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1679,10 +1909,19 @@ async function resolveActiveSession() {
|
|||||||
const keys = entries.map(e => (e.key || e.sessionKey || e.name || "?").replace(/^agent:main:/, ""));
|
const keys = entries.map(e => (e.key || e.sessionKey || e.name || "?").replace(/^agent:main:/, ""));
|
||||||
log("info", "server", `Verfuegbare Sessions: [${keys.join(", ")}]`);
|
log("info", "server", `Verfuegbare Sessions: [${keys.join(", ")}]`);
|
||||||
|
|
||||||
// Neueste Session nehmen
|
// Neueste Session nehmen — aber user-definierte bevorzugen.
|
||||||
|
// aria-bridge / aria-diagnostic werden von den Services auto-erstellt;
|
||||||
|
// bei erstem Start soll lieber eine "echte" Session gewaehlt werden,
|
||||||
|
// falls vorhanden.
|
||||||
|
const AUTO_KEYS = new Set(["aria-bridge", "aria-diagnostic"]);
|
||||||
|
const normalise = (e) => (e.key || e.sessionKey || e.name || "").replace(/^agent:main:/, "");
|
||||||
|
|
||||||
|
const userEntries = entries.filter(e => !AUTO_KEYS.has(normalise(e)));
|
||||||
|
const pool = userEntries.length > 0 ? userEntries : entries;
|
||||||
|
|
||||||
let newest = null;
|
let newest = null;
|
||||||
let newestTime = 0;
|
let newestTime = 0;
|
||||||
for (const entry of entries) {
|
for (const entry of pool) {
|
||||||
const t = entry.updatedAt || entry.createdAt || 0;
|
const t = entry.updatedAt || entry.createdAt || 0;
|
||||||
if (t >= newestTime) {
|
if (t >= newestTime) {
|
||||||
newestTime = t;
|
newestTime = t;
|
||||||
@@ -1691,12 +1930,11 @@ async function resolveActiveSession() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (newest) {
|
if (newest) {
|
||||||
const rawKey = newest.key || newest.sessionKey || newest.name || "";
|
const key = normalise(newest);
|
||||||
const key = rawKey.replace(/^agent:main:/, "");
|
|
||||||
if (key) {
|
if (key) {
|
||||||
activeSessionKey = key;
|
activeSessionKey = key;
|
||||||
try { fs.writeFileSync(SESSION_KEY_FILE, activeSessionKey); } catch {}
|
persistActiveSession(activeSessionKey);
|
||||||
log("info", "server", `Aktive Session auf neueste gewechselt: '${activeSessionKey}'`);
|
log("info", "server", `Auto-Wahl Erststart: '${activeSessionKey}'`);
|
||||||
for (const c of browserClients) {
|
for (const c of browserClients) {
|
||||||
c.send(JSON.stringify({ type: "active_session", sessionKey: activeSessionKey }));
|
c.send(JSON.stringify({ type: "active_session", sessionKey: activeSessionKey }));
|
||||||
}
|
}
|
||||||
@@ -1785,8 +2023,11 @@ function handleSetActiveSession(clientWs, sessionKey) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
activeSessionKey = sessionKey;
|
activeSessionKey = sessionKey;
|
||||||
try { fs.writeFileSync(SESSION_KEY_FILE, activeSessionKey); } catch {}
|
const ok = persistActiveSession(activeSessionKey);
|
||||||
log("info", "server", `Aktive Session: ${activeSessionKey}`);
|
log("info", "server", `Aktive Session: ${activeSessionKey}${ok ? "" : " (WARN: nicht persistiert!)"}`);
|
||||||
|
if (!ok) {
|
||||||
|
clientWs.send(JSON.stringify({ type: "active_session", ok: false, sessionKey: activeSessionKey, error: "Persistierung fehlgeschlagen — /data Volume pruefen" }));
|
||||||
|
}
|
||||||
// Allen Clients mitteilen
|
// Allen Clients mitteilen
|
||||||
for (const c of browserClients) {
|
for (const c of browserClients) {
|
||||||
c.send(JSON.stringify({ type: "active_session", sessionKey: activeSessionKey }));
|
c.send(JSON.stringify({ type: "active_session", sessionKey: activeSessionKey }));
|
||||||
@@ -1802,7 +2043,7 @@ async function handleCreateSession(clientWs, sessionName) {
|
|||||||
try {
|
try {
|
||||||
// Session wird automatisch erstellt wenn man die erste Nachricht sendet
|
// Session wird automatisch erstellt wenn man die erste Nachricht sendet
|
||||||
activeSessionKey = sessionName;
|
activeSessionKey = sessionName;
|
||||||
try { fs.writeFileSync(SESSION_KEY_FILE, activeSessionKey); } catch {}
|
persistActiveSession(activeSessionKey);
|
||||||
log("info", "server", `Neue Session erstellt und aktiviert: ${sessionName}`);
|
log("info", "server", `Neue Session erstellt und aktiviert: ${sessionName}`);
|
||||||
// Allen Clients mitteilen
|
// Allen Clients mitteilen
|
||||||
for (const c of browserClients) {
|
for (const c of browserClients) {
|
||||||
|
|||||||
@@ -6,9 +6,9 @@
|
|||||||
- [x] Sprachnachrichten werden als Text angezeigt (STT → Chat-Bubble)
|
- [x] Sprachnachrichten werden als Text angezeigt (STT → Chat-Bubble)
|
||||||
- [x] Cache leeren + Auto-Download von Anhaengen
|
- [x] Cache leeren + Auto-Download von Anhaengen
|
||||||
- [x] ARIA liest Nachrichten vor (TTS via Piper)
|
- [x] ARIA liest Nachrichten vor (TTS via Piper)
|
||||||
- [x] Autoscroll zur letzten Nachricht
|
- [x] Autoscroll zur letzten Nachricht (inverted FlatList)
|
||||||
- [x] Bilder im Chat groesser + Vollbild-Vorschau
|
- [x] Bilder im Chat groesser + Vollbild-Vorschau
|
||||||
- [x] Ohr-Button Absturz gefixt (LiveAudioStream entfernt, Phase 1 Placeholder)
|
- [x] Ohr-Button → Gespraechsmodus (Auto-Aufnahme nach ARIA-Antwort)
|
||||||
- [x] Play-Button in ARIA-Nachrichten fuer Sprachwiedergabe
|
- [x] Play-Button in ARIA-Nachrichten fuer Sprachwiedergabe
|
||||||
- [x] Chat-Suche in der App (Lupe in Statusleiste)
|
- [x] Chat-Suche in der App (Lupe in Statusleiste)
|
||||||
- [x] Watchdog mit Container-Restart (2min Warnung → 5min doctor --fix → 8min Restart)
|
- [x] Watchdog mit Container-Restart (2min Warnung → 5min doctor --fix → 8min Restart)
|
||||||
@@ -22,31 +22,60 @@
|
|||||||
- [x] XTTS Voice Cloning (Audio-Samples hochladen, eigene Stimme)
|
- [x] XTTS Voice Cloning (Audio-Samples hochladen, eigene Stimme)
|
||||||
- [x] TTS Engine waehlbar (Piper/XTTS) in Diagnostic + App
|
- [x] TTS Engine waehlbar (Piper/XTTS) in Diagnostic + App
|
||||||
- [x] Auto-Update System (APK via RVS WebSocket)
|
- [x] Auto-Update System (APK via RVS WebSocket)
|
||||||
|
- [x] Auto-Update: APK-Installation via FileProvider
|
||||||
|
- [x] Auto-Update: "Auf Updates pruefen" Button in App-Einstellungen
|
||||||
- [x] Audio-Queue (sequentielle Wiedergabe, kein Ueberlappen)
|
- [x] Audio-Queue (sequentielle Wiedergabe, kein Ueberlappen)
|
||||||
|
- [x] Textnachrichten werden von ARIA beantwortet (Bridge chat handler fix)
|
||||||
|
- [x] Mehrere Anhaenge + Text vor dem Senden (Pending-Vorschau)
|
||||||
|
- [x] Paste-Support fuer Bilder in Diagnostic Chat
|
||||||
|
- [x] Markdown-Bereinigung fuer TTS (fett, kursiv, code, links, etc.)
|
||||||
|
- [x] SSH Volume read-write fuer Proxy (kein -F Workaround mehr)
|
||||||
|
- [x] Diagnostic: Sessions als Markdown exportieren (Download-Button)
|
||||||
|
- [x] Speech Gate: Aufnahme wird verworfen wenn keine Sprache erkannt (verhindert dass Umgebungsgeraeusche an Whisper gehen)
|
||||||
|
- [x] Session-Persistenz: Gewaehlte Session bleibt ueber Container-Restarts erhalten (sessionFromFile-Flag, atomic write)
|
||||||
|
- [x] Diagnostic: "ARIA denkt..." bleibt nicht mehr stehen (pipelineEnd broadcastet immer idle, auch bei Timeout/Fehler/Disconnect)
|
||||||
|
- [x] App: "ARIA denkt..." Indicator + Abbrechen-Button (Bridge spiegelt agent_activity via RVS)
|
||||||
|
- [x] Whisper STT: Model-Auswahl in Diagnostic (tiny/base/small/medium/large-v3), Hot-Reload in Bridge, Default auf medium
|
||||||
|
- [x] App: Audio-Aufnahme explizit 16kHz mono (spart Resample, optimal fuer Whisper)
|
||||||
|
- [x] Gespraechsmodus: Speech-Gate strenger (-28dB / 500ms) — keine Umgebungsgeraeusche mehr
|
||||||
|
- [x] Gespraechsmodus: Max-Dauer 30s pro Aufnahme, Cache-Cleanup alter Files, Messages-Array gekappt (500)
|
||||||
|
- [x] Diagnostic: Archivierte Session-Versionen (.reset.*) werden angezeigt + exportierbar — OpenClaw resettet Sessions bei erster Nutzung nach Container-Restart, Inhalt ist aber in .reset.<timestamp> Dateien gesichert
|
||||||
|
- [x] tools/export-jsonl-to-md.js: CLI-Konverter fuer beliebige Session-JSONL zu Markdown
|
||||||
|
- [x] NO_REPLY-Filter in Bridge + Diagnostic — still verworfen (kein Chat, kein TTS)
|
||||||
|
- [x] Audio-Ducking + Exklusiv-Focus (Kotlin AudioFocusModule): andere Apps leiser bei TTS, pausiert bei Aufnahme
|
||||||
|
- [x] TTS-Cleanup serverseitig: Code-Bloecke raus, Einheiten ausgeschrieben (22GB → Gigabyte), Abkuerzungen buchstabiert (CPU), URLs zu "ein Link". `<voice></voice>` Tag wird bevorzugt wenn ARIA ihn liefert.
|
||||||
|
- [x] QR-Code Onboarding: Diagnostic generiert QR, App scannt (bestehender QRScanner funktioniert out of the box)
|
||||||
|
- [x] TTS-Audio-Cache im Filesystem: Piper-Audio wird mit messageId verknuepft, als WAV in DocumentDirectory/tts_cache gespeichert, Play-Button spielt aus Cache statt regenerieren
|
||||||
|
- [x] Config via Diagnostic: RVS-Credentials + Aria-Auth-Token via /api/runtime-config, persistiert in /shared/config/runtime.json, Bridge liest beim Start (Overrides der ENV)
|
||||||
|
|
||||||
## Offen
|
## Offen
|
||||||
|
|
||||||
### Bugs (Prioritaet)
|
### Bugs (Prioritaet)
|
||||||
- [ ] Session-Persistenz: Bei Container-Restart wird immer aria-bridge geladen statt die zuletzt gewaehlte Session. Wird nicht persistent gespeichert.
|
|
||||||
- [ ] App: Textnachrichten, Bilder und Anhaenge werden von ARIA nicht beantwortet — nur Sprachnachrichten funktionieren.
|
|
||||||
- [ ] App: Audioausgabe hoert ab und zu einfach auf (mitten im Satz oder zwischen Chunks)
|
- [ ] App: Audioausgabe hoert ab und zu einfach auf (mitten im Satz oder zwischen Chunks)
|
||||||
- [ ] Auto-Update: release.sh kopiert APK nicht auf den RVS-Server (rvs/updates/ bleibt leer)
|
- [ ] NO_REPLY wird als "NO" im Chat angezeigt — sollte still verworfen werden (Token nicht gesaeubert)
|
||||||
- [ ] App: Kein Auto-Scroll zur letzten Nachricht beim App-Start (soll direkt springen, nicht animiert scrollen)
|
|
||||||
- [ ] App: Bei neuen Nachrichten soll automatisch zur letzten Nachricht gescrollt werden
|
|
||||||
|
|
||||||
### App Features
|
### App Features
|
||||||
- [ ] App: Zu Anhaengen noch Text/Sprache hinzufuegen koennen (z.B. Bild senden + "Was siehst du?")
|
- [ ] Wake Word on-device (Porcupine "ARIA" Keyword, Phase 2 — passives Lauschen)
|
||||||
- [ ] Wake Word on-device (Porcupine "ARIA" Keyword, Phase 2)
|
|
||||||
- [ ] Chat-History zuverlaessiger laden (AsyncStorage Race Condition)
|
- [ ] Chat-History zuverlaessiger laden (AsyncStorage Race Condition)
|
||||||
- [ ] Background Audio Service (TTS auch bei minimierter App)
|
- [ ] Background Audio Service (TTS auch bei minimierter App)
|
||||||
|
- [ ] Audio-Ducking: andere App-Audio-Ausgaben leiser stellen waehrend ARIA spricht (AudioFocus API)
|
||||||
|
- [ ] Audio-Muten waehrend Aufnahme/Ohr-Modus: andere Audio stumm (wie WhatsApp-Sprachaufnahme)
|
||||||
|
- [ ] Spracheingabe-Timeout erhoehen fuer laengere Texte
|
||||||
|
- [ ] Generierte TTS-Audiodaten in der Chat-Nachricht einbetten (oder lokal cachen), Play-Button spielt aus Cache statt Regenerierung via XTTS. Base64 im Tag <soundfile></soundfile> (invisible) oder lokaler Datei-Cache mit Referenz in der Message.
|
||||||
|
- [ ] QR-Code Onboarding: Diagnostic generiert QR mit RVS-Credentials, App scannt — keine manuelle Eingabe mehr
|
||||||
|
|
||||||
### TTS / Audio
|
### TTS / Audio
|
||||||
- [ ] XTTS Audio-Streaming verbessern (minimales Stottern bei Chunk-Uebergaengen)
|
- [ ] XTTS Audio-Streaming (PCM-Stream statt WAV-Dateien, eliminiert Stottern komplett)
|
||||||
- [ ] Audio-Normalisierung (Lautstaerke zwischen Chunks angleichen)
|
- [ ] Audio-Normalisierung (Lautstaerke zwischen Chunks angleichen)
|
||||||
- [ ] Piper Voices Download ueber Diagnostic (neue Sprachen/Stimmen)
|
- [ ] Piper Voices Download ueber Diagnostic (neue Sprachen/Stimmen)
|
||||||
|
- [ ] TTS-Text-Aufbereitung: Code-Bloecke rausfiltern, Einheiten ausschreiben ("22GB" → "zweiundzwanzig Gigabyte"). Zwei Varianten denkbar: (a) server-side Cleanup in Bridge, (b) ARIA schreibt `<voice></voice>` Block der in UI hidden bleibt aber fuer TTS genutzt wird.
|
||||||
|
- [ ] Piper evtl. komplett entfernen (klingt schlecht vs. XTTS) — oder nur als Fallback wenn XTTS offline ist
|
||||||
|
|
||||||
### Architektur
|
### Architektur
|
||||||
- [ ] Bilder: Claude Vision direkt nutzen (aktuell nur Dateipfad an ARIA)
|
- [ ] Bilder: Claude Vision direkt nutzen (aktuell nur Dateipfad an ARIA)
|
||||||
- [ ] Auto-Compacting und Memory/Brain Verwaltung (SQLite?)
|
- [ ] Auto-Compacting und Memory/Brain Verwaltung (SQLite?)
|
||||||
- [ ] Diagnostic: System-Info Tab (Container-Status, Disk, RAM, CPU)
|
- [ ] Diagnostic: System-Info Tab (Container-Status, Disk, RAM, CPU)
|
||||||
- [ ] RVS Zombie-Connections endgueltig loesen (WebRTC statt WebSocket?)
|
- [ ] RVS Zombie-Connections endgueltig loesen
|
||||||
|
- [ ] Alle .env-Variablen ueber Diagnostic konfigurierbar machen (kein File-Sync mehr noetig, da alle ARIA-Container auf der gleichen VM laufen). Fallback .env bleibt fuer initialen Bootstrap.
|
||||||
|
- [ ] XTTS-Container: kleine Web-Oberflaeche fuer Credentials/Server-Config, oder zentral aus Diagnostic per RVS push
|
||||||
|
- [ ] Root-Cause OpenClaw Session-Reset: Herausfinden warum Sessions beim ersten chat.send nach Container-Restart verworfen werden (abortedLastRun / systemSent Theorie pruefen, ggf. Flag preemptiv patchen)
|
||||||
|
|||||||
+7
-2
@@ -76,8 +76,11 @@ echo -e " ${GREEN}✓${NC} SettingsScreen → Version $VERSION"
|
|||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# ── APK bauen ─────────────────────────────────
|
# ── APK bauen ─────────────────────────────────
|
||||||
echo -e "${GREEN}[2/5] APK bauen...${NC}"
|
echo -e "${GREEN}[2/5] APK bauen (Cache leeren + Build)...${NC}"
|
||||||
cd android
|
cd android
|
||||||
|
# Metro + Gradle Cache leeren damit neue Version sauber eingebettet wird
|
||||||
|
rm -rf node_modules/.cache 2>/dev/null
|
||||||
|
cd android && ./gradlew clean 2>/dev/null; cd ..
|
||||||
./build.sh release
|
./build.sh release
|
||||||
cd ..
|
cd ..
|
||||||
|
|
||||||
@@ -174,9 +177,11 @@ fi
|
|||||||
RVS_UPDATE_HOST="${RVS_UPDATE_HOST:-}"
|
RVS_UPDATE_HOST="${RVS_UPDATE_HOST:-}"
|
||||||
if [ -n "$RVS_UPDATE_HOST" ]; then
|
if [ -n "$RVS_UPDATE_HOST" ]; then
|
||||||
echo -e "${GREEN}[6/6] APK auf RVS-Server kopieren (Auto-Update)...${NC}"
|
echo -e "${GREEN}[6/6] APK auf RVS-Server kopieren (Auto-Update)...${NC}"
|
||||||
|
# Alte APKs auf dem RVS loeschen, dann neue hochladen
|
||||||
|
ssh "$RVS_UPDATE_HOST" "rm -f ~/ARIA-AGENT/rvs/updates/ARIA-*.apk" 2>/dev/null
|
||||||
scp "$APK_PATH" "${RVS_UPDATE_HOST}:~/ARIA-AGENT/rvs/updates/${APK_NAME}" 2>/dev/null
|
scp "$APK_PATH" "${RVS_UPDATE_HOST}:~/ARIA-AGENT/rvs/updates/${APK_NAME}" 2>/dev/null
|
||||||
if [ $? -eq 0 ]; then
|
if [ $? -eq 0 ]; then
|
||||||
echo -e " ${GREEN}✓${NC} APK auf RVS-Server kopiert — Apps werden benachrichtigt"
|
echo -e " ${GREEN}✓${NC} APK auf RVS-Server kopiert (alte Versionen geloescht)"
|
||||||
else
|
else
|
||||||
echo -e " ${YELLOW}APK konnte nicht auf RVS kopiert werden (RVS_UPDATE_HOST=$RVS_UPDATE_HOST)${NC}"
|
echo -e " ${YELLOW}APK konnte nicht auf RVS kopiert werden (RVS_UPDATE_HOST=$RVS_UPDATE_HOST)${NC}"
|
||||||
echo -e " ${YELLOW}Manuell: scp $APK_PATH $RVS_UPDATE_HOST:~/ARIA-AGENT/rvs/updates/${APK_NAME}${NC}"
|
echo -e " ${YELLOW}Manuell: scp $APK_PATH $RVS_UPDATE_HOST:~/ARIA-AGENT/rvs/updates/${APK_NAME}${NC}"
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const ALLOWED_TYPES = new Set([
|
|||||||
"file_request", "file_response", "file_saved", "stt_result", "config", "tts_request",
|
"file_request", "file_response", "file_saved", "stt_result", "config", "tts_request",
|
||||||
"xtts_request", "xtts_response", "xtts_list_voices", "xtts_voices_list", "voice_upload", "xtts_voice_saved",
|
"xtts_request", "xtts_response", "xtts_list_voices", "xtts_voices_list", "voice_upload", "xtts_voice_saved",
|
||||||
"update_check", "update_available", "update_download", "update_data",
|
"update_check", "update_available", "update_download", "update_data",
|
||||||
|
"agent_activity", "cancel_request",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Token-Raum: token -> { clients: Set<ws> }
|
// Token-Raum: token -> { clients: Set<ws> }
|
||||||
|
|||||||
Executable
+74
@@ -0,0 +1,74 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Exportiert ein OpenClaw Session-JSONL (auch .reset.*) als Markdown.
|
||||||
|
*
|
||||||
|
* Nutzung:
|
||||||
|
* node export-jsonl-to-md.js <input.jsonl> [output.md]
|
||||||
|
*
|
||||||
|
* Oder direkt aus dem aria-core Container:
|
||||||
|
* docker exec aria-core cat /home/node/.openclaw/agents/main/sessions/<ID>.jsonl.reset.<TS> \
|
||||||
|
* | node export-jsonl-to-md.js - > output.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
|
||||||
|
const inputArg = process.argv[2];
|
||||||
|
const outputArg = process.argv[3];
|
||||||
|
|
||||||
|
if (!inputArg) {
|
||||||
|
console.error("Usage: export-jsonl-to-md.js <input.jsonl|-> [output.md]");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = inputArg === "-" ? fs.readFileSync(0, "utf-8") : fs.readFileSync(inputArg, "utf-8");
|
||||||
|
const lines = raw.split("\n").filter(l => l.trim());
|
||||||
|
|
||||||
|
const blocks = [];
|
||||||
|
for (const line of lines) {
|
||||||
|
let obj;
|
||||||
|
try { obj = JSON.parse(line); } catch { continue; }
|
||||||
|
if (obj.type !== "message" || !obj.message) continue;
|
||||||
|
const role = obj.message.role;
|
||||||
|
if (role !== "user" && role !== "assistant") continue;
|
||||||
|
|
||||||
|
let text = "";
|
||||||
|
const content = obj.message.content;
|
||||||
|
if (typeof content === "string") text = content;
|
||||||
|
else if (Array.isArray(content)) text = content.filter(c => c.type === "text").map(c => c.text || "").join("\n");
|
||||||
|
if (!text) continue;
|
||||||
|
|
||||||
|
if (role === "user") {
|
||||||
|
text = text.replace(/^Sender \(untrusted metadata\):[\s\S]*?```[\s\S]*?```\s*\n*/m, "").trim();
|
||||||
|
text = text.replace(/^\[.*?\]\s*/, "").trim();
|
||||||
|
} else {
|
||||||
|
text = text.replace(/^\[\[reply_to_\w+\]\]\s*/g, "").trim();
|
||||||
|
}
|
||||||
|
if (!text) continue;
|
||||||
|
|
||||||
|
const ts = obj.message.timestamp || obj.timestamp || 0;
|
||||||
|
const when = ts ? new Date(ts).toISOString().replace("T", " ").slice(0, 19) : "";
|
||||||
|
const heading = role === "user" ? "## 🧑 User" : "## 🤖 ARIA";
|
||||||
|
blocks.push(`${heading}${when ? ` — ${when}` : ""}\n\n${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportedAt = new Date().toISOString().replace("T", " ").slice(0, 19);
|
||||||
|
const title = inputArg === "-" ? "Session" : inputArg.split("/").pop().replace(/\.jsonl.*/, "");
|
||||||
|
const md = [
|
||||||
|
`# Session: ${title}`,
|
||||||
|
``,
|
||||||
|
`Exportiert: ${exportedAt} `,
|
||||||
|
`Quelle: ${inputArg === "-" ? "stdin" : inputArg}`,
|
||||||
|
`Nachrichten: ${blocks.length}`,
|
||||||
|
``,
|
||||||
|
`---`,
|
||||||
|
``,
|
||||||
|
blocks.join("\n\n---\n\n"),
|
||||||
|
``,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
if (outputArg) {
|
||||||
|
fs.writeFileSync(outputArg, md);
|
||||||
|
console.error(`OK: ${blocks.length} Nachrichten → ${outputArg}`);
|
||||||
|
} else {
|
||||||
|
process.stdout.write(md);
|
||||||
|
}
|
||||||
+16
-2
@@ -97,8 +97,22 @@ async function handleTTSRequest(payload) {
|
|||||||
const { text, voice, requestId, language } = payload;
|
const { text, voice, requestId, language } = payload;
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
|
|
||||||
// Markdown entfernen
|
// Markdown + Sonderzeichen entfernen fuer natuerliche Sprache
|
||||||
const cleanText = text.replace(/\*\*([^*]+)\*\*/g, "$1").trim();
|
let cleanText = text
|
||||||
|
.replace(/\*\*([^*]+)\*\*/g, "$1") // **fett** → fett
|
||||||
|
.replace(/\*([^*]+)\*/g, "$1") // *kursiv* → kursiv
|
||||||
|
.replace(/`([^`]+)`/g, "$1") // `code` → code
|
||||||
|
.replace(/```[\s\S]*?```/g, "") // Code-Bloecke entfernen
|
||||||
|
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // [text](url) → text
|
||||||
|
.replace(/#{1,6}\s*/g, "") // ### Ueberschriften → entfernen
|
||||||
|
.replace(/>\s*/g, "") // > Zitate → entfernen
|
||||||
|
.replace(/[-*]\s+/g, "") // - Listen → entfernen
|
||||||
|
.replace(/\n{2,}/g, ". ") // Mehrere Newlines → Punkt
|
||||||
|
.replace(/\n/g, ", ") // Einzelne Newlines → Komma
|
||||||
|
.replace(/\s{2,}/g, " ") // Mehrfach-Leerzeichen
|
||||||
|
.replace(/["""„]/g, "") // Anfuehrungszeichen entfernen
|
||||||
|
.replace(/\(\)/g, "") // Leere Klammern
|
||||||
|
.trim();
|
||||||
|
|
||||||
// Text in Saetze aufteilen, dann zu Chunks von 2-3 Saetzen zusammenfassen
|
// Text in Saetze aufteilen, dann zu Chunks von 2-3 Saetzen zusammenfassen
|
||||||
// (mehr Kontext = konsistentere Stimme/Lautstaerke, aber nicht zu lang fuer WebSocket)
|
// (mehr Kontext = konsistentere Stimme/Lautstaerke, aber nicht zu lang fuer WebSocket)
|
||||||
|
|||||||
Reference in New Issue
Block a user