Compare commits

..

35 Commits

Author SHA1 Message Date
duffyduck e438bb11ff feat(app): Pinch-Zoom + Pan im Vollbild-Modal
Neue ZoomableImage-Komponente — reine RN-Implementation mit
PanResponder + Animated, ohne extra Dependency.

- 2-Finger-Pinch: Zoom 1x..5x, Focal-Point folgt der Geste
- 1-Finger-Pan: nur aktiv wenn gezoomt, mit Bounds-Clamping
- Doppel-Tap: Toggle 1x ↔ 2.5x

Vollbild-Modal ersetzt das simple <Image> durch ZoomableImage fuer
JPEG/PNG/etc. SVGs bleiben non-zoomable (SvgUri-Limitation), Tap
schliesst sie. Plus dedicated ✕-Close-Button oben rechts da Tap-to-
Close mit PanResponder kollidiert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:15:58 +02:00
duffyduck 8b4f75bf91 fix(diagnostic): /shared/uploads/-Bilder im History-Load auch inline rendern
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:12:59 +02:00
duffyduck d7e7386954 fix(diagnostic): ARIA-Datei-Bubbles ueberleben Browser-Refresh
Beim Page-Reload laedt Diagnostic die Chat-History aus dem OpenClaw-
Session-File. file_from_aria-Events sind nur live-broadcast, nicht im
jsonl gespeichert → nach F5 waren die Anhang-Bubbles weg.

Fix: Server parsed [FILE: ...]-Marker aus assistant-messages beim
History-Load und schickt fuer existierende Files ein "aria_file"-
Message-Stueck mit allen Metadaten (Pfad, MIME, Groesse). Frontend
ruft addAriaFile mit denen, sodass die Bubbles wieder erscheinen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:08:06 +02:00
duffyduck 2100c64b91 release: bump version to 0.1.1.8 2026-05-11 19:04:25 +02:00
duffyduck 74ebf59c6f feat(ui): ARIA-Abkuerzung ausgeschrieben in App-Ueber + Diagnostic-Einstellungen
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:02:30 +02:00
duffyduck 53b49eacad release: bump version to 0.1.1.7 2026-05-11 18:59:57 +02:00
duffyduck 0f11d23c75 fix(bridge): User informieren wenn ARIA Marker fuer nicht-existente Datei setzt
Bridge-Logs zeigten: ARIA setzt zwei Marker (aria_rave2.mid und .mp3),
hat die .mid aber nie wirklich erstellt. Bridge filterte sie silent →
Stefan sah nur eine Bubble und dachte das Marker-System ist kaputt.

Jetzt: _extract_file_markers gibt auch eine Liste der "missing"-Pfade
zurueck, und im Antworttext steht ein Hinweis-Block fuer den User
welche Files versprochen aber nicht erstellt wurden.

Plus System-Prompt geschaerft: ARIA soll vor dem Marker pruefen ob
das File wirklich existiert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 18:41:22 +02:00
duffyduck 311030bdaa fix(diagnostic): [FILE: ...]-Marker-Filter ueberall (in addChat statt nur chat_final)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 18:33:49 +02:00
duffyduck 1e05c66baa release: bump version to 0.1.1.6 2026-05-11 02:25:38 +02:00
duffyduck 4082a6bf2a feat: Auto-Compact nach N User-Messages — verhindert E2BIG bei langer Session
E2BIG (Argument list too long) tritt auf wenn aria-core's Subprocess-
Spawn das Linux argv-Limit (~128KB-2MB) sprengt. Bei >140 Messages
samt Memory + System-Prompt + Tools laeuft das voll, ARIA antwortet
nur noch leer auf jede Anfrage.

