feat: ARIA kann Dateien an User zurueckgeben (PDFs, Bilder, Office-Docs, ...)

ARIA setzt im Antworttext einen Marker `[FILE: /shared/uploads/aria_xxx.ext]`.
Bridge extrahiert ihn (Marker wird aus dem TTS-Text entfernt) und sendet
ein neues file_from_aria-Event ueber RVS an App + Diagnostic.

Diagnostic:
- Eigene Bubble mit Datei-Icon + Klick-Handler
- PDF/Bild → neuer Browser-Tab via /shared/* HTTP-Route
- Andere → Download via download-Attribut

App:
- Neues FileOpenerModule (Kotlin) — Intent.ACTION_VIEW mit FileProvider,
  Android-Picker waehlt App nach MIME-Type
- file_paths.xml erweitert (cache + files + external)
- file_response liefert jetzt mimeType mit
- Klick auf ARIA-Anhang: lokal vorhanden → direkt oeffnen, sonst
  file_request mit autoOpen-Flag → bei Empfang persistAttachment + open

Stefan muss noch im aria-core/OpenClaw System-Prompt einen Hinweis
einbauen: "Wenn du dem User eine Datei erstellt hast (Pfad in
/shared/uploads/), haenge am Ende deiner Antwort einmalig
[FILE: /shared/uploads/aria_<name>.<ext>] an. Der Marker wird aus dem
sichtbaren Text entfernt und als Anhang in App und Diagnostic angezeigt."

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-10 17:56:47 +02:00
parent fc3ecaacca
commit 3b19f05c5b
8 changed files with 224 additions and 6 deletions
@@ -7,7 +7,7 @@ import com.facebook.react.uimanager.ViewManager
class ApkInstallerPackage : ReactPackage { class ApkInstallerPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> { override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
return listOf(ApkInstallerModule(reactContext)) return listOf(ApkInstallerModule(reactContext), FileOpenerModule(reactContext))
} }
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> { override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
@@ -0,0 +1,54 @@
package com.ariacockpit
import android.content.Intent
import android.net.Uri
import android.os.Build
import androidx.core.content.FileProvider
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import java.io.File
/**
* Oeffnet eine beliebige Datei (PDF, Bild, Office-Doc, ...) mit der vom User
* gewaehlten App via Android-Intent-Picker. Nutzt FileProvider damit auch
* Android 7+ (content:// statt file://) das URI lesen darf.
*
* MIME-Type wird vom Caller bestimmt — App-Auswahl ist davon abhaengig (PDF
* → PDF-Viewer, image/* → Galerie, etc.).
*/
class FileOpenerModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
override fun getName() = "FileOpener"
@ReactMethod
fun open(filePath: String, mimeType: String, promise: Promise) {
try {
val cleanPath = filePath.removePrefix("file://")
val file = File(cleanPath)
if (!file.exists()) {
promise.reject("FILE_NOT_FOUND", "Datei nicht gefunden: $cleanPath")
return
}
val context = reactApplicationContext
val uri: Uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
} else {
Uri.fromFile(file)
}
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, mimeType.ifBlank { "*/*" })
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
// Chooser zeigt Android-Auswahl falls mehrere Apps das MIME oeffnen koennen.
val chooser = Intent.createChooser(intent, "Oeffnen mit").apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(chooser)
promise.resolve(true)
} catch (e: Exception) {
promise.reject("OPEN_ERROR", e.message, e)
}
}
}
@@ -1,4 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<paths> <paths>
<cache-path name="cache" path="." /> <cache-path name="cache" path="." />
<files-path name="files" path="." />
<external-path name="external" path="." />
<external-files-path name="external_files" path="." />
<external-cache-path name="external_cache" path="." />
</paths> </paths>
+66 -5
View File
@@ -20,6 +20,7 @@ import {
Modal, Modal,
ToastAndroid, ToastAndroid,
AppState, AppState,
NativeModules,
} from 'react-native'; } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import RNFS from 'react-native-fs'; import RNFS from 'react-native-fs';
@@ -80,6 +81,23 @@ const capMessages = (msgs: ChatMessage[]): ChatMessage[] =>
const DEFAULT_ATTACHMENT_DIR = `${RNFS.DocumentDirectoryPath}/chat_attachments`; const DEFAULT_ATTACHMENT_DIR = `${RNFS.DocumentDirectoryPath}/chat_attachments`;
const STORAGE_PATH_KEY = 'aria_attachment_storage_path'; const STORAGE_PATH_KEY = 'aria_attachment_storage_path';
const { FileOpener } = NativeModules as {
FileOpener?: { open: (filePath: string, mimeType: string) => Promise<boolean> };
};
/** Datei mit Android-Intent-Picker oeffnen (System waehlt App nach MIME). */
async function openFileWithIntent(filePath: string, mimeType: string): Promise<void> {
if (!FileOpener) {
ToastAndroid.show('FileOpener Native Module fehlt', ToastAndroid.SHORT);
return;
}
try {
await FileOpener.open(filePath, mimeType || 'application/octet-stream');
} catch (err: any) {
ToastAndroid.show(`Oeffnen fehlgeschlagen: ${err?.message || err}`, ToastAndroid.LONG);
}
}
/** Image-Vorschau in der Chat-Bubble. Misst die echte Bild-Dimension via /** Image-Vorschau in der Chat-Bubble. Misst die echte Bild-Dimension via
* Image.getSize + setzt aspectRatio dynamisch — dadurch passt sich die * Image.getSize + setzt aspectRatio dynamisch — dadurch passt sich die
* Bubble ans Bild an (kein "Strich" mehr bei breiten oder hohen Bildern). */ * Bubble ans Bild an (kein "Strich" mehr bei breiten oder hohen Bildern). */
@@ -179,6 +197,10 @@ const ChatScreen: React.FC = () => {
const flatListRef = useRef<FlatList>(null); const flatListRef = useRef<FlatList>(null);
const messageIdCounter = useRef(0); const messageIdCounter = useRef(0);
// ServerPaths fuer die der User auf "oeffnen" geklickt hat — beim
// file_response wird die Datei nach dem Speichern direkt mit dem System-
// Intent geoeffnet (PDF-Viewer, Galerie, etc.).
const autoOpenPaths = useRef<Set<string>>(new Set());
// Eindeutige Message-ID generieren // Eindeutige Message-ID generieren
const nextId = (): string => { const nextId = (): string => {
@@ -349,11 +371,32 @@ const ChatScreen: React.FC = () => {
return; return;
} }
// file_from_aria: ARIA hat eine Datei rausgegeben → als ARIA-Bubble anzeigen
if (message.type === 'file_from_aria') {
const p = message.payload || {};
const ariaMsg: ChatMessage = {
id: nextId(),
sender: 'aria',
text: '',
timestamp: Date.now(),
attachments: [{
type: (typeof p.mimeType === 'string' && p.mimeType.startsWith('image/')) ? 'image' : 'file',
name: (p.name as string) || 'datei',
size: (p.size as number) || 0,
mimeType: (p.mimeType as string) || '',
serverPath: (p.serverPath as string) || '',
}],
};
setMessages(prev => capMessages([...prev, ariaMsg]));
return;
}
// file_response: Re-Download von Server — lokal speichern // file_response: Re-Download von Server — lokal speichern
if (message.type === 'file_response') { if (message.type === 'file_response') {
const reqId = (message.payload.requestId as string) || ''; const reqId = (message.payload.requestId as string) || '';
const b64 = (message.payload.base64 as string) || ''; const b64 = (message.payload.base64 as string) || '';
const serverPath = (message.payload.serverPath as string) || ''; const serverPath = (message.payload.serverPath as string) || '';
const mimeType = (message.payload.mimeType as string) || '';
if (b64 && reqId) { if (b64 && reqId) {
const fileName = (message.payload.name as string) || 'download'; const fileName = (message.payload.name as string) || 'download';
persistAttachment(b64, reqId, fileName).then(filePath => { persistAttachment(b64, reqId, fileName).then(filePath => {
@@ -363,6 +406,11 @@ const ChatScreen: React.FC = () => {
a.serverPath === serverPath ? { ...a, uri: filePath } : a a.serverPath === serverPath ? { ...a, uri: filePath } : a
), ),
}))); })));
// Wenn der User dieses File explizit oeffnen wollte → Intent-Picker
if (serverPath && autoOpenPaths.current.has(serverPath)) {
autoOpenPaths.current.delete(serverPath);
openFileWithIntent(filePath.replace(/^file:\/\//, ''), mimeType);
}
}).catch(() => {}); }).catch(() => {});
} }
return; return;
@@ -1008,7 +1056,22 @@ const ChatScreen: React.FC = () => {
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
) : ( ) : (
<View style={styles.attachmentFile}> <TouchableOpacity
style={styles.attachmentFile}
onPress={() => {
// Lokal vorhanden \u2192 direkt mit System-Intent oeffnen
if (att.uri) {
openFileWithIntent(att.uri.replace(/^file:\/\//, ''), att.mimeType || '');
return;
}
// Sonst: file_request \u2192 bei file_response wird die Datei
// gespeichert UND geoeffnet (autoOpenPaths-Tracking).
if (att.serverPath) {
autoOpenPaths.current.add(att.serverPath);
rvs.send('file_request' as any, { serverPath: att.serverPath, requestId: item.id });
}
}}
>
<Text style={styles.attachmentFileIcon}> <Text style={styles.attachmentFileIcon}>
{att.mimeType?.includes('pdf') ? '\uD83D\uDCC4' : {att.mimeType?.includes('pdf') ? '\uD83D\uDCC4' :
att.mimeType?.includes('word') || att.mimeType?.includes('document') ? '\uD83D\uDCC3' : att.mimeType?.includes('word') || att.mimeType?.includes('document') ? '\uD83D\uDCC3' :
@@ -1018,12 +1081,10 @@ const ChatScreen: React.FC = () => {
<Text style={styles.attachmentFileName} numberOfLines={1}>{att.name}</Text> <Text style={styles.attachmentFileName} numberOfLines={1}>{att.name}</Text>
{att.size ? <Text style={styles.attachmentFileSize}>{Math.round(att.size / 1024)}KB</Text> : null} {att.size ? <Text style={styles.attachmentFileSize}>{Math.round(att.size / 1024)}KB</Text> : null}
{!att.uri && att.serverPath && ( {!att.uri && att.serverPath && (
<TouchableOpacity onPress={() => rvs.send('file_request' as any, { serverPath: att.serverPath, requestId: item.id })}> <Text style={[styles.attachmentFileSize, {color: '#0096FF'}]}>(tippen zum oeffnen)</Text>
<Text style={[styles.attachmentFileSize, {color: '#0096FF'}]}>(laden)</Text>
</TouchableOpacity>
)} )}
{!att.uri && !att.serverPath && <Text style={styles.attachmentFileSize}>(nicht verfuegbar)</Text>} {!att.uri && !att.serverPath && <Text style={styles.attachmentFileSize}>(nicht verfuegbar)</Text>}
</View> </TouchableOpacity>
)} )}
</View> </View>
))} ))}
+54
View File
@@ -16,7 +16,9 @@ import asyncio
import base64 import base64
import json import json
import logging import logging
import mimetypes
import os import os
import re
import signal import signal
import ssl import ssl
import sys import sys
@@ -882,6 +884,48 @@ class ARIABridge:
pass pass
return payload.get("text", "") return payload.get("text", "")
# File-Marker-Pattern: `[FILE: /pfad/zur/datei.ext]` (Pfad kann Spaces
# enthalten, Endung beliebig). Mehrfach im Text moeglich.
_FILE_MARKER_RE = re.compile(r"\[FILE:\s*(/shared/uploads/[^\]]+?)\s*\]", re.IGNORECASE)
def _extract_file_markers(self, text: str) -> tuple[str, list[dict]]:
"""Sucht [FILE: /shared/uploads/...]-Marker, gibt (cleaned_text, file_list) zurueck."""
files: list[dict] = []
for m in self._FILE_MARKER_RE.finditer(text):
path = m.group(1).strip()
if not path.startswith("/shared/uploads/"):
logger.warning("[core] FILE-Marker mit unerlaubtem Pfad ignoriert: %s", path)
continue
if not os.path.isfile(path):
logger.warning("[core] FILE-Marker zeigt auf nicht existente Datei: %s", path)
continue
name = os.path.basename(path)
mime, _ = mimetypes.guess_type(path)
size = os.path.getsize(path)
files.append({
"serverPath": path,
"name": name,
"mimeType": mime or "application/octet-stream",
"size": size,
})
cleaned = self._FILE_MARKER_RE.sub("", text).strip()
# Zwei aufeinanderfolgende Leerzeilen → eine
cleaned = re.sub(r"\n{3,}", "\n\n", cleaned)
return cleaned, files
async def _broadcast_aria_file(self, file_info: dict) -> None:
"""ARIA hat eine Datei fuer den User erstellt — App+Diagnostic informieren."""
logger.info("[rvs] ARIA-Datei rausgeben: %s (%s, %dKB)",
file_info["name"], file_info["mimeType"], file_info["size"] // 1024)
try:
await self._send_to_rvs({
"type": "file_from_aria",
"payload": file_info,
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
except Exception as e:
logger.warning("[rvs] file_from_aria broadcast fehlgeschlagen: %s", e)
async def _process_core_response(self, text: str, payload: dict) -> None: async def _process_core_response(self, text: str, payload: dict) -> None:
"""Verarbeitet eine fertige Antwort von aria-core. """Verarbeitet eine fertige Antwort von aria-core.
@@ -896,6 +940,14 @@ class ARIABridge:
logger.info("[core] NO_REPLY empfangen — Antwort still verworfen") logger.info("[core] NO_REPLY empfangen — Antwort still verworfen")
return return
# File-Marker `[FILE: /shared/uploads/aria_xyz.pdf]` extrahieren —
# ARIA legt damit Dateien fuer den User bereit (Bilder, PDFs, etc.).
# Der Marker wird aus dem Antworttext entfernt (TTS soll ihn nicht
# vorlesen) und parallel als file_from_aria-Event geschickt.
text, aria_files = self._extract_file_markers(text)
for f in aria_files:
await self._broadcast_aria_file(f)
metadata = payload.get("metadata", {}) metadata = payload.get("metadata", {})
is_critical = metadata.get("critical", False) is_critical = metadata.get("critical", False)
requested_voice = metadata.get("voice") requested_voice = metadata.get("voice")
@@ -1545,6 +1597,7 @@ class ARIABridge:
return return
with open(server_path, "rb") as f: with open(server_path, "rb") as f:
file_b64 = base64.b64encode(f.read()).decode("ascii") file_b64 = base64.b64encode(f.read()).decode("ascii")
mime, _ = mimetypes.guess_type(server_path)
logger.info("[rvs] Re-Download: %s (%dKB)", server_path, len(file_b64) // 1365) logger.info("[rvs] Re-Download: %s (%dKB)", server_path, len(file_b64) // 1365)
await self._send_to_rvs({ await self._send_to_rvs({
"type": "file_response", "type": "file_response",
@@ -1553,6 +1606,7 @@ class ARIABridge:
"serverPath": server_path, "serverPath": server_path,
"base64": file_b64, "base64": file_b64,
"name": os.path.basename(server_path), "name": os.path.basename(server_path),
"mimeType": mime or "application/octet-stream",
}, },
"timestamp": int(asyncio.get_event_loop().time() * 1000), "timestamp": int(asyncio.get_event_loop().time() * 1000),
}) })
+39
View File
@@ -996,6 +996,11 @@
addChat('received', msg.text, 'chat:final'); addChat('received', msg.text, 'chat:final');
return; return;
} }
if (msg.type === 'file_from_aria') {
const p = msg.payload || {};
addAriaFile(p);
return;
}
if (msg.type === 'chat_delta') { return; } if (msg.type === 'chat_delta') { return; }
if (msg.type === 'chat_error') { if (msg.type === 'chat_error') {
addChat('error', msg.error, 'chat:error'); addChat('error', msg.error, 'chat:error');
@@ -1475,6 +1480,40 @@
} }
} }
/** ARIA hat eine Datei rausgegeben — als eigene Bubble mit Klick-Handler. */
function addAriaFile(p) {
const name = p.name || 'datei';
const serverPath = p.serverPath || '';
const mimeType = p.mimeType || '';
const sizeKB = p.size ? Math.round(p.size / 1024) : 0;
const isImage = mimeType.startsWith('image/');
const isPdf = mimeType === 'application/pdf';
const url = serverPath; // Diagnostic-Server liefert /shared/* aus
const sizeStr = sizeKB > 1024 ? `${(sizeKB/1024).toFixed(1)}MB` : `${sizeKB}KB`;
const icon = isImage ? '🖼️' : isPdf ? '📄' : '📎';
// PDFs/Bilder: target=_blank → neuer Tab. Andere: download-Attribut.
const linkAttrs = (isImage || isPdf)
? `href="${url}" target="_blank" rel="noopener"`
: `href="${url}" download="${escapeHtml(name)}"`;
let preview = '';
if (isImage) {
preview = `<img src="${url}" class="chat-media" onclick="openLightbox('image','${url}')" onerror="this.style.display='none'" style="margin-top:6px;">`;
}
const html = `<div style="font-weight:bold;">${icon} ARIA hat eine Datei erstellt</div>` +
`<a ${linkAttrs} style="color:#0096FF;text-decoration:underline;">${escapeHtml(name)}</a>` +
` <span style="color:#888;font-size:11px;">(${escapeHtml(mimeType)}, ${sizeStr})</span>` +
preview +
`<div class="meta">ARIA-Datei — ${new Date().toLocaleTimeString('de-DE')}</div>`;
for (const box of [chatBox, document.getElementById('chat-box-fs')]) {
if (!box) continue;
const el = document.createElement('div');
el.className = 'chat-msg received';
el.innerHTML = html;
box.appendChild(el);
box.scrollTop = box.scrollHeight;
}
}
let chatFullscreen = false; let chatFullscreen = false;
function toggleChatFullscreen() { function toggleChatFullscreen() {
const modal = document.getElementById('chat-fullscreen'); const modal = document.getElementById('chat-fullscreen');
+5
View File
@@ -620,6 +620,11 @@ function connectRVS(forcePlain) {
type: "chat", type: "chat",
payload: { text: `Anhang: ${name}\n${serverPath}`, sender: "user" } payload: { text: `Anhang: ${name}\n${serverPath}`, sender: "user" }
}}); }});
} else if (msg.type === "file_from_aria" && msg.payload) {
// ARIA hat eine Datei fuer den User erstellt — im Chat als Anhang anzeigen
const p = msg.payload;
log("info", "rvs", `ARIA-Datei: ${p.name} (${p.mimeType}, ${(p.size||0)/1024|0}KB)`);
broadcast({ type: "file_from_aria", payload: p });
} else if (msg.type === "heartbeat") { } else if (msg.type === "heartbeat") {
// ignorieren // ignorieren
} else if (msg.type === "mode") { } else if (msg.type === "mode") {
+1
View File
@@ -18,6 +18,7 @@ const ALLOWED_TYPES = new Set([
"update_check", "update_available", "update_download", "update_data", "update_check", "update_available", "update_download", "update_data",
"agent_activity", "cancel_request", "agent_activity", "cancel_request",
"audio_pcm", "audio_pcm",
"file_from_aria",
"xtts_delete_voice", "xtts_delete_voice",
"voice_preload", "voice_ready", "voice_preload", "voice_ready",
"stt_request", "stt_response", "stt_request", "stt_response",