feat(skills): Skill steuert Ausgabe selbst — speak + converse, pro Aufruf

Verallgemeinert die Ausgabesteuerung, dynamisch statt hardcoded:
- Zwei Flags: speak (vorlesen?) + converse (danach 30s weiterlauschen?).
  Getrennt, weil 'vorlesen' und 'Dialog offen halten' verschiedene Dinge sind
  ('was laeuft gerade' → vorlesen JA, aber keine 30s).
- Statischer Manifest-Default (speak/converse) PLUS: der Skill kann beide im
  JSON-Output PRO AUFRUF setzen und den Default ueberschreiben — so kann EIN
  Skill gemischt sein (Spotify: 'next' stumm/stop, 'was laeuft' vorlesen/stop).
- chat() gibt jetzt (reply, answered_by, speak, converse) zurueck; gilt fuer
  Fast-Path (converse immer False), local UND Claude (run_*-Skill setzt beide).
- Brain: _last_skill_flags aus Skill-stdout-JSON, _skill_response_flags mergt
  Output > Manifest > False. main.py ChatOut.converse, background.py angepasst.
- Bridge: converse aus /chat gelesen + in Chat-Payload + _process_core_response.
- App: converseRef aus der Payload; onPlaybackFinished endet mit skipPassive
  wenn converse=false (vorlesen ohne 30s).
- Skill-Bau-Anleitung (skill_create/update-Schema): speak+converse dokumentiert
  MIT dem WARUM, damit ARIA sie beim Bauen sinnvoll setzt (nicht nur mechanisch).
- Prompt-Hardcode fuer Spotify-Faehigkeiten raus → Skills beschreiben sich selbst.

