Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e628170760 | ||
|
|
7af0b76ad3 | ||
|
|
614ba48531 | ||
|
|
5b5591aa72 | ||
|
|
b70623bb78 | ||
|
|
73b6827f6b | ||
|
|
a7998bcd8b | ||
|
|
07df538763 | ||
|
|
5761c5f6b9 | ||
|
|
87755b9c58 | ||
|
|
b6195d3a53 | ||
|
|
b9354283af | ||
|
|
bf1db6cbf9 | ||
|
|
ea159d4ed7 | ||
|
|
dd1e1ac458 | ||
|
|
ddd77c7ad4 | ||
|
|
7c3c932054 | ||
|
|
d6c7502810 | ||
|
|
dc77933014 | ||
|
|
6a6b320f37 |
@@ -79,8 +79,8 @@ android {
|
||||
applicationId "com.ariacockpit"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 20501
|
||||
versionName "0.2.5.1"
|
||||
versionCode 20505
|
||||
versionName "0.2.5.5"
|
||||
// Fallback fuer Libraries mit Product Flavors
|
||||
missingDimensionStrategy 'react-native-camera', 'general'
|
||||
}
|
||||
|
||||
@@ -49,6 +49,28 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Teilen-Ziel: ARIA erscheint im Teilen-Menue anderer Apps (WhatsApp,
|
||||
Galerie, Browser ...). EINE Datei bzw. reiner Text. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/*" />
|
||||
<data android:mimeType="image/*" />
|
||||
<data android:mimeType="video/*" />
|
||||
<data android:mimeType="audio/*" />
|
||||
<data android:mimeType="application/*" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Mehrere Dateien auf einmal. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="image/*" />
|
||||
<data android:mimeType="video/*" />
|
||||
<data android:mimeType="audio/*" />
|
||||
<data android:mimeType="application/*" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.ariacockpit
|
||||
|
||||
import android.content.Intent
|
||||
import com.facebook.react.ReactActivity
|
||||
import com.facebook.react.ReactActivityDelegate
|
||||
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
|
||||
@@ -19,4 +20,26 @@ class MainActivity : ReactActivity() {
|
||||
*/
|
||||
override fun createReactActivityDelegate(): ReactActivityDelegate =
|
||||
DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled)
|
||||
|
||||
/**
|
||||
* Teilen in die LAUFENDE App (launchMode=singleTask liefert das hier ab).
|
||||
* setIntent() ist wichtig, damit getSharedData() denselben Inhalt sieht, und
|
||||
* das Event weckt die JS-Seite sofort — ohne es wuerde der geteilte Inhalt
|
||||
* erst beim naechsten Kaltstart auftauchen.
|
||||
*/
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
// Best effort: je nach RN-Architektur ist der Kontext anders erreichbar.
|
||||
// Scheitert es, greift der zuverlaessige Zweitweg — die JS-Seite fragt beim
|
||||
// Zurueckkehren in den Vordergrund ohnehin nochmal via getSharedData() nach
|
||||
// (setIntent() oben sorgt dafuer, dass sie denselben Inhalt sieht).
|
||||
try {
|
||||
val ctx = (application as? com.facebook.react.ReactApplication)
|
||||
?.reactNativeHost?.reactInstanceManager?.currentReactContext
|
||||
ShareIntentModule.notifyNewIntent(
|
||||
ctx as? com.facebook.react.bridge.ReactApplicationContext, intent)
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ class MainApplication : Application(), ReactApplication {
|
||||
add(OpenWakeWordPackage())
|
||||
add(PhoneCallPackage())
|
||||
add(BackgroundAudioPackage())
|
||||
add(ShareIntentPackage())
|
||||
}
|
||||
|
||||
override fun getJSMainModuleName(): String = "index"
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package com.ariacockpit
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.facebook.react.bridge.Promise
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||
import com.facebook.react.bridge.ReactMethod
|
||||
import com.facebook.react.bridge.WritableArray
|
||||
import com.facebook.react.bridge.WritableMap
|
||||
import com.facebook.react.modules.core.DeviceEventManagerModule
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Teilen-Ziel: nimmt Inhalte entgegen, die aus anderen Apps (WhatsApp, Galerie,
|
||||
* Browser ...) per "Teilen → ARIA" geschickt werden.
|
||||
*
|
||||
* Die geteilten Dateien kommen als content://-URI, auf die nur DIESE App und nur
|
||||
* JETZT zugreifen darf. Deshalb kopieren wir sie sofort in den App-Cache und
|
||||
* geben einen echten Dateipfad zurueck — den kann die JS-Seite wie eine normal
|
||||
* ausgewaehlte Datei weiterverarbeiten (RNFS.readFile → Base64 → Upload).
|
||||
*
|
||||
* Ablauf:
|
||||
* App geschlossen → Intent liegt beim Start an → getSharedData()
|
||||
* App laeuft → onNewIntent (launchMode=singleTask) → Event 'ShareReceived'
|
||||
*/
|
||||
class ShareIntentModule(private val reactContext: ReactApplicationContext) :
|
||||
ReactContextBaseJavaModule(reactContext) {
|
||||
|
||||
override fun getName(): String = "ShareIntent"
|
||||
|
||||
companion object {
|
||||
private const val EVENT = "ShareReceived"
|
||||
|
||||
/** Vom MainActivity.onNewIntent gerufen, wenn die App schon laeuft. */
|
||||
@JvmStatic
|
||||
fun notifyNewIntent(ctx: ReactApplicationContext?, intent: Intent?) {
|
||||
if (ctx == null || intent == null || !isShare(intent)) return
|
||||
try {
|
||||
val data = extract(ctx, intent)
|
||||
ctx.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
||||
.emit(EVENT, data)
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun isShare(i: Intent): Boolean =
|
||||
i.action == Intent.ACTION_SEND || i.action == Intent.ACTION_SEND_MULTIPLE
|
||||
|
||||
/** Baut {text, items:[{path,name,type,size}]} aus dem Intent. */
|
||||
private fun extract(ctx: ReactApplicationContext, intent: Intent): WritableMap {
|
||||
val out = Arguments.createMap()
|
||||
out.putString("text", intent.getStringExtra(Intent.EXTRA_TEXT) ?: "")
|
||||
|
||||
val uris = mutableListOf<Uri>()
|
||||
if (intent.action == Intent.ACTION_SEND) {
|
||||
@Suppress("DEPRECATION")
|
||||
(intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM))?.let { uris.add(it) }
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)
|
||||
?.forEach { uris.add(it) }
|
||||
}
|
||||
|
||||
val items: WritableArray = Arguments.createArray()
|
||||
for (uri in uris) {
|
||||
copyToCache(ctx, uri)?.let { items.pushMap(it) }
|
||||
}
|
||||
out.putArray("items", items)
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Kopiert den geteilten Inhalt in den Cache. Noetig, weil die Leseerlaubnis
|
||||
* fuer die content://-URI nur fuer diesen Intent gilt — spaeter (nach der
|
||||
* Projektauswahl) waere sie womoeglich schon erloschen.
|
||||
*/
|
||||
private fun copyToCache(ctx: ReactApplicationContext, uri: Uri): WritableMap? {
|
||||
return try {
|
||||
val cr = ctx.contentResolver
|
||||
var name = "geteilt"
|
||||
var size = 0L
|
||||
cr.query(uri, null, null, null, null)?.use { c ->
|
||||
val ni = c.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
val si = c.getColumnIndex(OpenableColumns.SIZE)
|
||||
if (c.moveToFirst()) {
|
||||
if (ni >= 0) c.getString(ni)?.let { name = it }
|
||||
if (si >= 0) size = c.getLong(si)
|
||||
}
|
||||
}
|
||||
val type = cr.getType(uri) ?: "application/octet-stream"
|
||||
val dir = File(ctx.cacheDir, "shared").apply { mkdirs() }
|
||||
// Eindeutiger Name, damit zwei gleichnamige Bilder sich nicht ueberschreiben.
|
||||
val safe = name.replace(Regex("[^A-Za-z0-9._-]"), "_").take(80)
|
||||
val dst = File(dir, "${System.currentTimeMillis()}_$safe")
|
||||
cr.openInputStream(uri)?.use { input ->
|
||||
dst.outputStream().use { output -> input.copyTo(output) }
|
||||
} ?: return null
|
||||
|
||||
Arguments.createMap().apply {
|
||||
putString("path", dst.absolutePath)
|
||||
putString("name", name)
|
||||
putString("type", type)
|
||||
putDouble("size", (if (size > 0) size else dst.length()).toDouble())
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt den Inhalt, mit dem die App GESTARTET wurde (App war zu).
|
||||
* Gibt {text, items:[]} zurueck — items leer, wenn nichts geteilt wurde.
|
||||
*/
|
||||
@ReactMethod
|
||||
fun getSharedData(promise: Promise) {
|
||||
try {
|
||||
val activity = currentActivity
|
||||
val intent = activity?.intent
|
||||
if (intent == null || !isShare(intent)) {
|
||||
promise.resolve(Arguments.createMap().apply {
|
||||
putString("text", "")
|
||||
putArray("items", Arguments.createArray())
|
||||
})
|
||||
return
|
||||
}
|
||||
val data = extract(reactContext, intent)
|
||||
// Verbraucht markieren, damit ein Rotieren/Zurueckkehren den Dialog
|
||||
// nicht erneut oeffnet.
|
||||
activity.intent = Intent(Intent.ACTION_MAIN)
|
||||
promise.resolve(data)
|
||||
} catch (e: Throwable) {
|
||||
promise.reject("SHARE_READ_FAILED", e.message, e)
|
||||
}
|
||||
}
|
||||
|
||||
/** Aufgeraeumte Kopien loeschen (nach erfolgreichem Senden). */
|
||||
@ReactMethod
|
||||
fun clearCache(promise: Promise) {
|
||||
try {
|
||||
File(reactContext.cacheDir, "shared").listFiles()?.forEach { it.delete() }
|
||||
promise.resolve(true)
|
||||
} catch (e: Throwable) {
|
||||
promise.resolve(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Fuer NativeEventEmitter (RN verlangt diese beiden Methoden).
|
||||
@ReactMethod fun addListener(eventName: String) {}
|
||||
@ReactMethod fun removeListeners(count: Int) {}
|
||||
}
|
||||
@@ -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 ShareIntentPackage : ReactPackage {
|
||||
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
|
||||
return listOf(ShareIntentModule(reactContext))
|
||||
}
|
||||
|
||||
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
|
||||
return emptyList()
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aria-cockpit",
|
||||
"version": "0.2.5.1",
|
||||
"version": "0.2.5.5",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"android": "react-native run-android",
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Teilen-Dialog — was aus anderen Apps per "Teilen → ARIA" hereinkommt.
|
||||
*
|
||||
* Ablauf wie gewuenscht: erst sehen WAS geteilt wurde, dann WOHIN (Projekt
|
||||
* oder Hauptchat), dann optional ein Text dazu. Ohne Text geht genauso.
|
||||
*/
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator, FlatList, Image, Modal, StyleSheet, Text,
|
||||
TextInput, TouchableOpacity, View,
|
||||
} from 'react-native';
|
||||
import brainApi, { Project } from '../services/brainApi';
|
||||
|
||||
export interface SharedItem {
|
||||
path: string;
|
||||
name: string;
|
||||
type: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
items: SharedItem[];
|
||||
/** Text, den die sendende App mitgegeben hat (z.B. geteilter Link). */
|
||||
initialText?: string;
|
||||
onCancel: () => void;
|
||||
/** projectId: '' = Hauptchat. text: optional, darf leer sein. */
|
||||
onSend: (projectId: string, text: string) => void;
|
||||
}
|
||||
|
||||
const fmtSize = (n: number) =>
|
||||
n > 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(1)} MB`
|
||||
: n > 1024 ? `${Math.round(n / 1024)} KB` : `${n} B`;
|
||||
|
||||
const ShareModal: React.FC<Props> = ({ visible, items, initialText, onCancel, onSend }) => {
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pid, setPid] = useState<string | null>(null); // null = noch nichts gewaehlt
|
||||
const [text, setText] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
setPid(null);
|
||||
setText(initialText || '');
|
||||
setSending(false);
|
||||
setLoading(true);
|
||||
brainApi.listProjects(false)
|
||||
.then(ps => setProjects(ps || []))
|
||||
.catch(() => setProjects([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [visible, initialText]);
|
||||
|
||||
const send = useCallback(() => {
|
||||
if (pid === null || sending) return;
|
||||
setSending(true);
|
||||
onSend(pid, text.trim());
|
||||
}, [pid, text, sending, onSend]);
|
||||
|
||||
const preview = items.find(i => i.type?.startsWith('image/'));
|
||||
|
||||
return (
|
||||
<Modal visible={visible} animationType="slide" transparent onRequestClose={onCancel}>
|
||||
<View style={s.backdrop}>
|
||||
<View style={s.box}>
|
||||
<Text style={s.title}>Mit ARIA teilen</Text>
|
||||
|
||||
{/* Was wird geteilt */}
|
||||
<View style={s.itemsBox}>
|
||||
{preview ? (
|
||||
<Image source={{ uri: 'file://' + preview.path }} style={s.thumb} resizeMode="cover" />
|
||||
) : null}
|
||||
<View style={{ flex: 1 }}>
|
||||
{items.length === 0 ? (
|
||||
<Text style={s.dim}>Nur Text</Text>
|
||||
) : items.slice(0, 3).map((it, i) => (
|
||||
<Text key={i} style={s.itemLine} numberOfLines={1}>
|
||||
{it.name} <Text style={s.dim}>({fmtSize(it.size)})</Text>
|
||||
</Text>
|
||||
))}
|
||||
{items.length > 3 ? (
|
||||
<Text style={s.dim}>+ {items.length - 3} weitere</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Wohin */}
|
||||
<Text style={s.label}>Wohin soll das?</Text>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#FFD60A" style={{ marginVertical: 12 }} />
|
||||
) : (
|
||||
<FlatList
|
||||
style={s.list}
|
||||
data={[{ id: '', name: 'Hauptchat', description: '' } as Project, ...projects]}
|
||||
keyExtractor={p => p.id || '__main__'}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
renderItem={({ item }) => {
|
||||
const sel = pid === item.id;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[s.row, sel && s.rowSel]}
|
||||
onPress={() => setPid(item.id)}>
|
||||
<Text style={[s.rowText, sel && s.rowTextSel]} numberOfLines={1}>
|
||||
{item.id ? '📁 ' : '💬 '}{item.name}
|
||||
</Text>
|
||||
{sel ? <Text style={s.check}>✓</Text> : null}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Optionaler Text */}
|
||||
<Text style={s.label}>Text dazu <Text style={s.dim}>(optional)</Text></Text>
|
||||
<TextInput
|
||||
style={s.input}
|
||||
value={text}
|
||||
onChangeText={setText}
|
||||
placeholder="z.B. „Schau dir das mal an“ — kann auch leer bleiben"
|
||||
placeholderTextColor="#666680"
|
||||
multiline
|
||||
/>
|
||||
|
||||
<View style={s.actions}>
|
||||
<TouchableOpacity style={s.btnGhost} onPress={onCancel} disabled={sending}>
|
||||
<Text style={s.btnGhostText}>Abbrechen</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[s.btn, (pid === null || sending) && s.btnDisabled]}
|
||||
onPress={send}
|
||||
disabled={pid === null || sending}>
|
||||
<Text style={s.btnText}>
|
||||
{sending ? 'Senden …' : pid === null ? 'Ziel wählen' : 'Senden'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const s = StyleSheet.create({
|
||||
backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.75)', justifyContent: 'flex-end' },
|
||||
box: {
|
||||
backgroundColor: '#0D0D1A', borderTopLeftRadius: 16, borderTopRightRadius: 16,
|
||||
padding: 18, maxHeight: '88%',
|
||||
},
|
||||
title: { color: '#E0E0F0', fontSize: 19, fontWeight: '700', marginBottom: 12 },
|
||||
itemsBox: {
|
||||
flexDirection: 'row', gap: 10, alignItems: 'center',
|
||||
backgroundColor: '#12121E', borderRadius: 10, padding: 10, marginBottom: 14,
|
||||
},
|
||||
thumb: { width: 56, height: 56, borderRadius: 8, backgroundColor: '#1E1E2E' },
|
||||
itemLine: { color: '#E0E0F0', fontSize: 13 },
|
||||
dim: { color: '#8888AA', fontSize: 12 },
|
||||
label: { color: '#8888AA', fontSize: 12, marginBottom: 6 },
|
||||
list: { maxHeight: 190, marginBottom: 14 },
|
||||
row: {
|
||||
flexDirection: 'row', alignItems: 'center', paddingVertical: 10,
|
||||
paddingHorizontal: 12, borderRadius: 8, marginBottom: 4, backgroundColor: '#12121E',
|
||||
},
|
||||
rowSel: { backgroundColor: '#1E2A1E', borderWidth: 1, borderColor: '#3FFF9F' },
|
||||
rowText: { color: '#E0E0F0', fontSize: 14, flex: 1 },
|
||||
rowTextSel: { color: '#3FFF9F', fontWeight: '600' },
|
||||
check: { color: '#3FFF9F', fontSize: 16 },
|
||||
input: {
|
||||
backgroundColor: '#12121E', borderRadius: 8, color: '#E0E0F0',
|
||||
paddingHorizontal: 12, paddingVertical: 10, minHeight: 56,
|
||||
textAlignVertical: 'top', marginBottom: 16, fontSize: 14,
|
||||
},
|
||||
actions: { flexDirection: 'row', gap: 10, justifyContent: 'flex-end' },
|
||||
btnGhost: { paddingVertical: 11, paddingHorizontal: 18 },
|
||||
btnGhostText: { color: '#8888AA', fontSize: 15 },
|
||||
btn: {
|
||||
backgroundColor: '#FFD60A', borderRadius: 8,
|
||||
paddingVertical: 11, paddingHorizontal: 24,
|
||||
},
|
||||
btnDisabled: { backgroundColor: '#3A3A2A' },
|
||||
btnText: { color: '#0D0D1A', fontSize: 15, fontWeight: '700' },
|
||||
});
|
||||
|
||||
export default ShareModal;
|
||||
@@ -49,6 +49,8 @@ import updateService from '../services/updater';
|
||||
import VoiceButton from '../components/VoiceButton';
|
||||
import FileUpload, { FileData } from '../components/FileUpload';
|
||||
import CameraUpload, { PhotoData } from '../components/CameraUpload';
|
||||
import ShareModal, { SharedItem } from '../components/ShareModal';
|
||||
import shareIntent from '../services/shareIntent';
|
||||
import MessageText from '../components/MessageText';
|
||||
import { loadTtsSpeed, TTS_SPEED_DEFAULT, loadSttEndpointMs, loadMaxRecordingMs, loadBargeInEnabled } from '../services/audio';
|
||||
import Geolocation from '@react-native-community/geolocation';
|
||||
@@ -198,6 +200,35 @@ function formatDur(sec: number): string {
|
||||
return `${m}:${r.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// ─── Grenzen fuer den Anhang-Re-Download ────────────────────────────
|
||||
//
|
||||
// Ein Re-Download kommt als base64 in EINER WebSocket-Nachricht. Die muss die
|
||||
// App komplett im Speicher puffern und dann durch JSON.parse schicken — bei
|
||||
// einem 150-MB-Video sind das ~200 MB base64, und das Handy stirbt daran mit
|
||||
// einem nativen OOM (kein JS-Fehler, darum tauchte nie ein Stacktrace auf).
|
||||
//
|
||||
// Genau das war die Absturzschleife: App startet → Auto-Re-Download fordert
|
||||
// ungefragt das 150-MB-Video an → OOM → Android startet den Vordergrund-
|
||||
// Dienst neu → App startet → von vorn. Alle ~70s, stundenlang.
|
||||
//
|
||||
// Darum zwei Grenzen:
|
||||
// AUTO — was die App OHNE Nachfrage holt. Klein halten; Bilder und
|
||||
// Dokumente passen rein, Videos nicht.
|
||||
// TAP — was sie auf ausdruecklichen Tipp holt. Hoeher, aber immer noch
|
||||
// weit unter der Schmerzgrenze. Drueber gibt's eine ehrliche
|
||||
// Meldung statt eines Absturzes.
|
||||
/** Signatur von sendPendingAttachments — fuer den Ref weiter unten. */
|
||||
type SendAttachmentsFn = (
|
||||
messageText: string,
|
||||
explicitAttachments?: { file: any; isPhoto: boolean }[],
|
||||
voiceMode?: { audioRequestId: string; clientMsgId: string },
|
||||
) => Promise<void>;
|
||||
|
||||
const AUTO_DL_MAX_BYTES = 25 * 1024 * 1024;
|
||||
const TAP_DL_MAX_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
const fmtMB = (n: number) => `${(n / 1024 / 1024).toFixed(0)} MB`;
|
||||
|
||||
const DEFAULT_ATTACHMENT_DIR = `${RNFS.DocumentDirectoryPath}/chat_attachments`;
|
||||
const STORAGE_PATH_KEY = 'aria_attachment_storage_path';
|
||||
|
||||
@@ -348,6 +379,10 @@ const ChatScreen: React.FC = () => {
|
||||
} | null>(null);
|
||||
const [searchIndex, setSearchIndex] = useState(0); // welcher Treffer aktiv ist
|
||||
const [pendingAttachments, setPendingAttachments] = useState<{file: any, isPhoto: boolean}[]>([]);
|
||||
// Teilen-Ziel: Inhalte, die aus anderen Apps hereingereicht wurden.
|
||||
const [shareItems, setShareItems] = useState<SharedItem[]>([]);
|
||||
const [shareText, setShareText] = useState('');
|
||||
const [shareVisible, setShareVisible] = useState(false);
|
||||
const [agentActivity, setAgentActivity] = useState<{activity: string, tool: string}>({activity: 'idle', tool: ''});
|
||||
// Multi-Threading: Activity pro Kontext (key = projectId, '' = Hauptchat).
|
||||
// Der Indikator zeigt nur den fokussierten Kontext — nicht global.
|
||||
@@ -363,6 +398,16 @@ const ChatScreen: React.FC = () => {
|
||||
// Service-Status (AI-Box: F5-TTS / Whisper Lade-Status) + Banner-Sichtbarkeit
|
||||
const [serviceStatus, setServiceStatus] = useState<Record<string, {state: string, model?: string, loadSeconds?: number, error?: string, downloading?: boolean, freshlyDownloaded?: boolean}>>({});
|
||||
const [serviceBannerDismissed, setServiceBannerDismissed] = useState(false);
|
||||
// Zwischenruf per Sprache: der 📣-Knopf ohne Text "spannt" die naechste
|
||||
// Aufnahme als Zwischenruf. Mit Text schickt er sofort (wie bisher). Der Ref
|
||||
// wird gelesen, wenn die Aufnahme startet — der State nur fuer die Optik.
|
||||
const [interjectArmed, setInterjectArmed] = useState(false);
|
||||
const interjectArmedRef = useRef(false);
|
||||
// sendPendingAttachments ueber einen Ref: handleVoiceButtonStart steht weiter
|
||||
// oben in der Datei und wuerde die Funktion sonst in ihrer Temporal Dead Zone
|
||||
// greifen (TS2448 — siehe Kommentar an sendTextMessage). Umsortieren waere
|
||||
// riskanter als dieser Ref, weil an der Reihenfolge mehrere Callbacks haengen.
|
||||
const sendPendingAttachmentsRef = useRef<SendAttachmentsFn | null>(null);
|
||||
// Gerätelokale TTS-Config: globaler Toggle (aus Settings) + temporäres Muten (Mund-Button)
|
||||
const [ttsDeviceEnabled, setTtsDeviceEnabled] = useState(true);
|
||||
const [ttsMuted, setTtsMuted] = useState(false);
|
||||
@@ -419,6 +464,33 @@ const ChatScreen: React.FC = () => {
|
||||
// Intent geoeffnet (PDF-Viewer, Galerie, etc.).
|
||||
const autoOpenPaths = useRef<Set<string>>(new Set());
|
||||
|
||||
/**
|
||||
* Re-Download anfordern — mit Groessen-Bremse.
|
||||
* Gibt false zurueck (und sagt warum), wenn die Datei zu gross fuer einen
|
||||
* Transfer durch die WebSocket ist. Ohne das holt sich die App ein 150-MB-
|
||||
* Video als ~200 MB base64 und stirbt am nativen OOM (siehe TAP_DL_MAX_BYTES).
|
||||
*/
|
||||
const requestAttachment = useCallback((att: Attachment, reqId: string): boolean => {
|
||||
if (!att.serverPath) {
|
||||
if (Platform.OS === 'android') {
|
||||
ToastAndroid.show('Datei kann nicht nachgeladen werden (kein serverPath)', ToastAndroid.LONG);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (typeof att.size === 'number' && att.size > TAP_DL_MAX_BYTES) {
|
||||
if (Platform.OS === 'android') {
|
||||
ToastAndroid.show(
|
||||
`${att.name}: ${fmtMB(att.size)} — zu gross fuers Handy. `
|
||||
+ `Liegt auf dem Server unter ${att.serverPath}`,
|
||||
ToastAndroid.LONG,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
rvs.send('file_request' as any, { serverPath: att.serverPath, requestId: reqId });
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
// Eindeutige Message-ID generieren
|
||||
const nextId = (): string => {
|
||||
messageIdCounter.current += 1;
|
||||
@@ -854,13 +926,26 @@ const ChatScreen: React.FC = () => {
|
||||
setTimeout(() => {
|
||||
setMessages(prev => {
|
||||
const missing: {id: string, serverPath: string}[] = [];
|
||||
const skipped: string[] = [];
|
||||
for (const msg of prev) {
|
||||
for (const att of msg.attachments || []) {
|
||||
if (att.serverPath && !att.uri) {
|
||||
missing.push({ id: msg.id, serverPath: att.serverPath });
|
||||
if (!att.serverPath || att.uri) continue;
|
||||
// Grosse Dateien NICHT ungefragt holen (siehe AUTO_DL_MAX_BYTES).
|
||||
// Sie bleiben als "tippen zum Laden" stehen — der User
|
||||
// entscheidet, ob er sie wirklich aufs Handy zieht.
|
||||
if (typeof att.size === 'number' && att.size > AUTO_DL_MAX_BYTES) {
|
||||
skipped.push(`${att.name} (${fmtMB(att.size)})`);
|
||||
continue;
|
||||
}
|
||||
missing.push({ id: msg.id, serverPath: att.serverPath });
|
||||
}
|
||||
}
|
||||
if (skipped.length > 0) {
|
||||
console.log('[Chat] Auto-Download uebersprungen (zu gross): %s', skipped.join(', '));
|
||||
import('../services/logger')
|
||||
.then(m => m.reportAppDebug('chat.autodl', `uebersprungen: ${skipped.join(', ')}`))
|
||||
.catch(() => {});
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
console.log(`[Chat] ${missing.length} fehlende Anhaenge — lade nach...`);
|
||||
for (const m of missing) {
|
||||
@@ -1256,7 +1341,11 @@ const ChatScreen: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
setMessages(prev => {
|
||||
const newText = `\uD83C\uDFA4 ${sttText}`;
|
||||
// Zwischenruf per Sprache bekommt das Megafon statt des Mikros \u2014
|
||||
// sonst sieht die Bubble wie eine normale Frage aus, obwohl sie in
|
||||
// den laufenden Turn gegangen ist und keine eigene Antwort bekommt.
|
||||
const isInterject = !!(message.payload as any).interject;
|
||||
const newText = isInterject ? `\uD83D\uDCE3 Zwischenruf: ${sttText}` : `\uD83C\uDFA4 ${sttText}`;
|
||||
// Primaer: matche per audioRequestId (eindeutig pro Aufnahme).
|
||||
// So gibt's keine Verwechslung wenn zwei Audios kurz hintereinander
|
||||
// gesendet wurden und ihre STT-Results ueberlappen.
|
||||
@@ -1271,7 +1360,9 @@ const ChatScreen: React.FC = () => {
|
||||
// Fallback: alte Bridge-Version ohne audioRequestId \u2014 match per Substring,
|
||||
// nimmt die ERSTE noch unaufgeloeste Placeholder.
|
||||
const idx = prev.findIndex(m =>
|
||||
m.sender === 'user' && m.text.includes('Spracheingabe wird verarbeitet')
|
||||
m.sender === 'user'
|
||||
&& (m.text.includes('Spracheingabe wird verarbeitet')
|
||||
|| m.text.includes('Zwischenruf wird verarbeitet'))
|
||||
);
|
||||
if (idx >= 0) {
|
||||
const next = prev.slice();
|
||||
@@ -1303,6 +1394,10 @@ const ChatScreen: React.FC = () => {
|
||||
sender: 'user',
|
||||
text: diagText,
|
||||
timestamp: message.timestamp,
|
||||
// projectId MUSS mit: die Chat-Ansicht filtert danach. Ohne das
|
||||
// landete die Nachricht im Hauptchat-Topf und war in jedem Projekt
|
||||
// unsichtbar — man sah nur ARIAs Antwort, nicht die eigene Frage.
|
||||
projectId: (message.payload.projectId as string) || '',
|
||||
}]));
|
||||
}
|
||||
return;
|
||||
@@ -1802,9 +1897,20 @@ const ChatScreen: React.FC = () => {
|
||||
// dem STT-Text gefuellte Bubble NICHT loeschen, sonst verschwindet die
|
||||
// Sprachnachricht obwohl ARIA schon an der Antwort arbeitet.
|
||||
if (ev.audioRequestId) {
|
||||
setMessages(prev => prev.filter(m =>
|
||||
!(m.audioRequestId === ev.audioRequestId
|
||||
&& m.text.includes('Spracheingabe wird verarbeitet'))));
|
||||
// 'no-response' heisst: die Spracherkennung hat GAR NICHT geantwortet
|
||||
// (Worker neu gestartet / nicht erreichbar) — die Aufnahme ist also
|
||||
// wirklich verloren gegangen, Du hast nicht etwa nichts gesagt. Das
|
||||
// zeigen wir an, statt die Bubble stillschweigend zu entfernen; sonst
|
||||
// wirkt es, als waere die Nachricht nie gesendet worden.
|
||||
const lost = String(ev.reason || '').includes('no-response');
|
||||
setMessages(prev => lost
|
||||
? prev.map(m => (m.audioRequestId === ev.audioRequestId
|
||||
&& m.text.includes('Spracheingabe wird verarbeitet'))
|
||||
? { ...m, text: '⚠️ Aufnahme ging verloren — die Spracherkennung war gerade nicht erreichbar. Bitte nochmal aufnehmen.' }
|
||||
: m)
|
||||
: prev.filter(m =>
|
||||
!(m.audioRequestId === ev.audioRequestId
|
||||
&& m.text.includes('Spracheingabe wird verarbeitet'))));
|
||||
}
|
||||
// Passiv-Lauschen: leeres Endpoint (no-speech / Silero-Musik / speaker_
|
||||
// mismatch). NICHT sofort beenden — solange die Stille-Toleranz ab
|
||||
@@ -2330,11 +2436,18 @@ const ChatScreen: React.FC = () => {
|
||||
// produziert das Brain weiter TTS, die ins offene Mikro laeuft → genau der
|
||||
// "Mischmasch" (ARIA antwortet weiter waehrend ich rede). Fuer bewusstes
|
||||
// Nicht-Abbrechen gibt es weiterhin den separaten Zwischenruf-Button (📣).
|
||||
const interruptAriaIfBusy = useCallback(() => {
|
||||
// forInterject: bei einem Zwischenruf darf der laufende Turn NICHT abgebrochen
|
||||
// werden — das ist der ganze Sinn. Die Wiedergabe stoppen wir trotzdem, sonst
|
||||
// redet ARIA in das offene Mikro hinein.
|
||||
const interruptAriaIfBusy = useCallback((forInterject = false) => {
|
||||
if (audioService.isPlayingAudio()) {
|
||||
audioService.haltAllPlayback('user startet Aufnahme — Interrupt');
|
||||
rvs.send('cancel_request' as any, { hard: true, source: 'voice-interrupt' });
|
||||
return true;
|
||||
audioService.haltAllPlayback(forInterject
|
||||
? 'user ruft dazwischen — nur Wiedergabe stoppen'
|
||||
: 'user startet Aufnahme — Interrupt');
|
||||
if (!forInterject) {
|
||||
rvs.send('cancel_request' as any, { hard: true, source: 'voice-interrupt' });
|
||||
}
|
||||
return !forInterject;
|
||||
}
|
||||
return false;
|
||||
}, []);
|
||||
@@ -2347,18 +2460,35 @@ const ChatScreen: React.FC = () => {
|
||||
const handleVoiceButtonStart = useCallback(async (): Promise<boolean> => {
|
||||
const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
|
||||
rememberMyRequest(audioRequestId);
|
||||
const wasInterrupted = interruptAriaIfBusy();
|
||||
const asInterject = interjectArmedRef.current;
|
||||
const wasInterrupted = interruptAriaIfBusy(asInterject);
|
||||
const location = await getCurrentLocation();
|
||||
|
||||
const userMsg: ChatMessage = {
|
||||
id: nextId(),
|
||||
sender: 'user',
|
||||
text: '🎙 Spracheingabe wird verarbeitet...',
|
||||
timestamp: Date.now(),
|
||||
attachments: [{ type: 'audio', name: 'Sprachaufnahme' }],
|
||||
audioRequestId,
|
||||
};
|
||||
setMessages(prev => capMessages([...prev, userMsg]));
|
||||
// Warten Anhaenge? Dann sind sie das Thema dieser Aufnahme: sofort hochladen
|
||||
// (laeuft parallel zum Reden) und der Bridge per clientMsgId sagen, dass der
|
||||
// Satz dazu noch kommt. Die Bubble mit den Bildern macht
|
||||
// sendPendingAttachments — sie traegt die audioRequestId und bekommt den
|
||||
// gesprochenen Text spaeter eingesetzt. Also hier KEINE zweite Bubble.
|
||||
let attachmentCmid = '';
|
||||
if (pendingAttachments.length > 0 && !asInterject && sendPendingAttachmentsRef.current) {
|
||||
attachmentCmid = nextClientMsgId();
|
||||
const forVoice = pendingAttachments;
|
||||
setPendingAttachments([]);
|
||||
await sendPendingAttachmentsRef.current('', forVoice, {
|
||||
audioRequestId, clientMsgId: attachmentCmid,
|
||||
});
|
||||
} else {
|
||||
const userMsg: ChatMessage = {
|
||||
id: nextId(),
|
||||
sender: 'user',
|
||||
text: asInterject ? '📣 Zwischenruf wird verarbeitet...'
|
||||
: '🎙 Spracheingabe wird verarbeitet...',
|
||||
timestamp: Date.now(),
|
||||
attachments: [{ type: 'audio', name: 'Sprachaufnahme' }],
|
||||
audioRequestId,
|
||||
};
|
||||
setMessages(prev => capMessages([...prev, userMsg]));
|
||||
}
|
||||
|
||||
const { ok } = await audioService.startStreamingRecording({
|
||||
audioRequestId,
|
||||
@@ -2373,15 +2503,37 @@ const ChatScreen: React.FC = () => {
|
||||
endpointMs: await loadSttEndpointMs(),
|
||||
hardCapMs: await loadMaxRecordingMs(),
|
||||
projectId: focusedProjectIdRef.current,
|
||||
...(attachmentCmid && { clientMsgId: attachmentCmid }),
|
||||
...(asInterject && { interject: true }),
|
||||
});
|
||||
if (!ok) {
|
||||
if (attachmentCmid) {
|
||||
// Die Anhaenge sind SCHON hochgeladen — die Bubble darf nicht weg,
|
||||
// sonst verschwinden die Bilder aus der Ansicht, obwohl sie auf dem
|
||||
// Server liegen. Stattdessen ehrlich kennzeichnen; der Nutzer kann den
|
||||
// Auftrag einfach tippen.
|
||||
setMessages(prev => prev.map(m => m.audioRequestId === audioRequestId
|
||||
? { ...m, text: '📎 Anhang gesendet — Mikro war belegt, bitte tippen',
|
||||
audioRequestId: undefined }
|
||||
: m));
|
||||
if (Platform.OS === 'android') {
|
||||
ToastAndroid.show('Mikro nicht verfügbar — Anhang ist aber da', ToastAndroid.LONG);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// Mikro nicht verfuegbar (Anruf? OpenWakeWord blockiert?) — Bubble weg.
|
||||
setMessages(prev => prev.filter(m => m.audioRequestId !== audioRequestId));
|
||||
return false;
|
||||
}
|
||||
// Zwischenruf-Scharfschaltung gilt genau fuer EINE Aufnahme.
|
||||
if (asInterject) {
|
||||
interjectArmedRef.current = false;
|
||||
setInterjectArmed(false);
|
||||
}
|
||||
scheduleStaleAudioCleanup(audioRequestId, 60000);
|
||||
return true;
|
||||
}, [getCurrentLocation, interruptAriaIfBusy, scheduleStaleAudioCleanup]);
|
||||
}, [getCurrentLocation, interruptAriaIfBusy, scheduleStaleAudioCleanup,
|
||||
pendingAttachments]);
|
||||
|
||||
// ARIA schliesst die Aufnahme SELBST — das programmatische Gegenstueck zum
|
||||
// Stop-Button. Aufgerufen nach einem stillen Steuerbefehl (speak=false): der
|
||||
@@ -2456,16 +2608,52 @@ const ChatScreen: React.FC = () => {
|
||||
setPendingAttachments(prev => [...prev, { file: photo, isPhoto: true }]);
|
||||
}, []);
|
||||
|
||||
// ── Teilen-Ziel ────────────────────────────────────────────────
|
||||
// Beim Start pruefen (App war zu) und auf spaetere Shares lauschen
|
||||
// (App laeuft schon → onNewIntent im MainActivity).
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const open = (p: { text: string; items: SharedItem[] }) => {
|
||||
if (!alive) return;
|
||||
if (!p.items.length && !p.text) return; // nichts dabei
|
||||
setShareItems(p.items);
|
||||
setShareText(p.text);
|
||||
setShareVisible(true);
|
||||
};
|
||||
shareIntent.getShared().then(open).catch(() => {});
|
||||
const unsub = shareIntent.onShare(open);
|
||||
// Zweitweg: Teilen holt die App in den Vordergrund. Beim Wechsel nach
|
||||
// 'active' nochmal nachfragen — das greift auch, wenn das native Event
|
||||
// (je nach RN-Architektur) nicht durchkommt. getSharedData() markiert den
|
||||
// Intent als verbraucht, es kann also nicht doppelt aufpoppen.
|
||||
const appSub = AppState.addEventListener('change', st => {
|
||||
if (st === 'active') shareIntent.getShared().then(open).catch(() => {});
|
||||
});
|
||||
return () => { alive = false; unsub(); try { appSub.remove(); } catch (_) {} };
|
||||
}, []);
|
||||
|
||||
|
||||
// Alle Pending Anhaenge + Text senden
|
||||
const sendPendingAttachments = useCallback(async (messageText: string) => {
|
||||
if (pendingAttachments.length === 0) return;
|
||||
console.log('[Chat] sendPendingAttachments: %d Anhang/Anhaenge', pendingAttachments.length);
|
||||
const sendPendingAttachments = useCallback(async (
|
||||
messageText: string,
|
||||
// Explizite Liste (Teilen-Weg): der State waere beim direkten Aufruf noch
|
||||
// nicht aktualisiert. Ohne Angabe wie bisher die Pending-Liste.
|
||||
explicitAttachments?: { file: any; isPhoto: boolean }[],
|
||||
// Sprach-Modus: der Nutzer spricht die Beschreibung gerade erst ein. Dann
|
||||
// gehen die Dateien SOFORT raus (der Upload laeuft parallel zum Reden),
|
||||
// aber ohne Text — den liefert spaeter der stt_endpoint, und die Bridge
|
||||
// merged beides ueber die clientMsgId zu einem Turn.
|
||||
voiceMode?: { audioRequestId: string; clientMsgId: string },
|
||||
) => {
|
||||
const atts = explicitAttachments ?? pendingAttachments;
|
||||
if (atts.length === 0) return;
|
||||
console.log('[Chat] sendPendingAttachments: %d Anhang/Anhaenge', atts.length);
|
||||
const location = await getCurrentLocation();
|
||||
const msgId = nextId();
|
||||
|
||||
// Alle Attachments fuer die Chat-Nachricht sammeln
|
||||
const attachments: Attachment[] = [];
|
||||
for (const { file, isPhoto } of pendingAttachments) {
|
||||
for (const { file, isPhoto } of atts) {
|
||||
const isImage = isPhoto || (file.type && file.type.startsWith('image/'));
|
||||
const name = isPhoto ? file.fileName : file.name;
|
||||
const base64 = file.base64 || '';
|
||||
@@ -2483,16 +2671,23 @@ const ChatScreen: React.FC = () => {
|
||||
|
||||
// Chat-Nachricht mit allen Anhaengen. clientMsgId nur wenn Text dabei
|
||||
// ist — files selber haben (noch) kein ACK-Tracking auf der Bridge.
|
||||
const cmid = messageText ? nextClientMsgId() : undefined;
|
||||
// Im Sprach-Modus kommt sie von aussen: dieselbe ID muss am
|
||||
// stt_stream_start haengen, damit die Bridge Anhang und Satz zusammenfuehrt.
|
||||
const cmid = voiceMode ? voiceMode.clientMsgId : (messageText ? nextClientMsgId() : undefined);
|
||||
const activePid = focusedProjectIdRef.current;
|
||||
const userMsg: ChatMessage = {
|
||||
id: msgId,
|
||||
sender: 'user',
|
||||
text: messageText || `${pendingAttachments.length} Anhang/Anhaenge`,
|
||||
// Im Sprach-Modus bekommt DIESE Bubble gleich den gesprochenen Text —
|
||||
// via audioRequestId-Match, genau wie eine normale Sprachnachricht. So
|
||||
// stehen Bild und Satz zusammen in einer Bubble statt in zwei.
|
||||
text: voiceMode ? '🎙 Spracheingabe wird verarbeitet...'
|
||||
: (messageText || `${atts.length} Anhang/Anhaenge`),
|
||||
timestamp: Date.now(),
|
||||
attachments,
|
||||
projectId: activePid,
|
||||
...(cmid && {
|
||||
...(voiceMode && { audioRequestId: voiceMode.audioRequestId }),
|
||||
...(cmid && !voiceMode && {
|
||||
clientMsgId: cmid,
|
||||
deliveryStatus: connectionStateRef.current === 'connected' ? 'sending' : 'queued',
|
||||
sendAttempts: 1,
|
||||
@@ -2501,7 +2696,7 @@ const ChatScreen: React.FC = () => {
|
||||
setMessages(prev => capMessages([...prev, userMsg]));
|
||||
|
||||
// Alle Dateien an RVS senden + auf Disk speichern
|
||||
for (const { file, isPhoto } of pendingAttachments) {
|
||||
for (const { file, isPhoto } of atts) {
|
||||
const name = isPhoto ? file.fileName : file.name;
|
||||
const base64 = file.base64 || '';
|
||||
const mimeType = file.type || '';
|
||||
@@ -2530,6 +2725,9 @@ const ChatScreen: React.FC = () => {
|
||||
// die Datei genau DIESER Nachricht zuordnet — auch wenn Files (fire-
|
||||
// and-forget) und Text (ACK-getrackt, bei Queue verzoegert) desyncen.
|
||||
...(cmid && { clientMsgId: cmid }),
|
||||
// awaitVoice: die Bridge soll den Anhang NICHT nach 0.8s alleine ans
|
||||
// Brain schicken, sondern auf die Sprachnachricht warten.
|
||||
...(voiceMode && { awaitVoice: true }),
|
||||
...(isPhoto && file.width && { width: file.width, height: file.height }),
|
||||
...(location && { location }),
|
||||
});
|
||||
@@ -2537,8 +2735,8 @@ const ChatScreen: React.FC = () => {
|
||||
|
||||
// Text als separate Nachricht (damit ARIA weiss was zu tun ist) — mit
|
||||
// dem clientMsgId der Bubble, damit Bridge+ACK die richtige Bubble
|
||||
// adressieren.
|
||||
if (messageText && cmid) {
|
||||
// adressieren. Im Sprach-Modus gibt es hier noch keinen Text.
|
||||
if (messageText && cmid && !voiceMode) {
|
||||
dispatchWithAck(cmid, 'chat', {
|
||||
text: messageText,
|
||||
voice: localXttsVoiceRef.current,
|
||||
@@ -2551,11 +2749,41 @@ const ChatScreen: React.FC = () => {
|
||||
setPendingAttachments([]);
|
||||
setInputText('');
|
||||
}, [pendingAttachments, getCurrentLocation, dispatchWithAck]);
|
||||
// Ref aktuell halten — handleVoiceButtonStart greift die Funktion von hier
|
||||
// (steht weiter oben in der Datei, siehe sendPendingAttachmentsRef).
|
||||
sendPendingAttachmentsRef.current = sendPendingAttachments;
|
||||
|
||||
// Reine Text-Nachricht senden. Steht bewusst NACH interruptAriaIfBusy und
|
||||
// sendPendingAttachments — beide stehen in seinem deps-Array, und wenn es
|
||||
// davor deklariert waere, laendeten sie dort in der Temporal Dead Zone
|
||||
// (tsc TS2448/2454; lief nur zufaellig, weil Babel const→var hebt).
|
||||
/** Geteiltes senden: Ziel setzen, Dateien anhaengen, abschicken. */
|
||||
const handleShareSend = useCallback(async (projectId: string, text: string) => {
|
||||
try {
|
||||
// Chat-Focus aufs gewaehlte Ziel — die Nachricht landet dort, und Stefan
|
||||
// steht nach dem Senden gleich im richtigen Kontext.
|
||||
setFocusedProjectId(projectId);
|
||||
focusedProjectIdRef.current = projectId;
|
||||
|
||||
const files = await shareIntent.toFileData(shareItems);
|
||||
if (files.length) {
|
||||
// Liste explizit uebergeben: der State waere hier noch leer.
|
||||
await sendPendingAttachments(text, files.map(f => ({ file: f, isPhoto: false })));
|
||||
} else if (text) {
|
||||
// Reiner Text (z.B. geteilter Link): ins Eingabefeld, Stefan schickt ab.
|
||||
setInputText(text);
|
||||
}
|
||||
shareIntent.clearShareCache();
|
||||
} catch (e) {
|
||||
console.warn('[Share] Senden fehlgeschlagen:', e);
|
||||
Alert.alert('Teilen fehlgeschlagen', String((e as any)?.message || e));
|
||||
} finally {
|
||||
setShareVisible(false);
|
||||
setShareItems([]);
|
||||
setShareText('');
|
||||
}
|
||||
}, [shareItems, sendPendingAttachments]);
|
||||
|
||||
const sendTextMessage = useCallback(async () => {
|
||||
const text = inputText.trim();
|
||||
|
||||
@@ -2790,16 +3018,15 @@ const ChatScreen: React.FC = () => {
|
||||
) : att.type === 'image' && !att.uri ? (
|
||||
<TouchableOpacity
|
||||
style={styles.attachmentFile}
|
||||
onPress={() => {
|
||||
if (att.serverPath) {
|
||||
rvs.send('file_request' as any, { serverPath: att.serverPath, requestId: item.id });
|
||||
}
|
||||
}}
|
||||
onPress={() => { requestAttachment(att, item.id); }}
|
||||
>
|
||||
<Text style={styles.attachmentFileIcon}>{'\uD83D\uDDBC\uFE0F'}</Text>
|
||||
<Text style={styles.attachmentFileName} numberOfLines={1}>{att.name}</Text>
|
||||
<Text style={styles.attachmentFileSize}>
|
||||
{att.serverPath ? '(tippen zum Laden)' : '(nicht verfuegbar)'}
|
||||
{!att.serverPath ? '(nicht verfuegbar)'
|
||||
: typeof att.size === 'number' && att.size > AUTO_DL_MAX_BYTES
|
||||
? `(${fmtMB(att.size)} — tippen zum Laden)`
|
||||
: '(tippen zum Laden)'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
) : att.type === 'audio' ? (
|
||||
@@ -2836,11 +3063,9 @@ const ChatScreen: React.FC = () => {
|
||||
}
|
||||
// Re-Download via file_request \u2192 bei file_response wird die
|
||||
// Datei gespeichert UND geoeffnet (autoOpenPaths-Tracking).
|
||||
if (att.serverPath) {
|
||||
autoOpenPaths.current.add(att.serverPath);
|
||||
rvs.send('file_request' as any, { serverPath: att.serverPath, requestId: item.id });
|
||||
} else if (Platform.OS === 'android') {
|
||||
ToastAndroid.show('Datei kann nicht nachgeladen werden (kein serverPath)', ToastAndroid.LONG);
|
||||
if (att.serverPath) autoOpenPaths.current.add(att.serverPath);
|
||||
if (!requestAttachment(att, item.id) && att.serverPath) {
|
||||
autoOpenPaths.current.delete(att.serverPath);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -3391,7 +3616,9 @@ const ChatScreen: React.FC = () => {
|
||||
style={styles.textInput}
|
||||
value={inputText}
|
||||
onChangeText={setInputText}
|
||||
placeholder={pendingAttachments.length > 0 ? "Text zu den Anhaengen (optional)..." : "Nachricht an ARIA..."}
|
||||
placeholder={pendingAttachments.length > 0
|
||||
? "Text zu den Anhaengen — oder einsprechen 🎤"
|
||||
: "Nachricht an ARIA..."}
|
||||
placeholderTextColor="#555570"
|
||||
multiline
|
||||
maxLength={4000}
|
||||
@@ -3399,29 +3626,52 @@ const ChatScreen: React.FC = () => {
|
||||
returnKeyType="send"
|
||||
/>
|
||||
|
||||
{/* Senden oder Sprache */}
|
||||
{/* Rechte Knopfreihe.
|
||||
Das Mikro ist jetzt IMMER da \u2014 auch mit Anhang oder getipptem Text.
|
||||
Vorher war es ein Entweder-oder: sobald etwas im Feld stand oder ein
|
||||
Bild dranhing, verschwand das Mikro. Damit ging weder "Bild anhaengen
|
||||
und den Auftrag dazu sprechen" noch ein Zwischenruf per Sprache.
|
||||
Der Senden-Pfeil und die Mund-/Ohr-Knoepfe weichen dafuer je nach
|
||||
Lage, damit die Zeile nicht ueberlaeuft. */}
|
||||
|
||||
{/* Zwischenruf \u2014 sichtbar, wenn ARIA im aktiven Kontext arbeitet (oder
|
||||
noch scharf geschaltet ist). Mit Text: sofort abschicken, wie bisher.
|
||||
Ohne Text: die naechste Aufnahme geht als Zwischenruf in den
|
||||
laufenden Turn, statt sich als neue Anfrage anzustellen. */}
|
||||
{interjectArmed || (agentActivityByCtx[focusedProjectId]?.activity || 'idle') !== 'idle' ? (
|
||||
<TouchableOpacity
|
||||
style={[styles.interjectButton, interjectArmed && styles.interjectButtonArmed]}
|
||||
onPress={() => {
|
||||
if (inputText.trim()) { sendInterject(); return; }
|
||||
const next = !interjectArmedRef.current;
|
||||
interjectArmedRef.current = next;
|
||||
setInterjectArmed(next);
|
||||
if (Platform.OS === 'android') {
|
||||
ToastAndroid.show(
|
||||
next ? 'N\u00E4chste Aufnahme geht als Zwischenruf raus'
|
||||
: 'Zwischenruf abgew\u00E4hlt',
|
||||
ToastAndroid.SHORT);
|
||||
}
|
||||
}}
|
||||
accessibilityLabel={inputText.trim() ? 'Zwischenruf senden' : 'Zwischenruf per Sprache'}
|
||||
>
|
||||
<Text style={styles.interjectIcon}>{'\uD83D\uDCE3'}</Text>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
|
||||
<VoiceButton
|
||||
onTapStart={handleVoiceButtonStart}
|
||||
onTapStop={handleVoiceButtonStop}
|
||||
disabled={connectionState !== 'connected'}
|
||||
wakeWordActive={wakeWordActive}
|
||||
/>
|
||||
|
||||
{inputText.trim() || pendingAttachments.length > 0 ? (
|
||||
<>
|
||||
{/* Zwischenruf: nur wenn ARIA im aktiven Kontext gerade arbeitet und
|
||||
Text da ist. Schiebt die Korrektur in den laufenden Turn statt
|
||||
sie anzustellen. */}
|
||||
{inputText.trim() && (agentActivityByCtx[focusedProjectId]?.activity || 'idle') !== 'idle' ? (
|
||||
<TouchableOpacity style={styles.interjectButton} onPress={sendInterject} accessibilityLabel="Zwischenruf">
|
||||
<Text style={styles.interjectIcon}>{'\uD83D\uDCE3'}</Text>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
<TouchableOpacity style={styles.sendButton} onPress={sendTextMessage}>
|
||||
<Text style={styles.sendIcon}>{'\u2B06\uFE0F'}</Text>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
<TouchableOpacity style={styles.sendButton} onPress={sendTextMessage}>
|
||||
<Text style={styles.sendIcon}>{'\u2B06\uFE0F'}</Text>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<>
|
||||
<VoiceButton
|
||||
onTapStart={handleVoiceButtonStart}
|
||||
onTapStop={handleVoiceButtonStop}
|
||||
disabled={connectionState !== 'connected'}
|
||||
wakeWordActive={wakeWordActive}
|
||||
/>
|
||||
{/* Mund-Button: TTS auf diesem Geraet muten/aufheben.
|
||||
Nur sichtbar wenn TTS in den Settings grundsaetzlich aktiv ist. */}
|
||||
{ttsDeviceEnabled && (
|
||||
@@ -3693,6 +3943,13 @@ const ChatScreen: React.FC = () => {
|
||||
{/* Datei-Upload Modal */}
|
||||
<Modal visible={showFileUpload} transparent animationType="slide">
|
||||
<View style={styles.modalOverlay}>
|
||||
<ShareModal
|
||||
visible={shareVisible}
|
||||
items={shareItems}
|
||||
initialText={shareText}
|
||||
onCancel={() => { setShareVisible(false); setShareItems([]); setShareText(''); }}
|
||||
onSend={handleShareSend}
|
||||
/>
|
||||
<FileUpload
|
||||
onFileSelected={handleFileSelected}
|
||||
onCancel={() => setShowFileUpload(false)}
|
||||
@@ -3940,6 +4197,13 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'center',
|
||||
marginRight: 6,
|
||||
},
|
||||
// Scharf geschaltet: die naechste Aufnahme geht als Zwischenruf raus. Heller
|
||||
// plus Rand, damit man das vor dem Sprechen sieht und nicht erst danach.
|
||||
interjectButtonArmed: {
|
||||
backgroundColor: '#FFB340',
|
||||
borderWidth: 2,
|
||||
borderColor: '#FFF',
|
||||
},
|
||||
interjectIcon: {
|
||||
fontSize: 18,
|
||||
},
|
||||
|
||||
@@ -1141,6 +1141,13 @@ class AudioService {
|
||||
* Leer = Hauptchat. Ohne Prefix / Sticky landet die STT-Nachricht damit
|
||||
* automatisch in dem Kontext den Stefan gerade sieht. */
|
||||
projectId?: string;
|
||||
/** clientMsgId wartender Anhaenge: diese Aufnahme ist ihre Beschreibung.
|
||||
* Die Bridge fuehrt Anhang + gesprochenen Satz darueber zu EINEM Turn
|
||||
* zusammen, statt das Bild ohne Auftrag und den Satz ohne Bild zu sehen. */
|
||||
clientMsgId?: string;
|
||||
/** Zwischenruf: der Text geht in den LAUFENDEN Turn, nicht als neue
|
||||
* Anfrage in die Warteschlange (Sprach-Gegenstueck zum 📣-Knopf). */
|
||||
interject?: boolean;
|
||||
}): Promise<{ requestId: string; ok: boolean; error?: string }> {
|
||||
if (this.recordingState !== 'idle') {
|
||||
console.warn('[Audio] startStreamingRecording: bereits aktiv (state=%s)', this.recordingState);
|
||||
@@ -1161,8 +1168,12 @@ class AudioService {
|
||||
// start, Chunks und end waren weg, die Bubble verschwand, und erst der
|
||||
// zweite Versuch klappte (da war der Reconnect durch). Nachtraeglich
|
||||
// gepuffertes Audio waere sinnlos: die STT-Sitzung laeuft in Echtzeit.
|
||||
if (rvs.getState() !== 'connected') {
|
||||
console.warn('[Audio] startStreamingRecording: RVS nicht verbunden (state=%s)',
|
||||
// WICHTIG: den SOCKET fragen, nicht den gemerkten Zustand. Der kann
|
||||
// faelschlich 'disconnected' sein, obwohl die Verbindung laeuft (Zombie) —
|
||||
// dann wuerde hier jede Aufnahme abgelehnt, bevor sie beginnt, und der
|
||||
// Nutzer kann gar nicht mehr sprechen.
|
||||
if (!rvs.isSocketOpen()) {
|
||||
console.warn('[Audio] startStreamingRecording: Socket nicht offen (state=%s)',
|
||||
rvs.getState());
|
||||
return { requestId: '', ok: false, error: 'not-connected' };
|
||||
}
|
||||
@@ -1241,6 +1252,8 @@ class AudioService {
|
||||
sampleRate: 16000,
|
||||
projectId: opts.projectId || '',
|
||||
targetInstance: this.streamTargetInstance,
|
||||
...(opts.clientMsgId ? { clientMsgId: opts.clientMsgId } : {}),
|
||||
...(opts.interject ? { interject: true } : {}),
|
||||
});
|
||||
|
||||
const started = await PcmStreamRecorder.start();
|
||||
|
||||
@@ -49,8 +49,15 @@ const BREADCRUMB_KEY = 'aria_last_breadcrumb';
|
||||
let _breadcrumb: { ts: number; scope: string; message: string } = { ts: 0, scope: '', message: '' };
|
||||
let _breadcrumbDirty = false;
|
||||
|
||||
/** Letzte App-Aktivitaet merken — Crash-Kontext fuer den naechsten Boot. */
|
||||
/** Letzte App-Aktivitaet merken — Crash-Kontext fuer den naechsten Boot.
|
||||
*
|
||||
* Die Crash-Meldung selbst darf den Brotkrumen NICHT ueberschreiben: sonst
|
||||
* merkt sich der zweite Absturz nur noch "letzte Aktivitaet: Crash-Meldung",
|
||||
* der dritte "letzte Aktivitaet: Crash-Meldung ueber Crash-Meldung" — und bei
|
||||
* einer Absturzschleife ist nach dem ersten Durchlauf jede Spur weg. Genau
|
||||
* das ist passiert; die eigentliche Ursache stand nur in der ERSTEN Meldung. */
|
||||
export function noteBreadcrumb(scope: string, message: string): void {
|
||||
if (scope === 'app.crash-detected') return;
|
||||
_breadcrumb = { ts: Date.now(), scope: scope || '', message: String(message || '').slice(0, 120) };
|
||||
_breadcrumbDirty = true;
|
||||
}
|
||||
|
||||
@@ -104,11 +104,7 @@ class RVSConnection {
|
||||
// Verbindungen + doppelte Events.
|
||||
if (this.ws) {
|
||||
this.log('info', 'Bestehende WS-Verbindung wird geschlossen vor Neu-Connect');
|
||||
try {
|
||||
this.ws.onclose = null; // verhindert dass scheduleReconnect doppelt feuert
|
||||
this.ws.onerror = null;
|
||||
this.ws.close();
|
||||
} catch (_) {}
|
||||
this.detachSocket(this.ws);
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
@@ -134,6 +130,15 @@ class RVSConnection {
|
||||
this.setState('disconnected');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ist die Verbindung WIRKLICH offen? Fragt den Socket selbst, nicht den
|
||||
* gemerkten Zustand — der kann (z.B. nach einem Zombie) falsch sein. Wer
|
||||
* davon abhaengig macht, ob ueberhaupt gesendet wird, muss das Echte wissen.
|
||||
*/
|
||||
isSocketOpen(): boolean {
|
||||
return !!this.ws && this.ws.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
/** Nachricht an den RVS senden */
|
||||
send(type: MessageType, payload: Record<string, unknown>): void {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
@@ -215,7 +220,12 @@ class RVSConnection {
|
||||
this.startHeartbeat();
|
||||
};
|
||||
|
||||
const socket = this.ws; // genau DIESE Verbindung (nicht 'this.ws' spaeter)
|
||||
this.ws.onmessage = (event: WebSocketMessageEvent) => {
|
||||
// Nur die AKTUELLE Verbindung darf Nachrichten einspeisen. Sonst
|
||||
// koennte eine alte, laengst aufgegebene Verbindung weiter Daten
|
||||
// liefern und vortaeuschen, es sei alles in Ordnung.
|
||||
if (socket !== this.ws) return;
|
||||
try {
|
||||
const message: RVSMessage = JSON.parse(event.data as string);
|
||||
this.notifyMessageListeners(message);
|
||||
@@ -258,8 +268,7 @@ class RVSConnection {
|
||||
this.log('warn', 'TLS fehlgeschlagen — Fallback auf ws:// (ohne TLS)');
|
||||
this.clearTimers();
|
||||
if (this.ws) {
|
||||
this.ws.onclose = null; // onclose-Handler entfernen um Doppel-Reconnect zu verhindern
|
||||
try { this.ws.close(); } catch (_) {}
|
||||
this.detachSocket(this.ws); // inkl. onmessage — sonst bleibt ein Zombie, der weiter empfaengt
|
||||
}
|
||||
this.ws = null;
|
||||
this.shouldReconnect = true;
|
||||
@@ -302,6 +311,25 @@ class RVSConnection {
|
||||
}, HEARTBEAT_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Klemmt eine Verbindung VOLLSTAENDIG ab, bevor sie verworfen wird.
|
||||
*
|
||||
* Wichtig: frueher wurden nur onclose/onerror entfernt — onmessage blieb
|
||||
* haengen. Eine aufgegebene (Zombie-)Verbindung lieferte dadurch weiter
|
||||
* Nachrichten an die App, waehrend gesendet wurde ueber die neue/tote.
|
||||
* Symptom: die App zeigt "getrennt", ARIAs Sprachausgabe kommt aber trotzdem
|
||||
* an — und nichts, was man schreibt oder spricht, erreicht den Server.
|
||||
*/
|
||||
private detachSocket(ws: WebSocket): void {
|
||||
try {
|
||||
ws.onopen = null;
|
||||
ws.onmessage = null; // <- das fehlte
|
||||
ws.onclose = null; // verhindert doppelten Reconnect
|
||||
ws.onerror = null;
|
||||
ws.close();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
private clearTimers(): void {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Teilen-Ziel — Bruecke zum nativen ShareIntent-Modul.
|
||||
*
|
||||
* Zwei Faelle:
|
||||
* App war ZU → der Inhalt liegt beim Start am Intent → getShared()
|
||||
* App LAEUFT → onNewIntent feuert das Event 'ShareReceived' → onShare()
|
||||
*
|
||||
* Die Dateien hat die native Seite schon in den App-Cache kopiert und liefert
|
||||
* echte Pfade; content://-URIs waeren nach dem Dialog womoeglich nicht mehr
|
||||
* lesbar (die Leseerlaubnis gilt nur fuer den Intent).
|
||||
*/
|
||||
import { NativeEventEmitter, NativeModules } from 'react-native';
|
||||
import RNFS from 'react-native-fs';
|
||||
import type { FileData } from '../components/FileUpload';
|
||||
import type { SharedItem } from '../components/ShareModal';
|
||||
|
||||
const { ShareIntent } = NativeModules;
|
||||
|
||||
export interface SharedPayload {
|
||||
text: string;
|
||||
items: SharedItem[];
|
||||
}
|
||||
|
||||
const EMPTY: SharedPayload = { text: '', items: [] };
|
||||
|
||||
/** Inhalt, mit dem die App gestartet wurde (leer, wenn normal geoeffnet). */
|
||||
export async function getShared(): Promise<SharedPayload> {
|
||||
if (!ShareIntent?.getSharedData) return EMPTY;
|
||||
try {
|
||||
const r = await ShareIntent.getSharedData();
|
||||
return { text: r?.text || '', items: Array.isArray(r?.items) ? r.items : [] };
|
||||
} catch (e) {
|
||||
console.warn('[Share] getSharedData fehlgeschlagen:', e);
|
||||
return EMPTY;
|
||||
}
|
||||
}
|
||||
|
||||
/** Abonniert Shares in die bereits laufende App. Gibt Unsubscribe zurueck. */
|
||||
export function onShare(cb: (p: SharedPayload) => void): () => void {
|
||||
if (!ShareIntent) return () => {};
|
||||
const emitter = new NativeEventEmitter(ShareIntent);
|
||||
const sub = emitter.addListener('ShareReceived', (r: any) => {
|
||||
cb({ text: r?.text || '', items: Array.isArray(r?.items) ? r.items : [] });
|
||||
});
|
||||
return () => { try { sub.remove(); } catch (_) {} };
|
||||
}
|
||||
|
||||
/** Zwischenkopien loeschen (nach erfolgreichem Senden). */
|
||||
export async function clearShareCache(): Promise<void> {
|
||||
try { await ShareIntent?.clearCache?.(); } catch (_) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Macht aus geteilten Dateien das Format, das der bestehende Upload-Weg
|
||||
* erwartet (Base64) — so laeuft Teilen durch exakt dieselbe Strecke wie die
|
||||
* Bueroklammer, inklusive Anhang-Darstellung im Chat.
|
||||
*/
|
||||
export async function toFileData(items: SharedItem[]): Promise<FileData[]> {
|
||||
const out: FileData[] = [];
|
||||
for (const it of items) {
|
||||
try {
|
||||
const base64 = await RNFS.readFile(it.path, 'base64');
|
||||
out.push({
|
||||
name: it.name || 'geteilt',
|
||||
type: it.type || 'application/octet-stream',
|
||||
size: it.size || 0,
|
||||
base64,
|
||||
uri: 'file://' + it.path,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[Share] Datei nicht lesbar (%s): %s', it.name, e);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export default { getShared, onShare, clearShareCache, toFileData };
|
||||
+79
-8
@@ -1549,14 +1549,18 @@ META_TOOLS = [
|
||||
"description": (
|
||||
"Startet die Wiedergabe eines Titels auf einem Jellyfin-Geraet. Ablauf: "
|
||||
"1. jellyfin_search -> itemId, 2. jellyfin_devices -> Geraet/Sitzung, "
|
||||
"3. jellyfin_play. Laeuft die Jellyfin-App auf dem Zielgeraet noch nicht, "
|
||||
"vorher host_app_launch (query='Jellyfin') aufrufen."
|
||||
"3. jellyfin_play. Prueft selbst nach, ob die Wiedergabe wirklich "
|
||||
"angelaufen ist. GIB IMMER 'host' MIT (Name des Host-Agenten am "
|
||||
"Fernseher, z.B. 'Fire-TV Schlafzimmer'): dann starte ich Jellyfin bei "
|
||||
"einer eingeschlafenen Sitzung automatisch neu und lege nochmal nach, "
|
||||
"statt faelschlich Erfolg zu melden."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"item_id": {"type": "string", "description": "itemId aus jellyfin_search."},
|
||||
"device": {"type": "string", "description": "Geraete-/Sitzungsname aus jellyfin_devices (z.B. 'Fire TV')."},
|
||||
"host": {"type": "string", "description": "Host-Agent am Geraet (fuer automatischen Neustart bei toter Sitzung)."},
|
||||
"server": {"type": "string", "description": "Server-Name (optional)."},
|
||||
"resume": {"type": "boolean", "description": "Wenn moeglich fortsetzen statt von vorn (Default true)."},
|
||||
},
|
||||
@@ -2876,9 +2880,15 @@ class Agent:
|
||||
except Exception as exc:
|
||||
return None, f"Jellyfin nicht erreichbar: {exc}"
|
||||
|
||||
def _jellyfin_find_session(self, server: dict, device: str):
|
||||
"""Sucht die Sitzung eines Geraets. Gibt (session, fehlertext)."""
|
||||
sessions, err = self._jellyfin_call(server, "/Sessions")
|
||||
def _jellyfin_find_session(self, server: dict, device: str, fresh_sec: int = 600):
|
||||
"""Sucht die Sitzung eines Geraets. Gibt (session, fehlertext).
|
||||
|
||||
`activeWithinSeconds` filtert eingeschlafene Sitzungen aus: Jellyfin
|
||||
listet Clients noch eine Weile, nachdem die App laengst weg ist — ein
|
||||
Play-Befehl an so eine Karteileiche verpufft wortlos.
|
||||
"""
|
||||
sessions, err = self._jellyfin_call(
|
||||
server, "/Sessions", params={"activeWithinSeconds": fresh_sec})
|
||||
if err:
|
||||
return None, err
|
||||
d = (device or "").strip().lower()
|
||||
@@ -2895,6 +2905,27 @@ class Agent:
|
||||
f"Geraet GEOEFFNET sein — sonst vorher host_app_launch "
|
||||
f"(query='Jellyfin') aufrufen.")
|
||||
|
||||
def _jellyfin_playing(self, server: dict, sess: dict, item_id: str,
|
||||
tries: int = 4, wait: float = 1.5) -> bool:
|
||||
"""Prueft, ob auf der Sitzung wirklich etwas abgespielt wird.
|
||||
|
||||
Jellyfin quittiert einen Play-Befehl auch dann mit 204, wenn die Sitzung
|
||||
laengst tot ist — ohne diese Kontrolle meldet man faelschlich Erfolg.
|
||||
"""
|
||||
want = (item_id or "").strip()
|
||||
for _ in range(max(1, tries)):
|
||||
time.sleep(wait)
|
||||
sessions, err = self._jellyfin_call(server, "/Sessions", timeout=10.0)
|
||||
if err:
|
||||
continue
|
||||
for s in (sessions or []):
|
||||
if s.get("Id") != sess.get("Id"):
|
||||
continue
|
||||
now = s.get("NowPlayingItem") or {}
|
||||
if now and (not want or now.get("Id") == want):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _dispatch_jellyfin(self, name: str, arguments: dict) -> str:
|
||||
server = self._jellyfin_server(arguments.get("server") or "")
|
||||
if not server:
|
||||
@@ -2925,7 +2956,10 @@ class Agent:
|
||||
return f"Treffer auf {label}:\n" + "\n".join(lines)
|
||||
|
||||
if name == "jellyfin_devices":
|
||||
sessions, err = self._jellyfin_call(server, "/Sessions")
|
||||
# Nur Sitzungen, die zuletzt aktiv waren — sonst listet Jellyfin
|
||||
# auch laengst geschlossene Apps, an die kein Befehl mehr geht.
|
||||
sessions, err = self._jellyfin_call(
|
||||
server, "/Sessions", params={"activeWithinSeconds": 600})
|
||||
if err:
|
||||
return f"FEHLER: {err}"
|
||||
out = []
|
||||
@@ -2967,8 +3001,45 @@ class Agent:
|
||||
params=play_params)
|
||||
if err:
|
||||
return f"FEHLER: {err}"
|
||||
return (f"OK — Wiedergabe auf '{sess.get('DeviceName')}' gestartet"
|
||||
f"{note} (Server {label}).")
|
||||
|
||||
# Nachsehen, ob wirklich etwas laeuft. Eine eingeschlafene Sitzung
|
||||
# nimmt den Befehl wortlos an, ohne dass der Film startet — genau
|
||||
# der Fall "beim zweiten Mal ging es nicht".
|
||||
if self._jellyfin_playing(server, sess, item_id):
|
||||
# Ausdruecklich vermerken, dass NICHTS repariert werden musste —
|
||||
# sonst erzaehlt das Modell gern die Panne aus einem frueheren
|
||||
# Turn nach ("Session war eingeschlafen"), obwohl alles glatt lief.
|
||||
return (f"OK — Wiedergabe auf '{sess.get('DeviceName')}' laeuft"
|
||||
f"{note} (Server {label}). Sitzung war aktiv, kein Neustart "
|
||||
f"noetig — erwaehne also KEINEN Neustart und KEINE "
|
||||
f"eingeschlafene Sitzung.")
|
||||
|
||||
# Sitzung war tot. Wenn wir den zugehoerigen Agenten kennen:
|
||||
# Jellyfin dort neu starten, frische Sitzung holen, einmal
|
||||
# nachlegen. Sonst ehrlich sagen, was zu tun ist.
|
||||
host = (arguments.get("host") or "").strip()
|
||||
if not host:
|
||||
return ("FEHLER: Der Befehl wurde angenommen, aber es laeuft nichts — "
|
||||
"die Jellyfin-Sitzung auf dem Geraet war eingeschlafen. "
|
||||
"Starte die Jellyfin-App neu (host_app_launch mit "
|
||||
"query='Jellyfin') und versuche es erneut. Tipp: Gib "
|
||||
"jellyfin_play den Parameter 'host' mit (Name des "
|
||||
"Host-Agenten), dann mache ich das automatisch.")
|
||||
self._dispatch_host("host_app_launch", {"host": host, "query": "Jellyfin"})
|
||||
time.sleep(6) # App braucht einen Moment, bis sie sich anmeldet
|
||||
sess2, err2 = self._jellyfin_find_session(server, device, fresh_sec=120)
|
||||
if err2:
|
||||
return (f"FEHLER: Jellyfin auf '{host}' neu gestartet, aber es meldet "
|
||||
f"sich keine Sitzung: {err2}")
|
||||
self._jellyfin_call(server, f"/Sessions/{sess2['Id']}/Playing",
|
||||
method="POST", params=play_params)
|
||||
if self._jellyfin_playing(server, sess2, item_id):
|
||||
return (f"OK — Wiedergabe laeuft auf '{sess2.get('DeviceName')}'"
|
||||
f"{note}. (Die Sitzung war eingeschlafen, ich habe Jellyfin "
|
||||
f"auf '{host}' neu gestartet.)")
|
||||
return (f"FEHLER: Auch nach dem Neustart von Jellyfin auf '{host}' startet "
|
||||
f"die Wiedergabe nicht. Laeuft die App auf dem Bildschirm? "
|
||||
f"Ggf. dort einmal anmelden.")
|
||||
|
||||
if name == "jellyfin_control":
|
||||
device = (arguments.get("device") or "").strip()
|
||||
|
||||
+124
-17
@@ -739,6 +739,14 @@ class ARIABridge:
|
||||
# weder Meta-Kommando noch Prefix ist und der Sticky abgelaufen,
|
||||
# nutzen wir das als Default (Voice folgt dem sichtbaren Kontext).
|
||||
self._stt_stream_projects: dict[str, str] = {}
|
||||
# Zusatz-Absicht pro Stream, auch von stt_stream_start:
|
||||
# cmid — clientMsgId der Anhaenge, die zu DIESER Aufnahme gehoeren
|
||||
# (Sprachnachricht als Beschreibung zu einem Bild/Video).
|
||||
# interject — die Aufnahme ist ein Zwischenruf in den laufenden Turn,
|
||||
# keine neue Anfrage.
|
||||
# Getrennt von _stt_stream_projects gehalten: der Fokus-Default greift
|
||||
# auch fuer alte App-Versionen, die diese Felder nicht mitschicken.
|
||||
self._stt_stream_meta: dict[str, dict] = {}
|
||||
# Voice-Override aus letzter Chat-Nachricht einer App.
|
||||
# Wird fuer die direkt folgende ARIA-Antwort genutzt und dann zurueckgesetzt.
|
||||
# So kann jedes Geraet seine bevorzugte Stimme bekommen (pro Request).
|
||||
@@ -808,6 +816,18 @@ class ARIABridge:
|
||||
# Projekt landen statt im Hauptchat.
|
||||
self._pending_files_project_id: str = ""
|
||||
self._PENDING_FILES_WINDOW_SEC: float = 0.8
|
||||
# Wartet ein Upload auf eine SPRACHNACHRICHT als Beschreibung, reichen
|
||||
# 0.8s nicht: der Nutzer spricht erst, und Whisper braucht danach noch
|
||||
# ein paar Sekunden fuers Final-Transkript. Mit dem kurzen Fenster waere
|
||||
# der Anhang laengst alleine rausgegangen ("warte auf seine Anweisung"),
|
||||
# und der gesprochene Text kaeme als zweiter, unverbundener Turn.
|
||||
# Das lange Fenster ist nur eine Notbremse gegen Haengenbleiben, falls
|
||||
# gar kein Endpoint mehr kommt — der Normalfall ist der Merge im
|
||||
# stt_endpoint-Handler. Es MUSS ueber dem Aufnahme-Hard-Cap der App
|
||||
# liegen (Default 5 Minuten) plus Whisper-Final, sonst waeren die
|
||||
# Anhaenge bei einer langen Aufnahme vorher alleine rausgegangen.
|
||||
self._PENDING_FILES_VOICE_WINDOW_SEC: float = 420.0
|
||||
self._pending_files_await_voice: bool = False
|
||||
|
||||
def initialize(self) -> None:
|
||||
"""Initialisiert alle Komponenten.
|
||||
@@ -1879,6 +1899,13 @@ class ARIABridge:
|
||||
return (f"Stefan hat dir {n} {anhang} geschickt:\n{files_summary}\n\n"
|
||||
f"Warte auf seine Anweisung was du damit tun sollst.")
|
||||
|
||||
def _pending_files_window(self) -> float:
|
||||
"""Wie lange auf den zugehoerigen Text gewartet wird, bevor die Anhaenge
|
||||
alleine rausgehen. Lang, wenn eine Sprachnachricht unterwegs ist."""
|
||||
return (self._PENDING_FILES_VOICE_WINDOW_SEC
|
||||
if self._pending_files_await_voice
|
||||
else self._PENDING_FILES_WINDOW_SEC)
|
||||
|
||||
async def _flush_pending_files_after(self, delay: float) -> None:
|
||||
"""Wenn nach `delay`s kein chat-Text gekommen ist: alle noch gepufferten
|
||||
Files alleine an aria-core senden ('warte auf Anweisung'-Variante)."""
|
||||
@@ -1892,13 +1919,15 @@ class ARIABridge:
|
||||
text = self._build_pending_files_message("", files)
|
||||
self._pending_files = []
|
||||
self._pending_files_flush_task = None
|
||||
self._pending_files_await_voice = False
|
||||
pid = self._pending_files_project_id
|
||||
self._pending_files_project_id = ""
|
||||
await self.send_to_core(text, source="app-file", project_id=pid)
|
||||
|
||||
async def _flush_pending_files_with_text(self, user_text: str,
|
||||
project_id: str = "",
|
||||
client_msg_id: str = "") -> bool:
|
||||
client_msg_id: str = "",
|
||||
require_match: bool = False) -> bool:
|
||||
"""Wenn ein chat-Text reinkommt waehrend Files gepuffert sind:
|
||||
Files + Text zu einer einzigen aria-core-Nachricht mergen.
|
||||
Returns True wenn gemerged wurde (Caller soll dann nicht nochmal senden).
|
||||
@@ -1913,12 +1942,20 @@ class ARIABridge:
|
||||
|
||||
project_id: Projekt-Kontext aus dem chat-Payload (der sichtbare Focus
|
||||
beim Absenden). Faellt auf den beim File-Upload gemerkten Kontext zurueck.
|
||||
|
||||
require_match: nur mergen, wenn die cmid wirklich passt. Der Sprach-Pfad
|
||||
nutzt das — sonst wuerde eine Sprachnachricht, die zufaellig waehrend
|
||||
eines fremden Uploads fertig wird, dessen Anhaenge an sich reissen. Beim
|
||||
Text-Pfad bleibt der Fallback (alles mergen) wie bisher, damit nie ein
|
||||
Bild verloren geht.
|
||||
"""
|
||||
if not self._pending_files:
|
||||
return False
|
||||
cmid = (client_msg_id or "").strip()
|
||||
matching = [f for f in self._pending_files if cmid and f[6] == cmid]
|
||||
if not matching:
|
||||
if require_match:
|
||||
return False
|
||||
# Kein cmid-Treffer → altes Verhalten: alle gepufferten Files mergen.
|
||||
matching = list(self._pending_files)
|
||||
remaining: list = []
|
||||
@@ -1935,10 +1972,11 @@ class ARIABridge:
|
||||
self._pending_files_flush_task = None
|
||||
if remaining:
|
||||
self._pending_files_flush_task = asyncio.create_task(
|
||||
self._flush_pending_files_after(self._PENDING_FILES_WINDOW_SEC)
|
||||
self._flush_pending_files_after(self._pending_files_window())
|
||||
)
|
||||
else:
|
||||
self._pending_files_project_id = ""
|
||||
self._pending_files_await_voice = False
|
||||
# create_task statt await — sonst blockt der RVS-recv-Loop bis Brain
|
||||
# fertig ist (siehe chat-handler oben).
|
||||
asyncio.create_task(self.send_to_core(text, source="app-file+chat", project_id=pid))
|
||||
@@ -2752,10 +2790,17 @@ class ARIABridge:
|
||||
# genau SEINER Nachricht zu (Queue-Korrelation, s. _flush_*).
|
||||
file_cmid = str(payload.get("clientMsgId") or "")
|
||||
self._pending_files.append((file_path, file_name, file_type, size_kb, int(width or 0), int(height or 0), file_cmid))
|
||||
# awaitVoice: der Nutzer spricht gerade die Beschreibung zu diesem
|
||||
# Anhang ein. Dann NICHT nach 0.8s alleine rausschicken, sondern auf
|
||||
# den stt_endpoint warten (siehe _PENDING_FILES_VOICE_WINDOW_SEC).
|
||||
if payload.get("awaitVoice"):
|
||||
self._pending_files_await_voice = True
|
||||
logger.info("[rvs] Anhang wartet auf Sprachnachricht: %s (cmid=%s)",
|
||||
file_name, file_cmid[:12] or "?")
|
||||
if self._pending_files_flush_task and not self._pending_files_flush_task.done():
|
||||
self._pending_files_flush_task.cancel()
|
||||
self._pending_files_flush_task = asyncio.create_task(
|
||||
self._flush_pending_files_after(self._PENDING_FILES_WINDOW_SEC)
|
||||
self._flush_pending_files_after(self._pending_files_window())
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -3324,8 +3369,18 @@ class ARIABridge:
|
||||
focused_pid = str(payload.get("projectId") or "")
|
||||
if req_id:
|
||||
self._stt_stream_projects[req_id] = focused_pid
|
||||
logger.info("[rvs] stt_stream_start id=%s focus=%s",
|
||||
req_id[:12], focused_pid or "(main)")
|
||||
# Absicht dieser Aufnahme: gehoert sie zu wartenden Anhaengen,
|
||||
# und ist sie ein Zwischenruf statt einer neuen Anfrage?
|
||||
att_cmid = str(payload.get("clientMsgId") or "")
|
||||
is_interject = bool(payload.get("interject"))
|
||||
if att_cmid or is_interject:
|
||||
self._stt_stream_meta[req_id] = {
|
||||
"cmid": att_cmid, "interject": is_interject,
|
||||
}
|
||||
logger.info("[rvs] stt_stream_start id=%s focus=%s%s%s",
|
||||
req_id[:12], focused_pid or "(main)",
|
||||
f" anhang-cmid={att_cmid[:12]}" if att_cmid else "",
|
||||
" [ZWISCHENRUF]" if is_interject else "")
|
||||
return
|
||||
|
||||
elif msg_type == "stt_stream_end":
|
||||
@@ -3340,10 +3395,10 @@ class ARIABridge:
|
||||
if req_id:
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.call_later(
|
||||
20.0,
|
||||
lambda rid=req_id: self._stt_stream_projects.pop(rid, None),
|
||||
)
|
||||
def _drop(rid: str = req_id) -> None:
|
||||
self._stt_stream_projects.pop(rid, None)
|
||||
self._stt_stream_meta.pop(rid, None)
|
||||
loop.call_later(20.0, _drop)
|
||||
except Exception:
|
||||
# Kein laufender Loop (sollte im ws-Handler nie passieren) —
|
||||
# dann lieber gar nicht aufraeumen als crashen.
|
||||
@@ -3427,11 +3482,36 @@ class ARIABridge:
|
||||
# App-Version die projectId noch nicht mitschickt: leer = Hauptchat).
|
||||
stream_req_id = payload.get("requestId", "") or ""
|
||||
focused_pid = self._stt_stream_projects.pop(stream_req_id, "")
|
||||
meta = self._stt_stream_meta.pop(stream_req_id, {})
|
||||
|
||||
# Zwischenruf per Sprache: in den LAUFENDEN Turn schieben, nicht als
|
||||
# neue Anfrage anstellen. Gleicher Weg wie der 📣-Knopf mit Text.
|
||||
if meta.get("interject"):
|
||||
logger.info("[rvs] stt_endpoint ist ein Zwischenruf → project=%s: %s",
|
||||
focused_pid or "(main)", text[:80])
|
||||
asyncio.create_task(
|
||||
self._interject_proxy_for_project(focused_pid, text))
|
||||
# Den erkannten Text trotzdem broadcasten, damit die
|
||||
# Sprach-Bubble in der App ihren Text bekommt statt ewig auf
|
||||
# "wird verarbeitet..." zu stehen.
|
||||
asyncio.create_task(self._send_to_rvs({
|
||||
"type": "chat",
|
||||
"payload": {
|
||||
"text": text,
|
||||
"sender": "stt",
|
||||
"audioRequestId": audio_request_id,
|
||||
"projectId": focused_pid,
|
||||
"interject": True,
|
||||
},
|
||||
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||
}))
|
||||
return
|
||||
|
||||
asyncio.create_task(self._process_endpoint_text(
|
||||
text, interrupted, audio_request_id, location,
|
||||
client_msg_id=client_msg_id,
|
||||
focused_project_id=focused_pid))
|
||||
focused_project_id=focused_pid,
|
||||
attachment_client_msg_id=str(meta.get("cmid") or "")))
|
||||
return
|
||||
|
||||
elif msg_type == "oauth_callback":
|
||||
@@ -3550,6 +3630,10 @@ class ARIABridge:
|
||||
elif msg_type == "sat_hello":
|
||||
sid = (payload.get("id") or "").strip()
|
||||
if sid:
|
||||
# sat_hello kommt alle ~25s erneut. Nur Neuanmeldungen und echte
|
||||
# Aenderungen loggen — sonst ersaeuft das Bridge-Log in
|
||||
# "Satellit online", und die interessanten Zeilen sind weg.
|
||||
prev = self._satellites.get(sid)
|
||||
self._satellites[sid] = {
|
||||
"id": sid,
|
||||
"location": payload.get("location") or sid,
|
||||
@@ -3557,9 +3641,12 @@ class ARIABridge:
|
||||
"control": bool(payload.get("control")),
|
||||
"last_seen": time.time(),
|
||||
}
|
||||
logger.info("[sat] Satellit online: %s (%s) caps=%s control=%s",
|
||||
sid, self._satellites[sid]["location"],
|
||||
self._satellites[sid]["caps"], self._satellites[sid]["control"])
|
||||
cur = self._satellites[sid]
|
||||
if prev is None or any(prev.get(k) != cur.get(k)
|
||||
for k in ("location", "caps", "control")):
|
||||
logger.info("[sat] Satellit %s: %s (%s) caps=%s control=%s",
|
||||
"online" if prev is None else "aktualisiert",
|
||||
sid, cur["location"], cur["caps"], cur["control"])
|
||||
return
|
||||
|
||||
elif msg_type == "host_hello":
|
||||
@@ -3625,9 +3712,16 @@ class ARIABridge:
|
||||
"busy": bool(prev.get("busy", False)),
|
||||
"last_seen": time.time(),
|
||||
}
|
||||
logger.info("[worker] online: %s (service=%s node=%s gpus=%s model=%s)",
|
||||
iid, self._workers[iid]["service"], self._workers[iid]["node"],
|
||||
self._workers[iid]["gpus"] or "?", self._workers[iid]["model"] or "?")
|
||||
# Wie bei sat_hello: worker_hello wiederholt sich im Takt von
|
||||
# ~30s. Nur neue Boxen und echte Aenderungen (anderes Modell,
|
||||
# andere GPUs) ins Log — der Rest ist Rauschen.
|
||||
cur = self._workers[iid]
|
||||
if not prev or any(prev.get(k) != cur.get(k)
|
||||
for k in ("service", "node", "gpus", "model", "models")):
|
||||
logger.info("[worker] %s: %s (service=%s node=%s gpus=%s model=%s)",
|
||||
"online" if not prev else "aktualisiert",
|
||||
iid, cur["service"], cur["node"],
|
||||
cur["gpus"] or "?", cur["model"] or "?")
|
||||
return
|
||||
|
||||
elif msg_type == "stt_lease_request":
|
||||
@@ -3877,7 +3971,8 @@ class ARIABridge:
|
||||
audio_request_id: str = "",
|
||||
location: Optional[dict] = None,
|
||||
client_msg_id: Optional[str] = None,
|
||||
focused_project_id: str = "") -> None:
|
||||
focused_project_id: str = "",
|
||||
attachment_client_msg_id: str = "") -> None:
|
||||
"""Phase-2 Brain-Shortcut: Streaming-Whisper hat den finalen Text
|
||||
schon ermittelt — wir uebernehmen den Pfad ab broadcast-STT + brain.
|
||||
|
||||
@@ -3946,6 +4041,18 @@ class ARIABridge:
|
||||
except Exception as e:
|
||||
logger.warning("[rvs] STT-Text (endpoint) konnte nicht broadcastet werden: %s", e)
|
||||
|
||||
# Sprachnachricht als Beschreibung zu Anhaengen: die liegen schon
|
||||
# gepuffert (awaitVoice) und warten genau auf diesen Text — beides geht
|
||||
# als EIN Turn ans Brain, sonst sieht ARIA ein Bild ohne Auftrag und
|
||||
# danach einen Satz ohne Bild.
|
||||
if attachment_client_msg_id and await self._flush_pending_files_with_text(
|
||||
cleaned, project_id=project_id,
|
||||
client_msg_id=attachment_client_msg_id,
|
||||
require_match=True):
|
||||
logger.info("[rvs] Sprachnachricht mit ihren Anhaengen gemerged (cmid=%s)",
|
||||
attachment_client_msg_id[:12])
|
||||
return
|
||||
|
||||
core_text = self._build_core_text(cleaned, interrupted, location)
|
||||
await self.send_to_core(core_text,
|
||||
source="app-voice-stream" + (" [barge-in]" if interrupted else ""),
|
||||
|
||||
+60
-1
@@ -2278,6 +2278,7 @@
|
||||
if (msg.type === 'host_update') { hosts = msg.hosts || []; renderHosts(); return; }
|
||||
if (msg.type === 'rooms_info') { rooms = (msg.payload && msg.payload.rooms) || []; renderRooms(); return; }
|
||||
if (msg.type === 'sat_result') { onSatResult(msg.payload || {}); return; }
|
||||
if (msg.type === 'llm_openai_config_result') { onOpenAiConfigResult(msg.payload || {}); return; }
|
||||
if (msg.type === 'agent_history_list') {
|
||||
if (agentHistoryHost && msg.payload && msg.payload.host === agentHistoryHost) {
|
||||
renderAgentHistory(msg.payload.events || []);
|
||||
@@ -4621,7 +4622,9 @@
|
||||
'<span style="color:#8888AA;">' + escapeHtml(modelText) + '</span>' +
|
||||
(w.gpus ? '<span style="color:#8888AA;">GPU ' + escapeHtml(w.gpus) + '</span>' : '') +
|
||||
'<span style="margin-left:auto;color:' + dot + ';">' + stat + '</span>' +
|
||||
'</div>';
|
||||
'</div>' +
|
||||
// Nur LLM-Boxen: OpenAI-Endpunkt fuer Fremd-Tools (OpenHands & Co.)
|
||||
(w.service === 'llm' ? renderOpenAiEndpoint(w) : '');
|
||||
}).join('');
|
||||
// Auslastungs-Button pro Node — Ziel bevorzugt die llm-Instanz (sieht alle
|
||||
// GPUs + Tokens), sonst irgendein online Worker des Node.
|
||||
@@ -4634,6 +4637,62 @@
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ── OpenAI-Endpunkt einer LLM-Box (fuer OpenHands, Continue, ...) ──
|
||||
// Der PORT kommt immer aus der .env der Box (Docker-Port-Mapping, steht beim
|
||||
// Container-Start fest) und wird hier nur angezeigt. Der API-Key ist hier
|
||||
// setzbar — ausser er steht in der .env, dann hat die Vorrang und das Feld
|
||||
// ist schreibgeschuetzt.
|
||||
function renderOpenAiEndpoint(w) {
|
||||
const id = 'oa-' + (w.instanceId || '').replace(/[^a-zA-Z0-9]/g, '-');
|
||||
const port = w.openaiPort || '8081';
|
||||
const bind = w.openaiBind || '127.0.0.1';
|
||||
const lanOpen = bind === '0.0.0.0';
|
||||
const locked = !!w.openaiEnvLocked;
|
||||
const keyState = w.openaiKeySet
|
||||
? '<span style="color:#34C759;">Key gesetzt</span>'
|
||||
: '<span style="color:#FF9500;">kein Key — Endpunkt offen</span>';
|
||||
const warn = (lanOpen && !w.openaiKeySet)
|
||||
? '<div style="color:#FF6E6E;margin-top:4px;">⚠ Im LAN erreichbar OHNE Key — jeder im Netz kann das Modell nutzen.</div>'
|
||||
: '';
|
||||
const field = locked
|
||||
? '<input type="text" value="aus .env übernommen" disabled ' +
|
||||
'style="flex:1;background:#15151F;color:#666;border:1px solid #1E1E2E;border-radius:4px;padding:4px 8px;font-size:11px;">' +
|
||||
'<span style="color:#8888AA;font-size:10px;">🔒 .env hat Vorrang</span>'
|
||||
: '<input type="password" id="' + id + '" placeholder="API-Key setzen (leer = entfernen)" ' +
|
||||
'style="flex:1;background:#0D0D1A;color:#E0E0F0;border:1px solid #1E1E2E;border-radius:4px;padding:4px 8px;font-size:11px;">' +
|
||||
'<button class="btn secondary" onclick="saveOpenAiKey(\'' + escapeHtml(w.instanceId) + '\',\'' + id + '\')" ' +
|
||||
'style="padding:2px 8px;font-size:10px;">Speichern</button>';
|
||||
return '<div style="margin:2px 0 8px 18px;padding:6px 8px;background:#0D0D1A;border-radius:6px;font-size:11px;">' +
|
||||
'<div style="color:#8888AA;">OpenAI-Endpunkt (OpenHands & Co.): ' +
|
||||
'<code style="color:#FFD60A;">http://' + escapeHtml(bind === '0.0.0.0' ? '<box-ip>' : '127.0.0.1') +
|
||||
':' + escapeHtml(port) + '/v1</code> · ' + keyState + '</div>' +
|
||||
'<div style="display:flex;gap:6px;align-items:center;margin-top:5px;">' + field + '</div>' +
|
||||
'<div id="' + id + '-msg" style="color:#8888AA;margin-top:4px;"></div>' +
|
||||
'<div style="color:#666;margin-top:3px;">Port und Erreichbarkeit kommen aus der <code>.env</code> der Box ' +
|
||||
'(<code>LLM_OPENAI_PORT</code>, <code>LLM_OPENAI_BIND</code>) — sie stehen beim Container-Start fest.</div>' +
|
||||
warn + '</div>';
|
||||
}
|
||||
|
||||
function saveOpenAiKey(instanceId, fieldId) {
|
||||
const el = document.getElementById(fieldId);
|
||||
const msg = document.getElementById(fieldId + '-msg');
|
||||
if (!el) return;
|
||||
openAiReqId = 'oa_' + Date.now() + '_' + Math.floor(Math.random() * 100000);
|
||||
if (msg) msg.textContent = 'speichere …';
|
||||
send({ action: 'llm_openai_config', instanceId, apiKey: el.value, requestId: openAiReqId });
|
||||
}
|
||||
let openAiReqId = null;
|
||||
|
||||
function onOpenAiConfigResult(p) {
|
||||
if (!p || (openAiReqId && p.requestId && p.requestId !== openAiReqId)) return;
|
||||
const id = 'oa-' + String(p.instanceId || '').replace(/[^a-zA-Z0-9]/g, '-');
|
||||
const msg = document.getElementById(id + '-msg');
|
||||
if (!msg) return;
|
||||
msg.innerHTML = p.ok
|
||||
? '<span style="color:#34C759;">✓ ' + escapeHtml(p.message || 'gespeichert') + '</span>'
|
||||
: '<span style="color:#FF6E6E;">✗ ' + escapeHtml(p.error || 'fehlgeschlagen') + '</span>';
|
||||
}
|
||||
|
||||
// Sprung von den Einstellungen zur Compute-Flotte (dort sitzt der
|
||||
// Auslastung-Button pro Node → Live nvidia-smi + Graphen).
|
||||
function gotoComputeFleet() {
|
||||
|
||||
@@ -656,6 +656,8 @@ function workerList() {
|
||||
return Array.from(workers.values()).map(w => ({
|
||||
instanceId: w.instanceId, service: w.service, node: w.node,
|
||||
gpus: w.gpus, model: w.model, models: w.models || [], busy: !!w.busy,
|
||||
openaiPort: w.openaiPort || "", openaiBind: w.openaiBind || "",
|
||||
openaiKeySet: !!w.openaiKeySet, openaiEnvLocked: !!w.openaiEnvLocked,
|
||||
online: (now - (w.last_seen || 0)) < WORKER_OFFLINE_MS,
|
||||
}));
|
||||
}
|
||||
@@ -1180,6 +1182,8 @@ function connectRVS(forcePlain) {
|
||||
} else if (msg.type === "rooms_info") {
|
||||
// Antwort des RVS auf rooms_query → an den Browser (Raum-Diagnose).
|
||||
broadcast({ type: "rooms_info", payload: msg.payload || {} });
|
||||
} else if (msg.type === "llm_openai_config_result") {
|
||||
broadcast({ type: "llm_openai_config_result", payload: msg.payload || {} });
|
||||
} else if (msg.type === "sat_result") {
|
||||
// Ergebnis einer Satelliten-Aktion → an den Browser (z.B. APK-Rollout).
|
||||
broadcast({ type: "sat_result", payload: msg.payload || {} });
|
||||
@@ -1209,6 +1213,11 @@ function connectRVS(forcePlain) {
|
||||
instanceId: p.instanceId, service: p.service || "",
|
||||
node: p.node || "", gpus: p.gpus || "", model: p.model || "",
|
||||
models: Array.isArray(p.models) ? p.models : (p.model ? [p.model] : []),
|
||||
// OpenAI-Endpunkt (nur llm-Worker): Port/Bind kommen aus der .env der
|
||||
// Box, der Key wird NIE uebertragen — nur ob einer gesetzt ist und ob
|
||||
// die .env ihn vorgibt (dann ist das Feld hier schreibgeschuetzt).
|
||||
openaiPort: p.openaiPort || "", openaiBind: p.openaiBind || "",
|
||||
openaiKeySet: !!p.openaiKeySet, openaiEnvLocked: !!p.openaiEnvLocked,
|
||||
busy: !!prev.busy, last_seen: Date.now(),
|
||||
});
|
||||
broadcastWorkers();
|
||||
@@ -2964,6 +2973,17 @@ wss.on("connection", (ws) => {
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} else if (msg.action === "llm_openai_config") {
|
||||
// Browser setzt den API-Key des OpenAI-Endpunkts einer Box.
|
||||
sendToRVS_raw({
|
||||
type: "llm_openai_config",
|
||||
payload: {
|
||||
instanceId: String(msg.instanceId || ""),
|
||||
apiKey: String(msg.apiKey || ""),
|
||||
requestId: String(msg.requestId || ""),
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} else if (msg.action === "agent_history_query") {
|
||||
// Browser will die gespeicherte Historie eines Agenten — die Bridge
|
||||
// antwortet mit agent_history_list (auf derselben rvsWs).
|
||||
|
||||
@@ -11,8 +11,8 @@ android {
|
||||
applicationId 'de.hackersoft.ariaagent'
|
||||
minSdk 26
|
||||
targetSdk 33 // 33 vermeidet die Foreground-Service-Typ-Pflicht von 34
|
||||
versionCode 102
|
||||
versionName '0.0.1.2'
|
||||
versionCode 103
|
||||
versionName '0.0.1.3'
|
||||
}
|
||||
|
||||
// Fester Signaturschlüssel: jeder Build signiert mit DEMSELBEN Key, damit
|
||||
|
||||
@@ -162,7 +162,7 @@ OUT_MAX_CHARS_HARD = int(os.environ.get("OUT_MAX_CHARS_HARD", "200000") or "2000
|
||||
FILE_MAX_BYTES = int(os.environ.get("FILE_MAX_BYTES", str(10 * 1024 * 1024)) or str(10 * 1024 * 1024))
|
||||
|
||||
# Version (wird von release_agent.sh beim Release gesetzt).
|
||||
AGENT_VERSION = "0.0.1.2"
|
||||
AGENT_VERSION = "0.0.1.3"
|
||||
|
||||
HEARTBEAT_SEC = 25
|
||||
|
||||
|
||||
+109
@@ -113,6 +113,9 @@ const ALLOWED_TYPES = new Set([
|
||||
// OHNE diese Typen verwirft der RVS die Meldungen an der Allow-List (Z. 312),
|
||||
// und die Box bleibt in der Flotte unsichtbar, obwohl sie sendet.
|
||||
"worker_hello", "worker_ping", "worker_update", "worker_list",
|
||||
// OpenAI-Endpunkt einer LLM-Box: Diagnostic setzt den API-Key (der PORT kommt
|
||||
// immer aus der .env der Box — er ist ein Docker-Port-Mapping).
|
||||
"llm_openai_config", "llm_openai_config_result",
|
||||
"node_stats", "node_stats_stream_start", "node_stats_stream_stop",
|
||||
"node_stats_history_request", "node_stats_history",
|
||||
"node_stats_reset", "node_stats_reset_done",
|
||||
@@ -121,6 +124,86 @@ const ALLOWED_TYPES = new Set([
|
||||
// Token-Raum: token -> { clients: Set<ws> }
|
||||
const rooms = new Map();
|
||||
|
||||
// ── Punkt-zu-Punkt-Antworten ────────────────────────────────────────
|
||||
//
|
||||
// Der RVS ist ein Broadcast-Relay: normalerweise bekommt JEDER Client im Raum
|
||||
// jede Nachricht. Bei Datei-Antworten ist das fatal. Ein 150-MB-Video wird als
|
||||
// base64 ~200 MB gross und ging bisher an ALLE Clients im Raum — auch an die
|
||||
// STT/TTS-Worker, die daran mit Code 1009 (message too big) gestorben sind und
|
||||
// die gerade laufende Sprachaufnahme mitgerissen haben ("erste Sprachnachricht
|
||||
// kommt nicht durch"). Die Worker-max_size-Angleichung verhindert den Absturz,
|
||||
// aber 200 MB an zehn Clients zu schicken bleibt Unsinn: nur EINER hat gefragt.
|
||||
//
|
||||
// Darum: wer eine Anfrage mit requestId stellt, wird gemerkt — die Antwort mit
|
||||
// derselben requestId geht nur an ihn zurueck. Unbekannte requestId → wie
|
||||
// bisher broadcasten (nichts geht verloren, falls ein Client mithoeren muss,
|
||||
// dessen Anfrage der RVS nie gesehen hat).
|
||||
const TARGETED = new Map([
|
||||
["file_request", "file_response"],
|
||||
["file_zip_request", "file_zip_response"],
|
||||
["file_version_download_request", "file_version_download_response"],
|
||||
["brain_request", "brain_response"],
|
||||
]);
|
||||
const TARGETED_REPLIES = new Set(TARGETED.values());
|
||||
|
||||
// `antwortTyp \0 requestId` -> { clients: Set<ws>, at }
|
||||
// Mehrere Clients koennen dieselbe requestId benutzen (die App nimmt die
|
||||
// Nachrichten-ID) — darum ein Set, sonst bekaeme nur der letzte die Antwort.
|
||||
const pendingReplies = new Map();
|
||||
const PENDING_TTL_MS = 180_000;
|
||||
|
||||
function pendingKey(replyType, requestId) {
|
||||
return `${replyType} | ||||