Compare commits

...

8 Commits

Author SHA1 Message Date
duffyduck da795d14f5 release: 0.1.2.0 — Text-Chat fertig, Ping-Pong stabil, OpenClaw als Basis (letzte OpenClaw-Version)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:25:46 +02:00
duffyduck d60c7e9110 docs(issue/readme): heutige Features dokumentiert — ARIA-Files, Reparatur-Buttons, Pinch-Zoom, Auto-Compact
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:20:10 +02:00
duffyduck 83c99a5e65 release: bump version to 0.1.1.9 2026-05-11 19:17:30 +02:00
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
9 changed files with 243 additions and 20 deletions
+10 -2
View File
@@ -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-`![alt](url)`-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)
+2 -2
View File
@@ -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 -1
View File
@@ -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",
+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;
+20 -11
View File
@@ -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 */}
+2 -1
View File
@@ -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
View File
@@ -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 &amp; 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
View File
@@ -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 {}
}
+14
View File
@@ -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): `![alt](url)`- 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