Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 099b9651a6 | |||
| 76d72a1eef | |||
| 87deede078 | |||
| 6fec8588c1 | |||
| aafdbcd57a | |||
| 08da28f475 | |||
| 8c1014d281 | |||
| 271fc4edf6 |
@@ -341,10 +341,10 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit aria-core.
|
||||
- **Chat-Test**: Nachrichten direkt an ARIA senden (Gateway oder via RVS), Vollbild-Modus
|
||||
- **"ARIA denkt..." Indikator**: Zeigt live was ARIA gerade tut (Denken, Tool, Schreiben)
|
||||
- **Abbrechen-Button**: Stoppt laufende Anfragen + doctor --fix
|
||||
- **Session-Verwaltung**: Sessions auflisten, wechseln, erstellen, loeschen
|
||||
- **Session-Verwaltung**: Sessions auflisten, wechseln, erstellen, loeschen, als Markdown exportieren (⬇ Button)
|
||||
- **Chat-History**: Wird beim Laden und Session-Wechsel angezeigt (read-only aus JSONL)
|
||||
- **TTS-Diagnose Tab**: Stimmen testen, Status pruefen, Fehler anzeigen
|
||||
- **Einstellungen**: TTS-Engine (Piper/XTTS), Stimmen, Speed, Highlight-Trigger, Betriebsmodi
|
||||
- **Einstellungen**: TTS-Engine (Piper/XTTS), Stimmen, Speed, Highlight-Trigger, Betriebsmodi, Whisper-Modell (tiny…large-v3, Hot-Reload)
|
||||
- **XTTS Voice Cloning**: Audio-Samples hochladen, eigene Stimme erstellen
|
||||
- **Claude Login**: Browser-Terminal zum Einloggen in den Proxy
|
||||
- **Core Terminal**: Shell in aria-core (openclaw CLI)
|
||||
@@ -370,7 +370,9 @@ API-Endpoint fuer andere Services: `GET http://localhost:3001/api/session`
|
||||
- **Sprachaufnahme**: Push-to-Talk (halten) oder Tap-to-Talk (tippen, Auto-Stop bei Stille)
|
||||
- **Gespraechsmodus** (Ohr-Button): Nach jeder ARIA-Antwort startet automatisch die Aufnahme — wie ein natuerliches Gespraech hin und her, ohne Buttons druecken
|
||||
- **VAD (Voice Activity Detection)**: Erkennt 1.8s Stille und stoppt automatisch
|
||||
- **STT (Speech-to-Text)**: Audio wird in der Bridge per Whisper transkribiert, transkribierter Text erscheint im Chat
|
||||
- **Speech Gate**: Aufnahme wird verworfen wenn keine Sprache erkannt (kein Rauschen an Whisper)
|
||||
- **STT (Speech-to-Text)**: Audio wird als 16kHz mono aufgenommen und in der Bridge per Whisper transkribiert, transkribierter Text erscheint im Chat
|
||||
- **"ARIA denkt..." Indicator**: Zeigt live den Status vom Core (Denken, Tool, Schreiben) + Abbrechen-Button
|
||||
- **TTS-Wiedergabe**: ARIA antwortet per Lautsprecher (Piper oder XTTS v2), Audio-Queue mit Preloading
|
||||
- **Play-Button**: Jede ARIA-Nachricht kann nochmal vorgelesen werden
|
||||
- **Chat-Suche**: Lupe in der Statusleiste filtert Nachrichten live
|
||||
@@ -424,6 +426,17 @@ GITEA_USER=stefan
|
||||
RVS_UPDATE_HOST=root@aria-rvs # Optional: fuer Auto-Update
|
||||
```
|
||||
|
||||
### Docker-Cleanup
|
||||
|
||||
Das Bridge-Image zieht grosse ML-Deps (faster-whisper, ctranslate2, onnxruntime,
|
||||
openwakeword, piper-tts) — bei jedem Rebuild waechst der Docker-Build-Cache. Wenn
|
||||
die VM voll laeuft:
|
||||
|
||||
```bash
|
||||
./cleanup.sh # sicher: Build-Cache + ungenutzte Images
|
||||
./cleanup.sh --full # aggressiv: zusaetzlich ungenutzte Volumes (mit Rueckfrage)
|
||||
```
|
||||
|
||||
### Auto-Update
|
||||
|
||||
Die App prueft beim Start ob eine neuere Version auf dem RVS liegt.
|
||||
@@ -717,6 +730,12 @@ docker exec aria-core ssh aria-wohnung hostname
|
||||
- [x] Markdown-Bereinigung fuer TTS
|
||||
- [x] Auto-Update mit FileProvider + Update-Check Button
|
||||
- [x] Inverted FlatList (zuverlaessiges Scroll-to-Bottom)
|
||||
- [x] Speech Gate (VAD verwirft Aufnahme ohne erkannte Sprache)
|
||||
- [x] Session-Persistenz ueber Container-Restarts (sessionFromFile + atomic write)
|
||||
- [x] Session-Export als Markdown-Datei (Download-Button pro Session)
|
||||
- [x] "ARIA denkt..."-Indicator + Abbrechen-Button in App (via Bridge → RVS)
|
||||
- [x] Whisper-Modell waehlbar in Diagnostic (tiny…large-v3, Hot-Reload)
|
||||
- [x] App-Aufnahme explizit 16kHz mono (optimal fuer Whisper, kein Resample)
|
||||
|
||||
### Phase 2 — ARIA wird produktiv
|
||||
|
||||
|
||||
@@ -79,8 +79,8 @@ android {
|
||||
applicationId "com.ariacockpit"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 308
|
||||
versionName "0.0.3.8"
|
||||
versionCode 400
|
||||
versionName "0.0.4.0"
|
||||
// Fallback fuer Libraries mit Product Flavors
|
||||
missingDimensionStrategy 'react-native-camera', 'general'
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aria-cockpit",
|
||||
"version": "0.0.3.8",
|
||||
"version": "0.0.4.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"android": "react-native run-android",
|
||||
|
||||
@@ -54,6 +54,12 @@ interface ChatMessage {
|
||||
|
||||
const CHAT_STORAGE_KEY = 'aria_chat_messages';
|
||||
const MAX_STORED_MESSAGES = 500;
|
||||
const MAX_MEMORY_MESSAGES = 500;
|
||||
|
||||
// Hilfe: Messages-Array auf Max kappen (aelteste raus) — verhindert OOM
|
||||
// im Gespraechsmodus bei sehr vielen Nachrichten.
|
||||
const capMessages = (msgs: ChatMessage[]): ChatMessage[] =>
|
||||
msgs.length > MAX_MEMORY_MESSAGES ? msgs.slice(-MAX_MEMORY_MESSAGES) : msgs;
|
||||
const DEFAULT_ATTACHMENT_DIR = `${RNFS.DocumentDirectoryPath}/chat_attachments`;
|
||||
const STORAGE_PATH_KEY = 'aria_attachment_storage_path';
|
||||
|
||||
@@ -218,12 +224,12 @@ const ChatScreen: React.FC = () => {
|
||||
if (sender === 'diagnostic') {
|
||||
const diagText = (message.payload.text as string) || '';
|
||||
if (diagText) {
|
||||
setMessages(prev => [...prev, {
|
||||
setMessages(prev => capMessages([...prev, {
|
||||
id: nextId(),
|
||||
sender: 'user',
|
||||
text: diagText,
|
||||
timestamp: message.timestamp,
|
||||
}]);
|
||||
}]));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -243,7 +249,7 @@ const ChatScreen: React.FC = () => {
|
||||
timestamp: ts,
|
||||
attachments: message.payload.attachments as Attachment[] | undefined,
|
||||
};
|
||||
return [...prev, ariaMsg];
|
||||
return capMessages([...prev, ariaMsg]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -318,7 +324,7 @@ const ChatScreen: React.FC = () => {
|
||||
timestamp: Date.now(),
|
||||
attachments: [{ type: 'audio', name: 'Sprachaufnahme' }],
|
||||
};
|
||||
setMessages(prev => [...prev, userMsg]);
|
||||
setMessages(prev => capMessages([...prev, userMsg]));
|
||||
rvs.send('audio', {
|
||||
base64: result.base64,
|
||||
durationMs: result.durationMs,
|
||||
@@ -423,7 +429,7 @@ const ChatScreen: React.FC = () => {
|
||||
text,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
setMessages(prev => [...prev, userMsg]);
|
||||
setMessages(prev => capMessages([...prev, userMsg]));
|
||||
|
||||
// An RVS senden
|
||||
rvs.send('chat', {
|
||||
@@ -448,7 +454,7 @@ const ChatScreen: React.FC = () => {
|
||||
text: '🎙 Spracheingabe wird verarbeitet...',
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
setMessages(prev => [...prev, userMsg]);
|
||||
setMessages(prev => capMessages([...prev, userMsg]));
|
||||
|
||||
rvs.send('audio', {
|
||||
base64: result.base64,
|
||||
@@ -502,7 +508,7 @@ const ChatScreen: React.FC = () => {
|
||||
timestamp: Date.now(),
|
||||
attachments,
|
||||
};
|
||||
setMessages(prev => [...prev, userMsg]);
|
||||
setMessages(prev => capMessages([...prev, userMsg]));
|
||||
|
||||
// Alle Dateien an RVS senden + auf Disk speichern
|
||||
for (const { file, isPhoto } of pendingAttachments) {
|
||||
|
||||
@@ -42,8 +42,11 @@ const AUDIO_ENCODING = 'audio/wav';
|
||||
// VAD (Voice Activity Detection) — Stille-Erkennung
|
||||
const VAD_SILENCE_THRESHOLD_DB = -45; // dB unter dem als "Stille" gilt
|
||||
const VAD_SILENCE_DURATION_MS = 1800; // ms Stille bevor Auto-Stop
|
||||
const VAD_SPEECH_THRESHOLD_DB = -35; // dB ueber dem als "Sprache" gilt (Sprach-Gate)
|
||||
const VAD_SPEECH_MIN_MS = 300; // ms Sprache bevor Aufnahme zaehlt
|
||||
const VAD_SPEECH_THRESHOLD_DB = -28; // dB ueber dem als "Sprache" gilt (Sprach-Gate) — hoeher = weniger Umgebungsgeraeusche
|
||||
const VAD_SPEECH_MIN_MS = 500; // ms Sprache bevor Aufnahme zaehlt — laenger = keine Huestler/Klopfer mehr
|
||||
|
||||
// Max-Dauer einer Aufnahme in Gespraechsmodus (Notbremse gegen Runaway-Loops)
|
||||
const MAX_RECORDING_MS = 30000;
|
||||
|
||||
// --- Audio-Service ---
|
||||
|
||||
@@ -71,6 +74,7 @@ class AudioService {
|
||||
private vadEnabled: boolean = false;
|
||||
private lastSpeechTime: number = 0;
|
||||
private vadTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private maxDurationTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor() {
|
||||
this.recorder = new AudioRecorderPlayer();
|
||||
@@ -120,6 +124,10 @@ class AudioService {
|
||||
// Laufende Wiedergabe stoppen (damit ARIA sich nicht selbst hoert)
|
||||
this.stopPlayback();
|
||||
|
||||
// Aufraeumen: Alte aria_recording_ und aria_tts_ Files loeschen
|
||||
// (Schutz gegen Cache-Ueberlauf im Gespraechsmodus bei vielen Zyklen)
|
||||
this._cleanupStaleCacheFiles().catch(() => {});
|
||||
|
||||
this.recordingPath = `${RNFS.CachesDirectoryPath}/aria_recording_${Date.now()}.mp4`;
|
||||
|
||||
// Aufnahme mit Metering starten
|
||||
@@ -174,6 +182,11 @@ class AudioService {
|
||||
this.silenceListeners.forEach(cb => cb());
|
||||
}
|
||||
}, 200);
|
||||
// Notbremse: Nach MAX_RECORDING_MS zwangsweise stoppen
|
||||
this.maxDurationTimer = setTimeout(() => {
|
||||
console.warn(`[Audio] Max-Dauer ${MAX_RECORDING_MS}ms erreicht — Zwangs-Stop`);
|
||||
this.silenceListeners.forEach(cb => cb());
|
||||
}, MAX_RECORDING_MS);
|
||||
}
|
||||
|
||||
console.log('[Audio] Aufnahme gestartet (autoStop: %s)', autoStop);
|
||||
@@ -198,6 +211,10 @@ class AudioService {
|
||||
clearInterval(this.vadTimer);
|
||||
this.vadTimer = null;
|
||||
}
|
||||
if (this.maxDurationTimer) {
|
||||
clearTimeout(this.maxDurationTimer);
|
||||
this.maxDurationTimer = null;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.recorder.stopRecorder();
|
||||
@@ -379,6 +396,24 @@ class AudioService {
|
||||
this.stateListeners.forEach(cb => cb(state));
|
||||
}
|
||||
}
|
||||
|
||||
/** Alte Aufnahme- und TTS-Files aus dem Cache loeschen (>30s alt). */
|
||||
private async _cleanupStaleCacheFiles(): Promise<void> {
|
||||
try {
|
||||
const files = await RNFS.readDir(RNFS.CachesDirectoryPath);
|
||||
const now = Date.now();
|
||||
for (const f of files) {
|
||||
if (!f.isFile()) continue;
|
||||
if (!f.name.startsWith('aria_recording_') && !f.name.startsWith('aria_tts_')) continue;
|
||||
const age = now - (f.mtime ? f.mtime.getTime() : 0);
|
||||
if (age > 30000) {
|
||||
await RNFS.unlink(f.path).catch(() => {});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// silent — cleanup ist best-effort
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton
|
||||
|
||||
+13
-1
@@ -554,6 +554,9 @@ class ARIABridge:
|
||||
|
||||
# Letzter gesendeter agent_activity-State (zum Entduplizieren)
|
||||
self._last_activity_state: Optional[tuple] = None
|
||||
# Zeitstempel des letzten chat:final — waehrend 3s danach werden
|
||||
# trailing Agent-Events unterdrueckt (Core raeumt manchmal nach).
|
||||
self._last_chat_final_at: float = 0.0
|
||||
|
||||
def initialize(self) -> None:
|
||||
"""Initialisiert alle Komponenten.
|
||||
@@ -779,6 +782,7 @@ class ARIABridge:
|
||||
|
||||
if state == "final":
|
||||
text = self._extract_chat_text(payload)
|
||||
self._last_chat_final_at = asyncio.get_event_loop().time()
|
||||
await self._emit_activity("idle", "")
|
||||
if not text:
|
||||
logger.warning("[core] chat final ohne Text: %s", json.dumps(payload)[:200])
|
||||
@@ -790,6 +794,7 @@ class ARIABridge:
|
||||
if state == "error":
|
||||
error = payload.get("error", "Unbekannt")
|
||||
logger.error("[core] Chat-Fehler: %s", error)
|
||||
self._last_chat_final_at = asyncio.get_event_loop().time()
|
||||
await self._emit_activity("idle", "")
|
||||
await self._send_to_rvs({
|
||||
"type": "chat",
|
||||
@@ -1468,7 +1473,14 @@ class ARIABridge:
|
||||
logger.info("[cancel] Diagnostic /api/cancel: %s", status)
|
||||
|
||||
async def _emit_activity(self, activity: str, tool: str = "") -> None:
|
||||
"""Sendet agent_activity an die App — nur wenn sich der State geaendert hat."""
|
||||
"""Sendet agent_activity an die App — nur wenn sich der State geaendert hat.
|
||||
|
||||
Trailing Agent-Events nach chat:final werden 3s lang unterdrueckt
|
||||
(nur 'idle' kommt immer durch)."""
|
||||
if activity != "idle" and self._last_chat_final_at > 0:
|
||||
since_final = asyncio.get_event_loop().time() - self._last_chat_final_at
|
||||
if since_final < 3.0:
|
||||
return
|
||||
state = (activity, tool)
|
||||
if state == self._last_activity_state:
|
||||
return
|
||||
|
||||
Executable
+44
@@ -0,0 +1,44 @@
|
||||
#!/bin/bash
|
||||
# ARIA Docker Cleanup
|
||||
#
|
||||
# Standard: docker builder prune + image prune (sicher, loescht keine Volumes)
|
||||
# --full: Volle Reinigung inkl. --volumes (Vorsicht bei ungenutzten Volumes!)
|
||||
#
|
||||
# Usage:
|
||||
# ./cleanup.sh # sicherer Cleanup
|
||||
# ./cleanup.sh --full # aggressiver Cleanup (inkl. Volumes)
|
||||
|
||||
set -e
|
||||
|
||||
FULL=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--full|-f) FULL=1 ;;
|
||||
-h|--help)
|
||||
grep '^#' "$0" | sed 's/^# \{0,1\}//'
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "── Docker Speicher VOR Cleanup ───────────────────"
|
||||
docker system df
|
||||
echo
|
||||
|
||||
if [ "$FULL" = "1" ]; then
|
||||
echo ">>> VOLLE Reinigung (inkl. ungenutzter Volumes)"
|
||||
read -p "Wirklich? [y/N] " -n 1 -r REPLY
|
||||
echo
|
||||
[[ ! $REPLY =~ ^[Yy]$ ]] && { echo "Abgebrochen."; exit 0; }
|
||||
docker system prune -a --volumes -f
|
||||
else
|
||||
echo ">>> Sicherer Cleanup (Build-Cache + ungenutzte Images)"
|
||||
docker builder prune -a -f
|
||||
docker image prune -a -f
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "── Docker Speicher NACH Cleanup ──────────────────"
|
||||
docker system df
|
||||
echo
|
||||
df -h / | head -2
|
||||
+47
-16
@@ -201,7 +201,7 @@
|
||||
<button class="btn secondary" onclick="toggleChatFullscreen()" id="btn-chat-fs" style="padding:4px 10px;font-size:11px;">Vollbild</button>
|
||||
</div>
|
||||
<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;display:flex;align-items:center;justify-content:space-between;">
|
||||
<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;">💭</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>
|
||||
@@ -1304,7 +1304,11 @@
|
||||
label = 'ARIA schreibt...';
|
||||
}
|
||||
|
||||
indicators.forEach(el => { if (el) el.style.display = 'block'; });
|
||||
indicators.forEach((el, i) => {
|
||||
if (!el) return;
|
||||
// Haupt-Indicator ist flex (Abbrechen-Button rechts), Vollbild-Variante block
|
||||
el.style.display = i === 0 ? 'flex' : 'block';
|
||||
});
|
||||
texts.forEach(el => { if (el) el.textContent = label; });
|
||||
|
||||
// Auto-Hide nach 2min (falls idle Event verpasst wird — ARIA arbeitet max 15min)
|
||||
@@ -1699,33 +1703,60 @@
|
||||
: '<div style="color:#555570;padding:8px;text-align:center;">Keine Sessions gefunden</div>';
|
||||
return;
|
||||
}
|
||||
let html = '<table style="width:100%;border-collapse:collapse;">';
|
||||
html += '<tr style="color:#8888AA;font-size:10px;text-align:left;border-bottom:1px solid #1E1E2E;">'
|
||||
|
||||
const active = data.sessions.filter(s => !s.archived);
|
||||
const archives = data.sessions.filter(s => s.archived);
|
||||
|
||||
const headerRow = '<tr style="color:#8888AA;font-size:10px;text-align:left;border-bottom:1px solid #1E1E2E;">'
|
||||
+ '<th style="padding:4px 6px;">Session</th>'
|
||||
+ '<th style="padding:4px 6px;">Msgs</th>'
|
||||
+ '<th style="padding:4px 6px;">Zuletzt</th>'
|
||||
+ '<th style="padding:4px 6px;"></th></tr>';
|
||||
for (const s of data.sessions) {
|
||||
|
||||
const rowFor = (s, opts) => {
|
||||
const date = s.modified ? new Date(s.modified * 1000).toLocaleString('de-DE', {day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}) : '?';
|
||||
const key = escapeHtml(s.sessionKey || s.path.split('/').pop());
|
||||
const orphanBadge = s.orphan ? ' <span style="background:#FF3B30;color:#fff;font-size:9px;padding:1px 4px;border-radius:3px;">verwaist</span>' : '';
|
||||
const archivedBadge = s.archived ? ' <span style="background:#555570;color:#fff;font-size:9px;padding:1px 4px;border-radius:3px;">archiv</span>' : '';
|
||||
const modelBadge = s.model ? `<div style="font-size:9px;color:#555570;">${escapeHtml(s.model)}</div>` : '';
|
||||
const isActive = (s.sessionKey === currentActiveSession);
|
||||
const keyColor = isActive ? '#34C759' : (s.orphan ? '#555570' : '#E0E0F0');
|
||||
const isActive = (s.sessionKey === currentActiveSession) && !s.archived;
|
||||
const keyColor = isActive ? '#34C759' : (s.archived || s.orphan ? '#8888AA' : '#E0E0F0');
|
||||
const activeBadge = isActive ? ' <span style="background:#34C759;color:#000;font-size:9px;padding:1px 4px;border-radius:3px;">aktiv</span>' : '';
|
||||
const rowBg = isActive ? 'background:rgba(52,199,89,0.08);' : '';
|
||||
html += `<tr style="border-bottom:1px solid #0D0D1A;cursor:pointer;${rowBg}" onmouseover="this.style.background='#1E1E2E'" onmouseout="this.style.background='${isActive ? 'rgba(52,199,89,0.08)' : ''}'">`
|
||||
const rowBg = isActive ? 'background:rgba(52,199,89,0.08);' : (s.archived ? 'background:rgba(136,136,170,0.04);' : '');
|
||||
|
||||
let actions = '';
|
||||
if (s.archived) {
|
||||
// Archive: nur Export + Loeschen (kein Aktivieren — wuerde aktive Session ueberschreiben)
|
||||
actions = `<button class="btn secondary" onclick="event.stopPropagation();deleteSession('${escapeHtml(s.path)}')" style="padding:2px 6px;font-size:10px;color:#FF6B6B;margin-right:2px;" title="Archiv endgueltig loeschen">X</button>`
|
||||
+ `<button class="btn secondary" onclick="event.stopPropagation();exportSession('${escapeHtml(s.path)}','${escapeHtml(s.sessionKey)}')" style="padding:2px 6px;font-size:10px;color:#8888AA;" title="Als Markdown exportieren">⬇</button>`;
|
||||
} else {
|
||||
actions = (isActive ? '' : `<button class="btn secondary" onclick="event.stopPropagation();activateSession('${escapeHtml(s.sessionKey)}')" style="padding:2px 6px;font-size:10px;color:#34C759;margin-right:2px;" title="Aktivieren">▶</button>`)
|
||||
+ `<button class="btn secondary" onclick="event.stopPropagation();deleteSession('${escapeHtml(s.path)}')" style="padding:2px 6px;font-size:10px;color:#FF6B6B;margin-right:2px;" title="Loeschen">X</button>`
|
||||
+ `<button class="btn secondary" onclick="event.stopPropagation();exportSession('${escapeHtml(s.path)}','${escapeHtml(s.sessionKey)}')" style="padding:2px 6px;font-size:10px;color:#8888AA;" title="Als Markdown exportieren">⬇</button>`;
|
||||
}
|
||||
|
||||
return `<tr style="border-bottom:1px solid #0D0D1A;cursor:pointer;${rowBg}" onmouseover="this.style.background='#1E1E2E'" onmouseout="this.style.background='${isActive ? 'rgba(52,199,89,0.08)' : (s.archived ? 'rgba(136,136,170,0.04)' : '')}'">`
|
||||
+ `<td style="padding:4px 6px;" onclick="viewSession('${escapeHtml(s.path)}')">`
|
||||
+ `<div style="color:${keyColor};">${key}${activeBadge}${orphanBadge}</div>${modelBadge}</td>`
|
||||
+ `<div style="color:${keyColor};">${key}${activeBadge}${orphanBadge}${archivedBadge}</div>${modelBadge}</td>`
|
||||
+ `<td style="padding:4px 6px;color:#8888AA;">${s.lines}</td>`
|
||||
+ `<td style="padding:4px 6px;color:#8888AA;font-size:10px;">${date}</td>`
|
||||
+ `<td style="padding:4px 6px;white-space:nowrap;">`
|
||||
+ (isActive ? '' : `<button class="btn secondary" onclick="event.stopPropagation();activateSession('${escapeHtml(s.sessionKey)}')" style="padding:2px 6px;font-size:10px;color:#34C759;margin-right:2px;" title="Aktivieren">▶</button>`)
|
||||
+ `<button class="btn secondary" onclick="event.stopPropagation();deleteSession('${escapeHtml(s.path)}')" style="padding:2px 6px;font-size:10px;color:#FF6B6B;margin-right:2px;" title="Loeschen">X</button>`
|
||||
+ `<button class="btn secondary" onclick="event.stopPropagation();exportSession('${escapeHtml(s.path)}','${escapeHtml(s.sessionKey)}')" style="padding:2px 6px;font-size:10px;color:#8888AA;" title="Als Markdown exportieren">⬇</button>`
|
||||
+ `</td></tr>`;
|
||||
}
|
||||
+ `<td style="padding:4px 6px;white-space:nowrap;">${actions}</td></tr>`;
|
||||
};
|
||||
|
||||
let html = '<table style="width:100%;border-collapse:collapse;">' + headerRow;
|
||||
for (const s of active) html += rowFor(s);
|
||||
html += '</table>';
|
||||
|
||||
if (archives.length > 0) {
|
||||
html += `<details style="margin-top:12px;" ${archives.length <= 5 ? 'open' : ''}>`
|
||||
+ `<summary style="color:#8888AA;font-size:11px;cursor:pointer;padding:4px 0;">`
|
||||
+ `Archivierte Versionen (${archives.length}) — von OpenClaw beim Session-Reset gesichert`
|
||||
+ `</summary>`
|
||||
+ `<table style="width:100%;border-collapse:collapse;margin-top:6px;">` + headerRow;
|
||||
for (const s of archives) html += rowFor(s);
|
||||
html += '</table></details>';
|
||||
}
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
|
||||
+42
-8
@@ -82,6 +82,12 @@ const browserClients = new Set();
|
||||
let pipelineActive = false;
|
||||
let pipelineStartTime = 0;
|
||||
|
||||
// Nach chat:final kommen oft noch Trailing Agent-Events. Waehrend dieses
|
||||
// Fensters unterdruecken wir agent_activity-Broadcasts, damit der
|
||||
// Thinking-Indicator nicht wieder anspringt.
|
||||
let lastChatFinalAt = 0;
|
||||
const SETTLED_WINDOW_MS = 3000;
|
||||
|
||||
function plog(message, level) {
|
||||
const elapsed = pipelineActive ? `+${Date.now() - pipelineStartTime}ms` : "";
|
||||
const entry = { ts: new Date().toISOString(), level: level || "info", source: "pipeline", message: `${elapsed ? `[${elapsed}] ` : ""}${message}` };
|
||||
@@ -356,17 +362,22 @@ function handleGatewayMessage(msg) {
|
||||
broadcast({ type: "chat_delta", delta, payload });
|
||||
}
|
||||
|
||||
// Nach chat:final trickeln noch Aufraeum-Events rein — unterdruecken,
|
||||
// damit der Thinking-Indicator nicht wieder anspringt.
|
||||
const settled = lastChatFinalAt && (Date.now() - lastChatFinalAt) < SETTLED_WINDOW_MS;
|
||||
|
||||
// Tool-Nutzung erkennen und broadcasten
|
||||
if (stream === "tool_use" || data.type === "tool_use") {
|
||||
const toolName = data.name || data.tool || payload.tool || "";
|
||||
if (toolName) {
|
||||
if (toolName && !settled) {
|
||||
broadcast({ type: "agent_activity", activity: "tool", tool: toolName, data });
|
||||
log("info", "gateway", `Tool: ${toolName}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Genereller Activity-Heartbeat (ARIA denkt)
|
||||
broadcast({ type: "agent_activity", activity: stream || "thinking" });
|
||||
if (!settled) {
|
||||
broadcast({ type: "agent_activity", activity: stream || "thinking" });
|
||||
}
|
||||
updateAgentActivity();
|
||||
return;
|
||||
}
|
||||
@@ -381,6 +392,7 @@ function handleGatewayMessage(msg) {
|
||||
if (runId && seenFinalRuns.has(runId)) return; // Duplikat
|
||||
if (runId) { seenFinalRuns.add(runId); setTimeout(() => seenFinalRuns.delete(runId), 60000); }
|
||||
log("info", "gateway", `ANTWORT: "${text.slice(0, 200)}"`);
|
||||
lastChatFinalAt = Date.now();
|
||||
if (pipelineActive) pipelineEnd(true, `"${text.slice(0, 120)}"`);
|
||||
broadcast({ type: "chat_final", text, payload });
|
||||
broadcast({ type: "agent_activity", activity: "idle" });
|
||||
@@ -424,6 +436,7 @@ function handleGatewayMessage(msg) {
|
||||
if (runId) { seenFinalRuns.add(runId); setTimeout(() => seenFinalRuns.delete(runId), 60000); }
|
||||
const text = extractChatText(payload) || payload.text || "";
|
||||
log("info", "gateway", `ANTWORT: "${text.slice(0, 200)}"`);
|
||||
lastChatFinalAt = Date.now();
|
||||
if (pipelineActive) pipelineEnd(true, `"${text.slice(0, 120)}"`);
|
||||
else broadcast({ type: "agent_activity", activity: "idle" });
|
||||
broadcast({ type: "chat_final", text, payload });
|
||||
@@ -1562,17 +1575,17 @@ async function handleListSessions(clientWs) {
|
||||
try {
|
||||
log("info", "server", "Lade Sessions aus aria-core...");
|
||||
|
||||
// sessions.json als Index lesen + Datei-Details holen
|
||||
// sessions.json als Index lesen + Datei-Details holen (inkl. .reset.* Archive)
|
||||
const raw = await dockerExec("aria-core", `
|
||||
cat ${SESSIONS_DIR}/sessions.json 2>/dev/null || echo '{}' &&
|
||||
echo '===FILE_DETAILS===' &&
|
||||
for f in ${SESSIONS_DIR}/*.jsonl; do
|
||||
for f in ${SESSIONS_DIR}/*.jsonl ${SESSIONS_DIR}/*.jsonl.reset.*; do
|
||||
[ -f "$f" ] || continue
|
||||
name=$(basename "$f")
|
||||
lines=$(wc -l < "$f" 2>/dev/null || echo 0)
|
||||
msgs=$(grep -cE '"role":"(user|assistant)"' "$f" 2>/dev/null || echo 0)
|
||||
size=$(du -h "$f" 2>/dev/null | cut -f1)
|
||||
modified=$(stat -c '%Y' "$f" 2>/dev/null || echo 0)
|
||||
echo "FILE:$name|LINES:$lines|SIZE:$size|MODIFIED:$modified"
|
||||
echo "FILE:$name|LINES:$msgs|SIZE:$size|MODIFIED:$modified"
|
||||
done
|
||||
`.trim());
|
||||
|
||||
@@ -1627,8 +1640,29 @@ async function handleListSessions(clientWs) {
|
||||
delete fileDetails[filename];
|
||||
}
|
||||
|
||||
// Dateien die nicht im Index stehen (Waisen / Reset-Files)
|
||||
// Dateien die nicht im Index stehen (Waisen ODER Reset-Archive)
|
||||
for (const [filename, details] of Object.entries(fileDetails)) {
|
||||
// .jsonl.reset.<ISO-Timestamp>.Z → archivierte Session (OpenClaw-Reset)
|
||||
const resetMatch = filename.match(/^([a-f0-9-]+)\.jsonl\.reset\.(.+)\.Z$/);
|
||||
if (resetMatch) {
|
||||
const id = resetMatch[1];
|
||||
// Timestamp ISO-8601 parsen (in Dateinamen: : durch - ersetzt)
|
||||
// z.B. 2026-04-18T09-49-44.814 → 2026-04-18T09:49:44.814Z
|
||||
const tsStr = resetMatch[2].replace(/T(\d{2})-(\d{2})-(\d{2})/, "T$1:$2:$3");
|
||||
const resetAt = Math.floor(new Date(tsStr + "Z").getTime() / 1000) || parseInt(details.MODIFIED) || 0;
|
||||
sessions.push({
|
||||
path: `${SESSIONS_DIR}/${filename}`,
|
||||
sessionKey: id.slice(0, 8) + "… (archiv)",
|
||||
sessionId: id,
|
||||
lines: parseInt(details.LINES) || 0,
|
||||
size: details.SIZE || "?",
|
||||
modified: resetAt,
|
||||
archived: true,
|
||||
resetAt,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Echte Waisen (UUID.jsonl ohne Eintrag in sessions.json)
|
||||
const id = filename.replace(".jsonl", "");
|
||||
sessions.push({
|
||||
path: `${SESSIONS_DIR}/${filename}`,
|
||||
|
||||
@@ -37,6 +37,10 @@
|
||||
- [x] App: "ARIA denkt..." Indicator + Abbrechen-Button (Bridge spiegelt agent_activity via RVS)
|
||||
- [x] Whisper STT: Model-Auswahl in Diagnostic (tiny/base/small/medium/large-v3), Hot-Reload in Bridge, Default auf medium
|
||||
- [x] App: Audio-Aufnahme explizit 16kHz mono (spart Resample, optimal fuer Whisper)
|
||||
- [x] Gespraechsmodus: Speech-Gate strenger (-28dB / 500ms) — keine Umgebungsgeraeusche mehr
|
||||
- [x] Gespraechsmodus: Max-Dauer 30s pro Aufnahme, Cache-Cleanup alter Files, Messages-Array gekappt (500)
|
||||
- [x] Diagnostic: Archivierte Session-Versionen (.reset.*) werden angezeigt + exportierbar — OpenClaw resettet Sessions bei erster Nutzung nach Container-Restart, Inhalt ist aber in .reset.<timestamp> Dateien gesichert
|
||||
- [x] tools/export-jsonl-to-md.js: CLI-Konverter fuer beliebige Session-JSONL zu Markdown
|
||||
|
||||
## Offen
|
||||
|
||||
|
||||
Executable
+74
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Exportiert ein OpenClaw Session-JSONL (auch .reset.*) als Markdown.
|
||||
*
|
||||
* Nutzung:
|
||||
* node export-jsonl-to-md.js <input.jsonl> [output.md]
|
||||
*
|
||||
* Oder direkt aus dem aria-core Container:
|
||||
* docker exec aria-core cat /home/node/.openclaw/agents/main/sessions/<ID>.jsonl.reset.<TS> \
|
||||
* | node export-jsonl-to-md.js - > output.md
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
|
||||
const inputArg = process.argv[2];
|
||||
const outputArg = process.argv[3];
|
||||
|
||||
if (!inputArg) {
|
||||
console.error("Usage: export-jsonl-to-md.js <input.jsonl|-> [output.md]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const raw = inputArg === "-" ? fs.readFileSync(0, "utf-8") : fs.readFileSync(inputArg, "utf-8");
|
||||
const lines = raw.split("\n").filter(l => l.trim());
|
||||
|
||||
const blocks = [];
|
||||
for (const line of lines) {
|
||||
let obj;
|
||||
try { obj = JSON.parse(line); } catch { continue; }
|
||||
if (obj.type !== "message" || !obj.message) continue;
|
||||
const role = obj.message.role;
|
||||
if (role !== "user" && role !== "assistant") continue;
|
||||
|
||||
let text = "";
|
||||
const content = obj.message.content;
|
||||
if (typeof content === "string") text = content;
|
||||
else if (Array.isArray(content)) text = content.filter(c => c.type === "text").map(c => c.text || "").join("\n");
|
||||
if (!text) continue;
|
||||
|
||||
if (role === "user") {
|
||||
text = text.replace(/^Sender \(untrusted metadata\):[\s\S]*?```[\s\S]*?```\s*\n*/m, "").trim();
|
||||
text = text.replace(/^\[.*?\]\s*/, "").trim();
|
||||
} else {
|
||||
text = text.replace(/^\[\[reply_to_\w+\]\]\s*/g, "").trim();
|
||||
}
|
||||
if (!text) continue;
|
||||
|
||||
const ts = obj.message.timestamp || obj.timestamp || 0;
|
||||
const when = ts ? new Date(ts).toISOString().replace("T", " ").slice(0, 19) : "";
|
||||
const heading = role === "user" ? "## 🧑 User" : "## 🤖 ARIA";
|
||||
blocks.push(`${heading}${when ? ` — ${when}` : ""}\n\n${text}`);
|
||||
}
|
||||
|
||||
const exportedAt = new Date().toISOString().replace("T", " ").slice(0, 19);
|
||||
const title = inputArg === "-" ? "Session" : inputArg.split("/").pop().replace(/\.jsonl.*/, "");
|
||||
const md = [
|
||||
`# Session: ${title}`,
|
||||
``,
|
||||
`Exportiert: ${exportedAt} `,
|
||||
`Quelle: ${inputArg === "-" ? "stdin" : inputArg}`,
|
||||
`Nachrichten: ${blocks.length}`,
|
||||
``,
|
||||
`---`,
|
||||
``,
|
||||
blocks.join("\n\n---\n\n"),
|
||||
``,
|
||||
].join("\n");
|
||||
|
||||
if (outputArg) {
|
||||
fs.writeFileSync(outputArg, md);
|
||||
console.error(`OK: ${blocks.length} Nachrichten → ${outputArg}`);
|
||||
} else {
|
||||
process.stdout.write(md);
|
||||
}
|
||||
Reference in New Issue
Block a user