Compare commits

...

3 Commits

Author SHA1 Message Date
duffyduck 2ad1f57382 feat: Thinking indicator + cancel button in the app
- Bridge: _emit_activity() spiegelt OpenClaw agent events als agent_activity
  an RVS, dedupliziert State-Wechsel. chat:final/error senden idle.
- Bridge: Neuer cancel_request-Handler ruft Diagnostic /api/cancel per HTTP.
- Diagnostic: Neuer POST /api/cancel Endpoint (gleiche Logik wie WS-Cancel).
- RVS: agent_activity + cancel_request in ALLOWED_TYPES.
- App: Gelber Indicator ueber der Input-Bar mit Text je nach Activity,
  roter Abbrechen-Button. Cancel sendet cancel_request via RVS.
- issue.md: Erledigte Bugfixes + Features konsolidiert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 11:22:02 +02:00
duffyduck 58e3cfd3e6 feat: Session export as markdown in Diagnostic
- ⬇ Button per Session-Zeile — exportiert auch inaktive Sessions
- Server parst JSONL, extrahiert User/Assistant-Nachrichten mit Timestamp
- Metadata-Prefix wird entfernt, Markdown mit # Session-Header generiert
- Browser-Download via Blob + download-Attribut

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 11:14:15 +02:00
duffyduck 7de4ee8f5b fix: Stuck "ARIA denkt..." indicator after pipeline ends
- pipelineEnd() now broadcasts agent_activity: idle unconditionally
- chat:error and chat:final paths broadcast idle outside of active pipeline
- Gateway close event ends active pipeline + broadcasts idle
- Prevents indicator from hanging after timeout/error/disconnect

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 11:11:12 +02:00
6 changed files with 216 additions and 4 deletions
+57
View File
@@ -96,6 +96,7 @@ const ChatScreen: React.FC = () => {
const [searchQuery, setSearchQuery] = useState('');
const [searchVisible, setSearchVisible] = useState(false);
const [pendingAttachments, setPendingAttachments] = useState<{file: any, isPhoto: boolean}[]>([]);
const [agentActivity, setAgentActivity] = useState<{activity: string, tool: string}>({activity: 'idle', tool: ''});
const flatListRef = useRef<FlatList>(null);
const messageIdCounter = useRef(0);
@@ -250,6 +251,13 @@ const ChatScreen: React.FC = () => {
if (message.type === 'audio' && message.payload.base64) {
audioService.playAudio(message.payload.base64 as string);
}
// Thinking-Indicator Status von der Bridge
if (message.type === 'agent_activity') {
const activity = (message.payload.activity as string) || 'idle';
const tool = (message.payload.tool as string) || '';
setAgentActivity({ activity, tool });
}
});
const unsubState = rvs.onStateChange((state) => {
@@ -424,6 +432,12 @@ const ChatScreen: React.FC = () => {
});
}, [inputText, getCurrentLocation, pendingAttachments, sendPendingAttachments]);
// Anfrage abbrechen — sofort lokalen Indicator weg, Bridge triggert doctor --fix
const cancelRequest = useCallback(() => {
setAgentActivity({ activity: 'idle', tool: '' });
rvs.send('cancel_request' as any, {});
}, []);
// Sprachaufnahme abgeschlossen
const handleVoiceRecording = useCallback(async (result: RecordingResult) => {
const location = await getCurrentLocation();
@@ -674,6 +688,22 @@ const ChatScreen: React.FC = () => {
}
/>
{/* Thinking-Indicator */}
{agentActivity.activity !== 'idle' && (
<View style={styles.thinkingBar}>
<Text style={styles.thinkingText}>
{agentActivity.activity === 'tool' && agentActivity.tool
? `\uD83D\uDD27 ${agentActivity.tool}`
: agentActivity.activity === 'assistant'
? '\u270D\uFE0F ARIA schreibt...'
: '\uD83D\uDCAD ARIA denkt...'}
</Text>
<TouchableOpacity style={styles.thinkingCancel} onPress={cancelRequest}>
<Text style={styles.thinkingCancelText}>Abbrechen</Text>
</TouchableOpacity>
</View>
)}
{/* Pending Anhaenge Vorschau */}
{pendingAttachments.length > 0 && (
<View style={styles.pendingBar}>
@@ -970,6 +1000,33 @@ const styles = StyleSheet.create({
wakeWordIcon: {
fontSize: 16,
},
thinkingBar: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: '#1E1E2E',
paddingHorizontal: 12,
paddingVertical: 6,
borderTopWidth: 1,
borderTopColor: '#2A2A3E',
},
thinkingText: {
color: '#FFD60A',
fontSize: 12,
flex: 1,
},
thinkingCancel: {
paddingHorizontal: 10,
paddingVertical: 4,
borderWidth: 1,
borderColor: '#FF3B30',
borderRadius: 4,
},
thinkingCancelText: {
color: '#FF3B30',
fontSize: 11,
fontWeight: 'bold',
},
pendingBar: {
flexDirection: 'row',
alignItems: 'center',
+52 -1
View File
@@ -530,6 +530,9 @@ class ARIABridge:
self.ws_core: Optional[websockets.WebSocketClientProtocol] = None
self.ws_rvs: Optional[websockets.WebSocketClientProtocol] = None
# Letzter gesendeter agent_activity-State (zum Entduplizieren)
self._last_activity_state: Optional[tuple] = None
def initialize(self) -> None:
"""Initialisiert alle Komponenten.
@@ -734,8 +737,18 @@ class ARIABridge:
if event_name == "agent":
data = payload.get("data", {})
delta = data.get("delta", "")
if delta and payload.get("stream") == "assistant":
stream = payload.get("stream", "")
if delta and stream == "assistant":
logger.debug("[core] Delta: '%s'", delta[:40])
# Activity-Signal zur App (entdupliziert)
tool_name = data.get("name") or data.get("tool") or payload.get("tool") or ""
if stream == "tool_use" or data.get("type") == "tool_use":
activity = "tool"
elif stream == "assistant":
activity = "assistant"
else:
activity = "thinking"
await self._emit_activity(activity, tool_name)
return
# ── chat Events: Snapshots mit state=delta|final|error ──
@@ -744,6 +757,7 @@ class ARIABridge:
if state == "final":
text = self._extract_chat_text(payload)
await self._emit_activity("idle", "")
if not text:
logger.warning("[core] chat final ohne Text: %s", json.dumps(payload)[:200])
return
@@ -754,6 +768,7 @@ class ARIABridge:
if state == "error":
error = payload.get("error", "Unbekannt")
logger.error("[core] Chat-Fehler: %s", error)
await self._emit_activity("idle", "")
await self._send_to_rvs({
"type": "chat",
"payload": {
@@ -1063,6 +1078,12 @@ class ARIABridge:
await self.send_to_core(text, source="app")
return
if msg_type == "cancel_request":
logger.info("[rvs] Cancel-Request von App — rufe Diagnostic /api/cancel auf")
await self._cancel_via_diagnostic()
await self._emit_activity("idle", "")
return
elif msg_type == "xtts_response":
# XTTS-Audio vom Gaming-PC empfangen → an App weiterleiten
audio_b64 = payload.get("base64", "")
@@ -1396,6 +1417,36 @@ class ARIABridge:
# ── Log-Streaming an die App ─────────────────────────────
async def _cancel_via_diagnostic(self) -> None:
"""Ruft das Diagnostic /api/cancel an — dort laeuft die volle Abbruch-Logik
(openclaw doctor --fix mit Docker-Socket)."""
def _do_request():
try:
req = urllib.request.Request(
f"{self._diagnostic_url}/api/cancel",
method="POST",
data=b"",
)
with urllib.request.urlopen(req, timeout=5) as resp:
return resp.status
except Exception as e:
return f"error: {e}"
status = await asyncio.get_event_loop().run_in_executor(None, _do_request)
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."""
state = (activity, tool)
if state == self._last_activity_state:
return
self._last_activity_state = state
await self._send_to_rvs({
"type": "agent_activity",
"payload": {"activity": activity, "tool": tool},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
async def send_log_to_app(self, source: str, message: str, level: str = "info") -> None:
"""Sendet einen Log-Eintrag an die App (erscheint im Log-Viewer)."""
await self._send_to_rvs({
+18 -1
View File
@@ -891,6 +891,18 @@
else alert('Loeschen fehlgeschlagen: ' + (msg.error || '?'));
return;
}
if (msg.type === 'session_export') {
if (!msg.ok) { alert('Export fehlgeschlagen: ' + (msg.error || '?')); return; }
const blob = new Blob([msg.markdown], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = msg.filename;
document.body.appendChild(a);
a.click();
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100);
return;
}
if (msg.type === 'active_session') {
updateActiveSessionBar(msg.sessionKey);
loadSessions(); // Tabelle neu rendern
@@ -1679,7 +1691,8 @@
+ `<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">&#9654;</button>`)
+ `<button class="btn secondary" onclick="event.stopPropagation();deleteSession('${escapeHtml(s.path)}')" style="padding:2px 6px;font-size:10px;color:#FF6B6B;" title="Loeschen">X</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">&#x2B07;</button>`
+ `</td></tr>`;
}
html += '</table>';
@@ -1743,6 +1756,10 @@
send({ action: 'delete_session', sessionPath: path });
}
function exportSession(path, sessionKey) {
send({ action: 'export_session', sessionPath: path, sessionKey });
}
function activateSession(sessionKey) {
send({ action: 'set_active_session', sessionKey });
}
+83 -1
View File
@@ -117,6 +117,9 @@ function pipelineEnd(ok, detail) {
}
plog(`━━━ Pipeline Ende ━━━`);
pipelineActive = false;
// Thinking-Indikator IMMER zuruecksetzen — auch bei Timeout/Fehler/Abbruch
broadcast({ type: "agent_activity", activity: "idle" });
pendingMessageTime = 0;
}
// ── Auto-Restart bei Netzwerk-Namespace-Verlust ──────
@@ -283,8 +286,10 @@ async function connectGateway() {
state.gateway.handshakeOk = false;
gatewayWs = null;
broadcastState();
// Stuck "ARIA denkt..." vermeiden, falls Gateway waehrend Pipeline abkackt
if (pipelineActive) pipelineEnd(false, `Gateway-Verbindung verloren (${code})`);
else broadcast({ type: "agent_activity", activity: "idle" });
checkGatewayHealth();
// Auto-Reconnect nach 5s
setTimeout(connectGateway, 5000);
});
@@ -398,6 +403,7 @@ function handleGatewayMessage(msg) {
const error = payload.error || text || "Unbekannt";
log("error", "gateway", `Chat-Fehler: ${error}`);
if (pipelineActive) pipelineEnd(false, error);
else broadcast({ type: "agent_activity", activity: "idle" });
broadcast({ type: "chat_error", error, payload });
return;
}
@@ -419,6 +425,7 @@ function handleGatewayMessage(msg) {
const text = extractChatText(payload) || payload.text || "";
log("info", "gateway", `ANTWORT: "${text.slice(0, 200)}"`);
if (pipelineActive) pipelineEnd(true, `"${text.slice(0, 120)}"`);
else broadcast({ type: "agent_activity", activity: "idle" });
broadcast({ type: "chat_final", text, payload });
return;
}
@@ -426,6 +433,7 @@ function handleGatewayMessage(msg) {
const error = payload.error || payload.message || "Unbekannt";
log("error", "gateway", `Chat-Fehler: ${error}`);
if (pipelineActive) pipelineEnd(false, error);
else broadcast({ type: "agent_activity", activity: "idle" });
broadcast({ type: "chat_error", error, payload });
return;
}
@@ -1135,6 +1143,16 @@ const server = http.createServer((req, res) => {
} else if (req.url === "/api/session") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ sessionKey: activeSessionKey }));
} else if (req.url === "/api/cancel" && req.method === "POST") {
log("warn", "server", "HTTP /api/cancel — Cancel-Request (von Bridge)");
pendingMessageTime = 0;
watchdogWarned = false;
watchdogFixAttempted = false;
if (pipelineActive) pipelineEnd(false, "Vom Benutzer abgebrochen (App)");
else broadcast({ type: "agent_activity", activity: "idle" });
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.startsWith("/shared/")) {
// Dateien aus Shared Volume ausliefern (Bilder, Uploads)
const filePath = decodeURIComponent(req.url);
@@ -1266,6 +1284,8 @@ wss.on("connection", (ws) => {
handleListSessions(ws);
} else if (msg.action === "read_session") {
handleReadSession(ws, msg.sessionPath);
} else if (msg.action === "export_session") {
handleExportSession(ws, msg.sessionPath, msg.sessionKey);
} else if (msg.action === "delete_session") {
handleDeleteSession(ws, msg.sessionPath);
} else if (msg.action === "set_active_session") {
@@ -1648,6 +1668,68 @@ async function handleReadSession(clientWs, sessionPath) {
}
}
async function handleExportSession(clientWs, sessionPath, sessionKey) {
if (!sessionPath || sessionPath.includes("..") || !sessionPath.startsWith(SESSIONS_DIR)) {
clientWs.send(JSON.stringify({ type: "session_export", ok: false, error: "Ungueltiger Pfad" }));
return;
}
try {
const safePath = sessionPath.replace(/'/g, "");
const raw = await dockerExec("aria-core", `cat '${safePath}'`);
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 = sessionKey || sessionPath.split("/").pop().replace(".jsonl", "");
const markdown = [
`# Session: ${title}`,
``,
`Exportiert: ${exportedAt} `,
`Quelle: ${sessionPath}`,
``,
`---`,
``,
blocks.join("\n\n---\n\n"),
``,
].join("\n");
const safeKey = (sessionKey || "session").replace(/[^a-zA-Z0-9_-]/g, "_");
const filename = `${exportedAt.slice(0, 10)}_${safeKey}.md`;
clientWs.send(JSON.stringify({ type: "session_export", ok: true, filename, markdown }));
log("info", "server", `Session exportiert: ${filename} (${blocks.length} Nachrichten)`);
} catch (err) {
log("error", "server", `Session-Export fehlgeschlagen: ${err.message}`);
clientWs.send(JSON.stringify({ type: "session_export", ok: false, error: err.message }));
}
}
async function handleDeleteSession(clientWs, sessionPath) {
if (!sessionPath || sessionPath.includes("..") || !sessionPath.startsWith(SESSIONS_DIR)) {
clientWs.send(JSON.stringify({ type: "session_deleted", ok: false, error: "Ungueltiger Pfad" }));
+5 -1
View File
@@ -30,11 +30,15 @@
- [x] Paste-Support fuer Bilder in Diagnostic Chat
- [x] Markdown-Bereinigung fuer TTS (fett, kursiv, code, links, etc.)
- [x] SSH Volume read-write fuer Proxy (kein -F Workaround mehr)
- [x] Diagnostic: Sessions als Markdown exportieren (Download-Button)
- [x] Speech Gate: Aufnahme wird verworfen wenn keine Sprache erkannt (verhindert dass Umgebungsgeraeusche an Whisper gehen)
- [x] Session-Persistenz: Gewaehlte Session bleibt ueber Container-Restarts erhalten (sessionFromFile-Flag, atomic write)
- [x] Diagnostic: "ARIA denkt..." bleibt nicht mehr stehen (pipelineEnd broadcastet immer idle, auch bei Timeout/Fehler/Disconnect)
- [x] App: "ARIA denkt..." Indicator + Abbrechen-Button (Bridge spiegelt agent_activity via RVS)
## Offen
### Bugs (Prioritaet)
- [ ] Session-Persistenz: Bei Container-Restart wird immer aria-bridge geladen statt die zuletzt gewaehlte Session
- [ ] App: Audioausgabe hoert ab und zu einfach auf (mitten im Satz oder zwischen Chunks)
### App Features
+1
View File
@@ -16,6 +16,7 @@ const ALLOWED_TYPES = new Set([
"file_request", "file_response", "file_saved", "stt_result", "config", "tts_request",
"xtts_request", "xtts_response", "xtts_list_voices", "xtts_voices_list", "voice_upload", "xtts_voice_saved",
"update_check", "update_available", "update_download", "update_data",
"agent_activity", "cancel_request",
]);
// Token-Raum: token -> { clients: Set<ws> }