Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07df538763 | ||
|
|
5761c5f6b9 | ||
|
|
87755b9c58 | ||
|
|
b6195d3a53 | ||
|
|
b9354283af | ||
|
|
bf1db6cbf9 | ||
|
|
ea159d4ed7 | ||
|
|
dd1e1ac458 | ||
|
|
ddd77c7ad4 | ||
|
|
7c3c932054 | ||
|
|
d6c7502810 | ||
|
|
dc77933014 | ||
|
|
6a6b320f37 | ||
|
|
cc91497923 | ||
|
|
10e7faa92b | ||
|
|
d030cd7bc6 | ||
|
|
36641e6519 | ||
|
|
9b11c16479 | ||
|
|
8f9e3e5c79 | ||
|
|
f1ae18b658 | ||
|
|
27f9399eda | ||
|
|
c131820be0 | ||
|
|
e43b44725b | ||
|
|
586a2bf134 |
@@ -79,8 +79,8 @@ android {
|
||||
applicationId "com.ariacockpit"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 20500
|
||||
versionName "0.2.5.0"
|
||||
versionCode 20503
|
||||
versionName "0.2.5.3"
|
||||
// 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.0",
|
||||
"version": "0.2.5.3",
|
||||
"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';
|
||||
@@ -348,6 +350,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.
|
||||
@@ -1303,6 +1309,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 +1812,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
|
||||
@@ -2456,16 +2477,47 @@ 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 }[],
|
||||
) => {
|
||||
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 || '';
|
||||
@@ -2488,7 +2540,7 @@ const ChatScreen: React.FC = () => {
|
||||
const userMsg: ChatMessage = {
|
||||
id: msgId,
|
||||
sender: 'user',
|
||||
text: messageText || `${pendingAttachments.length} Anhang/Anhaenge`,
|
||||
text: messageText || `${atts.length} Anhang/Anhaenge`,
|
||||
timestamp: Date.now(),
|
||||
attachments,
|
||||
projectId: activePid,
|
||||
@@ -2501,7 +2553,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 || '';
|
||||
@@ -2556,6 +2608,33 @@ const ChatScreen: React.FC = () => {
|
||||
// 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();
|
||||
|
||||
@@ -3693,6 +3772,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)}
|
||||
|
||||
@@ -1141,7 +1141,7 @@ class AudioService {
|
||||
* Leer = Hauptchat. Ohne Prefix / Sticky landet die STT-Nachricht damit
|
||||
* automatisch in dem Kontext den Stefan gerade sieht. */
|
||||
projectId?: string;
|
||||
}): Promise<{ requestId: string; ok: boolean }> {
|
||||
}): Promise<{ requestId: string; ok: boolean; error?: string }> {
|
||||
if (this.recordingState !== 'idle') {
|
||||
console.warn('[Audio] startStreamingRecording: bereits aktiv (state=%s)', this.recordingState);
|
||||
return { requestId: '', ok: false };
|
||||
@@ -1155,12 +1155,32 @@ class AudioService {
|
||||
console.warn('[Audio] Keine Mikrofon-Berechtigung');
|
||||
return { requestId: '', ok: false };
|
||||
}
|
||||
// Ohne Verbindung gar nicht erst aufnehmen: rvs.send() verwirft still, wenn
|
||||
// der Socket nicht offen ist (kein Puffer, kein Retry). Genau deshalb fiel
|
||||
// nach App-Resume/Reconnect die ERSTE Aufnahme komplett unter den Tisch —
|
||||
// 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.
|
||||
// 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' };
|
||||
}
|
||||
|
||||
// Laufende Wiedergabe stoppen (damit ARIA sich nicht selbst hoert)
|
||||
this.stopPlayback();
|
||||
|
||||
const requestId = `sttstr_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
|
||||
this.streamRequestId = requestId;
|
||||
// Zustand SOFORT setzen (nicht erst nach den awaits weiter unten): sonst ist
|
||||
// isStreamingRecording() bereits true, recordingState aber noch 'idle' —
|
||||
// ein Abbruch in diesem Fenster liess den Start danach weiterlaufen und der
|
||||
// Recorder blieb haengen, sodass die NAECHSTE Aufnahme abgelehnt wurde.
|
||||
this.setState('recording');
|
||||
this.streamAudioRequestId = opts.audioRequestId || '';
|
||||
// Redundanz-Routing: freie STT-Instanz leasen BEVOR Chunks fliessen, damit
|
||||
// start + alle Chunks + end dieselbe Instanz adressieren. Kurzer Timeout →
|
||||
@@ -1202,20 +1222,17 @@ class AudioService {
|
||||
console.warn('[Audio] PcmStreamChunk-Subscription fehlgeschlagen:', err);
|
||||
}
|
||||
|
||||
const started = await PcmStreamRecorder.start();
|
||||
if (!started) {
|
||||
throw new Error('PcmStreamRecorder.start returned false');
|
||||
// Abbruch waehrend der awaits oben? Dann hier aussteigen, statt eine
|
||||
// Sitzung zu starten, die niemand mehr erwartet.
|
||||
if (this.streamRequestId !== requestId) {
|
||||
console.warn('[Audio] Start abgebrochen (Session gewechselt) — breche ab');
|
||||
return { requestId: '', ok: false, error: 'superseded' };
|
||||
}
|
||||
|
||||
// AudioFocus exklusiv — gleiche Semantik wie beim Legacy-Pfad.
|
||||
// Vorher merken ob Musik lief (fuer Resume am Dialog-Ende).
|
||||
await this._captureMediaActive();
|
||||
this._cancelDeferredFocusRelease();
|
||||
AudioFocus?.requestExclusive().catch(() => {});
|
||||
|
||||
this.setState('recording');
|
||||
|
||||
// stt_stream_start — der Whisper-Bridge mitteilen dass jetzt Chunks kommen.
|
||||
// stt_stream_start MUSS vor dem Recorder raus: sonst treffen die ersten
|
||||
// Chunks (200ms-Takt) auf eine Bridge, die die Sitzung noch nicht kennt,
|
||||
// und werden verworfen — der Wortanfang fehlt, bei kurzen Kommandos bleibt
|
||||
// gar nichts uebrig.
|
||||
rvs.send('stt_stream_start' as any, {
|
||||
requestId,
|
||||
audioRequestId: opts.audioRequestId || '',
|
||||
@@ -1230,18 +1247,40 @@ class AudioService {
|
||||
targetInstance: this.streamTargetInstance,
|
||||
});
|
||||
|
||||
const started = await PcmStreamRecorder.start();
|
||||
if (!started) {
|
||||
throw new Error('PcmStreamRecorder.start returned false');
|
||||
}
|
||||
|
||||
// AudioFocus exklusiv — gleiche Semantik wie beim Legacy-Pfad.
|
||||
// Vorher merken ob Musik lief (fuer Resume am Dialog-Ende).
|
||||
await this._captureMediaActive();
|
||||
this._cancelDeferredFocusRelease();
|
||||
AudioFocus?.requestExclusive().catch(() => {});
|
||||
|
||||
|
||||
// No-Speech-Watchdog — ersetzt den alten VAD-noSpeechTimer.
|
||||
// Wenn nach Konversationsfenster kein einziger stt_partial gekommen ist,
|
||||
// hat der User vermutlich nix gesagt → Session beenden.
|
||||
// WICHTIG: Der Aufrufer uebergibt hier die Sprechpausen-Toleranz
|
||||
// (sttEndpointMs, Default 2400ms). Die misst aber ab dem ENDE des
|
||||
// Sprechens — dieser Watchdog laeuft ab dem START der Aufnahme. Bis das
|
||||
// erste Zwischenergebnis zurueck sein KANN, vergehen realistisch 1,3-2,2s
|
||||
// (Mindest-Puffer 600ms + Transkriptions-Takt 700ms + Netzweg hin/zurueck).
|
||||
// Ohne Zuschlag reisst schon ein kurzes Ueberlegen nach dem Gong die
|
||||
// Grenze: die Aufnahme wurde abgebrochen und die Bubble verschwand — erst
|
||||
// der zweite Versuch klappte, weil man da sofort losredet.
|
||||
const NO_SPEECH_GRACE_MS = 2500;
|
||||
const noSpeechMs = Number(opts.noSpeechTimeoutMs || 0);
|
||||
if (noSpeechMs > 0) {
|
||||
const effectiveMs = noSpeechMs + NO_SPEECH_GRACE_MS;
|
||||
this.streamNoSpeechTimer = setTimeout(() => {
|
||||
if (this.streamRequestId === requestId && !this.streamGotPartial) {
|
||||
console.log('[Audio] Stream %s: no-speech nach %dms → cancel',
|
||||
requestId.slice(0, 12), noSpeechMs);
|
||||
requestId.slice(0, 12), effectiveMs);
|
||||
this.cancelStreamingRecording('no-speech').catch(() => {});
|
||||
}
|
||||
}, noSpeechMs);
|
||||
}, effectiveMs);
|
||||
}
|
||||
|
||||
// Hard-Cap als zweite Sicherheitsleine (App-seitig zusaetzlich zur Bridge).
|
||||
|
||||
@@ -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 };
|
||||
+384
-1
@@ -1501,6 +1501,155 @@ META_TOOLS = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "jellyfin_search",
|
||||
"description": (
|
||||
"Sucht Filme/Serien/Folgen auf einem Jellyfin-Server (Medien-Bibliothek). "
|
||||
"Liefert Titel, Jahr, Typ und die itemId, die Du zum Abspielen brauchst. "
|
||||
"NIMM DAS statt Dich durch die Fire-TV-Oberflaeche zu klicken — das ist "
|
||||
"zuverlaessig und schnell. Server werden im Diagnostic unter "
|
||||
"Integrationen gepflegt; ohne Angabe wird der Standard-Server genutzt."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Suchbegriff, z.B. 'Asterix Kleopatra'."},
|
||||
"server": {"type": "string", "description": "Server-Name (optional, sonst Standard)."},
|
||||
"limit": {"type": "integer", "description": "Max. Treffer (Default 10)."},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "jellyfin_devices",
|
||||
"description": (
|
||||
"Zeigt, auf welchen Geraeten gerade eine Jellyfin-App laeuft und damit "
|
||||
"fernsteuerbar ist (Sitzungen) — z.B. der Fire TV im Schlafzimmer. "
|
||||
"WICHTIG: Ein Geraet erscheint nur, wenn die Jellyfin-App dort GEOEFFNET "
|
||||
"ist. Ist sie zu, starte sie zuerst mit host_app_launch (query='Jellyfin') "
|
||||
"und frage dann erneut."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"server": {"type": "string", "description": "Server-Name (optional)."},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "jellyfin_play",
|
||||
"description": (
|
||||
"Startet die Wiedergabe eines Titels auf einem Jellyfin-Geraet. Ablauf: "
|
||||
"1. jellyfin_search -> itemId, 2. jellyfin_devices -> Geraet/Sitzung, "
|
||||
"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)."},
|
||||
},
|
||||
"required": ["item_id", "device"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "jellyfin_control",
|
||||
"description": (
|
||||
"Steuert die laufende Wiedergabe auf einem Jellyfin-Geraet: pause, "
|
||||
"unpause, stop, nextTrack, previousTrack."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"device": {"type": "string", "description": "Geraete-/Sitzungsname."},
|
||||
"command": {"type": "string", "description": "pause | unpause | stop | nextTrack | previousTrack"},
|
||||
"server": {"type": "string", "description": "Server-Name (optional)."},
|
||||
},
|
||||
"required": ["device", "command"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "host_notify",
|
||||
"description": (
|
||||
"Zeigt eine Nachricht AUF einem Host-Agenten an — bei einem Fire TV / "
|
||||
"Android TV als EINBLENDUNG ueber dem laufenden Bild (Netflix & Co. "
|
||||
"laufen weiter), wahlweise als Sprachausgabe. NIMM DIESES TOOL, wenn "
|
||||
"Stefan etwas 'auf dem Fernseher einblenden/anzeigen' will — versuche "
|
||||
"das NICHT ueber host_exec/adb (dort gibt es kein passendes Kommando). "
|
||||
"mode: 'overlay' (einblenden), 'speech' (vorlesen) oder 'both'; ohne "
|
||||
"Angabe gilt die Einstellung am Geraet. Braucht dort einmalig die "
|
||||
"Berechtigung 'Ueber anderen Apps anzeigen'."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"host": {"type": "string", "description": "Host-ID/Name (z.B. 'Fire-TV Schlafzimmer')."},
|
||||
"text": {"type": "string", "description": "Der anzuzeigende Text."},
|
||||
"title": {"type": "string", "description": "Ueberschrift (Default 'ARIA')."},
|
||||
"seconds": {"type": "integer", "description": "Anzeigedauer (Default 12)."},
|
||||
"mode": {"type": "string", "description": "overlay | speech | both"},
|
||||
"focus": {"type": "string", "description": "Bei Sprachausgabe: 'duck' (laufende Medien werden leiser, Standard) | 'pause' (Film/Musik anhalten) | 'none'."},
|
||||
},
|
||||
"required": ["host", "text"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "host_speak",
|
||||
"description": (
|
||||
"Laesst einen Host-Agenten Text laut vorlesen (Fire TV: ueber die "
|
||||
"Fernseher-Lautsprecher). Fuer reine Sprachansagen; soll es auch/nur "
|
||||
"sichtbar sein, nimm host_notify."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"host": {"type": "string", "description": "Host-ID/Name."},
|
||||
"text": {"type": "string", "description": "Was gesprochen werden soll."},
|
||||
"focus": {"type": "string", "description": "'duck' (Medien leiser, Standard) | 'pause' (Film/Musik anhalten) | 'none'."},
|
||||
},
|
||||
"required": ["host", "text"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "host_volume",
|
||||
"description": "Setzt die Medien-Lautstaerke eines Host-Agenten (Fire TV / Android).",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"host": {"type": "string", "description": "Host-ID/Name."},
|
||||
"percent": {"type": "integer", "description": "0-100."},
|
||||
},
|
||||
"required": ["host", "percent"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
@@ -2686,6 +2835,232 @@ class Agent:
|
||||
except Exception as exc:
|
||||
return f"FEHLER: Satellit/Bridge nicht erreichbar: {exc}"
|
||||
|
||||
# ── Jellyfin (Medien) ─────────────────────────────────────
|
||||
_JELLYFIN_CONFIG = "/shared/config/jellyfin.json"
|
||||
|
||||
def _jellyfin_server(self, wanted: str = "") -> Optional[dict]:
|
||||
"""Server aus der Diagnostic-Konfiguration waehlen (Name oder Standard)."""
|
||||
try:
|
||||
with open(self._JELLYFIN_CONFIG, "r", encoding="utf-8") as f:
|
||||
servers = (json.load(f) or {}).get("servers") or []
|
||||
except Exception:
|
||||
return None
|
||||
servers = [s for s in servers if s.get("url")]
|
||||
if not servers:
|
||||
return None
|
||||
w = (wanted or "").strip().lower()
|
||||
if w:
|
||||
for s in servers:
|
||||
if w in (s.get("name") or "").lower() or w in (s.get("url") or "").lower():
|
||||
return s
|
||||
for s in servers:
|
||||
if s.get("isDefault"):
|
||||
return s
|
||||
return servers[0]
|
||||
|
||||
def _jellyfin_call(self, server: dict, path: str, method: str = "GET",
|
||||
params: Optional[dict] = None, timeout: float = 15.0):
|
||||
"""Ruft die Jellyfin-HTTP-API. Gibt (daten, fehlertext)."""
|
||||
url = server["url"].rstrip("/") + path
|
||||
if params:
|
||||
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(params)
|
||||
req = urllib.request.Request(url, method=method, headers={
|
||||
"Authorization": f'MediaBrowser Token="{server.get("apiKey", "")}"',
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
raw = resp.read().decode("utf-8", "ignore")
|
||||
return (json.loads(raw) if raw.strip() else {}), ""
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code in (401, 403):
|
||||
return None, ("Jellyfin lehnt den API-Key ab (401/403) — im Diagnostic "
|
||||
"unter Integrationen pruefen.")
|
||||
return None, f"Jellyfin-Fehler HTTP {exc.code}"
|
||||
except Exception as exc:
|
||||
return None, f"Jellyfin nicht erreichbar: {exc}"
|
||||
|
||||
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()
|
||||
cands = [s for s in (sessions or []) if s.get("SupportsRemoteControl")]
|
||||
for s in cands:
|
||||
blob = f"{s.get('DeviceName', '')} {s.get('Client', '')}".lower()
|
||||
if d and d in blob:
|
||||
return s, ""
|
||||
if not d and len(cands) == 1:
|
||||
return cands[0], ""
|
||||
names = ", ".join(f"{s.get('DeviceName')} ({s.get('Client')})" for s in cands) or "keine"
|
||||
return None, (f"Kein steuerbares Jellyfin-Geraet fuer '{device}' gefunden. "
|
||||
f"Aktuell offen: {names}. Tipp: Die Jellyfin-App muss auf dem "
|
||||
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:
|
||||
return ("FEHLER: Kein Jellyfin-Server eingerichtet. Im Diagnostic unter "
|
||||
"Einstellungen → Integrationen → Jellyfin einen Server mit URL und "
|
||||
"API-Key anlegen.")
|
||||
label = server.get("name") or server.get("url")
|
||||
try:
|
||||
if name == "jellyfin_search":
|
||||
q = (arguments.get("query") or "").strip()
|
||||
if not q:
|
||||
return "FEHLER: query ist Pflicht."
|
||||
data, err = self._jellyfin_call(server, "/Items", params={
|
||||
"searchTerm": q, "Recursive": "true",
|
||||
"IncludeItemTypes": "Movie,Series,Episode",
|
||||
"Limit": int(arguments.get("limit") or 10),
|
||||
"Fields": "ProductionYear,Overview",
|
||||
})
|
||||
if err:
|
||||
return f"FEHLER: {err}"
|
||||
items = (data or {}).get("Items") or []
|
||||
if not items:
|
||||
return f"Keine Treffer fuer '{q}' auf {label}."
|
||||
lines = []
|
||||
for it in items:
|
||||
lines.append(f"- {it.get('Name')} ({it.get('ProductionYear') or '?'}) "
|
||||
f"[{it.get('Type')}] itemId={it.get('Id')}")
|
||||
return f"Treffer auf {label}:\n" + "\n".join(lines)
|
||||
|
||||
if name == "jellyfin_devices":
|
||||
# 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 = []
|
||||
for s in (sessions or []):
|
||||
if not s.get("SupportsRemoteControl"):
|
||||
continue
|
||||
now = (s.get("NowPlayingItem") or {}).get("Name")
|
||||
out.append(f"- {s.get('DeviceName')} ({s.get('Client')})"
|
||||
+ (f" — laeuft gerade: {now}" if now else ""))
|
||||
if not out:
|
||||
return (f"Auf {label} ist gerade kein steuerbares Geraet offen. Die "
|
||||
f"Jellyfin-App muss auf dem Zielgeraet laufen — starte sie "
|
||||
f"mit host_app_launch (query='Jellyfin') und frag dann erneut.")
|
||||
return f"Steuerbare Jellyfin-Geraete auf {label}:\n" + "\n".join(out)
|
||||
|
||||
if name == "jellyfin_play":
|
||||
item_id = (arguments.get("item_id") or "").strip()
|
||||
device = (arguments.get("device") or "").strip()
|
||||
if not item_id or not device:
|
||||
return "FEHLER: item_id und device sind Pflicht."
|
||||
sess, err = self._jellyfin_find_session(server, device)
|
||||
if err:
|
||||
return f"FEHLER: {err}"
|
||||
play_params = {"itemIds": item_id, "playCommand": "PlayNow"}
|
||||
note = ""
|
||||
if arguments.get("resume") is not False:
|
||||
# Fortsetzen: gespeicherte Position des Titels mitgeben.
|
||||
info, _e = self._jellyfin_call(server, "/Items",
|
||||
params={"ids": item_id, "Fields": "UserData"})
|
||||
items = (info or {}).get("Items") or []
|
||||
ticks = 0
|
||||
if items:
|
||||
ticks = ((items[0].get("UserData") or {}).get("PlaybackPositionTicks") or 0)
|
||||
if ticks > 0:
|
||||
play_params["startPositionTicks"] = ticks
|
||||
note = f" — fortgesetzt bei {int(ticks / 10_000_000 // 60)} min"
|
||||
_, err = self._jellyfin_call(
|
||||
server, f"/Sessions/{sess['Id']}/Playing", method="POST",
|
||||
params=play_params)
|
||||
if err:
|
||||
return f"FEHLER: {err}"
|
||||
|
||||
# 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()
|
||||
cmd = (arguments.get("command") or "").strip()
|
||||
allowed = {"pause", "unpause", "stop", "nexttrack", "previoustrack",
|
||||
"playpause", "seek"}
|
||||
if cmd.lower() not in allowed:
|
||||
return f"FEHLER: command '{cmd}' unbekannt ({', '.join(sorted(allowed))})."
|
||||
sess, err = self._jellyfin_find_session(server, device)
|
||||
if err:
|
||||
return f"FEHLER: {err}"
|
||||
_, err = self._jellyfin_call(
|
||||
server, f"/Sessions/{sess['Id']}/Playing/{cmd}", method="POST")
|
||||
if err:
|
||||
return f"FEHLER: {err}"
|
||||
return f"OK — '{cmd}' an '{sess.get('DeviceName')}' gesendet."
|
||||
|
||||
return f"FEHLER: unbekanntes Jellyfin-Tool {name}"
|
||||
except Exception as exc:
|
||||
return f"FEHLER: Jellyfin-Aufruf fehlgeschlagen: {exc}"
|
||||
|
||||
def _dispatch_host(self, name: str, arguments: dict) -> str:
|
||||
"""Wrapper um _dispatch_host_inner: fuehrt die Host-Aktion aus und schreibt
|
||||
danach einen Historie-Eintrag an die Bridge (fuer die Diagnostic-Ansicht
|
||||
@@ -2864,6 +3239,9 @@ class Agent:
|
||||
"host_ui_swipe": ("ui_swipe", ["x1", "y1", "x2", "y2", "duration_ms"]),
|
||||
"host_ui_key": ("ui_key", ["key"]),
|
||||
"host_app_launch": ("app_launch", ["package", "query", "command"]),
|
||||
"host_notify": ("notify", ["text", "title", "seconds", "mode", "focus"]),
|
||||
"host_speak": ("speak", ["text", "focus"]),
|
||||
"host_volume": ("volume", ["percent"]),
|
||||
}
|
||||
if name in _UI_ACTIONS:
|
||||
action, keys = _UI_ACTIONS[name]
|
||||
@@ -3671,10 +4049,15 @@ class Agent:
|
||||
return f"OK — Projekt '{updated['name']}' ist wieder ein normaler Chat."
|
||||
if name in ("satellite_list", "satellite_devices", "satellite_command"):
|
||||
return self._dispatch_satellite(name, arguments)
|
||||
if name in ("jellyfin_search", "jellyfin_devices", "jellyfin_play",
|
||||
"jellyfin_control"):
|
||||
return self._dispatch_jellyfin(name, arguments)
|
||||
|
||||
if name in ("host_list", "host_exec", "host_read", "host_write",
|
||||
"host_info", "host_screenshot", "host_ui_dump",
|
||||
"host_ui_tap", "host_ui_text", "host_ui_swipe",
|
||||
"host_ui_key", "host_app_launch"):
|
||||
"host_ui_key", "host_app_launch",
|
||||
"host_notify", "host_speak", "host_volume"):
|
||||
return self._dispatch_host(name, arguments)
|
||||
if name == "vm_register":
|
||||
pid = (project_id or "").strip()
|
||||
|
||||
+23
-7
@@ -3407,11 +3407,18 @@ class ARIABridge:
|
||||
# weiter auseinander (man hoert den Wechsel erst).
|
||||
_norm_txt = " ".join(text.lower().split())
|
||||
_now_t = asyncio.get_event_loop().time()
|
||||
# Fenster bewusst KURZ (1.5s): es soll nur den Zwilling abfangen, den
|
||||
# zwei STT-Worker fuer DIESELBE Aufnahme liefern. Mit den frueheren 5s
|
||||
# fielen echte Wiederholungen weg — also genau der Fall "ARIA hat mich
|
||||
# nicht gehoert, ich sag's nochmal" und kurze Kommandos wie "lauter",
|
||||
# "nächstes", "stopp". Die eigentliche Absicherung ist ohnehin die
|
||||
# Idempotenz ueber audioRequestId weiter oben.
|
||||
_DUPE_WINDOW_SEC = 1.5
|
||||
if (_norm_txt
|
||||
and _norm_txt == getattr(self, "_last_endpoint_norm", "")
|
||||
and (_now_t - getattr(self, "_last_endpoint_at", 0.0)) < 5.0):
|
||||
logger.info("[rvs] stt_endpoint Dupe verworfen (gleicher Text <5s): %r",
|
||||
text[:60])
|
||||
and (_now_t - getattr(self, "_last_endpoint_at", 0.0)) < _DUPE_WINDOW_SEC):
|
||||
logger.info("[rvs] stt_endpoint Dupe verworfen (gleicher Text <%.1fs): %r",
|
||||
_DUPE_WINDOW_SEC, text[:60])
|
||||
return
|
||||
self._last_endpoint_norm = _norm_txt
|
||||
self._last_endpoint_at = _now_t
|
||||
@@ -3777,8 +3784,12 @@ class ARIABridge:
|
||||
r")\s*[.!?]?\s*$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# Trenner bewusst nur ':' und '-' — NICHT das Komma. Whisper setzt gern
|
||||
# Kommata, und damit matchten normale Saetze wie "In Ordnung, mach mal ..."
|
||||
# als Projekt-Prefix: die Nachricht wanderte still in ein Projekt und war im
|
||||
# Hauptchat unsichtbar ("wird gar nicht verarbeitet").
|
||||
_VOICE_META_PROJECT_PREFIX = re.compile(
|
||||
r"^\s*(?:aria[,.]?\s+)?(?:f(?:ü|ue)r|ins?)\s+([\w\-äöüßÄÖÜ]{2,40})[:\-,]\s*(.+?)\s*$",
|
||||
r"^\s*(?:aria[,.]?\s+)?(?:f(?:ü|ue)r|ins?)\s+([\w\-äöüßÄÖÜ]{2,40})\s*[:\-]\s*(.+?)\s*$",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
|
||||
@@ -3833,7 +3844,10 @@ class ARIABridge:
|
||||
s = SequenceMatcher(None, q, pname).ratio()
|
||||
if s > best_score:
|
||||
best, best_score = p, s
|
||||
if best and best_score >= 0.6:
|
||||
# Schwelle bewusst hoch (0.82): bei 0.6 reichte schon eine vage
|
||||
# Aehnlichkeit, und die Nachricht verschwand still in einem
|
||||
# fremden Projekt. Lieber im Hauptchat lassen als falsch routen.
|
||||
if best and best_score >= 0.82:
|
||||
pid = best["id"]
|
||||
self._voice_sticky_project_id = pid
|
||||
self._voice_sticky_expires_at = now + self._VOICE_STICKY_TIMEOUT_SEC
|
||||
@@ -3845,8 +3859,10 @@ class ARIABridge:
|
||||
# Kein Match → als normale Nachricht weiter (Sticky wenn aktiv)
|
||||
# 3) Kein Meta / Prefix → Sticky oder Default
|
||||
if self._voice_sticky_project_id and now < self._voice_sticky_expires_at:
|
||||
# Sticky refreshen
|
||||
self._voice_sticky_expires_at = now + self._VOICE_STICKY_TIMEOUT_SEC
|
||||
# Sticky NICHT verlaengern: frueher hat jede Folgenachricht die Frist
|
||||
# erneuert — ein einziger Fehl-Match und danach landete dauerhaft
|
||||
# alles in diesem Projekt. Jetzt laeuft er nach der urspruenglichen
|
||||
# Zeit aus und der Hauptchat uebernimmt wieder.
|
||||
return (True, stripped, self._voice_sticky_project_id, "sticky")
|
||||
# Sticky abgelaufen — zurücksetzen
|
||||
self._voice_sticky_project_id = ""
|
||||
|
||||
+226
-6
@@ -961,6 +961,33 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Jellyfin (Medien) -->
|
||||
<div class="settings-section">
|
||||
<h2>Jellyfin (Filme & Serien)</h2>
|
||||
<div style="font-size:11px;color:#8888AA;margin-bottom:8px;">
|
||||
Damit steuert ARIA die Wiedergabe direkt über die Jellyfin-Schnittstelle statt
|
||||
sich durch die Fire-TV-Oberfläche zu klicken: Film suchen → Abspielgerät finden →
|
||||
starten. Mehrere Server möglich (eigener, vom Kumpel …) — einer davon ist der
|
||||
Standard, wenn ARIA keinen nennt.
|
||||
<br>API-Key: Jellyfin → <em>Dashboard → API-Schlüssel → +</em>.
|
||||
<strong>ARIAs Brain muss den Server erreichen können</strong> (URL aus Sicht des Servers,
|
||||
nicht des Browsers).
|
||||
</div>
|
||||
<div class="card" style="max-width:780px;">
|
||||
<div id="jellyfin-list" style="display:flex;flex-direction:column;gap:10px;">
|
||||
<div style="color:#555570;font-style:italic;">Lade …</div>
|
||||
</div>
|
||||
<div style="margin-top:14px;display:flex;gap:8px;align-items:center;">
|
||||
<button class="btn secondary" onclick="addJellyfinServer()" style="padding:6px 14px;font-size:12px;">+ Server</button>
|
||||
<button class="btn" onclick="saveJellyfinConfig()" style="padding:6px 14px;font-size:12px;">Speichern</button>
|
||||
<span id="jellyfin-status" style="color:#8888AA;font-size:11px;"></span>
|
||||
</div>
|
||||
<div style="color:#666680;font-size:10px;margin-top:8px;">
|
||||
API-Keys werden mit Datei-Rechten 0600 gespeichert (/shared/config/jellyfin.json).
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FLUX Bildgenerierung -->
|
||||
<div class="settings-section">
|
||||
<h2>FLUX Bildgenerierung</h2>
|
||||
@@ -2251,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 || []);
|
||||
@@ -4537,6 +4565,7 @@
|
||||
loadRuntimeConfig();
|
||||
loadOnboardingQR();
|
||||
loadOAuthServices();
|
||||
loadJellyfinConfig();
|
||||
refreshVoiceIdStatus();
|
||||
} else if (tab === 'brain') {
|
||||
loadBrainStatus();
|
||||
@@ -4593,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.
|
||||
@@ -4606,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() {
|
||||
@@ -4743,6 +4830,120 @@
|
||||
send({ action: 'host_forget', hostId });
|
||||
}
|
||||
|
||||
// ── Jellyfin-Server (Medien-Integration) ─────────────────────────
|
||||
let jellyfinServers = [];
|
||||
|
||||
async function loadJellyfinConfig() {
|
||||
try {
|
||||
const r = await fetch('/api/jellyfin-config');
|
||||
const d = await r.json();
|
||||
jellyfinServers = d.servers || [];
|
||||
} catch (e) {
|
||||
jellyfinServers = [];
|
||||
}
|
||||
renderJellyfin();
|
||||
}
|
||||
|
||||
function addJellyfinServer() {
|
||||
jellyfinServers.push({
|
||||
id: 'jf_' + Date.now().toString(36),
|
||||
name: '', url: '', apiKey: '',
|
||||
isDefault: jellyfinServers.length === 0,
|
||||
});
|
||||
renderJellyfin();
|
||||
}
|
||||
|
||||
function removeJellyfinServer(i) {
|
||||
const s = jellyfinServers[i];
|
||||
if (!confirm('Server "' + (s.name || s.url || 'neu') + '" entfernen?')) return;
|
||||
jellyfinServers.splice(i, 1);
|
||||
if (jellyfinServers.length && !jellyfinServers.some(x => x.isDefault)) {
|
||||
jellyfinServers[0].isDefault = true;
|
||||
}
|
||||
renderJellyfin();
|
||||
}
|
||||
|
||||
function setJellyfinDefault(i) {
|
||||
jellyfinServers.forEach((s, k) => { s.isDefault = (k === i); });
|
||||
renderJellyfin();
|
||||
}
|
||||
|
||||
function updateJellyfinField(i, field, value) {
|
||||
if (jellyfinServers[i]) jellyfinServers[i][field] = value;
|
||||
}
|
||||
|
||||
function renderJellyfin() {
|
||||
const box = document.getElementById('jellyfin-list');
|
||||
if (!box) return;
|
||||
if (!jellyfinServers.length) {
|
||||
box.innerHTML = '<div style="color:#555570;font-style:italic;">' +
|
||||
'Noch kein Server. Mit „+ Server" einen anlegen.</div>';
|
||||
return;
|
||||
}
|
||||
box.innerHTML = jellyfinServers.map((s, i) =>
|
||||
'<div style="border:1px solid #1E1E2E;border-radius:8px;padding:10px;">' +
|
||||
'<div style="display:flex;gap:8px;align-items:center;margin-bottom:8px;">' +
|
||||
'<input type="radio" name="jf-default" ' + (s.isDefault ? 'checked' : '') +
|
||||
' onchange="setJellyfinDefault(' + i + ')" title="Standard-Server">' +
|
||||
'<span style="color:#8888AA;font-size:11px;">Standard</span>' +
|
||||
'<input type="text" value="' + escapeHtml(s.name || '') + '" placeholder="Name (z.B. Zuhause, Kumpel Tim)"' +
|
||||
' oninput="updateJellyfinField(' + i + ',\'name\',this.value)"' +
|
||||
' style="flex:1;background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:6px;padding:5px 9px;font-size:12px;">' +
|
||||
'<button class="btn secondary" onclick="testJellyfinServer(' + i + ')" style="padding:4px 10px;font-size:11px;">Test</button>' +
|
||||
'<button class="btn secondary" onclick="removeJellyfinServer(' + i + ')" style="padding:4px 9px;font-size:11px;">✕</button>' +
|
||||
'</div>' +
|
||||
'<div style="display:flex;gap:8px;">' +
|
||||
'<input type="text" value="' + escapeHtml(s.url || '') + '" placeholder="http://192.168.0.10:8096"' +
|
||||
' oninput="updateJellyfinField(' + i + ',\'url\',this.value)"' +
|
||||
' style="flex:2;background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:6px;padding:5px 9px;font-size:12px;">' +
|
||||
'<input type="password" value="' + escapeHtml(s.apiKey || '') + '" placeholder="API-Key"' +
|
||||
' oninput="updateJellyfinField(' + i + ',\'apiKey\',this.value)"' +
|
||||
' style="flex:1;background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:6px;padding:5px 9px;font-size:12px;">' +
|
||||
'</div>' +
|
||||
'<div id="jf-test-' + i + '" style="font-size:11px;color:#8888AA;margin-top:6px;"></div>' +
|
||||
'</div>'
|
||||
).join('');
|
||||
}
|
||||
|
||||
async function testJellyfinServer(i) {
|
||||
const s = jellyfinServers[i];
|
||||
const out = document.getElementById('jf-test-' + i);
|
||||
if (!s || !out) return;
|
||||
out.textContent = 'prüfe …';
|
||||
try {
|
||||
const r = await fetch('/api/jellyfin-test', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: s.url, apiKey: s.apiKey }),
|
||||
});
|
||||
const d = await r.json();
|
||||
out.innerHTML = d.ok
|
||||
? '<span style="color:#34C759;">✓ erreichbar — ' + escapeHtml(d.name || '') +
|
||||
' (Version ' + escapeHtml(d.version || '?') + ')</span>'
|
||||
: '<span style="color:#FF6E6E;">✗ ' + escapeHtml(d.error || 'nicht erreichbar') + '</span>';
|
||||
} catch (e) {
|
||||
out.innerHTML = '<span style="color:#FF6E6E;">✗ ' + escapeHtml(e.message) + '</span>';
|
||||
}
|
||||
}
|
||||
|
||||
async function saveJellyfinConfig() {
|
||||
const el = document.getElementById('jellyfin-status');
|
||||
el.textContent = 'speichere …';
|
||||
try {
|
||||
const r = await fetch('/api/jellyfin-config', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ servers: jellyfinServers }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!d.ok) throw new Error(d.error || 'Fehler');
|
||||
jellyfinServers = d.config.servers || [];
|
||||
renderJellyfin();
|
||||
el.innerHTML = '<span style="color:#34C759;">✓ gespeichert (' + jellyfinServers.length + ')</span>';
|
||||
setTimeout(() => { el.textContent = ''; }, 4000);
|
||||
} catch (e) {
|
||||
el.innerHTML = '<span style="color:#FF6E6E;">✗ ' + escapeHtml(e.message) + '</span>';
|
||||
}
|
||||
}
|
||||
|
||||
// ── APK-Rollout auf Android/Fire TV (per Satellit + ADB) ─────────
|
||||
let apkTarget = null; // {satId, ip, name}
|
||||
let apkRequestId = null;
|
||||
@@ -4753,11 +4954,20 @@
|
||||
document.getElementById('apk-status').textContent = '';
|
||||
const log = document.getElementById('apk-log');
|
||||
log.style.display = 'none'; log.textContent = '';
|
||||
// Namensfeld vorbelegen — aber nicht mit Seriennummern/IP-Platzhaltern,
|
||||
// die die Netz-Erkennung bei Fire TV oft liefert (z.B. 0d94400fa5b63301).
|
||||
// Namensfeld vorbelegen. Reihenfolge:
|
||||
// 1. Name, den der Agent auf DIESEM Gerät schon trägt (per IP zugeordnet)
|
||||
// 2. zuletzt hier eingegebener Name (im Browser gemerkt)
|
||||
// 3. der Name aus der Netz-Erkennung — aber nur, wenn er menschenlesbar ist
|
||||
// (Fire TV meldet oft Seriennummern wie F8FCE10B4E6C@AS-AFTKA[AirPlay])
|
||||
const known = (hosts || []).find(h => h.ip && h.ip === ip);
|
||||
const remembered = (function () {
|
||||
try { return localStorage.getItem('apkName:' + satId + ':' + ip) || ''; }
|
||||
catch (e) { return ''; }
|
||||
})();
|
||||
const looksTechnical = !name || /^[0-9a-f]{8,}$/i.test(name) ||
|
||||
/^\d{1,3}(\.\d{1,3}){3}$/.test(name);
|
||||
document.getElementById('apk-name').value = looksTechnical ? '' : name;
|
||||
/^\d{1,3}(\.\d{1,3}){3}$/.test(name) || /[@\[\]:]/.test(name);
|
||||
document.getElementById('apk-name').value =
|
||||
(known && known.name) || remembered || (looksTechnical ? '' : name);
|
||||
document.getElementById('apk-modal').classList.add('open');
|
||||
}
|
||||
function closeApkDeploy() {
|
||||
@@ -4785,6 +4995,7 @@
|
||||
// Am PC getippter Name gewinnt; sonst der aus der Netz-Erkennung (der
|
||||
// ist bei Fire TV oft nur die Seriennummer).
|
||||
params.name = document.getElementById('apk-name').value.trim() || apkTarget.name;
|
||||
rememberApkName(params.name);
|
||||
}
|
||||
send({ action: 'sat_command', satellite: apkTarget.satId, cmd: 'adb.install',
|
||||
requestId: apkRequestId, params });
|
||||
@@ -4803,7 +5014,16 @@
|
||||
send({ action: 'sat_command', satellite: apkTarget.satId, cmd: 'adb.provision',
|
||||
requestId: apkRequestId,
|
||||
params: { ip: apkTarget.ip, provision: true,
|
||||
name: document.getElementById('apk-name').value.trim() || apkTarget.name } });
|
||||
name: rememberApkName(document.getElementById('apk-name').value.trim()
|
||||
|| apkTarget.name) } });
|
||||
}
|
||||
|
||||
/** Merkt sich den vergebenen Namen pro Gerät (Vorschlag beim naechsten Mal). */
|
||||
function rememberApkName(n) {
|
||||
try {
|
||||
if (apkTarget && n) localStorage.setItem('apkName:' + apkTarget.satId + ':' + apkTarget.ip, n);
|
||||
} catch (e) { /* privater Modus o.ae. */ }
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Prüft, ob der Agent auf dem Gerät installiert ist und läuft. */
|
||||
|
||||
+137
-2
@@ -328,6 +328,87 @@ function writeLocalLlmConfig(patch) {
|
||||
return cur;
|
||||
}
|
||||
|
||||
// ── Jellyfin-Server (Medien-Integration) ─────────────────────
|
||||
// /shared/config/jellyfin.json — mehrere Server moeglich (eigener + z.B. vom
|
||||
// Kumpel). ARIAs Brain liest dieselbe Datei und spricht die HTTP-API direkt an:
|
||||
// Film suchen -> Sitzung des Abspielgeraets finden -> abspielen. Das ist
|
||||
// deutlich zuverlaessiger, als sich durch die Fire-TV-Oberflaeche zu klicken.
|
||||
const JELLYFIN_CONFIG_FILE = "/shared/config/jellyfin.json";
|
||||
function readJellyfinConfig() {
|
||||
try {
|
||||
const p = JSON.parse(fs.readFileSync(JELLYFIN_CONFIG_FILE, "utf-8"));
|
||||
const servers = Array.isArray(p.servers) ? p.servers : [];
|
||||
return {
|
||||
servers: servers.map(s => ({
|
||||
id: String(s.id || ""),
|
||||
name: String(s.name || ""),
|
||||
url: String(s.url || ""),
|
||||
apiKey: String(s.apiKey || ""),
|
||||
isDefault: !!s.isDefault,
|
||||
})).filter(s => s.id && s.url),
|
||||
};
|
||||
} catch {
|
||||
return { servers: [] };
|
||||
}
|
||||
}
|
||||
function writeJellyfinConfig(cfg) {
|
||||
const servers = (Array.isArray(cfg.servers) ? cfg.servers : [])
|
||||
.map(s => ({
|
||||
id: String(s.id || "").trim() || Math.random().toString(36).slice(2, 10),
|
||||
name: String(s.name || "").trim(),
|
||||
url: String(s.url || "").trim().replace(/\/+$/, ""), // ohne Slash am Ende
|
||||
apiKey: String(s.apiKey || "").trim(),
|
||||
isDefault: !!s.isDefault,
|
||||
}))
|
||||
.filter(s => s.url);
|
||||
// Genau einen Standard-Server markieren (der erste, falls keiner gesetzt ist).
|
||||
if (servers.length && !servers.some(s => s.isDefault)) servers[0].isDefault = true;
|
||||
let seen = false;
|
||||
for (const s of servers) {
|
||||
if (s.isDefault && seen) s.isDefault = false;
|
||||
if (s.isDefault) seen = true;
|
||||
}
|
||||
fs.mkdirSync("/shared/config", { recursive: true });
|
||||
const tmp = JELLYFIN_CONFIG_FILE + ".tmp";
|
||||
fs.writeFileSync(tmp, JSON.stringify({ servers }, null, 2), { mode: 0o600 });
|
||||
fs.renameSync(tmp, JELLYFIN_CONFIG_FILE);
|
||||
try { fs.chmodSync(JELLYFIN_CONFIG_FILE, 0o600); } catch (_) {} // API-Keys!
|
||||
return { servers };
|
||||
}
|
||||
|
||||
/** Erreichbarkeit + Version eines Jellyfin-Servers pruefen. */
|
||||
function testJellyfin(url, apiKey) {
|
||||
return new Promise((resolve) => {
|
||||
let u;
|
||||
try { u = new URL(url.replace(/\/+$/, "") + "/System/Info"); }
|
||||
catch (e) { return resolve({ ok: false, error: "Ungültige URL" }); }
|
||||
const mod = u.protocol === "https:" ? require("https") : require("http");
|
||||
const req = mod.request(u, {
|
||||
method: "GET",
|
||||
timeout: 8000,
|
||||
headers: { "Authorization": `MediaBrowser Token="${apiKey}"` },
|
||||
}, (res) => {
|
||||
let body = "";
|
||||
res.on("data", c => { body += c; if (body.length > 65536) req.destroy(); });
|
||||
res.on("end", () => {
|
||||
if (res.statusCode === 401 || res.statusCode === 403) {
|
||||
return resolve({ ok: false, error: "API-Key abgelehnt (401/403)" });
|
||||
}
|
||||
if (res.statusCode !== 200) {
|
||||
return resolve({ ok: false, error: `HTTP ${res.statusCode}` });
|
||||
}
|
||||
try {
|
||||
const j = JSON.parse(body);
|
||||
resolve({ ok: true, name: j.ServerName || "", version: j.Version || "" });
|
||||
} catch (e) { resolve({ ok: false, error: "Antwort nicht lesbar" }); }
|
||||
});
|
||||
});
|
||||
req.on("timeout", () => { req.destroy(); resolve({ ok: false, error: "Zeitüberschreitung" }); });
|
||||
req.on("error", (e) => resolve({ ok: false, error: e.message }));
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Lokale Modell-Liste (Diagnostic-Dropdown) ────────────────
|
||||
// /shared/config/local_models.json — kuratierte Liste; muss zu den KEYS in
|
||||
// xtts/llama-swap/config.yaml passen. Wird bei Bedarf mit Defaults seeded.
|
||||
@@ -557,7 +638,7 @@ function hostList() {
|
||||
const now = Date.now();
|
||||
return Array.from(hosts.values()).map(h => ({
|
||||
hostId: h.hostId, name: h.name, os: h.os, caps: h.caps, control: h.control,
|
||||
online: (now - (h.last_seen || 0)) < 300000,
|
||||
ip: h.ip || "", online: (now - (h.last_seen || 0)) < 300000,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -575,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,
|
||||
}));
|
||||
}
|
||||
@@ -1087,7 +1170,8 @@ function connectRVS(forcePlain) {
|
||||
const wasKnown = hosts.has(p.hostId);
|
||||
hosts.set(p.hostId, {
|
||||
hostId: p.hostId, name: p.name || p.hostId, os: p.os || "",
|
||||
caps: p.caps || [], control: !!p.control, last_seen: Date.now(),
|
||||
caps: p.caps || [], control: !!p.control, ip: p.ip || "",
|
||||
last_seen: Date.now(),
|
||||
});
|
||||
if (!wasKnown) broadcastHosts();
|
||||
else hosts.get(p.hostId).last_seen = Date.now();
|
||||
@@ -1098,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 || {} });
|
||||
@@ -1127,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();
|
||||
@@ -2025,6 +2116,39 @@ const server = http.createServer((req, res) => {
|
||||
}
|
||||
});
|
||||
return;
|
||||
} else if (req.url === "/api/jellyfin-config" && req.method === "GET") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(readJellyfinConfig()));
|
||||
} else if (req.url === "/api/jellyfin-config" && req.method === "POST") {
|
||||
let body = "";
|
||||
req.on("data", chunk => { body += chunk; if (body.length > 65536) req.destroy(); });
|
||||
req.on("end", () => {
|
||||
try {
|
||||
const cfg = writeJellyfinConfig(JSON.parse(body));
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true, config: cfg }));
|
||||
log("info", "server", `Jellyfin-Config: ${cfg.servers.length} Server gespeichert`);
|
||||
} catch (err) {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: false, error: err.message }));
|
||||
}
|
||||
});
|
||||
return;
|
||||
} else if (req.url === "/api/jellyfin-test" && req.method === "POST") {
|
||||
let body = "";
|
||||
req.on("data", chunk => { body += chunk; if (body.length > 8192) req.destroy(); });
|
||||
req.on("end", async () => {
|
||||
try {
|
||||
const { url, apiKey } = JSON.parse(body);
|
||||
const r = await testJellyfin(String(url || ""), String(apiKey || ""));
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(r));
|
||||
} 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" });
|
||||
@@ -2849,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 101
|
||||
versionName '0.0.1.1'
|
||||
versionCode 102
|
||||
versionName '0.0.1.2'
|
||||
}
|
||||
|
||||
// Fester Signaturschlüssel: jeder Build signiert mit DEMSELBEN Key, damit
|
||||
|
||||
@@ -49,6 +49,17 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Unsichtbarer Helfer: schaltet die Bildschirm-Aufnahme nach einem
|
||||
Neustart automatisch wieder scharf (die Sitzung ist nicht speicherbar,
|
||||
nur die Berechtigung). Durchsichtig, ohne Animation -> man sieht nichts. -->
|
||||
<activity
|
||||
android:name=".CaptureActivity"
|
||||
android:exported="false"
|
||||
android:excludeFromRecents="true"
|
||||
android:noHistory="true"
|
||||
android:taskAffinity=""
|
||||
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
|
||||
|
||||
<service
|
||||
android:name=".AgentService"
|
||||
android:exported="false"
|
||||
|
||||
@@ -14,6 +14,10 @@ data class AgentConfig(
|
||||
/** Wie antwortet ARIA auf diesem Geraet standardmaessig: "overlay" oder "speech".
|
||||
* Pro Anfrage ueberschreibbar ("...bitte als Sprache"). */
|
||||
var replyMode: String = "overlay",
|
||||
/** Soll die Bildschirm-Aufnahme nach einem Neustart automatisch wieder scharf
|
||||
* geschaltet werden? Wird gesetzt, sobald sie einmal erlaubt wurde — die
|
||||
* SITZUNG ist naemlich nicht speicherbar, nur die Berechtigung. */
|
||||
var captureEnabled: Boolean = false,
|
||||
) {
|
||||
companion object {
|
||||
private const val PREFS = "aria_agent"
|
||||
@@ -28,6 +32,7 @@ data class AgentConfig(
|
||||
name = p.getString("name", "") ?: "",
|
||||
controlEnabled = p.getBoolean("control", false),
|
||||
replyMode = p.getString("replyMode", "overlay") ?: "overlay",
|
||||
captureEnabled = p.getBoolean("capture", false),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -41,6 +46,7 @@ data class AgentConfig(
|
||||
putString("name", name)
|
||||
putBoolean("control", controlEnabled)
|
||||
putString("replyMode", replyMode)
|
||||
putBoolean("capture", captureEnabled)
|
||||
apply()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +93,11 @@ class AgentService : Service() {
|
||||
}
|
||||
ScreenCapturer.start(applicationContext, code, data)
|
||||
if (ScreenCapturer.active) {
|
||||
// Merken: nach einem Neustart automatisch wieder scharf
|
||||
// schalten (die Sitzung selbst ist nicht speicherbar).
|
||||
AgentConfig.load(this).takeIf { !it.captureEnabled }?.let {
|
||||
it.captureEnabled = true; it.save(this)
|
||||
}
|
||||
updateNotification("$status · Bildschirm-Zugriff aktiv")
|
||||
} else {
|
||||
updateNotification("Bildschirm-Fehler: ${ScreenCapturer.lastError}")
|
||||
@@ -123,6 +128,19 @@ class AgentService : Service() {
|
||||
sendBroadcast(Intent(ACTION_STATUS).setPackage(packageName))
|
||||
}
|
||||
rvs?.start()
|
||||
|
||||
// Bildschirm-Aufnahme nach Geraete-/App-Neustart automatisch wieder scharf
|
||||
// schalten. Die Berechtigung (AppOp PROJECT_MEDIA) bleibt erhalten, die
|
||||
// SITZUNG nicht — ohne das muesste man am Fernseher jedes Mal von Hand
|
||||
// "Bildschirm-Zugriff erlauben" druecken. Ist der AppOp gesetzt, laeuft
|
||||
// die Anfrage ohne Dialog durch; sonst fragt Android einmal nach.
|
||||
if (cfg.captureEnabled && !ScreenCapturer.active) {
|
||||
android.os.Handler(mainLooper).postDelayed({
|
||||
if (!ScreenCapturer.active) {
|
||||
try { CaptureActivity.request(applicationContext) } catch (_: Throwable) {}
|
||||
}
|
||||
}, 2500)
|
||||
}
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package de.hackersoft.ariaagent
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFocusRequest
|
||||
import android.media.AudioManager
|
||||
|
||||
/**
|
||||
* Audio-Fokus — damit ARIA nicht einfach ueber den laufenden Film redet.
|
||||
*
|
||||
* Ohne Fokus mischt Android beide Tonspuren: Netflix/Jellyfin/Spotify laufen in
|
||||
* voller Lautstaerke weiter und ARIA ist kaum zu verstehen. Mit Fokus verhaelt
|
||||
* sie sich wie ein normaler Sprachassistent:
|
||||
*
|
||||
* "duck" (Standard) laufende Medien werden LEISER, ARIA spricht darueber,
|
||||
* danach geht die Lautstaerke von selbst wieder hoch.
|
||||
* "pause" laufende Medien werden PAUSIERT (Player reagieren auf
|
||||
* den Fokusverlust) — sinnvoll beim Zuhoeren, damit das
|
||||
* Mikrofon nicht den Filmton aufnimmt.
|
||||
* "none" kein Fokus (alte Mischung).
|
||||
*/
|
||||
object AudioFocusHelper {
|
||||
|
||||
private var request: AudioFocusRequest? = null
|
||||
private var holder: String = ""
|
||||
|
||||
/** Fordert den Fokus an. [mode] = duck | pause | none. */
|
||||
@Synchronized
|
||||
fun request(ctx: Context, mode: String, who: String): Boolean {
|
||||
if (mode == "none") return true
|
||||
abandon(ctx)
|
||||
val gain = if (mode == "pause") AudioManager.AUDIOFOCUS_GAIN_TRANSIENT
|
||||
else AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK
|
||||
val usage = if (who == "listen") AudioAttributes.USAGE_ASSISTANCE_SONIFICATION
|
||||
else AudioAttributes.USAGE_ASSISTANT
|
||||
return try {
|
||||
val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
val req = AudioFocusRequest.Builder(gain)
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(usage)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||
.build()
|
||||
)
|
||||
// Kurze Unterbrechungen nicht als "verloren" behandeln.
|
||||
.setWillPauseWhenDucked(false)
|
||||
.setOnAudioFocusChangeListener { }
|
||||
.build()
|
||||
request = req
|
||||
holder = who
|
||||
am.requestAudioFocus(req) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
||||
} catch (_: Throwable) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/** Gibt den Fokus zurueck — die Medien werden wieder lauter / laufen weiter. */
|
||||
@Synchronized
|
||||
fun abandon(ctx: Context) {
|
||||
val req = request ?: return
|
||||
request = null
|
||||
holder = ""
|
||||
try {
|
||||
val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
am.abandonAudioFocusRequest(req)
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package de.hackersoft.ariaagent
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.media.projection.MediaProjectionManager
|
||||
import android.os.Bundle
|
||||
|
||||
/**
|
||||
* Unsichtbarer Helfer, der die Bildschirm-Aufnahme (neu) scharf schaltet.
|
||||
*
|
||||
* Hintergrund: Die Berechtigung (AppOp PROJECT_MEDIA) bleibt dauerhaft erhalten,
|
||||
* die MediaProjection-SITZUNG aber nicht — Android laesst sie grundsaetzlich
|
||||
* nicht speichern, sie stirbt mit dem Prozess. Nach einem Neustart des Geraets
|
||||
* oder der App muesste man sonst jedes Mal von Hand "Bildschirm-Zugriff erlauben"
|
||||
* druecken (auf einem Fire TV mit der Fernbedienung besonders laestig).
|
||||
*
|
||||
* Diese Activity loest die Anfrage automatisch aus und reicht das Ergebnis an den
|
||||
* Dienst weiter. Ist PROJECT_MEDIA per ADB erlaubt, laeuft das OHNE Dialog durch —
|
||||
* der Nutzer sieht nichts (durchsichtiges Theme, kein Fenster-Inhalt).
|
||||
*/
|
||||
class CaptureActivity : Activity() {
|
||||
|
||||
companion object {
|
||||
private const val REQ = 4711
|
||||
|
||||
/** Startet die (Neu-)Scharfschaltung aus dem Dienst heraus. */
|
||||
fun request(ctx: Context) {
|
||||
val i = Intent(ctx, CaptureActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or
|
||||
Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS or
|
||||
Intent.FLAG_ACTIVITY_NO_ANIMATION)
|
||||
ctx.startActivity(i)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
try {
|
||||
val mpm = getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
|
||||
startActivityForResult(mpm.createScreenCaptureIntent(), REQ)
|
||||
} catch (e: Throwable) {
|
||||
ScreenCapturer.lastError = "Anfrage fehlgeschlagen: ${e.message}"
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
if (requestCode == REQ && resultCode == RESULT_OK && data != null) {
|
||||
val i = Intent(this, AgentService::class.java)
|
||||
.setAction(AgentService.ACTION_PROJECTION)
|
||||
.putExtra(AgentService.EXTRA_RESULT_CODE, resultCode)
|
||||
.putExtra(AgentService.EXTRA_RESULT_DATA, data)
|
||||
androidx.core.content.ContextCompat.startForegroundService(this, i)
|
||||
} else {
|
||||
// Abgelehnt oder (ohne AppOp) Dialog weggeklickt — kein Drama, der
|
||||
// Nutzer kann es in der App jederzeit nachholen.
|
||||
ScreenCapturer.lastError =
|
||||
"Bildschirm-Zugriff nicht erteilt (in der Agent-App nachholen)"
|
||||
}
|
||||
finish()
|
||||
overridePendingTransition(0, 0)
|
||||
}
|
||||
}
|
||||
@@ -246,6 +246,9 @@ class MainActivity : AppCompatActivity() {
|
||||
controlEnabled = intent.getBooleanExtra("control", true),
|
||||
replyMode = intent.getStringExtra("reply_mode")?.trim()
|
||||
.takeUnless { it.isNullOrEmpty() } ?: old.replyMode,
|
||||
// Der Satellit erteilt beim Einrichten PROJECT_MEDIA — dann darf sich
|
||||
// die Bildschirm-Aufnahme nach jedem Neustart selbst scharf schalten.
|
||||
captureEnabled = intent.getBooleanExtra("capture", old.captureEnabled),
|
||||
)
|
||||
cfg.save(this)
|
||||
AgentService.start(this)
|
||||
|
||||
@@ -126,11 +126,24 @@ class RvsClient(
|
||||
p.put("name", config.displayName())
|
||||
val kind = if (isTv) "Fire TV / Android TV" else "Android"
|
||||
p.put("os", "$kind ${Build.VERSION.RELEASE} (${Build.MODEL})")
|
||||
// Eigene LAN-IP mitmelden: so kann die Diagnostic diesen Agenten dem im
|
||||
// Netz entdeckten Geraet zuordnen (z.B. um beim Ausrollen den bereits
|
||||
// vergebenen Namen vorzuschlagen statt der technischen Kennung).
|
||||
localIp()?.let { p.put("ip", it) }
|
||||
p.put("caps", JSONArray(caps))
|
||||
p.put("control", config.controlEnabled)
|
||||
send(webSocket, "host_hello", p)
|
||||
}
|
||||
|
||||
/** Erste echte IPv4 dieses Geraets im LAN (ohne Loopback). */
|
||||
private fun localIp(): String? = try {
|
||||
java.net.NetworkInterface.getNetworkInterfaces().toList()
|
||||
.filter { it.isUp && !it.isLoopback }
|
||||
.flatMap { it.inetAddresses.toList() }
|
||||
.firstOrNull { !it.isLoopbackAddress && it is java.net.Inet4Address }
|
||||
?.hostAddress
|
||||
} catch (_: Throwable) { null }
|
||||
|
||||
private fun startPing(webSocket: WebSocket) {
|
||||
pingThread?.interrupt()
|
||||
pingThread = Thread {
|
||||
@@ -318,7 +331,7 @@ class RvsClient(
|
||||
if (mode == "speech" || mode == "both") {
|
||||
val r = speakOut(p, text)
|
||||
r.exceptionOrNull()?.let { problems += "Sprachausgabe: ${it.message}" }
|
||||
?: run { done += "gesprochen" }
|
||||
?: run { done += "gesprochen (ARIAs Stimme)" }
|
||||
}
|
||||
if (done.isEmpty()) return err(problems.joinToString("; ").ifBlank { "nichts ausgegeben" })
|
||||
val msg = done.joinToString(" + ") + (if (problems.isEmpty()) "" else " (${problems.joinToString("; ")})")
|
||||
@@ -329,19 +342,33 @@ class RvsClient(
|
||||
private fun doSpeak(p: JSONObject): JSONObject {
|
||||
val text = p.optString("text")
|
||||
if (text.isBlank() && !p.has("audio_base64")) return err("speak braucht 'text' oder 'audio_base64'.")
|
||||
val how = if (p.optString("audio_base64").isNotBlank()) "Audio abgespielt"
|
||||
else if (p.optString("engine") == "device") "über die Geräte-Stimme gesprochen"
|
||||
else "wird mit ARIAs Stimme gesprochen (Audio kommt vom Server)"
|
||||
val r = speakOut(p, text)
|
||||
return r.fold(
|
||||
{ JSONObject().put("ok", true).put("result", JSONObject().put("message", "gesprochen")) },
|
||||
{ JSONObject().put("ok", true).put("result", JSONObject().put("message", how)) },
|
||||
{ err("Sprachausgabe fehlgeschlagen: ${it.message}") },
|
||||
)
|
||||
}
|
||||
|
||||
/** Audio bevorzugt (ARIAs echte Stimme), sonst Geraete-TTS. */
|
||||
/**
|
||||
* Sprachausgabe. Reihenfolge:
|
||||
* 1. mitgeliefertes Audio abspielen,
|
||||
* 2. ARIAs ECHTE Stimme ueber den Server anfordern (Standard) — Fire TV
|
||||
* Sticks haben oft gar keine (deutsche) TTS-Engine,
|
||||
* 3. geraeteeigene Sprachausgabe nur auf ausdrueckliche Anforderung
|
||||
* (engine='device') oder wenn kein Server-Audio moeglich ist.
|
||||
* focus: duck (Medien leiser, Standard) | pause (Medien anhalten) | none.
|
||||
*/
|
||||
private fun speakOut(p: JSONObject, text: String): Result<Unit> {
|
||||
val audio = p.optString("audio_base64")
|
||||
return if (audio.isNotBlank())
|
||||
Speaker.play(appCtx, audio, p.optString("audio_format", "wav"))
|
||||
else Speaker.speak(appCtx, text)
|
||||
val focus = p.optString("focus").ifBlank { "duck" }
|
||||
if (audio.isNotBlank())
|
||||
return Speaker.play(appCtx, audio, p.optString("audio_format", "wav"), focus)
|
||||
if (p.optString("engine") == "device")
|
||||
return Speaker.speak(appCtx, text, focus)
|
||||
return runCatching { voice.requestTts(text); Unit }
|
||||
}
|
||||
|
||||
private fun doVolume(p: JSONObject): JSONObject {
|
||||
|
||||
@@ -24,28 +24,38 @@ object Speaker {
|
||||
private var ttsReady = false
|
||||
private var player: MediaPlayer? = null
|
||||
|
||||
/** Spielt fertiges Audio (Base64, z.B. WAV aus dem ARIA-TTS) ueber die Lautsprecher. */
|
||||
fun play(ctx: Context, audioB64: String, format: String = "wav"): Result<Unit> = runCatching {
|
||||
/** Spielt fertiges Audio (Base64, z.B. WAV aus dem ARIA-TTS) ueber die Lautsprecher.
|
||||
* [focus] = duck (Medien leiser, Standard) | pause (Medien anhalten) | none. */
|
||||
fun play(ctx: Context, audioB64: String, format: String = "wav",
|
||||
focus: String = "duck"): Result<Unit> = runCatching {
|
||||
stop()
|
||||
val bytes = Base64.decode(audioB64, Base64.DEFAULT)
|
||||
val f = File(ctx.cacheDir, "aria_speak.${format.ifBlank { "wav" }}")
|
||||
f.writeBytes(bytes)
|
||||
// Erst Fokus holen, damit Netflix & Co. leiser werden (sonst redet ARIA
|
||||
// einfach mittendrin mit und man versteht beides nicht).
|
||||
AudioFocusHelper.request(ctx, focus, "speak")
|
||||
player = MediaPlayer().apply {
|
||||
setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.setUsage(AudioAttributes.USAGE_ASSISTANT)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||
.build()
|
||||
)
|
||||
setDataSource(f.absolutePath)
|
||||
setOnCompletionListener { mp -> try { mp.release() } catch (_: Exception) {}; player = null }
|
||||
setOnCompletionListener { mp ->
|
||||
try { mp.release() } catch (_: Exception) {}
|
||||
player = null
|
||||
AudioFocusHelper.abandon(ctx) // Medien wieder laut / weiterlaufen
|
||||
}
|
||||
setOnErrorListener { _, _, _ -> AudioFocusHelper.abandon(ctx); false }
|
||||
prepare()
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
/** Rueckfallebene: geraeteeigene Sprachausgabe. */
|
||||
fun speak(ctx: Context, text: String): Result<Unit> = runCatching {
|
||||
fun speak(ctx: Context, text: String, focus: String = "duck"): Result<Unit> = runCatching {
|
||||
if (tts == null) {
|
||||
tts = TextToSpeech(ctx.applicationContext) { status ->
|
||||
ttsReady = status == TextToSpeech.SUCCESS
|
||||
@@ -55,8 +65,26 @@ object Speaker {
|
||||
var waited = 0
|
||||
while (!ttsReady && waited < 3000) { Thread.sleep(100); waited += 100 }
|
||||
}
|
||||
if (!ttsReady) throw IllegalStateException("Keine Sprachausgabe verfuegbar (TTS-Engine fehlt)")
|
||||
tts?.speak(text, TextToSpeech.QUEUE_FLUSH, null, "aria")
|
||||
if (!ttsReady) throw IllegalStateException(
|
||||
"Geraet hat keine Sprachausgabe (TTS-Engine fehlt — auf Fire TV ueblich)")
|
||||
// Sprache pruefen: eine Engine ohne deutsche Daten wuerde sonst STILL
|
||||
// nichts sagen und wir wuerden faelschlich 'gesprochen' melden.
|
||||
val lang = tts?.setLanguage(Locale.GERMAN) ?: TextToSpeech.LANG_NOT_SUPPORTED
|
||||
if (lang == TextToSpeech.LANG_MISSING_DATA || lang == TextToSpeech.LANG_NOT_SUPPORTED)
|
||||
throw IllegalStateException(
|
||||
"Geraete-Sprachausgabe kann kein Deutsch (Sprachdaten fehlen)")
|
||||
AudioFocusHelper.request(ctx, focus, "speak")
|
||||
tts?.setOnUtteranceProgressListener(object : android.speech.tts.UtteranceProgressListener() {
|
||||
override fun onStart(id: String?) {}
|
||||
override fun onDone(id: String?) { AudioFocusHelper.abandon(ctx) }
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onError(id: String?) { AudioFocusHelper.abandon(ctx) }
|
||||
})
|
||||
val rc = tts?.speak(text, TextToSpeech.QUEUE_FLUSH, null, "aria")
|
||||
if (rc != TextToSpeech.SUCCESS) {
|
||||
AudioFocusHelper.abandon(ctx)
|
||||
throw IllegalStateException("Sprachausgabe abgelehnt (Code $rc)")
|
||||
}
|
||||
Unit
|
||||
}
|
||||
|
||||
@@ -68,9 +96,10 @@ object Speaker {
|
||||
am.setStreamVolume(AudioManager.STREAM_MUSIC, v, AudioManager.FLAG_SHOW_UI)
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
fun stop(ctx: Context? = null) {
|
||||
try { player?.stop(); player?.release() } catch (_: Exception) {}
|
||||
player = null
|
||||
try { tts?.stop() } catch (_: Exception) {}
|
||||
ctx?.let { AudioFocusHelper.abandon(it) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,10 @@ class VoiceSession(
|
||||
/** Text der letzten ARIA-Antwort auf UNSERE Frage (fuer die Einblendung). */
|
||||
@Volatile private var lastReplyText: String = ""
|
||||
|
||||
/** Angeforderte TTS-Ausgaben (messageId) — deren Audio spielen wir ab, auch
|
||||
* wenn es nicht aus einem eigenen Sprach-Turn stammt. */
|
||||
private val expectedAudio = java.util.Collections.synchronizedSet(mutableSetOf<String>())
|
||||
|
||||
val isRecording: Boolean get() = recording
|
||||
|
||||
// ── Aufnehmen + streamen ────────────────────────────────────────
|
||||
@@ -100,6 +104,9 @@ class VoiceSession(
|
||||
|
||||
rec = r
|
||||
recording = true
|
||||
// Beim Zuhoeren laufende Medien ANHALTEN — sonst nimmt das Mikrofon den
|
||||
// Filmton mit auf und die Spracherkennung versteht nur Kauderwelsch.
|
||||
AudioFocusHelper.request(appCtx, "pause", "listen")
|
||||
|
||||
send("stt_stream_start", JSONObject().apply {
|
||||
put("requestId", sid)
|
||||
@@ -154,6 +161,9 @@ class VoiceSession(
|
||||
try { aec?.release() } catch (_: Exception) {}
|
||||
try { ns?.release() } catch (_: Exception) {}
|
||||
rec = null; aec = null; ns = null
|
||||
// Aufnahme vorbei -> Medien laufen weiter (die Antwort holt sich gleich
|
||||
// selbst wieder Fokus, sobald ARIAs Audio kommt).
|
||||
AudioFocusHelper.abandon(appCtx)
|
||||
sessionId?.let {
|
||||
send("stt_stream_end", JSONObject().apply {
|
||||
put("requestId", it); put("reason", reason); put("targetInstance", "")
|
||||
@@ -164,6 +174,32 @@ class VoiceSession(
|
||||
.put("result", JSONObject().put("message", "Aufnahme beendet"))
|
||||
}
|
||||
|
||||
/**
|
||||
* Laesst ARIAs ECHTE Stimme etwas sagen: fordert das TTS-Audio ueber den
|
||||
* bestehenden Server-Pfad an (tts_request -> Bridge -> F5/XTTS -> audio_pcm)
|
||||
* und spielt es ab, sobald es hereinkommt.
|
||||
*
|
||||
* Besser als die geraeteeigene Sprachausgabe: Fire TV Sticks haben oft gar
|
||||
* keine (deutsche) TTS-Engine — und es klingt nach ARIA, nicht nach Roboter.
|
||||
*/
|
||||
fun requestTts(text: String): String {
|
||||
val mid = "spk_${System.currentTimeMillis()}_${Random.nextInt(100000, 999999)}"
|
||||
expectedAudio.add(mid)
|
||||
// Nicht unbegrenzt wachsen lassen, falls mal keine Antwort kommt.
|
||||
if (expectedAudio.size > 20) {
|
||||
synchronized(expectedAudio) {
|
||||
expectedAudio.iterator().let { if (it.hasNext()) { it.next(); it.remove() } }
|
||||
}
|
||||
}
|
||||
send("tts_request", JSONObject().apply {
|
||||
put("text", text)
|
||||
put("messageId", mid)
|
||||
put("voice", "")
|
||||
put("speed", 1.0)
|
||||
})
|
||||
return mid
|
||||
}
|
||||
|
||||
/** Text direkt an ARIA schicken (ohne Mikrofon) — Antwort landet hier genauso. */
|
||||
fun ask(text: String): JSONObject {
|
||||
if (text.isBlank()) return JSONObject().put("ok", false).put("error", "leerer Text")
|
||||
@@ -200,10 +236,15 @@ class VoiceSession(
|
||||
true
|
||||
}
|
||||
"audio_pcm" -> {
|
||||
// Nur abspielen, wenn es die Antwort auf UNSERE Frage ist und
|
||||
// Sprachausgabe gewuenscht war (sonst redet auch das Handy mit).
|
||||
// Abspielen, wenn (a) wir dieses Audio selbst angefordert haben
|
||||
// (host_speak/notify) oder (b) es die Antwort auf UNSERE Frage ist und
|
||||
// Sprachausgabe gewuenscht war. Sonst wuerde auch das Handy mitreden.
|
||||
val mid = p.optString("messageId")
|
||||
if (mid.isNotBlank() && mid == myMessageId && wantSpeech()) feedAudio(p)
|
||||
if (mid.isNotBlank() &&
|
||||
(expectedAudio.contains(mid) || (mid == myMessageId && wantSpeech()))) {
|
||||
feedAudio(p)
|
||||
if (p.optBoolean("final", false)) expectedAudio.remove(mid)
|
||||
}
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
@@ -245,6 +286,8 @@ class VoiceSession(
|
||||
try { track?.stop() } catch (_: Exception) {}
|
||||
try { track?.release() } catch (_: Exception) {}
|
||||
track = null; playingMessageId = null
|
||||
// Fertig gesprochen -> Medien wieder hochfahren/weiterlaufen lassen.
|
||||
AudioFocusHelper.abandon(appCtx)
|
||||
onEvent("bereit")
|
||||
return
|
||||
}
|
||||
@@ -253,6 +296,8 @@ class VoiceSession(
|
||||
val pcm = try { Base64.decode(b64, Base64.DEFAULT) } catch (_: Exception) { return }
|
||||
if (track == null || playingMessageId != mid) {
|
||||
try { track?.release() } catch (_: Exception) {}
|
||||
// ARIA antwortet -> laufende Medien leiser (duck), bis sie fertig ist.
|
||||
AudioFocusHelper.request(appCtx, "duck", "speak")
|
||||
val min = AudioTrack.getMinBufferSize(rate, AudioFormat.CHANNEL_OUT_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT)
|
||||
track = AudioTrack.Builder()
|
||||
@@ -276,5 +321,6 @@ class VoiceSession(
|
||||
stopListening("shutdown")
|
||||
try { track?.stop(); track?.release() } catch (_: Exception) {}
|
||||
track = null
|
||||
AudioFocusHelper.abandon(appCtx)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.1"
|
||||
AGENT_VERSION = "0.0.1.2"
|
||||
|
||||
HEARTBEAT_SEC = 25
|
||||
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
*/
|
||||
import { spawn } from "child_process";
|
||||
import { EventEmitter } from "events";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { isAssistantMessage, isResultMessage, isContentDelta } from "../types/claude-cli.js";
|
||||
const DEFAULT_TIMEOUT = 86400000; // 24h — lange Agent-Loops (Pentests etc.)
|
||||
export class ClaudeSubprocess extends EventEmitter {
|
||||
@@ -26,6 +29,8 @@ export class ClaudeSubprocess extends EventEmitter {
|
||||
timeoutId = null;
|
||||
isKilled = false;
|
||||
_stdinClosed = false;
|
||||
_systemPromptFile = null;
|
||||
_systemPromptDir = null;
|
||||
/**
|
||||
* Start the Claude CLI subprocess with the given prompt
|
||||
*/
|
||||
@@ -51,6 +56,7 @@ export class ClaudeSubprocess extends EventEmitter {
|
||||
// Handle spawn errors (e.g., claude not found)
|
||||
this.process.on("error", (err) => {
|
||||
this.clearTimeout();
|
||||
this._cleanupSystemPrompt();
|
||||
if (err.message.includes("ENOENT")) {
|
||||
reject(new Error("Claude CLI not found. Install with: npm install -g @anthropic-ai/claude-code"));
|
||||
}
|
||||
@@ -85,6 +91,7 @@ export class ClaudeSubprocess extends EventEmitter {
|
||||
this.process.on("close", (code) => {
|
||||
console.error(`[Subprocess] Process closed with code: ${code}`);
|
||||
this.clearTimeout();
|
||||
this._cleanupSystemPrompt();
|
||||
// Process any remaining buffer
|
||||
if (this.buffer.trim()) {
|
||||
this.processBuffer();
|
||||
@@ -96,6 +103,7 @@ export class ClaudeSubprocess extends EventEmitter {
|
||||
}
|
||||
catch (err) {
|
||||
this.clearTimeout();
|
||||
this._cleanupSystemPrompt();
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
@@ -114,13 +122,47 @@ export class ClaudeSubprocess extends EventEmitter {
|
||||
"stream-json", // ARIA: User-Messages via stdin (Zwischenruf)
|
||||
"--model",
|
||||
options.model, // Model alias (opus/sonnet/haiku)
|
||||
"--no-session-persistence", "--dangerously-skip-permissions", "--system-prompt", options.systemPrompt, "--safe-mode",
|
||||
"--no-session-persistence", "--dangerously-skip-permissions", "--safe-mode",
|
||||
];
|
||||
// ARIA: System-Prompt ueber eine DATEI statt als Kommandozeilen-Argument.
|
||||
// Grund: execve() begrenzt argv+envp zusammen auf ARG_MAX — im Container
|
||||
// nur 128 KiB. Ein gewachsener System-Prompt (gepinnte Erinnerungen,
|
||||
// Skills, Projektkontext; real gesehen ~77k Zeichen) sprengte das und der
|
||||
// Start brach mit "spawn E2BIG" ab: der Turn ging verloren, die App bekam
|
||||
// 502 und die Nachricht blieb unbeantwortet. Ueber die Datei gibt es
|
||||
// praktisch keine Groessengrenze mehr.
|
||||
this._systemPromptFile = null;
|
||||
const sysPrompt = options.systemPrompt || "";
|
||||
if (sysPrompt) {
|
||||
try {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "aria-sysprompt-"));
|
||||
const file = path.join(dir, "system-prompt.txt");
|
||||
fs.writeFileSync(file, sysPrompt, "utf-8");
|
||||
this._systemPromptFile = file;
|
||||
this._systemPromptDir = dir;
|
||||
args.push("--system-prompt-file", file);
|
||||
}
|
||||
catch (err) {
|
||||
// Notfalls wie bisher (kleine Prompts funktionieren so weiterhin).
|
||||
console.error(`[Subprocess] System-Prompt-Datei fehlgeschlagen (${err.message}) — nutze Argument`);
|
||||
args.push("--system-prompt", sysPrompt);
|
||||
}
|
||||
}
|
||||
if (options.sessionId) {
|
||||
args.push("--session-id", options.sessionId);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
/** Temporaere System-Prompt-Datei wieder entfernen. */
|
||||
_cleanupSystemPrompt() {
|
||||
try {
|
||||
if (this._systemPromptFile)
|
||||
fs.rmSync(this._systemPromptDir || this._systemPromptFile, { recursive: true, force: true });
|
||||
}
|
||||
catch (_) { }
|
||||
this._systemPromptFile = null;
|
||||
this._systemPromptDir = null;
|
||||
}
|
||||
/**
|
||||
* Eine User-Message im stream-json-Input-Format an stdin schreiben.
|
||||
* Genutzt fuer den initialen Prompt UND fuer Zwischenrufe (sendMessage).
|
||||
|
||||
@@ -93,6 +93,15 @@ const ALLOWED_TYPES = new Set([
|
||||
// ab, die Bridge antwortet mit agent_history_list. OHNE diese Typen verwirft
|
||||
// der RVS sie still an der Allow-List und die Historie bleibt leer.
|
||||
"agent_history", "agent_history_query", "agent_history_list",
|
||||
// STT-Lease: die App fragt vor einer Aufnahme, WELCHE STT-Instanz sie nehmen
|
||||
// soll (stt_lease_request -> stt_lease). Fehlten diese Typen hier, lief die
|
||||
// Anfrage IMMER in den 250ms-Timeout: jede Aufnahme startete verspaetet (erste
|
||||
// Silben weg) und ohne targetInstance nahmen ALLE STT-Worker dieselbe Session
|
||||
// an -> doppelte stt_endpoint. chat_ack quittiert getippte Nachrichten; ohne
|
||||
// ihn liefen sie in der App nach 3 Versuchen auf "fehlgeschlagen", obwohl sie
|
||||
// laengst verarbeitet waren. stt_transcribe_* ist die Wake-Wort-Bestaetigung.
|
||||
"stt_lease", "stt_lease_request", "stt_transcribe_blob", "stt_transcribe_result",
|
||||
"chat_ack",
|
||||
// Raum-Diagnose: Diagnostic fragt die aktuellen RVS-Raeume ab (rooms_query),
|
||||
// RVS antwortet direkt mit rooms_info (Fingerprint + Laenge + Client-Zahl je
|
||||
// Raum). Deckt Token-Prefix-Kollisionen auf (2 Raeume, gleicher 8-Zeichen-Log).
|
||||
@@ -104,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",
|
||||
|
||||
@@ -48,9 +48,11 @@ CONTROL_ENABLED=true
|
||||
# adb.provision Agent FERN einrichten: RVS-Zugang uebergeben, statt ihn mit der
|
||||
# Fernbedienung abzutippen (params: ip, rvs_host, rvs_token, ...)
|
||||
# adb.status laeuft/installiert der Agent auf dem Geraet? (params: ip)
|
||||
# adb.permissions Sonderrechte per ADB erteilen (Bildschirmaufnahme/Overlay/
|
||||
# Mikro) — spart das Zusammenklicken mit der Fernbedienung
|
||||
# ACHTUNG: Diese Liste ueberschreibt den Code-Default. Wird eine neue Aktion
|
||||
# ergaenzt, MUSS sie hier nachgetragen werden — sonst lehnt der Satellit sie ab.
|
||||
CONTROL_ALLOWLIST=dial.launch,wol,http.get,snmp.get,snmp.walk,snmp.printer,snmp.ports,snmp.info,fritzbox.info,fritzbox.hosts,ssh.exec,adb.devices,adb.connect,adb.install,adb.shell,adb.provision,adb.status
|
||||
CONTROL_ALLOWLIST=dial.launch,wol,http.get,snmp.get,snmp.walk,snmp.printer,snmp.ports,snmp.info,fritzbox.info,fritzbox.hosts,ssh.exec,adb.devices,adb.connect,adb.install,adb.shell,adb.provision,adb.status,adb.permissions
|
||||
|
||||
# ─── ADB / Android-Rollout (optional) ──────────────────────────────
|
||||
# Fire TV Sticks & Android-Geraete im LAN: APK ausrollen ohne Datei-Kopieren.
|
||||
|
||||
+53
-3
@@ -637,6 +637,8 @@ async def _control(action: str, params: dict, devices: list[dict]) -> dict:
|
||||
return await loop.run_in_executor(None, _do_adb_provision, params)
|
||||
if action == "adb.status":
|
||||
return await loop.run_in_executor(None, _do_adb_status, params)
|
||||
if action == "adb.permissions":
|
||||
return await loop.run_in_executor(None, _do_adb_permissions, params)
|
||||
return {"ok": False, "error": f"Aktion '{action}' nicht implementiert."}
|
||||
except Exception as exc:
|
||||
return {"ok": False, "error": f"{action} fehlgeschlagen: {exc}"}
|
||||
@@ -1267,6 +1269,42 @@ def _do_adb_status(params: dict) -> dict:
|
||||
"nicht installiert")}}
|
||||
|
||||
|
||||
def _adb_grant_permissions(target: str, package: str) -> dict:
|
||||
"""Erteilt dem Agenten die Sonderrechte per ADB — das spart das Antippen mit
|
||||
der Fernbedienung (auf einem Fire TV besonders muehsam):
|
||||
PROJECT_MEDIA Bildschirmaufnahme ohne Nachfrage-Dialog (Screenshots)
|
||||
SYSTEM_ALERT_WINDOW Einblendung ueber dem laufenden Bild (Overlay)
|
||||
RECORD_AUDIO Mikrofon (Sprachdialog)
|
||||
Best effort: was nicht geht, wird gemeldet, bricht aber nichts ab."""
|
||||
granted, failed = [], []
|
||||
ops = [("appops", ["appops", "set", package, "PROJECT_MEDIA", "allow"], "Bildschirmaufnahme"),
|
||||
("appops", ["appops", "set", package, "SYSTEM_ALERT_WINDOW", "allow"], "Einblendung")]
|
||||
perms = [("android.permission.RECORD_AUDIO", "Mikrofon")]
|
||||
for _, cmd, label in ops:
|
||||
rc, out, err = _adb_run(["-s", target, "shell"] + cmd, timeout=30)
|
||||
(granted if rc == 0 and "error" not in f"{out}{err}".lower() else failed).append(label)
|
||||
for perm, label in perms:
|
||||
rc, out, err = _adb_run(["-s", target, "shell", "pm", "grant", package, perm], timeout=30)
|
||||
(granted if rc == 0 and "error" not in f"{out}{err}".lower() else failed).append(label)
|
||||
return {"granted": granted, "failed": failed}
|
||||
|
||||
|
||||
def _do_adb_permissions(params: dict) -> dict:
|
||||
"""Nur die Berechtigungen setzen (ohne Neuinstallation/Einrichtung)."""
|
||||
target = _adb_target(params)
|
||||
if not target:
|
||||
return {"ok": False, "error": "ip/host erforderlich."}
|
||||
package = (params.get("package") or AGENT_PACKAGE).strip()
|
||||
ok, msg = _adb_connect(target)
|
||||
if not ok:
|
||||
return {"ok": False, "error": msg}
|
||||
r = _adb_grant_permissions(target, package)
|
||||
msg = "erteilt: " + (", ".join(r["granted"]) or "—")
|
||||
if r["failed"]:
|
||||
msg += " · nicht moeglich: " + ", ".join(r["failed"])
|
||||
return {"ok": True, "result": {"target": target, "message": msg, **r}}
|
||||
|
||||
|
||||
def _do_adb_provision(params: dict) -> dict:
|
||||
"""Richtet den ARIA-Agent auf dem Geraet FERN ein (RVS-Zugang uebergeben).
|
||||
|
||||
@@ -1301,6 +1339,11 @@ def _do_adb_provision(params: dict) -> dict:
|
||||
return {"ok": False, "error": f"'{package}' ist nicht installiert — "
|
||||
"erst adb.install ausfuehren."}
|
||||
|
||||
# Sonderrechte gleich mit erteilen (Bildschirm/Overlay/Mikro) — sonst muesste
|
||||
# man sie am Fernseher mit der Fernbedienung zusammenklicken.
|
||||
perms = ({"granted": [], "failed": []} if params.get("skip_permissions")
|
||||
else _adb_grant_permissions(target, package))
|
||||
|
||||
# WICHTIG: 'adb shell' haengt die Argumente zu EINER Kommandozeile zusammen,
|
||||
# die auf dem Geraet nochmal von der Shell zerlegt wird. Werte mit Leerzeichen
|
||||
# ("Wohnzimmer Fire TV") wuerden dabei abgeschnitten -> jeden Wert quoten.
|
||||
@@ -1319,7 +1362,10 @@ def _do_adb_provision(params: dict) -> dict:
|
||||
"-e", "token", q(rvs_token),
|
||||
"--ei", "port", str(int(params.get("rvs_port") or 443)),
|
||||
"--ez", "tls", "true" if _as_bool(params.get("rvs_tls"), True) else "false",
|
||||
"--ez", "control", "true" if _as_bool(params.get("control"), True) else "false"]
|
||||
"--ez", "control", "true" if _as_bool(params.get("control"), True) else "false",
|
||||
# Nur wenn die Bildschirm-Berechtigung wirklich sitzt: dann darf sich
|
||||
# die Aufnahme nach jedem Neustart selbst scharf schalten (dialogfrei).
|
||||
"--ez", "capture", "true" if "Bildschirmaufnahme" in perms.get("granted", []) else "false"]
|
||||
if params.get("name"):
|
||||
args += ["-e", "name", q(str(params["name"]))]
|
||||
if params.get("reply_mode"):
|
||||
@@ -1336,9 +1382,13 @@ def _do_adb_provision(params: dict) -> dict:
|
||||
"Android hat den Einrichtungs-Intent nicht ausgeliefert (Activity "
|
||||
"war schon offen). Auf dem Geraet die Agent-App einmal schliessen "
|
||||
"und erneut 'Nur einrichten' druecken. Details: " + txt[:200]}
|
||||
msg = f"Agent eingerichtet (RVS {rvs_host}) und gestartet"
|
||||
if perms.get("granted"):
|
||||
msg += " · Rechte erteilt: " + ", ".join(perms["granted"])
|
||||
if perms.get("failed"):
|
||||
msg += " · manuell noetig: " + ", ".join(perms["failed"])
|
||||
return {"ok": True, "result": {
|
||||
"target": target, "package": package,
|
||||
"message": f"Agent eingerichtet (RVS {rvs_host}) und gestartet"}}
|
||||
"target": target, "package": package, "permissions": perms, "message": msg}}
|
||||
|
||||
|
||||
def _as_bool(v, default: bool) -> bool:
|
||||
|
||||
+38
-1
@@ -4,7 +4,7 @@
|
||||
# ════════════════════════════════════════════════
|
||||
|
||||
# ─── Welche Dienste startet DIESER Node? ──────────
|
||||
# Komma-getrennt aus: voxtral, whisper, f5tts, llm
|
||||
# Komma-getrennt aus: voxtral, whisper, f5tts, llm, openhands
|
||||
# Nur die aufgefuehrten Dienste starten bei `docker compose up -d`.
|
||||
# nur STT-Box (Voxtral) → COMPOSE_PROFILES=voxtral
|
||||
# nur STT-Box (Whisper) → COMPOSE_PROFILES=whisper
|
||||
@@ -13,6 +13,12 @@
|
||||
# Kleine Karte (wenig VRAM): Whisper + F5-TTS auf EINER GPU
|
||||
# → COMPOSE_PROFILES=whisper,f5tts
|
||||
# All-in-One, grosse Karte → COMPOSE_PROFILES=voxtral,f5tts,llm
|
||||
# LLM + OpenHands-Oberflaeche → COMPOSE_PROFILES=llm,openhands
|
||||
# 'openhands' ist REIN OPTIONAL (Standalone-Entwickler-Agent, s.u.). Wer es nicht
|
||||
# auffuehrt, bei dem wird der Container gar nicht erst angelegt — das Image wird
|
||||
# nicht einmal geladen. Fuer den normalen ARIA-Betrieb also einfach weglassen.
|
||||
# ACHTUNG: Profile brauchen compose v2 (`docker compose`, mit Leerzeichen). Das
|
||||
# alte `docker-compose` 1.x behandelt sie unzuverlaessig.
|
||||
# Hinweis: voxtral UND whisper zusammen NICHT sinnvoll — beide sind STT und
|
||||
# wuerden dieselbe Anfrage doppelt beantworten. Pro Node genau EINEN STT waehlen:
|
||||
# Voxtral-3B (~9 GB, beste Qualitaet) ODER Whisper (klein, passt neben F5-TTS).
|
||||
@@ -56,3 +62,34 @@ RVS_SNI=
|
||||
# WHISPER_MODEL=small # tiny|base|small|medium|large-v3 (Hot-Swap via Diagnostic)
|
||||
# WHISPER_LANGUAGE=de
|
||||
# LLM_MODEL=qwen3-8b # Key aus llama-swap/config.yaml
|
||||
|
||||
# ─── OpenAI-Endpunkt des lokalen LLM (optional) ───
|
||||
# llama-swap ist OpenAI-kompatibel (/v1/chat/completions, /v1/models). Damit
|
||||
# koennen Fremd-Tools direkt auf das lokale Modell: OpenHands, Continue,
|
||||
# Open WebUI, Aider ... Base-URL dann: http://<box>:<LLM_OPENAI_PORT>/v1
|
||||
#
|
||||
# LLM_OPENAI_BIND steuert, WER drankommt:
|
||||
# 127.0.0.1 nur diese Maschine (Default, sicher)
|
||||
# 0.0.0.0 das ganze LAN → dann UNBEDINGT LLM_OPENAI_KEY setzen!
|
||||
LLM_OPENAI_BIND=127.0.0.1
|
||||
LLM_OPENAI_PORT=8081
|
||||
# API-Key. Leer = Endpunkt ohne Authentifizierung (nur mit BIND=127.0.0.1 ok).
|
||||
# WICHTIG: Steht hier ein Wert, hat er VORRANG — das Feld ist dann in der ARIA-
|
||||
# Diagnostic schreibgeschuetzt. Leer lassen, wenn Du den Key lieber dort pro Box
|
||||
# setzen willst (er wird dann auf der Box unter /models/openai_key.json abgelegt).
|
||||
# Der Key wirkt, sobald ein Modell das naechste Mal geladen wird.
|
||||
LLM_OPENAI_KEY=
|
||||
|
||||
# ─── OpenHands (optional, eigenes Profil) ─────────
|
||||
# Standalone-Entwickler-Agent, der das lokale LLM nutzt. Start:
|
||||
# COMPOSE_PROFILES=llm,openhands docker compose up -d
|
||||
# Dann http://<box>:<OPENHANDS_PORT> aufrufen.
|
||||
# ACHTUNG: OpenHands startet eigene Arbeits-Container und bekommt dafuer den
|
||||
# Docker-Socket — das ist faktisch Root-Zugriff auf diese Maschine. Deshalb
|
||||
# BIND auf 127.0.0.1 lassen und von aussen per SSH-Tunnel zugreifen:
|
||||
# ssh -L 3000:127.0.0.1:3000 user@box -> lokal http://localhost:3000
|
||||
OPENHANDS_BIND=127.0.0.1
|
||||
OPENHANDS_PORT=3000
|
||||
# Wie OpenHands das lokale Modell anspricht (Container-intern, daher :8080):
|
||||
OPENHANDS_LLM_BASE_URL=http://llama-swap:8080/v1
|
||||
OPENHANDS_LLM_MODEL=openai/qwen3-8b
|
||||
|
||||
@@ -70,3 +70,83 @@ nvidia-smi # Host sieht die GPU
|
||||
docker run --rm --gpus all nvidia/cuda:12.4.0-base-ubuntu22.04 nvidia-smi # Container auch
|
||||
docker logs -f aria-whisper-bridge # "RVS verbunden" + service_status ready
|
||||
```
|
||||
|
||||
## Lokales LLM von außen nutzen (OpenAI-Endpunkt)
|
||||
|
||||
`llama-swap` spricht **OpenAI-kompatibel** (`/v1/chat/completions`, `/v1/models`).
|
||||
Damit können Fremd-Tools direkt auf das lokale Modell: **OpenHands**, Continue,
|
||||
Open WebUI, Aider …
|
||||
|
||||
```
|
||||
Base-URL: http://<box>:<LLM_OPENAI_PORT>/v1
|
||||
Modell: qwen3-8b (oder ein anderer Key aus llama-swap/config.yaml)
|
||||
API-Key: LLM_OPENAI_KEY (falls gesetzt)
|
||||
```
|
||||
|
||||
In der `.env`:
|
||||
|
||||
| Variable | Bedeutung |
|
||||
|---|---|
|
||||
| `LLM_OPENAI_BIND` | `127.0.0.1` = nur diese Maschine (**Default, sicher**) · `0.0.0.0` = ganzes LAN |
|
||||
| `LLM_OPENAI_PORT` | Host-Port (Default `8081`) |
|
||||
| `LLM_OPENAI_KEY` | API-Key; leer = **ohne Authentifizierung** |
|
||||
|
||||
> ⚠️ `LLM_OPENAI_BIND=0.0.0.0` **ohne** `LLM_OPENAI_KEY` heißt: jeder im Netz kann
|
||||
> das Modell benutzen. Entweder Key setzen oder auf localhost gebunden lassen.
|
||||
|
||||
**Wo wird was konfiguriert?**
|
||||
|
||||
- **Port und Erreichbarkeit** immer in der `.env`. Beides ist ein Docker-Port-Mapping
|
||||
und steht beim Container-Start fest — es lässt sich nicht aus der Ferne ändern.
|
||||
- **API-Key**: entweder in der `.env` **oder** — wenn dort leer — in der ARIA-Diagnostic
|
||||
unter *Compute-Flotte* bei der jeweiligen Box. Dort abgelegt landet er auf der Box
|
||||
unter `/models/openai_key.json` (Rechte 0600).
|
||||
- **Die `.env` hat immer Vorrang.** Steht `LLM_OPENAI_KEY` dort, ist das Feld in der
|
||||
Diagnostic **schreibgeschützt** (🔒-Hinweis).
|
||||
- Ein geänderter Key greift, sobald ein Modell das **nächste Mal geladen** wird
|
||||
(llama-server bekommt ihn per `--api-key`). Laufendes Modell also einmal entladen
|
||||
lassen (TTL) oder ein anderes Modell anfragen.
|
||||
|
||||
Prüfen:
|
||||
```bash
|
||||
curl -s http://localhost:8081/v1/models # ohne Key
|
||||
curl -s -H "Authorization: Bearer $LLM_OPENAI_KEY" \
|
||||
http://localhost:8081/v1/models # mit Key
|
||||
```
|
||||
|
||||
## OpenHands (optionales Profil)
|
||||
|
||||
OpenHands ist **rein optional** und liegt in einem eigenen Profil. Wer es nicht in
|
||||
`COMPOSE_PROFILES` aufführt, bei dem wird der Container **gar nicht erst angelegt** —
|
||||
das Image wird nicht einmal geladen. Für den normalen ARIA-Betrieb also einfach weglassen:
|
||||
|
||||
```bash
|
||||
COMPOSE_PROFILES=voxtral,f5tts,llm docker compose up -d # ohne OpenHands
|
||||
COMPOSE_PROFILES=llm,openhands docker compose up -d # mit OpenHands
|
||||
```
|
||||
|
||||
Dann `http://<box>:<OPENHANDS_PORT>` (Default `3000`) öffnen. Das lokale Modell ist
|
||||
schon vorbelegt (`OPENHANDS_LLM_BASE_URL`, `OPENHANDS_LLM_MODEL`) und in der
|
||||
Oberfläche änderbar.
|
||||
|
||||
| Variable | Bedeutung |
|
||||
|---|---|
|
||||
| `OPENHANDS_BIND` | `127.0.0.1` (Default) oder `0.0.0.0` fürs LAN |
|
||||
| `OPENHANDS_PORT` | Host-Port (Default `3000`) |
|
||||
| `OPENHANDS_LLM_BASE_URL` | Default `http://llama-swap:8080/v1` (Container-intern — **nicht** der Host-Port) |
|
||||
| `OPENHANDS_LLM_MODEL` | Default `openai/qwen3-8b` |
|
||||
|
||||
> ⚠️ OpenHands startet eigene Arbeits-Container und bekommt dafür den **Docker-Socket**.
|
||||
> Das ist faktisch Root-Zugriff auf diese Maschine — wer die Oberfläche erreicht,
|
||||
> kann im Zweifel alles auf der Box.
|
||||
>
|
||||
> **Deshalb bleibt `OPENHANDS_BIND` auf `127.0.0.1`.** Von einem anderen Rechner
|
||||
> greifst Du per SSH-Tunnel zu, statt den Port ins LAN zu öffnen:
|
||||
> ```bash
|
||||
> ssh -L 3000:127.0.0.1:3000 user@box # dann lokal http://localhost:3000
|
||||
> ```
|
||||
> Das kostet nichts und ist die wirksamste Absicherung, solange der Socket
|
||||
> durchgereicht wird. Wer echte Isolation braucht: OpenHands in eine eigene VM,
|
||||
> oder einen rootless Docker-Daemon als Beiwagen statt des Host-Sockets.
|
||||
|
||||
Ohne das Profil ändert sich nichts: Wer nur ARIAs Box fahren will, startet OpenHands gar nicht.
|
||||
|
||||
@@ -139,6 +139,13 @@ services:
|
||||
# Liest die vom llm-adapter generierte Config. Beim allerersten Boot faengt
|
||||
# restart: unless-stopped die Reihenfolge ab, bis der Adapter sie geschrieben hat.
|
||||
command: ["--config", "/models/llama-swap.config.yaml", "--listen", "0.0.0.0:8080"]
|
||||
# OpenAI-kompatibler Endpunkt (/v1/chat/completions, /v1/models) — damit
|
||||
# koennen Fremd-Tools wie OpenHands, Continue oder Open WebUI direkt auf das
|
||||
# lokale LLM. Standardmaessig NUR auf localhost gebunden (sicher); fuer
|
||||
# Zugriff aus dem LAN LLM_OPENAI_BIND=0.0.0.0 setzen — dann aber unbedingt
|
||||
# LLM_OPENAI_KEY vergeben, sonst kann jeder im Netz das Modell nutzen.
|
||||
ports:
|
||||
- "${LLM_OPENAI_BIND:-127.0.0.1}:${LLM_OPENAI_PORT:-8081}:8080"
|
||||
restart: unless-stopped
|
||||
|
||||
# ─── Local-LLM-Adapter — RVS <-> llama.cpp ────
|
||||
@@ -172,3 +179,33 @@ services:
|
||||
# Erster Load eines Modells kann ein GGUF ziehen (mehrere GB) — grosszuegig.
|
||||
- LLM_TIMEOUT_SEC=${LLM_TIMEOUT_SEC:-600}
|
||||
restart: unless-stopped
|
||||
|
||||
# ─── OpenHands (optional, Standalone) ────────────────
|
||||
# Eigenstaendige Entwickler-Agenten-Oberflaeche, die das LOKALE LLM ueber den
|
||||
# OpenAI-Endpunkt von llama-swap nutzt. Bewusst ein eigenes Profil: wer nur
|
||||
# ARIAs Box fahren will, startet es gar nicht erst.
|
||||
# COMPOSE_PROFILES=llm,openhands docker compose up -d
|
||||
# Danach http://<box>:${OPENHANDS_PORT:-3000} und dort als Provider
|
||||
# "OpenAI-kompatibel" mit Base-URL http://llama-swap:8080/v1 eintragen
|
||||
# (aus dem Container heraus erreichbar), Modell z.B. qwen3-8b.
|
||||
openhands:
|
||||
image: docker.all-hands.dev/all-hands-ai/openhands:latest
|
||||
container_name: aria-openhands
|
||||
profiles: ["openhands"]
|
||||
environment:
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:latest
|
||||
- LOG_ALL_EVENTS=true
|
||||
# Vorbelegung fuer das lokale Modell — in der Oberflaeche aenderbar.
|
||||
- LLM_BASE_URL=${OPENHANDS_LLM_BASE_URL:-http://llama-swap:8080/v1}
|
||||
- LLM_API_KEY=${LLM_OPENAI_KEY:-local}
|
||||
- LLM_MODEL=${OPENHANDS_LLM_MODEL:-openai/qwen3-8b}
|
||||
volumes:
|
||||
# OpenHands startet seine Arbeits-Container selbst -> braucht den Socket.
|
||||
# ACHTUNG: das gibt dem Container Kontrolle ueber den Docker-Host.
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./openhands-state:/.openhands
|
||||
ports:
|
||||
- "${OPENHANDS_BIND:-127.0.0.1}:${OPENHANDS_PORT:-3000}:3000"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -896,7 +896,14 @@ async def run_loop(runner: F5Runner) -> None:
|
||||
url = f"{scheme}://{uri_host}:{RVS_PORT}?token={RVS_TOKEN}"
|
||||
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
|
||||
|
||||
connect_kwargs = {"ping_interval": 20, "ping_timeout": 10, "max_size": 50 * 1024 * 1024}
|
||||
# max_size MUSS zum RVS/zur Bridge passen (1500 MB). Der RVS ist ein
|
||||
# BROADCAST-Relay: eine grosse Nachricht (Datei-Transfer, Voice-Upload,
|
||||
# APK-Update) geht an JEDEN Client im Raum. Wer ein kleineres Limit hat,
|
||||
# schliesst die Verbindung mit Code 1009 ("message too big") und landet in
|
||||
# einer Reconnect-Schleife — real gesehen mit einem 209-MB-Frame, der
|
||||
# whisper und f5tts im Minutentakt neu starten liess.
|
||||
_MAX_WS_BYTES = int(os.getenv("RVS_MAX_SIZE_MB", "1500")) * 1024 * 1024
|
||||
connect_kwargs = {"ping_interval": 20, "ping_timeout": 10, "max_size": _MAX_WS_BYTES}
|
||||
try:
|
||||
logger.info("Verbinde zu RVS: %s%s", masked, f" (TCP {RVS_HOST})" if (use_tls and RVS_SNI) else "")
|
||||
async with websockets.connect(url, **connect_kwargs) as ws:
|
||||
|
||||
+110
-1
@@ -27,6 +27,8 @@ import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import time
|
||||
|
||||
import httpx
|
||||
@@ -94,6 +96,44 @@ BASE_CONFIG_PATH = os.getenv("LLAMA_BASE_CONFIG", "/llamaswap/config.yaml")
|
||||
GEN_CONFIG_PATH = os.getenv("LLAMA_GEN_CONFIG", "/models/llama-swap.config.yaml")
|
||||
REGISTRY_PATH = os.getenv("LLM_REGISTRY", "/models/aria_models.json")
|
||||
|
||||
# ── OpenAI-kompatibler Endpunkt (fuer OpenHands & Co.) ─────
|
||||
# Der Port ist ein Docker-Port-Mapping und steht beim Container-Start fest —
|
||||
# er kommt IMMER aus der .env. Der API-Key kann zusaetzlich zur Laufzeit aus der
|
||||
# Diagnostic gesetzt werden, ABER: steht er in der .env, hat die .env Vorrang
|
||||
# und das Feld ist in der Diagnostic schreibgeschuetzt.
|
||||
OPENAI_PORT = os.getenv("LLM_OPENAI_PORT", "8081").strip() or "8081"
|
||||
OPENAI_BIND = os.getenv("LLM_OPENAI_BIND", "127.0.0.1").strip() or "127.0.0.1"
|
||||
_OPENAI_KEY_ENV = os.getenv("LLM_OPENAI_KEY", "").strip()
|
||||
OPENAI_KEY_FILE = os.getenv("LLM_OPENAI_KEY_FILE", "/models/openai_key.json")
|
||||
|
||||
|
||||
def _openai_key() -> str:
|
||||
"""Gueltiger API-Key: .env gewinnt, sonst der in der Diagnostic gesetzte."""
|
||||
if _OPENAI_KEY_ENV:
|
||||
return _OPENAI_KEY_ENV
|
||||
try:
|
||||
with open(OPENAI_KEY_FILE) as f:
|
||||
return str((json.load(f) or {}).get("key") or "").strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _openai_key_save(key: str) -> bool:
|
||||
"""Speichert den in der Diagnostic gesetzten Key. Bei .env-Vorrang: nein."""
|
||||
if _OPENAI_KEY_ENV:
|
||||
return False
|
||||
try:
|
||||
os.makedirs(os.path.dirname(OPENAI_KEY_FILE), exist_ok=True)
|
||||
tmp = OPENAI_KEY_FILE + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
json.dump({"key": key.strip()}, f)
|
||||
os.replace(tmp, OPENAI_KEY_FILE)
|
||||
os.chmod(OPENAI_KEY_FILE, 0o600)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.error("OpenAI-Key speichern fehlgeschlagen: %s", exc)
|
||||
return False
|
||||
|
||||
# ── Auslastungs-Monitor (Stage E) ──────────────────────────
|
||||
import node_stats
|
||||
STATS_PATH = os.getenv("STATS_PATH", "/models/aria_stats.json")
|
||||
@@ -144,6 +184,19 @@ def _generate_config() -> int:
|
||||
f"-hf {repo}:{quant}\n-ngl {ngl} -c {ctx} --jinja"),
|
||||
"ttl": 3600,
|
||||
}
|
||||
# API-Key auf JEDES Modell anwenden (auch die aus dem Basis-Template) —
|
||||
# llama-server prueft dann den Authorization-Header. Ohne Key bleibt der
|
||||
# Endpunkt offen; deshalb ist er per Default nur an localhost gebunden.
|
||||
key = _openai_key()
|
||||
for k, spec in list(models.items()):
|
||||
cmd = str((spec or {}).get("cmd") or "")
|
||||
if not cmd:
|
||||
continue
|
||||
cmd = re.sub(r"\s*--api-key\s+\S+", "", cmd) # alten Key entfernen
|
||||
if key:
|
||||
cmd = cmd.rstrip() + f" --api-key {shlex.quote(key)}"
|
||||
models[k] = dict(spec, cmd=cmd)
|
||||
|
||||
out = dict(base)
|
||||
out["models"] = models
|
||||
try:
|
||||
@@ -270,6 +323,13 @@ async def _announce(ws) -> None:
|
||||
"instanceId": INSTANCE_ID, "service": WORKER_SERVICE,
|
||||
"node": NODE_NAME, "gpus": GPU_IDS, "model": LLM_MODEL,
|
||||
"models": models, # welche Modelle diese Box fahren kann (llama-swap-Keys)
|
||||
# OpenAI-Endpunkt fuer Fremd-Tools (OpenHands & Co.). Der Key selbst wird
|
||||
# NICHT gesendet (Broadcast!) — nur ob einer gesetzt ist und ob die .env
|
||||
# ihn vorgibt (dann ist das Feld in der Diagnostic schreibgeschuetzt).
|
||||
"openaiPort": OPENAI_PORT,
|
||||
"openaiBind": OPENAI_BIND,
|
||||
"openaiKeySet": bool(_openai_key()),
|
||||
"openaiEnvLocked": bool(_OPENAI_KEY_ENV),
|
||||
})
|
||||
logger.info("worker_hello: models=%s", models)
|
||||
|
||||
@@ -355,6 +415,45 @@ async def _do_llm_request(ws, payload: dict) -> None:
|
||||
})
|
||||
|
||||
|
||||
async def _handle_openai_config(ws, payload: dict) -> None:
|
||||
"""Setzt den API-Key des OpenAI-Endpunkts (aus der Diagnostic).
|
||||
|
||||
Der PORT wird hier bewusst NICHT geaendert: er ist ein Docker-Port-Mapping
|
||||
und steht beim Container-Start fest — dafuer ist die .env zustaendig.
|
||||
Steht der Key in der .env, gewinnt sie und wir lehnen ab.
|
||||
"""
|
||||
if (payload.get("instanceId") or INSTANCE_ID) != INSTANCE_ID:
|
||||
return
|
||||
req_id = payload.get("requestId", "")
|
||||
key = str(payload.get("apiKey") or "").strip()
|
||||
if _OPENAI_KEY_ENV:
|
||||
await _send(ws, "llm_openai_config_result", {
|
||||
"requestId": req_id, "instanceId": INSTANCE_ID, "ok": False,
|
||||
"error": "Der API-Key steht in der .env dieser Box — die hat Vorrang. "
|
||||
"Zum Aendern dort anpassen und die Box neu starten.",
|
||||
})
|
||||
return
|
||||
if not _openai_key_save(key):
|
||||
await _send(ws, "llm_openai_config_result", {
|
||||
"requestId": req_id, "instanceId": INSTANCE_ID, "ok": False,
|
||||
"error": "Key konnte nicht gespeichert werden (Schreibrechte auf /models?)",
|
||||
})
|
||||
return
|
||||
# Config neu schreiben, damit llama-server den Key beim naechsten Laden nutzt.
|
||||
_generate_config()
|
||||
logger.info("OpenAI-API-Key %s (wirkt beim naechsten Modell-Laden)",
|
||||
"gesetzt" if key else "entfernt")
|
||||
await _send(ws, "llm_openai_config_result", {
|
||||
"requestId": req_id, "instanceId": INSTANCE_ID, "ok": True,
|
||||
"keySet": bool(key),
|
||||
"message": ("API-Key gesetzt. Er greift, sobald das Modell das naechste Mal "
|
||||
"geladen wird — bereits laufende Modelle einmal entladen lassen "
|
||||
"(TTL) oder ein anderes Modell anfragen."
|
||||
if key else "API-Key entfernt — der Endpunkt ist jetzt offen."),
|
||||
})
|
||||
await _announce(ws)
|
||||
|
||||
|
||||
async def _handle_provision(ws, payload: dict) -> None:
|
||||
"""Fuegt ein Modell hinzu: Registry+Config schreiben, reload, dann Warmup
|
||||
(zieht das GGUF via -hf beim ersten Load). Meldet die neue Modell-Liste."""
|
||||
@@ -435,7 +534,14 @@ async def _run() -> None:
|
||||
uri_host = RVS_SNI if (use_tls and RVS_SNI) else RVS_HOST
|
||||
url = f"{scheme}://{uri_host}:{RVS_PORT}?token={RVS_TOKEN}"
|
||||
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
|
||||
connect_kwargs = {"ping_interval": 20, "ping_timeout": 10, "max_size": 16 * 1024 * 1024}
|
||||
# max_size MUSS zum RVS/zur Bridge passen (1500 MB). Der RVS ist ein
|
||||
# BROADCAST-Relay: eine grosse Nachricht (Datei-Transfer, Voice-Upload,
|
||||
# APK-Update) geht an JEDEN Client im Raum. Wer ein kleineres Limit hat,
|
||||
# schliesst die Verbindung mit Code 1009 ("message too big") und landet in
|
||||
# einer Reconnect-Schleife — real gesehen mit einem 209-MB-Frame, der
|
||||
# whisper und f5tts im Minutentakt neu starten liess.
|
||||
_MAX_WS_BYTES = int(os.getenv("RVS_MAX_SIZE_MB", "1500")) * 1024 * 1024
|
||||
connect_kwargs = {"ping_interval": 20, "ping_timeout": 10, "max_size": _MAX_WS_BYTES}
|
||||
try:
|
||||
logger.info("Verbinde zu RVS: %s%s (llama=%s)", masked,
|
||||
f" (TCP {RVS_HOST})" if (use_tls and RVS_SNI) else "", LLAMA_URL)
|
||||
@@ -464,6 +570,9 @@ async def _run() -> None:
|
||||
# Auslastungs-Monitor (node_stats_*) abfangen.
|
||||
if await _stats.handle(ws, mtype, payload, _send):
|
||||
continue
|
||||
if mtype == "llm_openai_config":
|
||||
asyncio.create_task(_handle_openai_config(ws, payload))
|
||||
continue
|
||||
if mtype not in ("llm_request", "llm_provision_model", "llm_remove_model"):
|
||||
continue
|
||||
if mtype == "llm_provision_model":
|
||||
|
||||
@@ -760,7 +760,14 @@ async def run_loop(sessions: SessionManager) -> None:
|
||||
uri_host = RVS_SNI if (use_tls and RVS_SNI) else RVS_HOST
|
||||
url = f"{scheme}://{uri_host}:{RVS_PORT}?token={RVS_TOKEN}"
|
||||
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
|
||||
connect_kwargs = {"ping_interval": 20, "ping_timeout": 10, "max_size": 50 * 1024 * 1024}
|
||||
# max_size MUSS zum RVS/zur Bridge passen (1500 MB). Der RVS ist ein
|
||||
# BROADCAST-Relay: eine grosse Nachricht (Datei-Transfer, Voice-Upload,
|
||||
# APK-Update) geht an JEDEN Client im Raum. Wer ein kleineres Limit hat,
|
||||
# schliesst die Verbindung mit Code 1009 ("message too big") und landet in
|
||||
# einer Reconnect-Schleife — real gesehen mit einem 209-MB-Frame, der
|
||||
# whisper und f5tts im Minutentakt neu starten liess.
|
||||
_MAX_WS_BYTES = int(os.getenv("RVS_MAX_SIZE_MB", "1500")) * 1024 * 1024
|
||||
connect_kwargs = {"ping_interval": 20, "ping_timeout": 10, "max_size": _MAX_WS_BYTES}
|
||||
try:
|
||||
logger.info("Verbinde zu RVS: %s%s", masked, f" (TCP {RVS_HOST})" if (use_tls and RVS_SNI) else "")
|
||||
async with websockets.connect(url, **connect_kwargs) as ws:
|
||||
|
||||
+23
-7
@@ -584,6 +584,12 @@ class SessionManager:
|
||||
if ws is None:
|
||||
return # disconnected — Endpointer pausiert bis Reconnect
|
||||
|
||||
# Modell laedt noch (Kaltstart): Audio weiter sammeln, aber NICHT
|
||||
# transkribieren/finalisieren — sonst kaeme leerer Text heraus und die
|
||||
# Aufnahme waere verloren. Sobald es geladen ist, laeuft es normal weiter.
|
||||
if self.runner.model is None:
|
||||
return
|
||||
|
||||
audio_ms = self._buffer_duration_ms(sess)
|
||||
|
||||
# Hard-Cap erreicht → wie Endpoint behandeln (egal ob neuer Text)
|
||||
@@ -892,7 +898,14 @@ async def run_loop(runner: WhisperRunner, sessions: SessionManager) -> None:
|
||||
uri_host = RVS_SNI if (use_tls and RVS_SNI) else RVS_HOST
|
||||
url = f"{scheme}://{uri_host}:{RVS_PORT}?token={RVS_TOKEN}"
|
||||
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
|
||||
connect_kwargs = {"ping_interval": 20, "ping_timeout": 10, "max_size": 50 * 1024 * 1024}
|
||||
# max_size MUSS zum RVS/zur Bridge passen (1500 MB). Der RVS ist ein
|
||||
# BROADCAST-Relay: eine grosse Nachricht (Datei-Transfer, Voice-Upload,
|
||||
# APK-Update) geht an JEDEN Client im Raum. Wer ein kleineres Limit hat,
|
||||
# schliesst die Verbindung mit Code 1009 ("message too big") und landet in
|
||||
# einer Reconnect-Schleife — real gesehen mit einem 209-MB-Frame, der
|
||||
# whisper und f5tts im Minutentakt neu starten liess.
|
||||
_MAX_WS_BYTES = int(os.getenv("RVS_MAX_SIZE_MB", "1500")) * 1024 * 1024
|
||||
connect_kwargs = {"ping_interval": 20, "ping_timeout": 10, "max_size": _MAX_WS_BYTES}
|
||||
try:
|
||||
logger.info("Verbinde zu RVS: %s%s", masked, f" (TCP {RVS_HOST})" if (use_tls and RVS_SNI) else "")
|
||||
async with websockets.connect(url, **connect_kwargs) as ws:
|
||||
@@ -962,19 +975,22 @@ async def run_loop(runner: WhisperRunner, sessions: SessionManager) -> None:
|
||||
# transcribe-Call mit Leerstring weil Model None.
|
||||
target_model = payload.get("model") or runner.model_size or WHISPER_MODEL
|
||||
needs_load = (runner.model is None) or (target_model != runner.model_size)
|
||||
# WICHTIG: Session SOFORT anlegen — auch wenn das Modell
|
||||
# noch laedt. Sonst laufen alle stt_audio_chunk der ersten
|
||||
# Aufnahme ins Leere (feed_chunk findet keine Session) und
|
||||
# der Take ist verloren; erst die zweite Aufnahme klappt,
|
||||
# weil das Modell dann warm ist. Der Buffer fuellt sich
|
||||
# jetzt waehrend des Ladens, das Transkribieren wartet.
|
||||
sessions.start_session(payload)
|
||||
if needs_load:
|
||||
async def _load_then_start(p, target):
|
||||
async def _load_only(target):
|
||||
await _broadcast_status(ws, "loading", model=target)
|
||||
try:
|
||||
await runner.ensure_loaded(target)
|
||||
await _broadcast_status(ws, "ready", model=runner.model_size)
|
||||
except Exception as e:
|
||||
await _broadcast_status(ws, "error", error=str(e)[:200])
|
||||
return
|
||||
sessions.start_session(p)
|
||||
asyncio.create_task(_load_then_start(payload, target_model))
|
||||
else:
|
||||
sessions.start_session(payload)
|
||||
asyncio.create_task(_load_only(target_model))
|
||||
|
||||
elif mtype == "stt_audio_chunk":
|
||||
ok = sessions.feed_chunk(payload)
|
||||
|
||||
Reference in New Issue
Block a user