Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| da795d14f5 | |||
| d60c7e9110 | |||
| 83c99a5e65 | |||
| e438bb11ff | |||
| 8b4f75bf91 | |||
| d7e7386954 | |||
| 2100c64b91 | |||
| 74ebf59c6f |
@@ -114,6 +114,7 @@ apt install -y docker.io docker-compose-plugin git curl jq
|
||||
git clone git@gitea.hackersoft.de:aria/aria.git ~/ARIA-AGENT
|
||||
cd ~/ARIA-AGENT
|
||||
cp .env.example .env
|
||||
bash init.sh # legt USER.md aus Vorlage an (idempotent, schadet nicht)
|
||||
```
|
||||
|
||||
`.env` Datei editieren (Details siehe `.env.example`):
|
||||
@@ -397,10 +398,17 @@ API-Endpoint fuer andere Services: `GET http://localhost:3001/api/session`
|
||||
- **Mehrere Anhaenge**: Bilder + Dateien sammeln, Text hinzufuegen, dann zusammen senden
|
||||
- **Paste-Support**: Bilder aus Zwischenablage einfuegen (Diagnostic)
|
||||
- **Anhaenge**: Bridge speichert in Shared Volume, ARIA kann darauf zugreifen, Re-Download ueber RVS
|
||||
- **Einstellungen**: TTS-aktiv, F5-TTS-Voice, Pre-Roll-Buffer, Stille-Toleranz, Speicherort, Auto-Download, GPS
|
||||
- **Einstellungen**: TTS-aktiv, F5-TTS-Voice, Pre-Roll-Buffer, Stille-Toleranz, Speicherort, Auto-Download, GPS, Verbose-Logging
|
||||
- **Auto-Update**: Prueft beim Start + per Button auf neue Version, Download + Installation ueber RVS (FileProvider)
|
||||
- GPS-Position (optional)
|
||||
- GPS-Position (optional, mit Runtime-Permission-Request) — wird in jeden Chat/Audio-Payload mitgegeben und ist in Diagnostic als Debug-Block einblendbar
|
||||
- QR-Code Scanner fuer Token-Pairing
|
||||
- **ARIA-Dateien empfangen**: Wenn ARIA eine PDF/Bild/Markdown/ZIP fuer dich erstellt (Marker `[FILE: /shared/uploads/aria_*]` in der Antwort), erscheint sie als eigene Anhang-Bubble. Tippen → wird via RVS geladen + mit Android-Intent-Picker geoeffnet (PDF-Viewer, Bildbetrachter, Standard-App). Inline-Bilder aus Markdown-``-Syntax werden direkt unter dem Text gerendert (PNG/JPG via Image, SVG via react-native-svg)
|
||||
- **Vollbild mit Pinch-Zoom**: Bilder im Vollbild-Modal sind pinch-zoombar (1x..5x), 1-Finger-Pan wenn gezoomt, Doppel-Tap toggelt 1x↔2.5x — alles ohne externe Lib
|
||||
- **ARIA-Reparatur-Buttons** (Settings → Reparatur und in der "ARIA denkt"-Bubble):
|
||||
- 🔧 *Reparieren* → `openclaw doctor --fix` (stuck Runs aufloesen)
|
||||
- 🚨 *Hart neu starten* → Container-Restart via Docker-API (15s Downtime)
|
||||
- 🧹 *Konversation komprimieren* → Sessions weg + Restart (Auto-Trigger bei >140 Messages via `COMPACT_AFTER_MESSAGES`-Env in Bridge, schuetzt vor E2BIG-Crash)
|
||||
- **Cache-Cleanup**: Beim App-Start werden orphane TTS-WAVs aus dem Cache geraeumt. Plus Settings-Buttons "TTS-Cache leeren", "Update-Cache leeren", "Anhang-Cache leeren"
|
||||
|
||||
### Wake-Word (openWakeWord, on-device)
|
||||
|
||||
|
||||
@@ -79,8 +79,8 @@ android {
|
||||
applicationId "com.ariacockpit"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 10107
|
||||
versionName "0.1.1.7"
|
||||
versionCode 10109
|
||||
versionName "0.1.1.9"
|
||||
// Fallback fuer Libraries mit Product Flavors
|
||||
missingDimensionStrategy 'react-native-camera', 'general'
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aria-cockpit",
|
||||
"version": "0.1.1.7",
|
||||
"version": "0.1.2.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"android": "react-native run-android",
|
||||
|
||||
@@ -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;
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
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';
|
||||
@@ -1378,25 +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 && (
|
||||
/\.svg(?:\?|$)/i.test(fullscreenImage) ? (
|
||||
<View style={styles.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" />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<Image
|
||||
source={{ uri: fullscreenImage }}
|
||||
// Pixel-Bild: Pinch-Zoom + Pan ueber ZoomableImage
|
||||
<ZoomableImage
|
||||
uri={fullscreenImage}
|
||||
containerWidth={Dimensions.get('window').width}
|
||||
containerHeight={Dimensions.get('window').height}
|
||||
style={styles.fullscreenImage}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</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 */}
|
||||
|
||||
@@ -1469,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
|
||||
|
||||
+23
-2
@@ -459,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 & Intelligence Assistant.
|
||||
Selbst gehosteter JARVIS-artiger KI-Assistent, gebaut von Stefan / HackerSoft Oldenburg.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Betriebsmodus -->
|
||||
<div class="settings-section">
|
||||
<h2>Betriebsmodus</h2>
|
||||
@@ -1090,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);
|
||||
|
||||
+29
-1
@@ -2231,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 {}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,20 @@ Wichtige Mechanismen:
|
||||
- [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
|
||||
- [x] **ARIA kann Dateien an User zurueckgeben** (PDFs, Bilder, Office-Docs, Markdown, ZIPs, ...): ARIA setzt am Antwort-Ende `[FILE: /shared/uploads/aria_<name>.<ext>]` Marker, Bridge parsed sie raus (TTS liest's nicht vor) und sendet `file_from_aria`-Event ueber RVS. App zeigt Anhang-Bubble + Klick oeffnet via Android-Intent-Picker (`FileOpenerModule`, FileProvider), Diagnostic zeigt Bubble + PDFs/Bilder neuer Tab, andere als Download. Mehrere Marker = mehrere Bubbles, nicht-existente Marker werden mit Hinweis an User gemeldet (statt silent gedroppt)
|
||||
- [x] **External Bilder/Dateien werden serverseitig persistiert**: ARIA laed externe URLs (Wikipedia, Wiki Commons) mit curl runter und gibt sie via `[FILE: ...]`-Marker zurueck — bleibt permanent im Chat auch wenn die Online-Quelle stirbt. System-Prompt instruiert sie das Pattern zu nutzen
|
||||
- [x] **ARIA-Datei-Bubbles ueberleben Browser-Refresh**: Diagnostic-Server parsed beim `load_chat_history` die Marker aus dem OpenClaw-Session-File und schickt `aria_file`-Eintraege mit, sodass die Anhang-Bubbles nach F5 wiederhergestellt werden. Plus: `/shared/uploads/`-Bildpfade werden im History-Render auch als Inline-Image gerendert (vorher nur in live-Bubbles)
|
||||
- [x] **"ARIA reparieren"-Button** in App + Diagnostic: triggert `openclaw doctor --fix` ueber RVS → Bridge → Diagnostic HTTP-API. Fix fuer stuck Runs ohne SSH
|
||||
- [x] **"ARIA hart neu starten"-Button**: docker compose-Restart ueber Docker-Socket-API im Diagnostic-Server. Mit Confirmation in der App, fuer Faelle wo doctor nicht reicht (alive aber haengender Run)
|
||||
- [x] **Auto-Compact nach N Messages**: bei zu langer Session wirft Linux beim Subprocess-spawn E2BIG (Argument list too long, ~128KB-2MB Limit). Bridge zaehlt User-Messages; bei `COMPACT_AFTER_MESSAGES` (env, default 140) werden Sessions geleert + Container neu gestartet, User bekommt Hinweis-Bubble. Plus manueller "🧹 Konversation komprimieren"-Button in App-Settings und Diagnostic
|
||||
- [x] **`[FILE: ...]`-Marker-Filter ueberall in Diagnostic**: Filter direkt in `addChat` damit er fuer alle Code-Pfade greift (chat_final, proxy_result, History-Load, ...) — vorher rutschten Marker als Text durch wenn sie nicht ueber chat_final kamen
|
||||
- [x] **Mehrere `[FILE: ...]`-Marker in einer Antwort**: Bridge zerlegt sauber in mehrere file_from_aria-Events, ARIA muss nicht selbst zwei Antworten posten. Bei nicht-existenten Files erscheint ein User-Hinweis statt silent skip
|
||||
- [x] **Inline-Bilder in Chat-Nachrichten** (App): ``- und plain-`https://image.png`-URLs werden als Image-Vorschau unter dem Text gerendert. Mit `react-native-svg` auch SVG-URLs inline
|
||||
- [x] **SVG-Anhaenge** werden korrekt gerendert: ChatImage-Komponente erkennt `.svg`-Endung und nutzt SvgUri statt Image (RN-Image kann SVG nicht). Vollbild-Modal genauso, mit `preserveAspectRatio="xMidYMid meet"` damit SVGs nicht gestreckt werden
|
||||
- [x] **Pinch-Zoom + Pan im Vollbild-Modal** (App): neue `ZoomableImage`-Komponente, reine RN-Implementation mit PanResponder+Animated, ohne externe Lib. 2-Finger-Pinch 1x..5x, 1-Finger-Pan wenn gezoomt, Doppel-Tap toggelt 1x↔2.5x. Plus ✕-Close-Button damit Tap-to-Close nicht mit Pan-Gesten kollidiert
|
||||
- [x] **ARIA-Abkuerzung ausgeschrieben**: in App → Einstellungen → Ueber und Diagnostic → Einstellungen ist jetzt erklaert: "ARIA — Autonomous Reasoning & Intelligence Assistant"
|
||||
- [x] **`init.sh`** legt fehlende Config-Dateien aus *.example-Vorlagen an — frischer Clone laeuft ohne Anleitung an
|
||||
- [x] **`USER.md` privat**: aus dem Repo genommen (enthielt interne Tool-Liste mit Gitea-URL etc.). Vorlage als `USER.md.example` checked-in, lokales File via `.gitignore` ausgeschlossen
|
||||
|
||||
### App Features
|
||||
|
||||
|
||||
Reference in New Issue
Block a user