This commit is contained in:
kacper 2026-03-12 09:25:15 -04:00
parent b7614eb3f8
commit db4ce8b14f
22 changed files with 3557 additions and 823 deletions

View file

@ -64,7 +64,7 @@ except Exception: # pragma: no cover - runtime fallback when aiortc is unavaila
SPEECH_FILTER_RE = re.compile(
r"^(spawned nanobot tui|stopped nanobot tui|nanobot tui exited|websocket)",
r"^(already connected to nanobot|connected to nanobot|disconnected from nanobot|nanobot closed the connection|websocket)",
re.IGNORECASE,
)
THINKING_STATUS_RE = re.compile(
@ -96,6 +96,38 @@ def _sanitize_tts_text(text: str) -> str:
return cleaned
def _coerce_message_metadata(raw: Any) -> dict[str, Any]:
def _coerce_jsonish(value: Any, depth: int = 0) -> Any:
if depth > 6:
return None
if value is None or isinstance(value, (str, int, float, bool)):
return value
if isinstance(value, dict):
cleaned_dict: dict[str, Any] = {}
for key, item in value.items():
cleaned_item = _coerce_jsonish(item, depth + 1)
if cleaned_item is not None:
cleaned_dict[str(key)] = cleaned_item
return cleaned_dict
if isinstance(value, list):
cleaned_list: list[Any] = []
for item in value[:50]:
cleaned_item = _coerce_jsonish(item, depth + 1)
if cleaned_item is not None:
cleaned_list.append(cleaned_item)
return cleaned_list
return None
if not isinstance(raw, dict):
return {}
cleaned: dict[str, Any] = {}
for key, value in raw.items():
cleaned_value = _coerce_jsonish(value)
if cleaned_value is not None:
cleaned[str(key)] = cleaned_value
return cleaned
def _optional_int_env(name: str) -> int | None:
raw_value = os.getenv(name, "").strip()
if not raw_value:
@ -876,6 +908,7 @@ class WebRTCVoiceSession:
)
self._last_stt_backlog_notice_at = 0.0
self._ptt_pressed = False
self._active_message_metadata: dict[str, Any] = {}
def set_push_to_talk_pressed(self, pressed: bool) -> None:
self._ptt_pressed = bool(pressed)
@ -917,12 +950,17 @@ class WebRTCVoiceSession:
await self._close_peer_connection()
self._ptt_pressed = False
self._active_message_metadata = {}
peer_connection = RTCPeerConnection()
self._pc = peer_connection
self._outbound_track = QueueAudioTrack()
self._outbound_track._on_playing_changed = self._on_track_playing_changed
peer_connection.addTrack(self._outbound_track)
offer_has_audio = bool(re.search(r"(?im)^m=audio\s", sdp))
if offer_has_audio:
self._outbound_track = QueueAudioTrack()
self._outbound_track._on_playing_changed = self._on_track_playing_changed
peer_connection.addTrack(self._outbound_track)
else:
self._outbound_track = None
@peer_connection.on("datachannel")
def on_datachannel(channel: Any) -> None:
@ -938,13 +976,14 @@ class WebRTCVoiceSession:
return
msg_type = str(msg.get("type", "")).strip()
if msg_type == "voice-ptt":
self._active_message_metadata = _coerce_message_metadata(msg.get("metadata", {}))
self.set_push_to_talk_pressed(bool(msg.get("pressed", False)))
elif msg_type == "command":
asyncio.create_task(self._gateway.send_command(str(msg.get("command", ""))))
elif msg_type == "ui-response":
elif msg_type == "card-response":
asyncio.create_task(
self._gateway.send_ui_response(
str(msg.get("request_id", "")),
self._gateway.send_card_response(
str(msg.get("card_id", "")),
str(msg.get("value", "")),
)
)
@ -1274,7 +1313,10 @@ class WebRTCVoiceSession:
await self._gateway.bus.publish(
WisperEvent(role="wisper", text=f"voice transcript: {transcript}")
)
await self._gateway.send_user_message(transcript)
await self._gateway.send_user_message(
transcript,
metadata=dict(self._active_message_metadata),
)
async def _close_peer_connection(self) -> None:
self._dc = None