From 2ad1f573824f82f359e568d7697d527347f041c3 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Sat, 18 Apr 2026 11:22:02 +0200 Subject: [PATCH] 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) --- android/src/screens/ChatScreen.tsx | 57 ++++++++++++++++++++++++++++++ bridge/aria_bridge.py | 53 ++++++++++++++++++++++++++- diagnostic/server.js | 10 ++++++ issue.md | 7 ++-- rvs/server.js | 1 + 5 files changed, 124 insertions(+), 4 deletions(-) diff --git a/android/src/screens/ChatScreen.tsx b/android/src/screens/ChatScreen.tsx index bcd017d..66bd2fe 100644 --- a/android/src/screens/ChatScreen.tsx +++ b/android/src/screens/ChatScreen.tsx @@ -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(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' && ( + + + {agentActivity.activity === 'tool' && agentActivity.tool + ? `\uD83D\uDD27 ${agentActivity.tool}` + : agentActivity.activity === 'assistant' + ? '\u270D\uFE0F ARIA schreibt...' + : '\uD83D\uDCAD ARIA denkt...'} + + + Abbrechen + + + )} + {/* Pending Anhaenge Vorschau */} {pendingAttachments.length > 0 && ( @@ -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', diff --git a/bridge/aria_bridge.py b/bridge/aria_bridge.py index c9d888a..6a239fd 100644 --- a/bridge/aria_bridge.py +++ b/bridge/aria_bridge.py @@ -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({ diff --git a/diagnostic/server.js b/diagnostic/server.js index 222f785..8067fef 100644 --- a/diagnostic/server.js +++ b/diagnostic/server.js @@ -1143,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); diff --git a/issue.md b/issue.md index 4702870..3a13483 100644 --- a/issue.md +++ b/issue.md @@ -31,16 +31,17 @@ - [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) -- [x] 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) -- [ ] Diagnostic: "ARIA denkt..." + Abbrechen bleibt stehen, auch wenn Pipeline laengst fertig ist ### App Features -- [ ] "ARIA denkt..." Indicator + Abbrechen-Button in der App (wie im Diagnostic) - [ ] Wake Word on-device (Porcupine "ARIA" Keyword, Phase 2 — passives Lauschen) - [ ] Chat-History zuverlaessiger laden (AsyncStorage Race Condition) - [ ] Background Audio Service (TTS auch bei minimierter App) diff --git a/rvs/server.js b/rvs/server.js index f80ef79..15a73d0 100644 --- a/rvs/server.js +++ b/rvs/server.js @@ -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 }