Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1fb1fdef9e | |||
| 593d26e0ff | |||
| 394abb58be | |||
| fc3bee6d05 | |||
| b203503fd8 | |||
| 8b0a72dc9b | |||
| 23add7a107 | |||
| caf84196fb | |||
| 099b9651a6 | |||
| 76d72a1eef | |||
| 87deede078 | |||
| 6fec8588c1 | |||
| aafdbcd57a |
@@ -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 309
|
versionCode 401
|
||||||
versionName "0.0.3.9"
|
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'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ class MainApplication : Application(), ReactApplication {
|
|||||||
override fun getPackages(): List<ReactPackage> =
|
override fun getPackages(): List<ReactPackage> =
|
||||||
PackageList(this).packages.apply {
|
PackageList(this).packages.apply {
|
||||||
add(ApkInstallerPackage())
|
add(ApkInstallerPackage())
|
||||||
|
add(AudioFocusPackage())
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getJSMainModuleName(): String = "index"
|
override fun getJSMainModuleName(): String = "index"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aria-cockpit",
|
"name": "aria-cockpit",
|
||||||
"version": "0.0.3.9",
|
"version": "0.0.4.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"android": "react-native run-android",
|
"android": "react-native run-android",
|
||||||
|
|||||||
@@ -48,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';
|
||||||
|
|
||||||
@@ -218,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;
|
||||||
}
|
}
|
||||||
@@ -242,14 +252,26 @@ 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
|
// Thinking-Indicator Status von der Bridge
|
||||||
@@ -318,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,
|
||||||
@@ -423,7 +445,7 @@ 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', {
|
||||||
@@ -448,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,
|
||||||
@@ -502,7 +524,7 @@ const ChatScreen: React.FC = () => {
|
|||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
attachments,
|
attachments,
|
||||||
};
|
};
|
||||||
setMessages(prev => [...prev, userMsg]);
|
setMessages(prev => capMessages([...prev, userMsg]));
|
||||||
|
|
||||||
// Alle Dateien an RVS senden + auf Disk speichern
|
// Alle Dateien an RVS senden + auf Disk speichern
|
||||||
for (const { file, isPhoto } of pendingAttachments) {
|
for (const { file, isPhoto } of pendingAttachments) {
|
||||||
@@ -614,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>
|
||||||
|
|||||||
@@ -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,8 +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 = -35; // dB ueber dem als "Sprache" gilt (Sprach-Gate)
|
const VAD_SPEECH_THRESHOLD_DB = -28; // dB ueber dem als "Sprache" gilt (Sprach-Gate) — hoeher = weniger Umgebungsgeraeusche
|
||||||
const VAD_SPEECH_MIN_MS = 300; // ms Sprache bevor Aufnahme zaehlt
|
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 ---
|
||||||
|
|
||||||
@@ -71,6 +83,7 @@ class AudioService {
|
|||||||
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();
|
||||||
@@ -120,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
|
||||||
@@ -164,6 +181,9 @@ class AudioService {
|
|||||||
this.speechStartTime = 0;
|
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) {
|
||||||
@@ -174,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);
|
||||||
@@ -198,11 +223,18 @@ 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;
|
const hadSpeech = this.speechDetected;
|
||||||
|
|
||||||
@@ -247,6 +279,46 @@ 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
|
// Callback wenn alle Audio-Teile abgespielt sind
|
||||||
private playbackFinishedListeners: (() => void)[] = [];
|
private playbackFinishedListeners: (() => void)[] = [];
|
||||||
|
|
||||||
@@ -261,11 +333,17 @@ class AudioService {
|
|||||||
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
|
// Alle Audio-Teile abgespielt → Listener benachrichtigen
|
||||||
this.playbackFinishedListeners.forEach(cb => cb());
|
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
|
||||||
@@ -341,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 ---
|
||||||
@@ -379,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
|
||||||
|
|||||||
+134
-19
@@ -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,21 +311,9 @@ class VoiceEngine:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Markdown + Sonderzeichen entfernen fuer natuerliche Sprache
|
# Zentraler TTS-Cleanup (Markdown, Code, Einheiten, URLs)
|
||||||
import re
|
import re
|
||||||
clean = text.strip()
|
clean = clean_text_for_tts(text)
|
||||||
clean = re.sub(r'\*\*([^*]+)\*\*', r'\1', clean) # **fett**
|
|
||||||
clean = re.sub(r'\*([^*]+)\*', r'\1', clean) # *kursiv*
|
|
||||||
clean = re.sub(r'`[^`]+`', '', clean) # `code`
|
|
||||||
clean = re.sub(r'```[\s\S]*?```', '', clean) # Code-Bloecke
|
|
||||||
clean = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', clean) # [text](url)
|
|
||||||
clean = re.sub(r'#{1,6}\s*', '', clean) # ### Ueberschriften
|
|
||||||
clean = re.sub(r'>\s*', '', clean) # > Zitate
|
|
||||||
clean = re.sub(r'[-*]\s+', '', clean) # Listen
|
|
||||||
clean = re.sub(r'\n{2,}', '. ', clean) # Absaetze
|
|
||||||
clean = re.sub(r'\n', ', ', clean) # Zeilenumbrueche
|
|
||||||
clean = re.sub(r'\s{2,}', ' ', clean) # Mehrfach-Leerzeichen
|
|
||||||
clean = re.sub(r'["""„]', '', clean) # Anfuehrungszeichen
|
|
||||||
sentences = re.split(r'(?<=[.!?])\s+', clean)
|
sentences = re.split(r'(?<=[.!?])\s+', clean)
|
||||||
sentences = [s.strip() for s in sentences if s.strip()]
|
sentences = [s.strip() for s in sentences if s.strip()]
|
||||||
|
|
||||||
@@ -867,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")
|
||||||
@@ -889,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",
|
||||||
@@ -896,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),
|
||||||
})
|
})
|
||||||
@@ -905,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
|
||||||
@@ -927,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:
|
||||||
@@ -941,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),
|
||||||
})
|
})
|
||||||
|
|||||||
+225
-17
@@ -201,7 +201,7 @@
|
|||||||
<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>
|
||||||
@@ -523,6 +523,69 @@
|
|||||||
</div>
|
</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>
|
||||||
@@ -1304,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)
|
||||||
@@ -1437,6 +1504,118 @@
|
|||||||
send({ action: 'send_voice_config', defaultVoice, highlightVoice, ttsEnabled, speedRamona, speedThorsten, ttsEngine, xttsVoice, whisperModel });
|
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 ────────────────────────
|
||||||
function loadHighlightTriggers() {
|
function loadHighlightTriggers() {
|
||||||
send({ action: 'get_triggers' });
|
send({ action: 'get_triggers' });
|
||||||
@@ -1699,33 +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;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>`
|
let html = '<table style="width:100%;border-collapse:collapse;">' + headerRow;
|
||||||
+ `</td></tr>`;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1890,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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+103
-5
@@ -58,6 +58,41 @@ let activeSessionKey = (() => {
|
|||||||
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.
|
// Atomic write: temp-file + rename, laute Logs bei Fehler.
|
||||||
function persistActiveSession(key) {
|
function persistActiveSession(key) {
|
||||||
try {
|
try {
|
||||||
@@ -391,6 +426,19 @@ 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();
|
lastChatFinalAt = Date.now();
|
||||||
if (pipelineActive) pipelineEnd(true, `"${text.slice(0, 120)}"`);
|
if (pipelineActive) pipelineEnd(true, `"${text.slice(0, 120)}"`);
|
||||||
@@ -1156,6 +1204,35 @@ 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") {
|
} else if (req.url === "/api/cancel" && req.method === "POST") {
|
||||||
log("warn", "server", "HTTP /api/cancel — Cancel-Request (von Bridge)");
|
log("warn", "server", "HTTP /api/cancel — Cancel-Request (von Bridge)");
|
||||||
pendingMessageTime = 0;
|
pendingMessageTime = 0;
|
||||||
@@ -1575,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());
|
||||||
|
|
||||||
@@ -1640,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}`,
|
||||||
|
|||||||
@@ -37,24 +37,45 @@
|
|||||||
- [x] App: "ARIA denkt..." Indicator + Abbrechen-Button (Bridge spiegelt agent_activity via RVS)
|
- [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] 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] 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)
|
||||||
- [ ] 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)
|
||||||
|
- [ ] NO_REPLY wird als "NO" im Chat angezeigt — sollte still verworfen werden (Token nicht gesaeubert)
|
||||||
|
|
||||||
### App Features
|
### App Features
|
||||||
- [ ] Wake Word on-device (Porcupine "ARIA" Keyword, Phase 2 — passives Lauschen)
|
- [ ] Wake Word on-device (Porcupine "ARIA" Keyword, Phase 2 — passives Lauschen)
|
||||||
- [ ] 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 (PCM-Stream statt WAV-Dateien, eliminiert Stottern komplett)
|
- [ ] 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
|
- [ ] 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)
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user