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 {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
return listOf(ApkInstallerModule(reactContext))
return listOf(ApkInstallerModule(reactContext), FileOpenerModule(reactContext))
}
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"?>
<paths>
<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>
+66 -5
View File
@@ -20,6 +20,7 @@ import {
Modal,
ToastAndroid,
AppState,
NativeModules,
} from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import RNFS from 'react-native-fs';
@@ -80,6 +81,23 @@ const capMessages = (msgs: ChatMessage[]): ChatMessage[] =>
const DEFAULT_ATTACHMENT_DIR = `${RNFS.DocumentDirectoryPath}/chat_attachments`;
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.getSize + setzt aspectRatio dynamisch — dadurch passt sich die
* 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 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
const nextId = (): string => {
@@ -349,11 +371,32 @@ const ChatScreen: React.FC = () => {
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
if (message.type === 'file_response') {
const reqId = (message.payload.requestId as string) || '';
const b64 = (message.payload.base64 as string) || '';
const serverPath = (message.payload.serverPath as string) || '';
const mimeType = (message.payload.mimeType as string) || '';
if (b64 && reqId) {
const fileName = (message.payload.name as string) || 'download';
persistAttachment(b64, reqId, fileName).then(filePath => {
@@ -363,6 +406,11 @@ const ChatScreen: React.FC = () => {
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(() => {});
}
return;
@@ -1008,7 +1056,22 @@ const ChatScreen: React.FC = () => {
</Text>
</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}>
{att.mimeType?.includes('pdf') ? '\uD83D\uDCC4' :
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>
{att.size ? <Text style={styles.attachmentFileSize}>{Math.round(att.size / 1024)}KB</Text> : null}
{!att.uri && att.serverPath && (
<TouchableOpacity onPress={() => rvs.send('file_request' as any, { serverPath: att.serverPath, requestId: item.id })}>
<Text style={[styles.attachmentFileSize, {color: '#0096FF'}]}>(laden)</Text>
</TouchableOpacity>
<Text style={[styles.attachmentFileSize, {color: '#0096FF'}]}>(tippen zum oeffnen)</Text>
)}
{!att.uri && !att.serverPath && <Text style={styles.attachmentFileSize}>(nicht verfuegbar)</Text>}
</View>
</TouchableOpacity>
)}
</View>
))}
+54
View File
@@ -16,7 +16,9 @@ import asyncio
import base64
import json
import logging
import mimetypes
import os
import re
import signal
import ssl
import sys
@@ -882,6 +884,48 @@ class ARIABridge:
pass
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:
"""Verarbeitet eine fertige Antwort von aria-core.
@@ -896,6 +940,14 @@ class ARIABridge:
logger.info("[core] NO_REPLY empfangen — Antwort still verworfen")
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", {})
is_critical = metadata.get("critical", False)
requested_voice = metadata.get("voice")
@@ -1545,6 +1597,7 @@ class ARIABridge:
return
with open(server_path, "rb") as f:
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)
await self._send_to_rvs({
"type": "file_response",
@@ -1553,6 +1606,7 @@ class ARIABridge:
"serverPath": server_path,
"base64": file_b64,
"name": os.path.basename(server_path),
"mimeType": mime or "application/octet-stream",
},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
+39
View File
@@ -996,6 +996,11 @@
addChat('received', msg.text, 'chat:final');
return;
}
if (msg.type === 'file_from_aria') {
const p = msg.payload || {};
addAriaFile(p);
return;
}
if (msg.type === 'chat_delta') { return; }
if (msg.type === '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;
function toggleChatFullscreen() {
const modal = document.getElementById('chat-fullscreen');
+5
View File
@@ -620,6 +620,11 @@ function connectRVS(forcePlain) {
type: "chat",
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") {
// ignorieren
} 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",
"agent_activity", "cancel_request",
"audio_pcm",
"file_from_aria",
"xtts_delete_voice",
"voice_preload", "voice_ready",
"stt_request", "stt_response",