Bridge zaehlt jetzt User-Nachrichten in send_to_core; bei COMPACT_AFTER_MESSAGES
(env, default 140) wird automatisch:
- Sessions geleert (rm sessions/*.jsonl + sessions.json = {})
- aria-core neu gestartet
- User informiert "Konversation komprimiert, letzte Nachricht nochmal"

Plus manueller "🧹 Konversation komprimieren"-Button in App-Settings
und 🧹 Compact-Button in Diagnostic-Thinking-Indicator.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:24:30 +02:00
duffyduck 3485642b3e fix(diagnostic): aria-restart ueber Docker-Socket-API statt CLI (Container hat kein docker installiert)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:03:08 +02:00
duffyduck 1240ae3829 release: bump version to 0.1.1.5 2026-05-11 01:59:58 +02:00
duffyduck 2dd4d38dce feat: "ARIA hart neu starten"-Button (docker restart aria-core)
Zweiter Eskalations-Button neben dem Reparieren-Button — fuer Faelle
wo doctor --fix nicht reicht (Run alive aber haengt im Tool-Call).
Mit Confirmation-Dialog damit's nicht versehentlich gedrueckt wird.

Wege:
- App-Settings: Reparatur-Sektion, zwei Buttons (Reparieren + Hart neu)
- App-Thinking-Bubble: 🔧 + 🚨 + Abbrechen
- Diagnostic-Thinking-Indicator: 🔧 + 🚨 + Abbrechen
- Diagnostic-Server: POST /api/aria-restart → child_process exec
  `docker restart aria-core`
- Bridge: rvs aria_restart → HTTP zu Diagnostic

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:58:44 +02:00
duffyduck 7f862ce1f4 release: bump version to 0.1.1.4 2026-05-11 01:48:33 +02:00
duffyduck 528fe97b59 feat: "ARIA reparieren"-Button in App + Diagnostic
Bei stuck OpenClaw-Runs (ARIA antwortet nicht mehr / "Antwort ohne Text"
auf jede Anfrage) kann der User jetzt selbst openclaw doctor --fix
anstossen — ohne SSH/docker exec.

Pfad:
- App-Button → rvs.send('doctor_fix') → Bridge → HTTP POST an
  Diagnostic /api/doctor-fix → dockerExec aria-core
- Diagnostic-Button → direkt HTTP POST /api/doctor-fix

Zwei Plaetze in der App: oben in der Thinking-Bubble (wenn ARIA denkt
aber haengt) und in Settings → Reparatur (immer erreichbar). In
Diagnostic neben dem Abbrechen-Button im Thinking-Indicator.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:46:35 +02:00
duffyduck 3483d1bfce release: bump version to 0.1.1.3 2026-05-10 18:47:10 +02:00
duffyduck 158423c155 fix(app): SVG im Vollbild via SvgUri rendern (statt Image) — preserveAspectRatio damit nicht gestreckt
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:46:13 +02:00
duffyduck 087e91dca1 release: bump version to 0.1.1.2 2026-05-10 18:44:05 +02:00
duffyduck 2de4cbc00f fix(app): SVG-Anhaenge mit SvgUri rendern statt Image
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:42:53 +02:00
duffyduck 03fc465057 fix(app): react-native-svg auf 14.1 (kompatibel mit RN 0.73) — 15.x braucht neuere RN-Version
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:35:15 +02:00
duffyduck b696b47feb feat(app): SVG-Inline-Rendering via react-native-svg
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:33:30 +02:00
duffyduck 6aae565541 docs(prompt): ARIA soll externe Bilder/Files runterladen statt nur verlinken
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:32:16 +02:00
duffyduck 214bd218a0 feat(app): Inline-Bilder in Chat-Nachrichten anzeigen (wie in Diagnostic)
MessageText erkennt http(s)-URLs auf Bilder (jpg/png/gif/webp/bmp/ico)
und rendert sie als <Image> unter dem Text. Markdown-Syntax
![alt](url) wird durch dasselbe Regex erfasst weil die URL drin ist.
SVGs ausgespart — React Native Image kann SVG nicht ohne Extra-Lib.

Aspect-Ratio wird via Image.getSize ermittelt, gecapped auf 0.5..2.5
damit Panorama-/Streifen-Bilder die Bubble nicht sprengen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:30:55 +02:00
duffyduck 2afeee29ee feat(diagnostic): ARIA-Datei-Pfad als kleiner Debug-Footer in der Bubble
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:27:17 +02:00
duffyduck c8dee4c416 fix(diagnostic): [FILE: ...]-Marker aus chat_final rausfiltern
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:26:35 +02:00
duffyduck f49f3c3b08 fix(prompt): File-Marker-Anweisung in BOOTSTRAP.md (echter System-Prompt) statt AGENT.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:23:22 +02:00
duffyduck c4bbb06710 docs(agent): File-Marker-Anweisung deutlich schaerfer formuliert
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:18:35 +02:00
duffyduck 4411cc4fff chore: init.sh — Setup-Script materialisiert *.example zu Config-Dateien
Frisch geclonte Repo / git pull nach .gitignore-Aenderungen lassen
USER.md (und andere Config-Files) fehlen — docker compose up failt
dann beim Bind-Mount. init.sh kopiert idempotent alle *.example zu
ihren Originalen wenn die noch nicht existieren.

Nach git clone und git pull empfohlen: bash init.sh

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:14:21 +02:00
duffyduck 24a91887ef fix(android): FileOpenerModule — kein '*/*' im Source (Kotlin-Lexer-Verwirrung)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:03:59 +02:00
duffyduck 4e62b2919f chore(config): USER.md aus Repo nehmen — enthielt interne Tool-Liste
USER.md hatte Stefan-spezifische Infos (Gitea-URL hackersoft.de,
OpenCRM, STARFACE, RustDesk Tool-Stack). Hat im Repo nichts zu suchen.

USER.md ist jetzt in .gitignore, USER.md.example als Vorlage
eingecheckt. Lokale USER.md bleibt funktional unangetastet — nur
nicht mehr getrackt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:00:45 +02:00
duffyduck fa774156fe docs(agent): ARIA-System-Prompt um File-Marker-Anweisung ergaenzt
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:58:36 +02:00
duffyduck 3b19f05c5b 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>
2026-05-10 17:56:47 +02:00
duffyduck fc3ecaacca docs(issue): heutige Session-Fixes ergaenzt
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:32:05 +02:00
duffyduck 08857093b5 release: bump version to 0.1.0.9 2026-05-10 17:25:48 +02:00
duffyduck 62018b3e51 revert(audio): kickReleaseMedia raus — bricht Spotify's Auto-Resume
Logs zeigen jetzt KEINEN haengenden RNSound-Focus mehr (Library-Version
oder Sound-Lifecycle hat sich geaendert). Der Kick mit AUDIOFOCUS_GAIN
(permanent) sagte Spotify "user hat manuell etwas anderes gestartet" →
Spotify resumed nicht automatisch.

Ohne Kick: unser Focus war AUDIOFOCUS_GAIN_TRANSIENT (USAGE_ASSISTANT) —
beim release bekommt Spotify einen sauberen GAIN nach TRANSIENT-Loss
und resumed automatisch.

Native kickReleaseMedia bleibt fuer den Fall dass es nochmal gebraucht
wird, wird aber nicht mehr gerufen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:24:55 +02:00
21 changed files with 1140 additions and 72 deletions
+4
View File
@@ -13,6 +13,10 @@ aria-data/config/*.env
!aria-data/config/*.env.example
!aria-data/config/openclaw.env
# Privater User-Profile-Snippet (Tool-Stack, interne URLs)
aria-data/config/USER.md
!aria-data/config/USER.md.example
# ── ARIAs Gedächtnis (nur per tar gesichert) ────
aria-data/brain/
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 10008
versionName "0.1.0.8"
versionCode 10108
versionName "0.1.1.8"
// Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general'
}
@@ -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,55 @@
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
* geht an PDF-Viewer, image/jpeg an 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 safeMime = if (mimeType.isBlank()) "application/octet-stream" else mimeType
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, safeMime)
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>
+18 -17
View File
@@ -1,6 +1,6 @@
{
"name": "aria-cockpit",
"version": "0.1.0.8",
"version": "0.1.1.8",
"private": true,
"scripts": {
"android": "react-native run-android",
@@ -10,31 +10,32 @@
"build:apk": "cd android && ./gradlew assembleRelease"
},
"dependencies": {
"@react-native-async-storage/async-storage": "^1.21.0",
"@react-native-community/geolocation": "^3.2.1",
"@react-navigation/bottom-tabs": "^6.5.11",
"@react-navigation/native": "^6.1.9",
"react": "18.2.0",
"react-native": "0.73.4",
"@react-navigation/native": "^6.1.9",
"@react-navigation/bottom-tabs": "^6.5.11",
"react-native-screens": "3.27.0",
"react-native-safe-area-context": "^4.8.2",
"react-native-audio-recorder-player": "^3.6.7",
"react-native-camera-kit": "^13.0.0",
"react-native-document-picker": "^9.1.1",
"react-native-sound": "^0.11.2",
"@react-native-community/geolocation": "^3.2.1",
"react-native-fs": "^2.20.0",
"react-native-image-picker": "^7.1.0",
"react-native-permissions": "^4.1.4",
"react-native-camera-kit": "^13.0.0",
"@react-native-async-storage/async-storage": "^1.21.0",
"react-native-fs": "^2.20.0",
"react-native-audio-recorder-player": "^3.6.7"
"react-native-safe-area-context": "^4.8.2",
"react-native-screens": "3.27.0",
"react-native-sound": "^0.11.2",
"react-native-svg": "^14.1.0"
},
"devDependencies": {
"typescript": "^5.3.3",
"@react-native/eslint-config": "^0.73.2",
"@react-native/metro-config": "^0.73.5",
"@react-native/typescript-config": "^0.73.1",
"@types/jest": "^29.5.11",
"@types/react": "^18.2.48",
"@types/react-native": "^0.73.0",
"@react-native/eslint-config": "^0.73.2",
"@react-native/typescript-config": "^0.73.1",
"@react-native/metro-config": "^0.73.5",
"metro-react-native-babel-preset": "^0.77.0",
"jest": "^29.7.0",
"@types/jest": "^29.5.11"
"metro-react-native-babel-preset": "^0.77.0",
"typescript": "^5.3.3"
}
}
+73 -11
View File
@@ -1,25 +1,87 @@
/**
* MessageText — selektierbarer Chat-Text mit Android-Auto-Linkifizierung.
* MessageText — selektierbarer Chat-Text mit Android-Auto-Linkifizierung,
* plus Inline-Image-Rendering wenn der Text Bild-URLs enthaelt.
*
* Wir nutzen Androids dataDetectorType="all" (System macht Phone/URL/Email
* automatisch klickbar) und ein einzelnes <Text selectable> ohne nested
* <Text> mit eigenem onPress. Nested Text mit onPress fingen die Long-Press-
* Geste ab, damit war Markieren+Kopieren defekt.
* - Markdown-Syntax `![alt](url)` und plain `https://...image.png` werden
* erkannt — die URL bleibt im Text sichtbar (klickbar via Linkify),
* zusaetzlich wird das Bild als <Image> oder <SvgUri> drunter gerendert.
* - Wir nutzen Androids dataDetectorType="all" (System macht Phone/URL/Email
* automatisch klickbar) und ein einzelnes <Text selectable> ohne nested
* <Text> mit eigenem onPress — Nested Text mit onPress fing die Long-Press-
* Geste ab, damit war Markieren+Kopieren defekt.
*/
import React from 'react';
import { Text, TextStyle, StyleProp } from 'react-native';
import React, { useEffect, useState } from 'react';
import { View, Text, Image, TextStyle, StyleProp } from 'react-native';
import { SvgUri } from 'react-native-svg';
interface Props {
text: string;
style?: StyleProp<TextStyle>;
}
const MessageText: React.FC<Props> = ({ text, style }) => {
// Bild-URL-Pattern: http(s)://... endend auf gaengige Bild-Endungen.
const IMG_URL_RE = /https?:\/\/[^\s)<"']+\.(?:jpe?g|png|gif|webp|bmp|ico|svg)(?:\?[^\s)<"']*)?/gi;
function extractImageUrls(text: string): string[] {
const urls = new Set<string>();
const matches = text.match(IMG_URL_RE);
if (matches) matches.forEach(u => urls.add(u));
return Array.from(urls);
}
const SVG_RE = /\.svg(?:\?|$)/i;
/** Image mit dynamischer Aspect-Ratio aus echten Bilddimensionen.
* SVGs werden ueber react-native-svg gerendert (kein Image.getSize). */
const InlineImage: React.FC<{ uri: string }> = ({ uri }) => {
const isSvg = SVG_RE.test(uri);
const [aspectRatio, setAspectRatio] = useState<number>(1);
const [failed, setFailed] = useState(false);
useEffect(() => {
if (isSvg) return; // Image.getSize geht fuer SVG nicht
let cancelled = false;
Image.getSize(
uri,
(w, h) => { if (!cancelled && w > 0 && h > 0) setAspectRatio(Math.max(0.5, Math.min(2.5, w / h))); },
() => { if (!cancelled) setFailed(true); },
);
return () => { cancelled = true; };
}, [uri, isSvg]);
if (failed) return null;
if (isSvg) {
return (
<View style={{ marginTop: 8, width: 260, height: 260, backgroundColor: '#0D0D1A', borderRadius: 8, alignItems: 'center', justifyContent: 'center' }}>
<SvgUri uri={uri} width="100%" height="100%" onError={() => setFailed(true)} />
</View>
);
}
return (
<Text style={style} selectable dataDetectorType="all">
{text}
</Text>
<Image
source={{ uri }}
style={{ width: 260, aspectRatio, borderRadius: 8, marginTop: 8, backgroundColor: '#0D0D1A' }}
resizeMode="cover"
onError={() => setFailed(true)}
/>
);
};
const MessageText: React.FC<Props> = ({ text, style }) => {
const imageUrls = extractImageUrls(text || '');
if (imageUrls.length === 0) {
return (
<Text style={style} selectable dataDetectorType="all">
{text}
</Text>
);
}
return (
<View>
<Text style={style} selectable dataDetectorType="all">
{text}
</Text>
{imageUrls.map(u => <InlineImage key={u} uri={u} />)}
</View>
);
};
+142
View File
@@ -0,0 +1,142 @@
/**
* ZoomableImage — Pinch-to-Zoom + Pan fuer das Vollbild-Modal.
*
* Reine React-Native-Implementation ohne externe Lib:
* - 1 Finger: Pan wenn schon gezoomt
* - 2 Finger: Pinch fuer Zoom + Pan
* - Doppel-Tap: Toggle 1x ↔ 2.5x Zoom
*
* Scale wird auf [1, 5] gecapped, Translation auf das verfuegbare
* Image-Volumen (kein Out-of-bounds-Pan).
*/
import React, { useRef } from 'react';
import { Animated, PanResponder, View, StyleSheet, ImageStyle, StyleProp } from 'react-native';
interface Props {
uri: string;
containerWidth: number;
containerHeight: number;
style?: StyleProp<ImageStyle>;
}
const ZoomableImage: React.FC<Props> = ({ uri, containerWidth, containerHeight, style }) => {
const scale = useRef(new Animated.Value(1)).current;
const translateX = useRef(new Animated.Value(0)).current;
const translateY = useRef(new Animated.Value(0)).current;
// Aktuelle Werte (Animated.Value lesen ist async, wir tracken parallel)
const current = useRef({ scale: 1, x: 0, y: 0 }).current;
// State beim Geste-Start (touchStart-Snapshot)
const start = useRef({ scale: 1, x: 0, y: 0, distance: 0, focalX: 0, focalY: 0 }).current;
// Doppel-Tap-Erkennung
const lastTapAt = useRef(0);
const distance = (touches: any[]) => {
const [a, b] = touches;
return Math.hypot(a.pageX - b.pageX, a.pageY - b.pageY);
};
const focal = (touches: any[]) => {
const [a, b] = touches;
return { x: (a.pageX + b.pageX) / 2, y: (a.pageY + b.pageY) / 2 };
};
const clamp = (v: number, min: number, max: number) => Math.max(min, Math.min(max, v));
const applyAndClamp = (newScale: number, newX: number, newY: number) => {
const s = clamp(newScale, 1, 5);
// Maximal-Translation: (imgSize * scale - imgSize) / 2
const maxX = Math.max(0, (containerWidth * s - containerWidth) / 2);
const maxY = Math.max(0, (containerHeight * s - containerHeight) / 2);
const x = clamp(newX, -maxX, maxX);
const y = clamp(newY, -maxY, maxY);
current.scale = s;
current.x = x;
current.y = y;
scale.setValue(s);
translateX.setValue(x);
translateY.setValue(y);
};
const responder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: () => true,
onPanResponderGrant: (_e, gestureState) => {
const touches = gestureState as any;
const t = touches.numberActiveTouches || 1;
// Doppel-Tap-Erkennung (nur bei 1 Finger)
if (t === 1) {
const now = Date.now();
if (now - lastTapAt.current < 280) {
// Doppel-Tap → Zoom-Toggle
if (current.scale > 1.1) {
applyAndClamp(1, 0, 0);
} else {
applyAndClamp(2.5, 0, 0);
}
lastTapAt.current = 0;
return;
}
lastTapAt.current = now;
}
start.scale = current.scale;
start.x = current.x;
start.y = current.y;
},
onPanResponderMove: (e, gestureState) => {
const touches = e.nativeEvent.touches;
if (touches.length >= 2) {
// Pinch + Pan
if (start.distance === 0) {
// Initialisiere die Pinch-Referenz beim Uebergang 1→2 Finger
start.distance = distance(touches);
const f = focal(touches);
start.focalX = f.x;
start.focalY = f.y;
start.scale = current.scale;
start.x = current.x;
start.y = current.y;
return;
}
const newDistance = distance(touches);
const newFocal = focal(touches);
const scaleFactor = newDistance / start.distance;
const newScale = clamp(start.scale * scaleFactor, 1, 5);
// Pan-Anteil aus Focal-Bewegung
const newX = start.x + (newFocal.x - start.focalX);
const newY = start.y + (newFocal.y - start.focalY);
applyAndClamp(newScale, newX, newY);
} else if (touches.length === 1 && current.scale > 1.05) {
// Single-Finger-Pan nur wenn gezoomt
start.distance = 0; // Reset Pinch-Tracking
applyAndClamp(current.scale, start.x + gestureState.dx, start.y + gestureState.dy);
}
},
onPanResponderRelease: () => { start.distance = 0; },
onPanResponderTerminate: () => { start.distance = 0; },
}),
).current;
return (
<View style={StyleSheet.absoluteFill} {...responder.panHandlers}>
<Animated.Image
source={{ uri }}
style={[
style,
{
transform: [
{ translateX },
{ translateY },
{ scale },
],
},
]}
resizeMode="contain"
/>
</View>
);
};
export default ZoomableImage;
+129 -20
View File
@@ -20,9 +20,14 @@ import {
Modal,
ToastAndroid,
AppState,
NativeModules,
Alert,
} from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import RNFS from 'react-native-fs';
import { SvgUri } from 'react-native-svg';
import { Dimensions } from 'react-native';
import ZoomableImage from '../components/ZoomableImage';
import rvs, { RVSMessage, ConnectionState } from '../services/rvs';
import audioService from '../services/audio';
import wakeWordService from '../services/wakeword';
@@ -80,6 +85,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). */
@@ -95,7 +117,9 @@ const ChatImage: React.FC<{
onError: () => void;
}> = ({ uri, onPress, onError }) => {
const [aspectRatio, setAspectRatio] = useState<number>(4 / 3);
const isSvg = /\.svg(?:\?|$)/i.test(uri);
useEffect(() => {
if (isSvg) return; // SvgUri hat kein getSize
let cancelled = false;
Image.getSize(uri, (w, h) => {
if (!cancelled && w > 0 && h > 0) {
@@ -106,7 +130,16 @@ const ChatImage: React.FC<{
}
}, () => {});
return () => { cancelled = true; };
}, [uri]);
}, [uri, isSvg]);
if (isSvg) {
return (
<TouchableOpacity onPress={onPress} activeOpacity={0.8}>
<View style={[CHAT_IMAGE_STYLE, { height: 260, alignItems: 'center', justifyContent: 'center' }]}>
<SvgUri uri={uri} width="100%" height="100%" onError={onError} />
</View>
</TouchableOpacity>
);
}
return (
<TouchableOpacity onPress={onPress} activeOpacity={0.8}>
<Image
@@ -179,6 +212,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 +386,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 +421,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 +1071,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 +1096,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>
))}
@@ -1176,9 +1252,29 @@ const ChatScreen: React.FC = () => {
? '\u270D\uFE0F ARIA schreibt...'
: '\uD83D\uDCAD ARIA denkt...'}
</Text>
<TouchableOpacity style={styles.thinkingCancel} onPress={cancelRequest}>
<Text style={styles.thinkingCancelText}>Abbrechen</Text>
</TouchableOpacity>
<View style={{flexDirection: 'row', gap: 6}}>
<TouchableOpacity style={[styles.thinkingCancel, {borderColor: '#FF9500'}]} onPress={() => rvs.send('doctor_fix' as any, {})}>
<Text style={[styles.thinkingCancelText, {color: '#FF9500'}]}>{'🔧'}</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.thinkingCancel, {borderColor: '#FF3B30'}]}
onPress={() => {
Alert.alert(
'ARIA hart neu starten?',
'Container-Restart (~15s). Laufende Anfragen gehen verloren.',
[
{ text: 'Abbrechen', style: 'cancel' },
{ text: 'Neu starten', style: 'destructive', onPress: () => rvs.send('aria_restart' as any, {}) },
],
);
}}
>
<Text style={[styles.thinkingCancelText, {color: '#FF3B30'}]}>{'🚨'}</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.thinkingCancel} onPress={cancelRequest}>
<Text style={styles.thinkingCancelText}>Abbrechen</Text>
</TouchableOpacity>
</View>
</View>
)}
@@ -1284,19 +1380,32 @@ const ChatScreen: React.FC = () => {
{/* Bild-Vollbild Modal */}
<Modal visible={!!fullscreenImage} transparent animationType="fade" onRequestClose={() => setFullscreenImage(null)}>
<TouchableOpacity
style={styles.fullscreenOverlay}
activeOpacity={1}
onPress={() => setFullscreenImage(null)}
>
<View style={styles.fullscreenOverlay}>
{fullscreenImage && (
<Image
source={{ uri: fullscreenImage }}
style={styles.fullscreenImage}
resizeMode="contain"
/>
/\.svg(?:\?|$)/i.test(fullscreenImage) ? (
// SVG: bisher keine Pinch-Zoom — Tap zum Schliessen
<TouchableOpacity style={styles.fullscreenImage} activeOpacity={1} onPress={() => setFullscreenImage(null)}>
<SvgUri uri={fullscreenImage} width="100%" height="100%" preserveAspectRatio="xMidYMid meet" />
</TouchableOpacity>
) : (
// Pixel-Bild: Pinch-Zoom + Pan ueber ZoomableImage
<ZoomableImage
uri={fullscreenImage}
containerWidth={Dimensions.get('window').width}
containerHeight={Dimensions.get('window').height}
style={styles.fullscreenImage}
/>
)
)}
</TouchableOpacity>
{/* Close-Button oben rechts — die TouchableOpacity-uebergreifend funktioniert
wegen ZoomableImage-PanResponder nicht zuverlaessig fuer Tap-to-Close */}
<TouchableOpacity
style={{ position: 'absolute', top: 32, right: 16, padding: 12, backgroundColor: 'rgba(0,0,0,0.5)', borderRadius: 24 }}
onPress={() => setFullscreenImage(null)}
>
<Text style={{ color: '#FFF', fontSize: 22 }}>{'✕'}</Text>
</TouchableOpacity>
</View>
</Modal>
{/* Datei-Upload Modal */}
+70 -1
View File
@@ -1288,6 +1288,74 @@ const SettingsScreen: React.FC = () => {
</TouchableOpacity>
</View>
{/* === ARIA Reparatur === */}
<Text style={[styles.sectionTitle, {marginTop: 16}]}>Reparatur</Text>
<View style={styles.card}>
<Text style={styles.toggleHint}>
Wenn ARIA gar nicht mehr antwortet oder auf jede Anfrage mit
"Antwort ohne Text" zurueckkommt meistens ein steckengebliebener
Run im aria-core. Dieser Button fuehrt {'“'}openclaw doctor --fix{'”'}
aus und macht ARIA wieder ansprechbar.
</Text>
<TouchableOpacity
style={[styles.clearButton, {marginTop: 8, backgroundColor: 'rgba(255,149,0,0.15)'}]}
onPress={() => {
rvs.send('doctor_fix' as any, {});
ToastAndroid.show('Reparatur-Befehl gesendet — Antwort kommt gleich', ToastAndroid.SHORT);
}}
>
<Text style={[styles.clearButtonText, {color: '#FF9500'}]}>{'🔧 ARIA reparieren'}</Text>
</TouchableOpacity>
<Text style={[styles.toggleHint, {marginTop: 12}]}>
Wenn auch Reparieren nicht hilft Container hart neu starten.
ARIA ist dann ~15 Sekunden weg und kommt mit frischem State zurueck.
Laufende Anfragen gehen verloren.
</Text>
<TouchableOpacity
style={[styles.clearButton, {marginTop: 8, backgroundColor: 'rgba(255,59,48,0.15)'}]}
onPress={() => {
Alert.alert(
'ARIA hart neu starten?',
'Container-Restart (~15s). Laufende Anfragen gehen verloren.',
[
{ text: 'Abbrechen', style: 'cancel' },
{ text: 'Neu starten', style: 'destructive', onPress: () => {
rvs.send('aria_restart' as any, {});
ToastAndroid.show('Container-Restart angestossen…', ToastAndroid.LONG);
}},
],
);
}}
>
<Text style={[styles.clearButtonText, {color: '#FF3B30'}]}>{'🚨 ARIA hart neu starten'}</Text>
</TouchableOpacity>
<Text style={[styles.toggleHint, {marginTop: 12}]}>
Konversation komplett zuruecksetzen alle bisherigen Nachrichten
aus ARIA's Session loeschen + Container neu. Anders als der harte
Restart wird hier auch ARIA's Erinnerung an die laufende
Konversation gewipt. Geschieht automatisch alle 140 Nachrichten
(Bridge-Setting COMPACT_AFTER_MESSAGES).
</Text>
<TouchableOpacity
style={[styles.clearButton, {marginTop: 8, backgroundColor: 'rgba(255,149,0,0.15)'}]}
onPress={() => {
Alert.alert(
'Konversation komprimieren?',
'Alle Nachrichten in ARIAs aktueller Session werden geloescht und der Container neu gestartet. ARIA vergisst den bisherigen Gespraechsverlauf.',
[
{ text: 'Abbrechen', style: 'cancel' },
{ text: 'Komprimieren', style: 'destructive', onPress: () => {
rvs.send('aria_session_reset' as any, {});
ToastAndroid.show('Session wird zurueckgesetzt…', ToastAndroid.LONG);
}},
],
);
}}
>
<Text style={[styles.clearButtonText, {color: '#FF9500'}]}>{'🧹 Konversation komprimieren'}</Text>
</TouchableOpacity>
</View>
</>)}
{/* === Logs === */}
@@ -1401,7 +1469,8 @@ const SettingsScreen: React.FC = () => {
<Text style={styles.aboutTitle}>ARIA Cockpit</Text>
<Text style={styles.aboutVersion}>Version {require('../../package.json').version}</Text>
<Text style={styles.aboutInfo}>
Stefans Kommandozentrale f{'\u00FC'}r ARIA.{'\n'}
ARIA \u2014 Autonomous Reasoning & Intelligence Assistant.{'\n'}
Stefans Kommandozentrale.{'\n'}
Gebaut mit React Native + TypeScript.
</Text>
<TouchableOpacity
+5 -5
View File
@@ -1256,13 +1256,13 @@ class AudioService {
this.pcmBuffer = [];
this.pcmBytesCollected = 0;
this.pcmMessageId = '';
// Audio-Focus sofort freigeben — User hat explizit abgebrochen
// Audio-Focus sofort freigeben — User hat explizit abgebrochen.
// Unser Focus war TRANSIENT, Spotify resumed darum automatisch beim
// Abandon. Den frueheren kickReleaseMedia haben wir entfernt: er
// requestete USAGE_MEDIA mit GAIN (permanent), was Spotify als
// "user-action stopp" interpretierte und Auto-Resume verhinderte.
this._cancelDeferredFocusRelease();
AudioFocus?.release().catch(() => {});
// Focus-Stack immer aufmischen — bei aelteren Nachrichten die ueber
// tts_request (PCM-Stream) re-rendert wurden, bleibt Spotify ohne den
// Kick auch pausiert. Kostet nichts, deckt beide Pfade ab.
AudioFocus?.kickReleaseMedia?.().catch(() => {});
}
// --- Status & Callbacks ---
+53
View File
@@ -52,6 +52,59 @@ Fuer Web-Anfragen: **WebFetch** oder **Bash mit curl**. Niemals sagen "ich habe
4. **Regelmaessig committen** — mit sinnvollen Commit-Messages.
5. **Tageslog fuehren** — was wurde getan, was ist offen.
## Dateien an Stefan zurueckgeben — KRITISCH
**Das ist die EINZIGE Methode wie Stefan an Dateien rankommt. Ohne
diese Schritte sieht und bekommt er die Datei NICHT.**
### Regel 1 — Speicher-Ort
Dateien fuer Stefan AUSSCHLIESSLICH unter `/shared/uploads/` speichern.
NIEMALS in:
- `/home/node/.openclaw/workspace/...` (das ist NUR dein Arbeitsverzeichnis,
Stefan hat keinen Zugriff darauf)
- `/tmp/...`, `/root/...`, oder sonst irgendwo
Dateinamen mit `aria_`-Prefix damit Cleanup-Scripts sie zuordnen koennen:
```
/shared/uploads/aria_<beschreibender_name>.<ext>
```
Beispiele: `aria_termin_zusage.pdf`, `aria_einkaufsliste.md`,
`aria_logs_2026-05-10.zip`.
### Regel 2 — Marker im Antworttext
Am Ende deiner Antwort EINMALIG den Marker setzen:
```
[FILE: /shared/uploads/aria_<name>.<ext>]
```
OHNE diesen Marker erscheint die Datei NICHT in der App / Diagnostic.
Mehrere Dateien: mehrere `[FILE: ...]`-Marker am Ende, jeder in
eigener Zeile.
### Beispiel — kompletter Workflow
User: "Schreib mir ein Lasagne-Rezept als md-Datei"
1. Du schreibst die Datei: `Write` Tool mit Pfad `/shared/uploads/aria_lasagne.md`
2. Antwort an Stefan:
```
Hier dein Lasagne-Rezept — Ragu am Vortag, echter Parmesan,
Ruhezeit nicht skippen. Beim Schichten Bechamel auf jede Lage.
[FILE: /shared/uploads/aria_lasagne.md]
```
Der Marker wird automatisch aus dem sichtbaren Text entfernt und
als Anhang-Bubble angezeigt. Stefan tippt drauf → oeffnet die Datei.
## Stimme
TTS laeuft ueber F5-TTS (Voice Cloning, Gaming-PC). Stefan kann eigene
+91
View File
@@ -78,6 +78,97 @@ Wenn ein Tool nicht klappt, probiere die Alternative. Niemals sagen "ich habe ke
- Destruktive Operationen (Dateien loeschen, Datenbanken droppen)
- Push auf main
## Dateien an Stefan zurueckgeben — KRITISCH
**Das ist die EINZIGE Methode wie Stefan an Dateien rankommt. Ohne diese
Schritte sieht und bekommt er die Datei NICHT.**
### Regel 1 — Speicher-Ort
Dateien fuer Stefan AUSSCHLIESSLICH unter `/shared/uploads/` speichern.
NIEMALS in:
- `/home/node/.openclaw/workspace/...` (NUR dein Arbeitsverzeichnis,
Stefan hat keinen Zugriff)
- `/tmp/...`, `/root/...`, oder sonst irgendwo
Dateinamen mit `aria_`-Prefix:
```
/shared/uploads/aria_<beschreibender_name>.<ext>
```
Beispiele: `aria_termin_zusage.pdf`, `aria_einkaufsliste.md`,
`aria_logs_2026-05-10.zip`.
### Regel 2 — Marker im Antworttext
Am Ende deiner Antwort EINMALIG den Marker setzen:
```
[FILE: /shared/uploads/aria_<name>.<ext>]
```
OHNE diesen Marker erscheint die Datei NICHT in der App / Diagnostic.
Mehrere Dateien: mehrere `[FILE: ...]`-Marker am Ende, jeder in
eigener Zeile.
**WICHTIG — Datei MUSS existieren bevor du den Marker setzt.**
Marker fuer nicht-existente Pfade werden silent gefiltert + Stefan
bekommt einen Hinweis dass du eine Datei versprochen aber nicht
erstellt hast. Wenn du z.B. eine MIDI-Datei nicht generieren kannst,
sag das offen statt nur den Marker zu setzen. Verifiziere zur Not
mit `Bash` + `ls -la /shared/uploads/aria_<name>.<ext>` dass die
Datei wirklich da ist.
### Beispiel — kompletter Workflow
User: "Schreib mir ein Lasagne-Rezept als md-Datei"
1. Du schreibst: `Write` Tool mit Pfad `/shared/uploads/aria_lasagne.md`
2. Antwort an Stefan:
```
Hier dein Lasagne-Rezept — Ragu am Vortag, echter Parmesan,
Ruhezeit nicht skippen. Beim Schichten Bechamel auf jede Lage.
[FILE: /shared/uploads/aria_lasagne.md]
```
Der Marker wird automatisch aus dem sichtbaren Text entfernt und
als Anhang-Bubble angezeigt. Stefan tippt drauf → oeffnet die Datei
im jeweiligen Standard-Programm.
### Externe Bilder/Dateien — IMMER runterladen, nicht nur verlinken
Wenn Stefan ein Bild oder eine Datei aus dem Netz haben will (Wikipedia,
Wiki Commons, ein Beispiel-PDF, etc.):
NICHT NUR die URL in die Antwort schreiben — das Bild ist dann nur
solange sichtbar wie der externe Server lebt.
STATTDESSEN:
1. Mit `Bash` + curl/wget herunterladen nach `/shared/uploads/aria_<name>.<ext>`
2. Mit `[FILE: ...]`-Marker als Anhang ausspielen
Beispiel — User: "Zeig mir ein Bild von Micky Maus"
```bash
curl -sL "https://upload.wikimedia.org/wikipedia/commons/7/7f/Mickey_Mouse.svg" \
-o /shared/uploads/aria_mickey_mouse.svg
```
Antwort:
```
Hier Micky Maus — offizielles SVG von Wikimedia Commons (Public Domain).
[FILE: /shared/uploads/aria_mickey_mouse.svg]
```
So bleibt das Bild permanent im Chat-Verlauf, auch wenn die Wiki-URL
spaeter offline geht oder umgezogen wird.
## Stimme
TTS laeuft ueber F5-TTS auf der Gamebox (Voice Cloning). Stefan kann
@@ -1,10 +1,10 @@
# Stefan — Benutzer-Praeferenzen
# <Username> — Benutzer-Praeferenzen
## Allgemein
- **Sprache:** Deutsch
- **Kommunikation:** Direkt, kein Bullshit, Humor willkommen
- **Rolle:** Chef, Auftraggeber, Entwickler bei HackerSoft Oldenburg
- **Sprache:** <z.B. Deutsch>
- **Kommunikation:** <z.B. Direkt, kein Bullshit, Humor willkommen>
- **Rolle:** <z.B. Chef, Auftraggeber, Entwickler bei XYZ>
## Bestaetigung erforderlich fuer
@@ -12,7 +12,6 @@
- Push auf main
- Aenderungen an Kundensystemen
- Server-Befehle die nicht rueckgaengig gemacht werden koennen
- Windows neu installieren (erst Daten sichern!)
## Autonomes Arbeiten OK fuer
@@ -28,8 +27,10 @@
| Tool | Zweck |
|------|-------|
| **Proxmox** | VM-Infrastruktur (ARIAs Zuhause) |
| **Gitea** | Code-Hosting (gitea.hackersoft.de) |
| **OpenCRM** | Kundenverwaltung |
| **STARFACE** | Telefonie |
| **RustDesk** | Remote IT-Support bei Kunden |
| **<Beispiel-Tool>** | <Zweck> |
<!--
Diese Datei ist eine Vorlage. Lokal als USER.md kopieren und mit
eigenen Praeferenzen + Tool-Stack fuellen. USER.md selbst ist via
.gitignore vom Repo ausgeschlossen.
-->
+182
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
@@ -547,6 +549,12 @@ class ARIABridge:
# Beeinflusst das Timeout fuer stt_request — bei "loading" warten wir laenger,
# weil das Modell beim ersten Request noch ~1-2 Min runtergeladen werden kann.
self._remote_stt_ready: bool = False
# User-Message-Counter fuer Auto-Compact. Bei zu langer Konversation
# sprengt die argv-Liste beim Claude-Subprocess-Spawn (E2BIG). Bei
# COMPACT_AFTER erreicht → Sessions reset + Container restart.
# Counter ueberlebt Bridge-Restart nicht (frischer Zaehler beim Start ok).
self._user_message_count: int = 0
self._compact_after = int(os.getenv("COMPACT_AFTER_MESSAGES", "140"))
# Pending Files: wenn die App ein Bild + Text gleichzeitig schickt, kommen
# zwei separate RVS-Events ('file' und 'chat') — wir buffern die Files
# kurz und mergen sie mit dem nachfolgenden Chat-Text zu einer einzigen
@@ -882,6 +890,51 @@ 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], list[str]]:
"""Sucht [FILE: /shared/uploads/...]-Marker.
Returns (cleaned_text, valid_files, missing_paths)."""
files: list[dict] = []
missing: list[str] = []
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)
missing.append(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, missing
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 +949,22 @@ 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, missing_files = self._extract_file_markers(text)
for f in aria_files:
await self._broadcast_aria_file(f)
# Bei fehlenden Files: User informieren (sonst sieht er nur stille
# Verluste — ARIA hat den Marker hingeschrieben aber das File nicht
# tatsaechlich angelegt).
if missing_files:
missing_list = "\n".join(f"{os.path.basename(p)}" for p in missing_files)
text = (text + "\n\n[Hinweis] Folgende Dateien hat ARIA zwar erwaehnt "
f"aber nicht erstellt:\n{missing_list}\n"
"Bitte ARIA bitten, sie wirklich zu schreiben.").strip()
metadata = payload.get("metadata", {})
is_critical = metadata.get("critical", False)
requested_voice = metadata.get("voice")
@@ -1118,12 +1187,53 @@ class ARIABridge:
await self.send_to_core(text, source="app-file+chat")
return True
async def _trigger_session_reset(self) -> None:
"""Sessions loeschen + Container restart via Diagnostic HTTP-API."""
try:
req = urllib.request.Request(
"http://localhost:3001/api/aria-session-reset",
data=b"{}",
method="POST",
headers={"Content-Type": "application/json"},
)
def _do_reset():
try:
with urllib.request.urlopen(req, timeout=45) as resp:
return resp.status
except Exception as e:
return f"err:{e}"
result = await asyncio.get_event_loop().run_in_executor(None, _do_reset)
logger.info("[core] Session-Reset Result: %s", result)
except Exception as e:
logger.warning("[core] Session-Reset Trigger fehlgeschlagen: %s", e)
async def send_to_core(self, text: str, source: str = "bridge") -> None:
"""Sendet Text an aria-core (OpenClaw chat.send Protokoll)."""
if self.ws_core is None:
logger.error("[core] Nicht verbunden — Nachricht verworfen: '%s'", text[:60])
return
# Auto-Compact: bei zu vielen User-Messages laeuft argv beim Subprocess-
# Spawn ueber (E2BIG). Vor send pruefen, ggf. Sessions resetten.
if source.startswith("app") and self._compact_after > 0:
self._user_message_count += 1
if self._user_message_count >= self._compact_after:
logger.warning("[core] Auto-Compact: %d Messages erreicht — Session-Reset",
self._user_message_count)
self._user_message_count = 0
# Reset triggern via Diagnostic (asynchron, blockiert send nicht)
asyncio.create_task(self._trigger_session_reset())
# User informieren — der naechste Request kommt erst nach Restart durch
await self._send_to_rvs({
"type": "chat",
"payload": {
"text": f"[Compact] Konversation war lang ({self._compact_after} Nachrichten) — Session wurde geleert, ARIA startet frisch. Deine letzte Nachricht bitte gleich nochmal senden.",
"sender": "aria",
},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
return
# Aktive Session vom Diagnostic holen
self._fetch_active_session()
@@ -1528,6 +1638,76 @@ class ARIABridge:
except Exception as e:
logger.warning("[rvs] file_saved konnte nicht an App gesendet werden: %s", e)
elif msg_type == "aria_session_reset":
# Manueller Compact-Trigger: Sessions weg + Restart
logger.warning("[rvs] aria_session_reset Request von App")
self._user_message_count = 0
asyncio.create_task(self._trigger_session_reset())
return
elif msg_type == "aria_restart":
# App-Button "ARIA hart neu starten" → docker restart aria-core
# via Diagnostic (der hat den Docker-Socket gemountet).
logger.warning("[rvs] aria_restart Request von App — harter Container-Restart")
try:
req = urllib.request.Request(
"http://localhost:3001/api/aria-restart",
data=b"{}",
method="POST",
headers={"Content-Type": "application/json"},
)
def _do_restart():
try:
with urllib.request.urlopen(req, timeout=45) as resp:
return resp.status, resp.read().decode("utf-8", errors="ignore")
except Exception as e:
return None, str(e)
status, body = await asyncio.get_event_loop().run_in_executor(None, _do_restart)
logger.info("[rvs] aria_restart Result: status=%s", status)
# Note: bei erfolgreichem Restart ist die RVS-Verbindung sehr
# wahrscheinlich kurz weg (aria-bridge ist im service:aria-Network).
# Die Antwort kommt evtl. nicht mehr durch — egal.
except Exception as e:
logger.warning("[rvs] aria_restart Weiterleitung fehlgeschlagen: %s", e)
return
elif msg_type == "doctor_fix":
# App-Button "ARIA reparieren" → openclaw doctor --fix anstossen.
# Bridge erreicht aria-core nicht via docker (kein docker-socket
# gemountet), aber der Diagnostic-Server hat den Socket. HTTP-Call
# an http://localhost:3001/api/doctor-fix.
logger.info("[rvs] doctor_fix Request von App — leite an Diagnostic weiter")
try:
req = urllib.request.Request(
"http://localhost:3001/api/doctor-fix",
data=b"{}",
method="POST",
headers={"Content-Type": "application/json"},
)
# Blocking call ist OK weil openclaw doctor schnell durchlaeuft.
# In Executor laufen lassen damit der asyncio-Loop nicht blockt.
def _do_fix():
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return resp.status, resp.read().decode("utf-8", errors="ignore")
except Exception as e:
return None, str(e)
status, body = await asyncio.get_event_loop().run_in_executor(None, _do_fix)
ok = status == 200
logger.info("[rvs] doctor_fix Result: status=%s ok=%s", status, ok)
await self._send_to_rvs({
"type": "chat",
"payload": {
"text": "[Reparatur] ARIA wurde durchgecheckt — sollte wieder antworten." if ok
else f"[Reparatur] Fehlgeschlagen: {body[:200]}",
"sender": "aria",
},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
except Exception as e:
logger.warning("[rvs] doctor_fix Weiterleitung fehlgeschlagen: %s", e)
return
elif msg_type == "file_request":
# App fordert eine Datei an (Re-Download nach Cache-Leerung)
server_path = payload.get("serverPath", "")
@@ -1545,6 +1725,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 +1734,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),
})
+115 -4
View File
@@ -288,7 +288,12 @@
<div class="chat-box" id="chat-box"></div>
<div id="thinking-indicator" style="display:none;padding:6px 10px;font-size:12px;color:#FFD60A;background:#1E1E2E;border-radius:0 0 6px 6px;margin-top:-8px;margin-bottom:8px;align-items:center;justify-content:space-between;">
<span><span style="animation:pulse 1s infinite;">&#x1F4AD;</span> <span id="thinking-text">ARIA denkt...</span></span>
<button class="btn secondary" onclick="cancelRequest()" style="padding:2px 10px;font-size:11px;color:#FF3B30;border-color:#FF3B30;">Abbrechen</button>
<div style="display:flex;gap:6px;">
<button class="btn secondary" onclick="doctorFix()" style="padding:2px 10px;font-size:11px;color:#FF9500;border-color:#FF9500;" title="ARIA reparieren — openclaw doctor --fix">&#x1F527; Reparieren</button>
<button class="btn secondary" onclick="ariaSessionReset()" style="padding:2px 10px;font-size:11px;color:#FF9500;border-color:#FF9500;" title="Konversation komprimieren — Sessions weg + Restart">&#x1F9F9; Compact</button>
<button class="btn secondary" onclick="ariaRestart()" style="padding:2px 10px;font-size:11px;color:#FF3B30;border-color:#FF3B30;" title="Container hart neu starten (~15s)">&#x1F6A8; Hart neu</button>
<button class="btn secondary" onclick="cancelRequest()" style="padding:2px 10px;font-size:11px;color:#FF3B30;border-color:#FF3B30;">Abbrechen</button>
</div>
</div>
<div id="diag-pending-attachments" style="display:none;padding:6px 10px;background:#1E1E2E;border-radius:6px 6px 0 0;margin-bottom:-4px;display:flex;gap:6px;flex-wrap:wrap;align-items:center;">
</div>
@@ -454,6 +459,14 @@
<!-- ══════ TAB: Einstellungen ══════ -->
<div id="tab-settings" class="main-tab">
<!-- Was ist ARIA? -->
<div class="settings-section">
<div class="card" style="max-width:700px;font-size:13px;color:#AAA;border-left:3px solid #0096FF;">
<strong style="color:#0096FF;">ARIA</strong> — Autonomous Reasoning &amp; Intelligence Assistant.
Selbst gehosteter JARVIS-artiger KI-Assistent, gebaut von Stefan / HackerSoft Oldenburg.
</div>
</div>
<!-- Betriebsmodus -->
<div class="settings-section">
<h2>Betriebsmodus</h2>
@@ -993,7 +1006,12 @@
}
if (msg.type === 'chat_final') {
addChat('received', msg.text, 'chat:final');
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; }
@@ -1080,10 +1098,23 @@
chatBox.innerHTML = '';
if (msg.messages && msg.messages.length > 0) {
for (const m of msg.messages) {
if (m.type === 'aria_file') {
// ARIA-Datei-Bubble rekonstruieren (statt addAriaFile damit
// kein Auto-Scroll-Race waehrend des Bulk-Loads)
addAriaFile({ serverPath: m.serverPath, name: m.name, mimeType: m.mimeType, size: m.size });
continue;
}
const el = document.createElement('div');
el.className = `chat-msg ${m.type}`;
const escaped = escapeHtml(m.text);
const linked = linkifyText(escaped);
// [FILE: ...]-Marker rausfiltern (gleicher Filter wie addChat)
const cleaned = (m.text || '').replace(/\[FILE:\s*\/shared\/uploads\/[^\]]+\]/gi, '').replace(/\n{3,}/g, '\n\n').trim();
const escaped = escapeHtml(cleaned);
let linked = linkifyText(escaped);
// /shared/uploads/-Bildpfade auch im History inline rendern
// (gleicher Replace wie in addChat — sonst sieht man nach F5 nur Text-Pfade)
linked = linked.replace(/\/shared\/uploads\/[^\s<"]+\.(jpg|jpeg|png|gif|webp|svg|bmp)/gi, (match) => {
return `<a href="${match}" target="_blank">${match}</a><img src="${match}" class="chat-media" onclick="openLightbox('image','${match}')" onerror="this.style.display='none'">`;
});
const time = m.ts ? new Date(m.ts).toLocaleTimeString('de-DE') : '?';
el.innerHTML = `${linked}<div class="meta">${escapeHtml(m.meta)}${time}</div>`;
chatBox.appendChild(el);
@@ -1434,6 +1465,10 @@
}
function addChat(type, text, meta, options) {
// [FILE: /shared/uploads/aria_xxx.ext]-Marker aus dem Antworttext entfernen —
// die Datei kommt separat via file_from_aria-Event als eigene Bubble.
// /gi entfernt mehrere Marker, falls ARIA mehrere Dateien in einer Antwort liefert.
if (text) text = text.replace(/\[FILE:\s*\/shared\/uploads\/[^\]]+\]/gi, '').replace(/\n{3,}/g, '\n\n').trim();
const escaped = escapeHtml(text);
let linked = linkifyText(escaped);
// /shared/uploads/ Pfade als Inline-Bilder anzeigen
@@ -1475,6 +1510,41 @@
}
}
/** 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 style="margin-top:4px;font-size:10px;color:#666;font-family:monospace;">${escapeHtml(serverPath)}</div>` +
`<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');
@@ -1813,6 +1883,47 @@
renderDiagPending();
}
// ── Reparieren — openclaw doctor --fix ──────
function doctorFix() {
fetch('/api/doctor-fix', { method: 'POST' })
.then(r => r.json())
.then(data => {
if (data.ok) {
addLog('info', 'server', 'Reparatur ausgefuehrt: ' + (data.output || 'OK').slice(0, 200));
} else {
addLog('error', 'server', 'Reparatur fehlgeschlagen: ' + (data.error || ''));
}
})
.catch(err => addLog('error', 'server', 'Reparatur Request fehlgeschlagen: ' + err.message));
}
// ── Hard-Restart — docker restart aria-core ──────
function ariaRestart() {
if (!confirm('ARIA wird hart neu gestartet (Container-Restart, ~15s).\n\nLaufende Anfragen gehen verloren. Sicher?')) return;
fetch('/api/aria-restart', { method: 'POST' })
.then(r => r.json())
.then(data => {
if (data.ok) {
addLog('info', 'server', 'ARIA neu gestartet — wartet auf Reconnect');
} else {
addLog('error', 'server', 'Restart fehlgeschlagen: ' + (data.error || ''));
}
})
.catch(err => addLog('error', 'server', 'Restart Request fehlgeschlagen: ' + err.message));
}
// ── Compact / Session-Reset ──────
function ariaSessionReset() {
if (!confirm('Konversation komprimieren: alle Nachrichten in ARIAs aktueller Session werden geloescht und der Container neu gestartet. ARIA vergisst den bisherigen Gespraechsverlauf. Sicher?')) return;
fetch('/api/aria-session-reset', { method: 'POST' })
.then(r => r.json())
.then(data => {
if (data.ok) addLog('info', 'server', 'Session geleert, ARIA neu gestartet');
else addLog('error', 'server', 'Reset fehlgeschlagen: ' + (data.error || ''));
})
.catch(err => addLog('error', 'server', 'Reset Request fehlgeschlagen: ' + err.message));
}
// ── Abbrechen ──────────────────────────────
function cancelRequest() {
send({ action: 'cancel_request' });
+125 -1
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") {
@@ -1337,6 +1342,97 @@ const server = http.createServer((req, res) => {
dockerExec("aria-core", "openclaw doctor --fix 2>/dev/null || true").catch(() => {});
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true }));
} else if (req.url === "/api/doctor-fix" && req.method === "POST") {
// Manueller "ARIA reparieren"-Button — stuck OpenClaw-Runs aufloesen.
log("info", "server", "HTTP /api/doctor-fix — manueller Reparatur-Trigger");
dockerExec("aria-core", "openclaw doctor --fix 2>&1")
.then(out => {
const summary = (out || "").split("\n").filter(l => l.trim()).slice(-3).join(" | ");
broadcast({ type: "watchdog", status: "fixed", message: `Reparatur ausgefuehrt: ${summary || "OK"}` });
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, output: out }));
})
.catch(err => {
broadcast({ type: "watchdog", status: "error", message: `Reparatur fehlgeschlagen: ${err.message}` });
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: err.message }));
});
return;
} else if (req.url === "/api/aria-session-reset" && req.method === "POST") {
// Sessions weg + Container neu — fuer Compact-After-N-Messages.
// E2BIG bei zu langen Sessions: argv beim Subprocess-spawn ueberschritten.
log("warn", "server", "HTTP /api/aria-session-reset — Sessions loeschen + Restart");
broadcast({ type: "watchdog", status: "fixing", message: "Sessions werden geleert — ARIA bekommt frischen Start" });
dockerExec("aria-core", "rm -f /home/node/.openclaw/agents/main/sessions/*.jsonl /home/node/.openclaw/agents/main/sessions/*.lock 2>&1 && echo '{}' > /home/node/.openclaw/agents/main/sessions/sessions.json")
.then(() => {
// Restart via Docker-API (gleicher Pfad wie /api/aria-restart)
const restartReq = http.request({
socketPath: "/var/run/docker.sock",
path: "/containers/aria-core/restart?t=10",
method: "POST",
headers: { "Content-Length": 0 },
timeout: 30000,
}, (dRes) => {
if (dRes.statusCode === 204) {
log("info", "server", "aria-session-reset OK");
broadcast({ type: "watchdog", status: "fixed", message: "Sessions geleert, ARIA neu gestartet" });
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true }));
} else {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: `Docker-API ${dRes.statusCode}` }));
}
});
restartReq.on("error", (err) => {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: err.message }));
});
restartReq.end();
})
.catch((err) => {
log("error", "server", `aria-session-reset Cleanup fehlgeschlagen: ${err.message}`);
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: err.message }));
});
return;
} else if (req.url === "/api/aria-restart" && req.method === "POST") {
// Harter Restart — fuer Faelle wo doctor --fix nicht reicht (alive aber
// haengender Run). Geht ueber Docker-API (Socket), kein CLI noetig.
// POST /containers/aria-core/restart?t=10 → SIGTERM, dann nach 10s SIGKILL.
log("warn", "server", "HTTP /api/aria-restart — harter Container-Restart");
broadcast({ type: "watchdog", status: "fixing", message: "ARIA wird hart neu gestartet (~15s)" });
const restartReq = http.request({
socketPath: "/var/run/docker.sock",
path: "/containers/aria-core/restart?t=10",
method: "POST",
headers: { "Content-Length": 0 },
timeout: 30000,
}, (dRes) => {
let body = "";
dRes.on("data", (c) => body += c);
dRes.on("end", () => {
// Docker-API: 204 = OK, 404 = container nicht da, 500 = anderer Fehler
if (dRes.statusCode === 204) {
log("info", "server", "aria-restart OK (Docker-API)");
broadcast({ type: "watchdog", status: "fixed", message: "ARIA wurde neu gestartet — sollte gleich antworten" });
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true }));
} else {
log("error", "server", `aria-restart Docker-API ${dRes.statusCode}: ${body.slice(0, 200)}`);
broadcast({ type: "watchdog", status: "error", message: `Restart fehlgeschlagen: HTTP ${dRes.statusCode}` });
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: `Docker-API ${dRes.statusCode}: ${body}` }));
}
});
});
restartReq.on("error", (err) => {
log("error", "server", `aria-restart Socket-Fehler: ${err.message}`);
broadcast({ type: "watchdog", status: "error", message: `Restart fehlgeschlagen: ${err.message}` });
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: err.message }));
});
restartReq.end();
return;
} else if (req.url.startsWith("/shared/")) {
// Dateien aus Shared Volume ausliefern (Bilder, Uploads)
const filePath = decodeURIComponent(req.url);
@@ -2135,7 +2231,35 @@ async function handleLoadChatHistory(clientWs) {
} else if (role === "assistant") {
// Reply-Prefix entfernen: "[[reply_to_current]] "
text = text.replace(/^\[\[reply_to_\w+\]\]\s*/g, "").trim();
if (text) chatMessages.push({ type: "received", text, meta: "chat:final", ts: msg.timestamp || obj.timestamp || 0 });
const ts = msg.timestamp || obj.timestamp || 0;
// ARIA-File-Marker aus dem Text parsen — pro existierender Datei
// eine separate file_from_aria-aehnliche Message einfuegen damit die
// Anhang-Bubble nach Browser-Refresh wieder erscheint.
const fileRe = /\[FILE:\s*(\/shared\/uploads\/[^\]]+?)\s*\]/gi;
let m;
while ((m = fileRe.exec(text)) !== null) {
const p = m[1].trim();
try {
if (fs.existsSync(p)) {
const st = fs.statSync(p);
const ext = path.extname(p).toLowerCase();
const mimeMap = { ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".gif": "image/gif",
".webp": "image/webp", ".svg": "image/svg+xml", ".pdf": "application/pdf",
".mp3": "audio/mpeg", ".mid": "audio/midi", ".midi": "audio/midi",
".wav": "audio/wav", ".txt": "text/plain", ".md": "text/markdown",
".json": "application/json", ".zip": "application/zip" };
chatMessages.push({
type: "aria_file",
serverPath: p,
name: path.basename(p),
mimeType: mimeMap[ext] || "application/octet-stream",
size: st.size,
ts,
});
}
} catch {}
}
if (text) chatMessages.push({ type: "received", text, meta: "chat:final", ts });
}
} catch {}
}
+1
View File
@@ -87,6 +87,7 @@ services:
- RVS_TLS=${RVS_TLS:-true}
- RVS_TLS_FALLBACK=${RVS_TLS_FALLBACK:-true}
- RVS_TOKEN=${RVS_TOKEN:-}
- COMPACT_AFTER_MESSAGES=${COMPACT_AFTER_MESSAGES:-140}
restart: unless-stopped
# ─── Diagnostic (Selbstcheck-UI und Einstellungen) ────
Executable
+43
View File
@@ -0,0 +1,43 @@
#!/bin/bash
# ════════════════════════════════════════════════════════════
# ARIA — Setup-Script
#
# Materialisiert Config-Dateien aus *.example-Vorlagen wenn
# das Original fehlt. Wird einmalig nach git clone und nach
# jedem git pull empfohlen — schadet auch sonst nichts (idempotent,
# ueberschreibt nichts Bestehendes).
#
# Beispiele:
# aria-data/config/USER.md.example → USER.md (wenn nicht vorhanden)
# aria-data/config/aria.env.example → aria.env (wenn nicht vorhanden)
#
# Diese Files sind via .gitignore vom Repo ausgeschlossen — die
# Vorlagen liegen aber im Repo damit ein frisches Setup ohne lange
# Anleitung lauffaehig ist.
# ════════════════════════════════════════════════════════════
set -e
cd "$(dirname "$0")"
created=0
skipped=0
for example in aria-data/config/*.example; do
[ -f "$example" ] || continue
target="${example%.example}"
if [ -e "$target" ]; then
skipped=$((skipped + 1))
else
cp "$example" "$target"
echo "$target erstellt aus $(basename "$example")"
created=$((created + 1))
fi
done
if [ $created -eq 0 ]; then
echo "Alle Config-Dateien vorhanden ($skipped uebersprungen)."
else
echo ""
echo "$created Datei(en) angelegt, $skipped uebersprungen."
echo "Falls noetig anpassen: aria-data/config/"
fi
+12
View File
@@ -89,6 +89,18 @@ Wichtige Mechanismen:
- [x] **Mute-Button stoppt jetzt auch laufenden PCM-Stream**`pcmStreamActive` wurde beim isFinal-Chunk schon false gesetzt, der AudioTrack spielte aber noch sekundenlang aus seinem Buffer. `stopPlayback()` uebersprang darum `PcmStreamPlayer.stop()`. Fix: stop() immer rufen (ist idempotent), kein Flag-Check mehr
- [x] **GPS-Permission im Manifest + Runtime-Request** beim Settings-Toggle — vorher fehlten ACCESS_COARSE_LOCATION / ACCESS_FINE_LOCATION komplett. `Geolocation.getCurrentPosition` schlug lautlos fehl, App sendete nie ein location-Feld
- [x] **GPS-Position auch im STT-Payload an Diagnostic** — die App sendet location einmal im audio-Payload. Die Bridge nutzte sie zwar (ging in aria-core's Kontext rein), reichte sie aber nicht im STT-broadcast an Diagnostic durch. Diagnostic zeigte darum bei Spracheingaben nie den GPS-Block, obwohl der "GPS einblenden"-Toggle aktiv war
- [x] **Auto-Resume nach Anruf — pcmBuffer bleibt erhalten**: `haltAllPlayback` leerte den pcmBuffer mid-Anruf, isFinal schrieb dann eine leere WAV. Neue `pauseForCall`-Methode statt `haltAllPlayback`: AudioTrack stoppt + Focus released, `pcmBuffer` und `pcmMessageId` bleiben — chunks werden weiter gesammelt damit isFinal die WAV schreibt und resumeFromInterruption sie findet. Plus `captureInterruption` idempotent gemacht (ringing → offhook ueberschreibt nicht)
- [x] **Replay-Resume nach Anruf**: `_firePlaybackStarted` ueberschrieb `currentPlaybackMsgId` mit leerem pcmMessageId — captureInterruption hatte nichts zu merken. Plus Regex `[0-9a-f-]+\.wav` matchte nicht alle Dateinamen. Plus `_playFromPathAtPosition` aktualisiert jetzt das Tracking damit ein zweiter Anruf in derselben Antwort auch funktioniert
- [x] **`pauseForCall` setzt `isPlaying` zurueck**: vorher haengten weitere Play-Button-Klicks nach Anruf, weil `playAudio` bei `isPlaying=true` den `_playNext`-Pfad ueberspringt
- [x] **Play-Button rendert neu wenn Cache-Datei weg ist**: vorher checkte der Button nur `if (item.audioPath)` — auf eine geloeschte Cache-Datei zeigte das aber stillschweigend ins Leere. Jetzt RNFS.exists-Check mit Fallback auf `tts_request` an die Bridge → F5-TTS rendert neu, WAV wandert zurueck in den Cache
- [x] **Bridge WebSocket max_size 50 MB**: Python `websockets.connect` hat 1 MiB Default — Stefan's 4MB JPEG (5.78 MB Base64) sprengte das, Bridge-Connection wurde silent gedroppt. f5tts/whisper-bridges hatten max_size schon, nur aria_bridge war vergessen
- [x] **Bridge resized Bilder >2 MB serverseitig auf 1568px**: Claude-Vision-API hat ~5 MB Base64-Limit. Galerie-Bilder via `react-native-image-picker` sind clientseitig schon klein, Buroklammer/DocumentPicker reichte das rohe File durch — Claude lieferte leere Antwort. Pillow im Bridge-Container, nur fuer JPEG/PNG/WebP/GIF (PDFs/ZIPs/SVGs unangetastet)
- [x] **Bridge `chat:error` liest auch `errorMessage`**: OpenClaw legt bei state=error den Text dort statt in `error` ab → Bridge meldete generisches "[Fehler] Unbekannt", echter Fehler nur in Container-Logs. Plus: `chat:final` ohne text wird jetzt mit Hinweis-Bubble an die App gemeldet (statt stumm), z.B. wenn Vision das Bild silent ablehnt
- [x] **Cache-Cleanup beim App-Start** — orphane `aria_tts_*.wav` Files (>5 min) im CachesDirectoryPath werden weggeraeumt, sammeln sich sonst an wenn Sound mid-playback gestoppt wird (Anruf, Mute, Barge-In) und der completion-Callback nicht feuert. Plus neuer Settings-Button "TTS-Cache leeren" mit Live-Groessenanzeige
- [x] **Verbose-Logging-Toggle in Settings → Protokoll**: `console.log` global stummschaltbar (warn/error bleiben aktiv) — spart adb-logcat-Speicher wenn alles laeuft
- [x] **800 ms-Delay vor Anruf-Auto-Resume**: ARIA's neuer Focus-Request kollidierte sonst mit Spotify's Auto-Resume nach Anruf-Ende. System haengt noch im IN_CALL→NORMAL-Mode-Uebergang, Spotify sieht Loss → Loss und bleibt pausiert. Mit Delay schafft Spotify den Resume-Schritt, dann pausiert ARIA wieder ordnungsgemaess
- [x] **Mute-Button = Stop fuer aktuelle Antwort**: vorher startete eine NEUE PCM-Chunk-Sequenz nach Mute-aus die alte Antwort weiter wo sie war (funktionierte 2x, dann nicht mehr weil isFinal schon kam). Jetzt mit `_stoppedMessageId`-Tracking: bei Mute wird die aktive msgId gemerkt, alle weiteren chunks dieser msgId bleiben silent — auch wenn Mute zurueckgenommen wird. Reset bei neuer msgId, neue Antworten spielen normal
- [x] **Spotify resumed nach Mute-Stop**: `stopPlayback` released seinen TRANSIENT-Focus (USAGE_ASSISTANT) sauber → Spotify bekommt GAIN-Event und resumed automatisch. Ein zwischenzeitlich eingebauter `kickReleaseMedia` (USAGE_MEDIA + GAIN) verhinderte das Auto-Resume sogar (Spotify interpretierte es als "user-action stopp") — wieder rausgenommen
### App Features
+4
View File
@@ -18,6 +18,10 @@ const ALLOWED_TYPES = new Set([
"update_check", "update_available", "update_download", "update_data",
"agent_activity", "cancel_request",
"audio_pcm",
"file_from_aria",
"doctor_fix",
"aria_restart",
"aria_session_reset",
"xtts_delete_voice",
"voice_preload", "voice_ready",
"stt_request", "stt_response",