Bestehende Skills ohne Flags = false/false = stumm/stop (kein Verhaltenswechsel).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 22:47:30 +02:00
co-authored by Claude Opus 4.8
parent 54ebd57990
commit d5bcbb4814
6 changed files with 137 additions and 35 deletions
+10 -1
View File
@@ -338,6 +338,10 @@ const ChatScreen: React.FC = () => {
// bliebe canPlay auf dem Mount-Initial-Wert haengen (mute ignoriert, // bliebe canPlay auf dem Mount-Initial-Wert haengen (mute ignoriert,
// oder AsyncStorage-Load nicht beruecksichtigt). // oder AsyncStorage-Load nicht beruecksichtigt).
const ttsCanPlayRef = useRef<boolean>(true); const ttsCanPlayRef = useRef<boolean>(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<boolean>(true);
const flatListRef = useRef<FlatList>(null); const flatListRef = useRef<FlatList>(null);
const messageIdCounter = useRef(0); const messageIdCounter = useRef(0);
@@ -1213,6 +1217,9 @@ const ChatScreen: React.FC = () => {
// ODER local). Ein Antwort-Skill (speak=true) faellt NICHT hierunter — // ODER local). Ein Antwort-Skill (speak=true) faellt NICHT hierunter —
// der wird vorgelesen + haelt das Gespraech offen. Kein answeredBy- // der wird vorgelesen + haelt das Gespraech offen. Kein answeredBy-
// Fallback mehr: die Bridge schickt speak zuverlaessig mit. // 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; const _isSilent = (message.payload as any).speak === false;
if (_isSilent && wakeWordService.isConversing()) { if (_isSilent && wakeWordService.isConversing()) {
// Klarer Steuerbefehl (Liedersteuerung etc.) = KEINE Konversation → // Klarer Steuerbefehl (Liedersteuerung etc.) = KEINE Konversation →
@@ -1451,8 +1458,10 @@ const ChatScreen: React.FC = () => {
// Gespraech (gesprochene Antwort): KEIN neuer Gong / kein Aufnahme-Fenster, // Gespraech (gesprochene Antwort): KEIN neuer Gong / kein Aufnahme-Fenster,
// sondern direkt passives Lauschen (30s weiterreden wie mit einem Menschen). // sondern direkt passives Lauschen (30s weiterreden wie mit einem Menschen).
// Im Hintergrund gibt's kein Multi-Turn → direkt re-armen (skipPassive). // 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'; const bg = AppState.currentState !== 'active';
wakeWordService.endConversation(bg).catch(() => {}); wakeWordService.endConversation(bg || !converseRef.current).catch(() => {});
}); });
return () => unsubPlayback(); return () => unsubPlayback();
}, []); }, []);
+103 -28
View File
@@ -237,19 +237,38 @@ META_TOOLS = [
"speak": { "speak": {
"type": "boolean", "type": "boolean",
"description": ( "description": (
"Soll die Antwort dieses Skills VORGELESEN werden (TTS)? " "STATISCHER Default fuers Vorlesen dieses Skills (TTS). "
"Default false. \n" "Default false.\n\n"
"- false = reiner STEUERBEFEHL (Spotify next/pause, Licht, " "WARUM es das gibt: ARIA ist voice-first. Ein reiner "
"Rollade): es gibt nichts vorzulesen, die App beendet direkt " "STEUERBEFEHL (Spotify next/pause, Licht an) muss NICHT "
"und lauscht wieder aufs Wake-Word (knackig, wie ein " "vorgelesen werden — die Aktion selbst ist die Bestaetigung, "
"Kommando).\n" "Vorlesen waere nervig. Ein ANTWORT-Skill dagegen liefert "
"- true = ANTWORT-Skill: das Ergebnis ist eine Information, " "eine Info, die Stefan HOEREN will (Wuerfelergebnis, Status, "
"die Stefan HOEREN will (z.B. ein Wuerfelergebnis, ein " "'was laeuft gerade'). Also: Kommando-Skill=false, "
"Nachschlage-Wert, ein Status). Dann wird die Antwort " "Antwort-Skill=true.\n\n"
"vorgelesen und das Gespraechs-Fenster bleibt offen.\n" "PRO AUFRUF (wichtig fuer gemischte Skills!): Wenn EIN Skill "
"Gilt fuer Fast-Path UND wenn das lokale LLM den Skill " "beides kann (z.B. Spotify: 'next' = stumm, 'was laeuft' = "
"aufruft. Im Zweifel bei Kommandos false, bei " "vorlesen), kann dein run.py im JSON-Output pro Aufruf "
"Frage-Antwort-Skills true." "`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": { "speak": {
"type": "boolean", "type": "boolean",
"description": ( "description": (
"Vorlesen ja/nein (siehe skill_create). false = " "Statischer Vorlese-Default (siehe skill_create). false = "
"Steuerbefehl/stumm, true = Antwort-Skill/vorlesen." "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 break
return tools return tools
def _skill_speak_flag(self, tname: str) -> bool: def _resolve_skill_manifest(self, tname: str) -> Optional[dict]:
"""speak-Flag eines run_*-Skills aus dem Manifest (Default False = """run_-Toolname → Skill-Manifest (fuzzy, Bindestrich-Mapping)."""
Steuerbefehl/stumm). Loest run_-Name → Skill fuzzy auf (Bindestriche)."""
if not tname.startswith("run_"): if not tname.startswith("run_"):
return True return None
suffix = tname[len("run_"):] suffix = tname[len("run_"):]
m = skills_mod.read_manifest(suffix) m = skills_mod.read_manifest(suffix)
if m is None: if m is None:
@@ -1278,7 +1306,28 @@ class Agent:
if re.sub(r"[^a-zA-Z0-9_]", "_", cn) == suffix: if re.sub(r"[^a-zA-Z0-9_]", "_", cn) == suffix:
m = cand m = cand
break 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, def _try_local_fast_lane(self, user_message: str,
active_project_id: str) -> Optional[str]: active_project_id: str) -> Optional[str]:
@@ -1290,6 +1339,10 @@ class Agent:
# dem Manifest (Steuerbefehl-Skill=False → stumm, Antwort-Skill=True). # dem Manifest (Steuerbefehl-Skill=False → stumm, Antwort-Skill=True).
# Info-Tools (web_search/memory_search/trigger_timer) lassen es bei True. # Info-Tools (web_search/memory_search/trigger_timer) lassen es bei True.
self._local_turn_speak = 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? # Hat local in diesem Turn ueberhaupt einen echten Skill (run_*) gerufen?
# Fuer den Anti-Halluzinations-Guard: behauptet local eine Spotify-/Skill- # Fuer den Anti-Halluzinations-Guard: behauptet local eine Spotify-/Skill-
# Aktion OHNE das Tool zu rufen → eskalieren statt eine erfundene # Aktion OHNE das Tool zu rufen → eskalieren statt eine erfundene
@@ -1346,7 +1399,8 @@ class Agent:
# Turn das speak-Flag des Skills (Steuerbefehl=stumm, # Turn das speak-Flag des Skills (Steuerbefehl=stumm,
# Antwort-Skill=vorlesen). Letzter Skill gewinnt. # Antwort-Skill=vorlesen). Letzter Skill gewinnt.
if tname.startswith("run_") and not self._local_tool_failed(tname, tresult): 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 self._local_ran_skill = True
if self._local_tool_failed(tname, tresult): if self._local_tool_failed(tname, tresult):
had_error = True had_error = True
@@ -1453,7 +1507,8 @@ class Agent:
# Vorlesen folgt dem Skill-Manifest (speak): Steuerbefehl=stumm, # Vorlesen folgt dem Skill-Manifest (speak): Steuerbefehl=stumm,
# Antwort-Skill=vorlesen. Default False (reiner Steuerbefehl). # Antwort-Skill=vorlesen. Default False (reiner Steuerbefehl).
speak = bool(getattr(self, "_fast_path_speak", False)) 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 # 1. User-Turn an die Konversation
self.conversation.add("user", user_message, source=source, self.conversation.add("user", user_message, source=source,
@@ -1467,10 +1522,11 @@ class Agent:
# teure Claude-Aufbau + Tool-Loop wird uebersprungen. Sonst None → Claude. # teure Claude-Aufbau + Tool-Loop wird uebersprungen. Sonst None → Claude.
local_reply = self._try_local_fast_lane(user_message, active_project_id) local_reply = self._try_local_fast_lane(user_message, active_project_id)
if local_reply is not None: if local_reply is not None:
# speak folgt dem Skill: Steuerbefehl-Skill (speak=false) → stumm + # speak = vorlesen? converse = danach 30s weiterlauschen? Beides folgt
# App beendet direkt; Antwort-Skill/Info-Tool → vorlesen + Gespraech. # dem Skill (bzw. Default: Info/Gespraech = vorlesen + 30s).
speak = getattr(self, "_local_turn_speak", True) 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) # 2. Hot Memory (alle pinned Punkte)
hot = self.store.list_pinned() hot = self.store.list_pinned()
@@ -1592,6 +1648,7 @@ class Agent:
# der Bubble (Stefans Wunsch). Antwort-Skills (speak=true) + reine # der Bubble (Stefans Wunsch). Antwort-Skills (speak=true) + reine
# Konversation bleiben gesprochen. # Konversation bleiben gesprochen.
self._claude_turn_speak = True self._claude_turn_speak = True
self._claude_turn_converse = True # Default Gespraech; run_*-Skill setzt es
try: try:
for iteration in range(self.MAX_TOOL_ITERATIONS): for iteration in range(self.MAX_TOOL_ITERATIONS):
result = self.proxy.chat_full(messages, tools=tools, result = self.proxy.chat_full(messages, tools=tools,
@@ -1613,7 +1670,8 @@ class Agent:
# ausgefuehrt → Antwort nicht vorlesen (letzter Skill gewinnt). # ausgefuehrt → Antwort nicht vorlesen (letzter Skill gewinnt).
_tn = tc.get("name") or "" _tn = tc.get("name") or ""
if _tn.startswith("run_") and not self._local_tool_failed(_tn, tool_result): 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 # Cap auf 50 KB — passt zur Cap in _dispatch_tool fuer
# Skill-Outputs (siehe agent.py weiter unten). 8 KB war # Skill-Outputs (siehe agent.py weiter unten). 8 KB war
# viel zu wenig: Spotify _all=true mit 90 Playlists # viel zu wenig: Spotify _all=true mit 90 Playlists
@@ -1682,8 +1740,10 @@ class Agent:
# 7. Assistant-Turn (final reply) in die Conversation # 7. Assistant-Turn (final reply) in die Conversation
self.conversation.add("assistant", final_reply, self.conversation.add("assistant", final_reply,
project_id=active_project_id) project_id=active_project_id)
# speak folgt dem ausgefuehrten Skill (Steuerbefehl=stumm), sonst True. # speak/converse folgen dem ausgefuehrten Skill (sonst Default: Gespraech).
return final_reply, "claude", bool(getattr(self, "_claude_turn_speak", True)) return (final_reply, "claude",
bool(getattr(self, "_claude_turn_speak", True)),
bool(getattr(self, "_claude_turn_converse", True)))
# ── Tool-Dispatcher ─────────────────────────────────────── # ── Tool-Dispatcher ───────────────────────────────────────
@@ -1705,6 +1765,7 @@ class Agent:
config_schema=arguments.get("config_schema") or None, config_schema=arguments.get("config_schema") or None,
fast_patterns=arguments.get("fast_patterns") or None, fast_patterns=arguments.get("fast_patterns") or None,
speak=bool(arguments.get("speak", False)), speak=bool(arguments.get("speak", False)),
converse=bool(arguments.get("converse", False)),
author="aria", author="aria",
) )
# Side-Channel-Event: Stefan soll sehen wenn ARIA was anlegt # Side-Channel-Event: Stefan soll sehen wenn ARIA was anlegt
@@ -1766,6 +1827,8 @@ class Agent:
patch[k] = arguments[k] patch[k] = arguments[k]
if "speak" in arguments and arguments["speak"] is not None: if "speak" in arguments and arguments["speak"] is not None:
patch["speak"] = bool(arguments["speak"]) 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): if "pip_packages" in arguments and isinstance(arguments["pip_packages"], list):
patch["pip_packages"] = arguments["pip_packages"] patch["pip_packages"] = arguments["pip_packages"]
if "config_schema" in arguments and isinstance(arguments["config_schema"], list): if "config_schema" in arguments and isinstance(arguments["config_schema"], list):
@@ -1867,6 +1930,18 @@ class Agent:
skill_name = cand_name skill_name = cand_name
break break
res = skills_mod.run_skill(skill_name, args=arguments) 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, # 2000 Zeichen war viel zu wenig — Spotify-JSON ist 5-15 KB,
# da wurde der Track-Name regelmaessig abgeschnitten und ARIA # da wurde der Track-Name regelmaessig abgeschnitten und ARIA
# hat aus dem Album-Kontext halluziniert. Claude kann hunderte # hat aus dem Album-Kontext halluziniert. Claude kann hunderte
+1 -1
View File
@@ -150,7 +150,7 @@ async def _fire(trigger: dict, agent_factory) -> None:
try: try:
agent = agent_factory() agent = agent_factory()
reply, _, _ = agent.chat(prompt, source="trigger") reply, _, _, _ = agent.chat(prompt, source="trigger")
events = agent.pop_events() events = agent.pop_events()
logger.info("[trigger] %s gefeuert → ARIA-Reply: %s", name, reply[:80]) logger.info("[trigger] %s gefeuert → ARIA-Reply: %s", name, reply[:80])
triggers_mod.append_log(name, {"event": "reply", "text": reply[:500]}) triggers_mod.append_log(name, {"event": "reply", "text": reply[:500]})
+5 -1
View File
@@ -636,6 +636,9 @@ class ChatOut(BaseModel):
# Soll die Antwort vorgelesen werden? Fast-Path (reiner Steuerbefehl) = False; # Soll die Antwort vorgelesen werden? Fast-Path (reiner Steuerbefehl) = False;
# ARIA-Antworten (local/claude) = True. System-Flag statt <voice>-Tag. # ARIA-Antworten (local/claude) = True. System-Flag statt <voice>-Tag.
speak: bool = True 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 # Echo der project_id die dieser Turn hatte. Bridge nutzt sie damit die
# ausgehende Chat-Bubble sauber getaggt in der richtigen Thread-Bahn der # ausgehende Chat-Bubble sauber getaggt in der richtigen Thread-Bahn der
# UI landet. # UI landet.
@@ -719,7 +722,7 @@ async def chat(body: ChatIn, background: BackgroundTasks):
# Sync-Aufruf im Executor damit wir den Event-Loop nicht blocken — # Sync-Aufruf im Executor damit wir den Event-Loop nicht blocken —
# chat() macht HTTP-Calls (Proxy) die 30-60s dauern koennen. # chat() macht HTTP-Calls (Proxy) die 30-60s dauern koennen.
loop = asyncio.get_running_loop() 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, None,
lambda: a.chat( lambda: a.chat(
body.message, source=body.source, project_id=pid, body.message, source=body.source, project_id=pid,
@@ -743,6 +746,7 @@ async def chat(body: ChatIn, background: BackgroundTasks):
project_id=pid, project_id=pid,
answered_by=answered_by, answered_by=answered_by,
speak=speak, speak=speak,
converse=converse,
) )
finally: finally:
_project_pending[pid] = [ _project_pending[pid] = [
+10 -3
View File
@@ -166,6 +166,7 @@ def create_skill(
config_schema: Optional[list] = None, config_schema: Optional[list] = None,
fast_patterns: Optional[list] = None, fast_patterns: Optional[list] = None,
speak: bool = False, speak: bool = False,
converse: bool = False,
) -> dict: ) -> dict:
"""Legt einen neuen Skill an. Wirft ValueError bei ungueltigen Inputs. """Legt einen neuen Skill an. Wirft ValueError bei ungueltigen Inputs.
@@ -218,9 +219,12 @@ def create_skill(
"fast_patterns": _normalize_fast_patterns(fast_patterns), "fast_patterns": _normalize_fast_patterns(fast_patterns),
# speak: soll die Antwort dieses Skills vorgelesen werden (TTS)? # speak: soll die Antwort dieses Skills vorgelesen werden (TTS)?
# False (Default) = reiner Steuerbefehl (Spotify, Licht) → stumm, App # False (Default) = reiner Steuerbefehl (Spotify, Licht) → stumm, App
# beendet direkt. True = Antwort-Skill (Info/Ergebnis) → vorlesen + # beendet direkt. True = Antwort-Skill (Info/Ergebnis) → vorlesen.
# Gespraechs-Fenster. Gilt fuer Fast-Path UND local-Skill-Ausfuehrung. # 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), "speak": bool(speak),
"converse": bool(converse),
"version_history": [], "version_history": [],
} }
write_manifest(name, manifest) 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) # nach archive_current_version manifest neu laden (version_history geupdatet)
manifest = read_manifest(name) or manifest 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(): for k, v in patch.items():
if k in allowed: if k in allowed:
manifest[k] = v manifest[k] = v
if "speak" in patch: if "speak" in patch:
manifest["speak"] = bool(patch["speak"]) manifest["speak"] = bool(patch["speak"])
if "converse" in patch:
manifest["converse"] = bool(patch["converse"])
if "config_schema" in patch: if "config_schema" in patch:
manifest["config_schema"] = _normalize_config_schema(patch["config_schema"]) manifest["config_schema"] = _normalize_config_schema(patch["config_schema"])
if "fast_patterns" in patch: if "fast_patterns" in patch:
+8 -1
View File
@@ -1568,6 +1568,9 @@ class ARIABridge:
# und daran haengt das Wake-Word-Re-Arm — sonst bleibt das Ohr # und daran haengt das Wake-Word-Re-Arm — sonst bleibt das Ohr
# nach einem Fast-Path-Befehl grau haengen. # nach einem Fast-Path-Befehl grau haengen.
"speak": bool(payload.get("speak", True)) if isinstance(payload, dict) else True, "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), "timestamp": int(asyncio.get_event_loop().time() * 1000),
}) })
@@ -1928,6 +1931,9 @@ class ARIABridge:
# Soll vorgelesen werden? Fast-Path (Steuerbefehl) = False. System-Flag # Soll vorgelesen werden? Fast-Path (Steuerbefehl) = False. System-Flag
# vom Brain — robust, unabhaengig von <voice>-Tags im Reply-Text. # vom Brain — robust, unabhaengig von <voice>-Tags im Reply-Text.
speak = data.get("speak", True) 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) # Side-Channel-Events VOR der Chat-Bubble broadcasten (z.B. skill_created)
# damit sie in der UI vor der Reply auftauchen # damit sie in der UI vor der Reply auftauchen
@@ -1996,7 +2002,8 @@ class ARIABridge:
try: try:
await self._process_core_response(reply, {"projectId": turn_project_id, await self._process_core_response(reply, {"projectId": turn_project_id,
"answeredBy": answered_by, "answeredBy": answered_by,
"speak": speak}) "speak": speak,
"converse": converse})
except Exception: except Exception:
logger.exception("[brain] _process_core_response Fehler") logger.exception("[brain] _process_core_response Fehler")
await self._emit_activity("idle", "", project_id=project_id) await self._emit_activity("idle", "", project_id=project_id)