diff --git a/android/src/screens/ChatScreen.tsx b/android/src/screens/ChatScreen.tsx index c1ff2be..01b6308 100644 --- a/android/src/screens/ChatScreen.tsx +++ b/android/src/screens/ChatScreen.tsx @@ -338,6 +338,10 @@ const ChatScreen: React.FC = () => { // bliebe canPlay auf dem Mount-Initial-Wert haengen (mute ignoriert, // oder AsyncStorage-Load nicht beruecksichtigt). const ttsCanPlayRef = useRef(true); + // Letzte Antwort: nach dem Vorlesen 30s weiterlauschen (Gespraech) oder direkt + // stoppen? Kommt als 'converse' in der Chat-Payload; onPlaybackFinished liest + // es. Default true (Konversation). false = Einzelaktion/Skill-Antwort. + const converseRef = useRef(true); const flatListRef = useRef(null); const messageIdCounter = useRef(0); @@ -1213,6 +1217,9 @@ const ChatScreen: React.FC = () => { // ODER local). Ein Antwort-Skill (speak=true) faellt NICHT hierunter — // der wird vorgelesen + haelt das Gespraech offen. Kein answeredBy- // Fallback mehr: die Bridge schickt speak zuverlaessig mit. + // Merken ob nach dem Vorlesen 30s weiterlauschen (Gespraech) oder direkt + // stoppen — onPlaybackFinished liest converseRef. Default true. + converseRef.current = (message.payload as any).converse !== false; const _isSilent = (message.payload as any).speak === false; if (_isSilent && wakeWordService.isConversing()) { // Klarer Steuerbefehl (Liedersteuerung etc.) = KEINE Konversation → @@ -1451,8 +1458,10 @@ const ChatScreen: React.FC = () => { // Gespraech (gesprochene Antwort): KEIN neuer Gong / kein Aufnahme-Fenster, // sondern direkt passives Lauschen (30s weiterreden wie mit einem Menschen). // Im Hintergrund gibt's kein Multi-Turn → direkt re-armen (skipPassive). + // converse=false (Skill-/Einzelantwort, z.B. 'was laeuft gerade') → auch + // ohne 30s: vorlesen + direkt zurueck aufs Wake-Word. const bg = AppState.currentState !== 'active'; - wakeWordService.endConversation(bg).catch(() => {}); + wakeWordService.endConversation(bg || !converseRef.current).catch(() => {}); }); return () => unsubPlayback(); }, []); diff --git a/aria-brain/agent.py b/aria-brain/agent.py index ac85812..399347d 100644 --- a/aria-brain/agent.py +++ b/aria-brain/agent.py @@ -237,19 +237,38 @@ META_TOOLS = [ "speak": { "type": "boolean", "description": ( - "Soll die Antwort dieses Skills VORGELESEN werden (TTS)? " - "Default false. \n" - "- false = reiner STEUERBEFEHL (Spotify next/pause, Licht, " - "Rollade): es gibt nichts vorzulesen, die App beendet direkt " - "und lauscht wieder aufs Wake-Word (knackig, wie ein " - "Kommando).\n" - "- true = ANTWORT-Skill: das Ergebnis ist eine Information, " - "die Stefan HOEREN will (z.B. ein Wuerfelergebnis, ein " - "Nachschlage-Wert, ein Status). Dann wird die Antwort " - "vorgelesen und das Gespraechs-Fenster bleibt offen.\n" - "Gilt fuer Fast-Path UND wenn das lokale LLM den Skill " - "aufruft. Im Zweifel bei Kommandos false, bei " - "Frage-Antwort-Skills true." + "STATISCHER Default fuers Vorlesen dieses Skills (TTS). " + "Default false.\n\n" + "WARUM es das gibt: ARIA ist voice-first. Ein reiner " + "STEUERBEFEHL (Spotify next/pause, Licht an) muss NICHT " + "vorgelesen werden — die Aktion selbst ist die Bestaetigung, " + "Vorlesen waere nervig. Ein ANTWORT-Skill dagegen liefert " + "eine Info, die Stefan HOEREN will (Wuerfelergebnis, Status, " + "'was laeuft gerade'). Also: Kommando-Skill=false, " + "Antwort-Skill=true.\n\n" + "PRO AUFRUF (wichtig fuer gemischte Skills!): Wenn EIN Skill " + "beides kann (z.B. Spotify: 'next' = stumm, 'was laeuft' = " + "vorlesen), kann dein run.py im JSON-Output pro Aufruf " + "`speak` (und `converse`, s.u.) setzen — das ueberschreibt " + "diesen statischen Default. Beispiel-Output:\n" + " next -> {\"ok\":true,\"speak\":false,\"converse\":false}\n" + " was_laeuft -> {\"ok\":true,\"speak\":true,\"converse\":false," + "\"reply\":\"Laeuft: …\"}" + ), + }, + "converse": { + "type": "boolean", + "description": ( + "Soll ARIA NACH dieser Skill-Antwort 30s WEITERLAUSCHEN " + "(Dialog), oder direkt zurueck aufs Wake-Word? Default false.\n\n" + "WARUM getrennt von speak: 'vorlesen' und 'weiterreden " + "koennen' sind zwei verschiedene Dinge. 'was laeuft gerade' " + "soll vorgelesen werden (speak=true), aber es ist eine " + "abgeschlossene Einzel-Info — Stefan will danach NICHT ins " + "30s-Mikrofon-Fenster gezwungen werden (converse=false). Nur " + "wenn eine echte Rueckfrage/ein Dialog sinnvoll ist " + "(converse=true). Am besten pro Aufruf im run.py-Output " + "setzen (dynamisch), nicht statisch." ), }, }, @@ -331,8 +350,18 @@ META_TOOLS = [ "speak": { "type": "boolean", "description": ( - "Vorlesen ja/nein (siehe skill_create). false = " - "Steuerbefehl/stumm, true = Antwort-Skill/vorlesen." + "Statischer Vorlese-Default (siehe skill_create). false = " + "Steuerbefehl/stumm, true = Antwort-Skill/vorlesen. Pro " + "Aufruf via run.py-JSON-Output ueberschreibbar." + ), + }, + "converse": { + "type": "boolean", + "description": ( + "Statischer Default: nach der Antwort 30s weiterlauschen " + "(Dialog=true) oder direkt zurueck aufs Wake-Word (false, " + "Default). Siehe skill_create. Pro Aufruf via Output " + "ueberschreibbar." ), }, }, @@ -1265,11 +1294,10 @@ class Agent: break return tools - def _skill_speak_flag(self, tname: str) -> bool: - """speak-Flag eines run_*-Skills aus dem Manifest (Default False = - Steuerbefehl/stumm). Loest run_-Name → Skill fuzzy auf (Bindestriche).""" + def _resolve_skill_manifest(self, tname: str) -> Optional[dict]: + """run_-Toolname → Skill-Manifest (fuzzy, Bindestrich-Mapping).""" if not tname.startswith("run_"): - return True + return None suffix = tname[len("run_"):] m = skills_mod.read_manifest(suffix) if m is None: @@ -1278,7 +1306,28 @@ class Agent: if re.sub(r"[^a-zA-Z0-9_]", "_", cn) == suffix: m = cand break - return bool((m or {}).get("speak", False)) + return m + + def _skill_speak_flag(self, tname: str) -> bool: + """speak-Flag eines run_*-Skills aus dem Manifest (Default False).""" + if not tname.startswith("run_"): + return True + return bool((self._resolve_skill_manifest(tname) or {}).get("speak", False)) + + def _skill_response_flags(self, tname: str) -> tuple: + """(speak, converse) fuer einen gerade ausgefuehrten run_*-Skill. + Prioritaet: JSON-Output des Skills (self._last_skill_flags, pro Aufruf) > + Manifest-Default > False. So kann EIN Skill pro Operation unterschiedlich + sein — 'next' stumm/stop, 'was laeuft' vorlesen aber trotzdem stop.""" + flags = getattr(self, "_last_skill_flags", None) or {} + m = self._resolve_skill_manifest(tname) or {} + speak = bool(m.get("speak", False)) + converse = bool(m.get("converse", False)) + if isinstance(flags.get("speak"), bool): + speak = flags["speak"] + if isinstance(flags.get("converse"), bool): + converse = flags["converse"] + return speak, converse def _try_local_fast_lane(self, user_message: str, active_project_id: str) -> Optional[str]: @@ -1290,6 +1339,10 @@ class Agent: # dem Manifest (Steuerbefehl-Skill=False → stumm, Antwort-Skill=True). # Info-Tools (web_search/memory_search/trigger_timer) lassen es bei True. self._local_turn_speak = True + # Nach der Antwort 30s weiterlauschen? Default True (Gespraech/Info via + # web_search). Ein run_*-Skill setzt es auf seinen Output-Wert (Default + # False = Einzelaktion). + self._local_turn_converse = True # Hat local in diesem Turn ueberhaupt einen echten Skill (run_*) gerufen? # Fuer den Anti-Halluzinations-Guard: behauptet local eine Spotify-/Skill- # Aktion OHNE das Tool zu rufen → eskalieren statt eine erfundene @@ -1346,7 +1399,8 @@ class Agent: # Turn das speak-Flag des Skills (Steuerbefehl=stumm, # Antwort-Skill=vorlesen). Letzter Skill gewinnt. if tname.startswith("run_") and not self._local_tool_failed(tname, tresult): - self._local_turn_speak = self._skill_speak_flag(tname) + self._local_turn_speak, self._local_turn_converse = \ + self._skill_response_flags(tname) self._local_ran_skill = True if self._local_tool_failed(tname, tresult): had_error = True @@ -1453,7 +1507,8 @@ class Agent: # Vorlesen folgt dem Skill-Manifest (speak): Steuerbefehl=stumm, # Antwort-Skill=vorlesen. Default False (reiner Steuerbefehl). speak = bool(getattr(self, "_fast_path_speak", False)) - return fast_reply, "fast-path", speak + # Fast-Path = reiner Steuerbefehl → nie ins 30s-Gespraech (converse=False). + return fast_reply, "fast-path", speak, False # 1. User-Turn an die Konversation self.conversation.add("user", user_message, source=source, @@ -1467,10 +1522,11 @@ class Agent: # teure Claude-Aufbau + Tool-Loop wird uebersprungen. Sonst None → Claude. local_reply = self._try_local_fast_lane(user_message, active_project_id) if local_reply is not None: - # speak folgt dem Skill: Steuerbefehl-Skill (speak=false) → stumm + - # App beendet direkt; Antwort-Skill/Info-Tool → vorlesen + Gespraech. + # speak = vorlesen? converse = danach 30s weiterlauschen? Beides folgt + # dem Skill (bzw. Default: Info/Gespraech = vorlesen + 30s). speak = getattr(self, "_local_turn_speak", True) - return local_reply, "local", speak + converse = getattr(self, "_local_turn_converse", True) + return local_reply, "local", speak, converse # 2. Hot Memory (alle pinned Punkte) hot = self.store.list_pinned() @@ -1592,6 +1648,7 @@ class Agent: # der Bubble (Stefans Wunsch). Antwort-Skills (speak=true) + reine # Konversation bleiben gesprochen. self._claude_turn_speak = True + self._claude_turn_converse = True # Default Gespraech; run_*-Skill setzt es try: for iteration in range(self.MAX_TOOL_ITERATIONS): result = self.proxy.chat_full(messages, tools=tools, @@ -1613,7 +1670,8 @@ class Agent: # ausgefuehrt → Antwort nicht vorlesen (letzter Skill gewinnt). _tn = tc.get("name") or "" if _tn.startswith("run_") and not self._local_tool_failed(_tn, tool_result): - self._claude_turn_speak = self._skill_speak_flag(_tn) + self._claude_turn_speak, self._claude_turn_converse = \ + self._skill_response_flags(_tn) # Cap auf 50 KB — passt zur Cap in _dispatch_tool fuer # Skill-Outputs (siehe agent.py weiter unten). 8 KB war # viel zu wenig: Spotify _all=true mit 90 Playlists @@ -1682,8 +1740,10 @@ class Agent: # 7. Assistant-Turn (final reply) in die Conversation self.conversation.add("assistant", final_reply, project_id=active_project_id) - # speak folgt dem ausgefuehrten Skill (Steuerbefehl=stumm), sonst True. - return final_reply, "claude", bool(getattr(self, "_claude_turn_speak", True)) + # speak/converse folgen dem ausgefuehrten Skill (sonst Default: Gespraech). + return (final_reply, "claude", + bool(getattr(self, "_claude_turn_speak", True)), + bool(getattr(self, "_claude_turn_converse", True))) # ── Tool-Dispatcher ─────────────────────────────────────── @@ -1705,6 +1765,7 @@ class Agent: config_schema=arguments.get("config_schema") or None, fast_patterns=arguments.get("fast_patterns") or None, speak=bool(arguments.get("speak", False)), + converse=bool(arguments.get("converse", False)), author="aria", ) # Side-Channel-Event: Stefan soll sehen wenn ARIA was anlegt @@ -1766,6 +1827,8 @@ class Agent: patch[k] = arguments[k] if "speak" in arguments and arguments["speak"] is not None: patch["speak"] = bool(arguments["speak"]) + if "converse" in arguments and arguments["converse"] is not None: + patch["converse"] = bool(arguments["converse"]) if "pip_packages" in arguments and isinstance(arguments["pip_packages"], list): patch["pip_packages"] = arguments["pip_packages"] if "config_schema" in arguments and isinstance(arguments["config_schema"], list): @@ -1867,6 +1930,18 @@ class Agent: skill_name = cand_name break res = skills_mod.run_skill(skill_name, args=arguments) + # Steuer-Flags aus dem Skill-JSON-Output (speak/converse) fuer + # DIESEN Aufruf merken. Der Aufrufer (local/claude/fast-path) + # liest sie und steuert damit Vorlesen (speak) + 30s-Weiter- + # lauschen (converse) — pro Aufruf, dynamisch. Kein JSON / nicht + # gesetzt → Aufrufer nimmt Manifest-Default (speak) bzw. False. + self._last_skill_flags = None + if res.get("ok"): + try: + _j = json.loads((res.get("stdout") or "").strip()) + self._last_skill_flags = _j if isinstance(_j, dict) else None + except Exception: + self._last_skill_flags = None # 2000 Zeichen war viel zu wenig — Spotify-JSON ist 5-15 KB, # da wurde der Track-Name regelmaessig abgeschnitten und ARIA # hat aus dem Album-Kontext halluziniert. Claude kann hunderte diff --git a/aria-brain/background.py b/aria-brain/background.py index 0620cbd..c7811bd 100644 --- a/aria-brain/background.py +++ b/aria-brain/background.py @@ -150,7 +150,7 @@ async def _fire(trigger: dict, agent_factory) -> None: try: agent = agent_factory() - reply, _, _ = agent.chat(prompt, source="trigger") + reply, _, _, _ = agent.chat(prompt, source="trigger") events = agent.pop_events() logger.info("[trigger] %s gefeuert → ARIA-Reply: %s", name, reply[:80]) triggers_mod.append_log(name, {"event": "reply", "text": reply[:500]}) diff --git a/aria-brain/main.py b/aria-brain/main.py index c4b6902..4996b6f 100644 --- a/aria-brain/main.py +++ b/aria-brain/main.py @@ -636,6 +636,9 @@ class ChatOut(BaseModel): # Soll die Antwort vorgelesen werden? Fast-Path (reiner Steuerbefehl) = False; # ARIA-Antworten (local/claude) = True. System-Flag statt -Tag. speak: bool = True + # Soll die App nach der Antwort 30s weiterlauschen (Gespraech)? Einzelaktionen/ + # Skills = False (direkt zurueck aufs Wake-Word), Konversation = True. + converse: bool = True # Echo der project_id die dieser Turn hatte. Bridge nutzt sie damit die # ausgehende Chat-Bubble sauber getaggt in der richtigen Thread-Bahn der # UI landet. @@ -719,7 +722,7 @@ async def chat(body: ChatIn, background: BackgroundTasks): # Sync-Aufruf im Executor damit wir den Event-Loop nicht blocken — # chat() macht HTTP-Calls (Proxy) die 30-60s dauern koennen. loop = asyncio.get_running_loop() - reply, answered_by, speak = await loop.run_in_executor( + reply, answered_by, speak, converse = await loop.run_in_executor( None, lambda: a.chat( body.message, source=body.source, project_id=pid, @@ -743,6 +746,7 @@ async def chat(body: ChatIn, background: BackgroundTasks): project_id=pid, answered_by=answered_by, speak=speak, + converse=converse, ) finally: _project_pending[pid] = [ diff --git a/aria-brain/skills.py b/aria-brain/skills.py index 2b9011a..0cb631f 100644 --- a/aria-brain/skills.py +++ b/aria-brain/skills.py @@ -166,6 +166,7 @@ def create_skill( config_schema: Optional[list] = None, fast_patterns: Optional[list] = None, speak: bool = False, + converse: bool = False, ) -> dict: """Legt einen neuen Skill an. Wirft ValueError bei ungueltigen Inputs. @@ -218,9 +219,12 @@ def create_skill( "fast_patterns": _normalize_fast_patterns(fast_patterns), # speak: soll die Antwort dieses Skills vorgelesen werden (TTS)? # False (Default) = reiner Steuerbefehl (Spotify, Licht) → stumm, App - # beendet direkt. True = Antwort-Skill (Info/Ergebnis) → vorlesen + - # Gespraechs-Fenster. Gilt fuer Fast-Path UND local-Skill-Ausfuehrung. + # beendet direkt. True = Antwort-Skill (Info/Ergebnis) → vorlesen. + # converse: nach der Antwort 30s weiterlauschen (Dialog)? Default False + # (Einzelaktion). Beide sind STATISCHE Defaults — der Skill kann sie im + # JSON-Output pro Aufruf ueberschreiben (gemischte Skills). "speak": bool(speak), + "converse": bool(converse), "version_history": [], } write_manifest(name, manifest) @@ -341,12 +345,15 @@ def update_skill(name: str, patch: dict) -> dict: # nach archive_current_version manifest neu laden (version_history geupdatet) manifest = read_manifest(name) or manifest - allowed = {"description", "args", "requires", "active", "version", "entry", "speak"} + allowed = {"description", "args", "requires", "active", "version", "entry", + "speak", "converse"} for k, v in patch.items(): if k in allowed: manifest[k] = v if "speak" in patch: manifest["speak"] = bool(patch["speak"]) + if "converse" in patch: + manifest["converse"] = bool(patch["converse"]) if "config_schema" in patch: manifest["config_schema"] = _normalize_config_schema(patch["config_schema"]) if "fast_patterns" in patch: diff --git a/bridge/aria_bridge.py b/bridge/aria_bridge.py index 6a319af..72d7110 100644 --- a/bridge/aria_bridge.py +++ b/bridge/aria_bridge.py @@ -1568,6 +1568,9 @@ class ARIABridge: # und daran haengt das Wake-Word-Re-Arm — sonst bleibt das Ohr # nach einem Fast-Path-Befehl grau haengen. "speak": bool(payload.get("speak", True)) if isinstance(payload, dict) else True, + # Nach dieser Antwort 30s weiterlauschen? Steuert das Gespraechs- + # Fenster in der App (Skill/Einzelaktion=False, Konversation=True). + "converse": bool(payload.get("converse", True)) if isinstance(payload, dict) else True, }, "timestamp": int(asyncio.get_event_loop().time() * 1000), }) @@ -1928,6 +1931,9 @@ class ARIABridge: # Soll vorgelesen werden? Fast-Path (Steuerbefehl) = False. System-Flag # vom Brain — robust, unabhaengig von -Tags im Reply-Text. speak = data.get("speak", True) + # Nach der Antwort 30s weiterlauschen (Gespraech) oder direkt zurueck aufs + # Wake-Word? Einzelaktionen/Skills = False. Reicht die App aus. + converse = data.get("converse", True) # Side-Channel-Events VOR der Chat-Bubble broadcasten (z.B. skill_created) # damit sie in der UI vor der Reply auftauchen @@ -1996,7 +2002,8 @@ class ARIABridge: try: await self._process_core_response(reply, {"projectId": turn_project_id, "answeredBy": answered_by, - "speak": speak}) + "speak": speak, + "converse": converse}) except Exception: logger.exception("[brain] _process_core_response Fehler") await self._emit_activity("idle", "", project_id=project_id)