From d1b3fb1d57bd81ff4d2943cfced288bbe3e8267d Mon Sep 17 00:00:00 2001 From: techartdev Date: Fri, 20 Feb 2026 20:16:48 +0200 Subject: [PATCH] breve webspeech config, type animation fixes, voice language fixes --- CHANGELOG.md | 16 +++ custom_components/openclaw/__init__.py | 9 +- custom_components/openclaw/config_flow.py | 9 ++ custom_components/openclaw/const.py | 2 + custom_components/openclaw/manifest.json | 2 +- custom_components/openclaw/strings.json | 3 +- .../openclaw/translations/en.json | 3 +- .../openclaw/www/openclaw-chat-card.js | 132 ++++++++++++++---- www/openclaw-chat-card.js | 2 +- 9 files changed, 147 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f999e3..8e91cee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ All notable changes to the OpenClaw Home Assistant Integration will be documented in this file. +## [0.1.26] - 2026-02-20 + +### Added +- Added integration option `allow_brave_webspeech` to **Settings → Devices & Services → OpenClaw → Configure**. +- Frontend card now reads this option via `openclaw/get_settings` and applies it automatically. + +### Changed +- Card version bumped to `0.2.6` and cache-busting URL updated to `v=0.1.26`. + +## [0.1.25] - 2026-02-20 + +### Fixed +- Voice input language now prioritizes integration/HA locale settings more reliably (including frontend locale fallback), reducing unwanted fallback to English. +- Voice-mode assistant replies now use improved speech synthesis voice selection for the active language and better voice-loading handling. +- Reworked chat pending-response tracking to support multiple in-flight messages without leaving stuck typing indicators. + ## [0.1.24] - 2026-02-20 ### Fixed diff --git a/custom_components/openclaw/__init__.py b/custom_components/openclaw/__init__.py index 9d4e564..b5d360d 100644 --- a/custom_components/openclaw/__init__.py +++ b/custom_components/openclaw/__init__.py @@ -46,6 +46,7 @@ from .const import ( CONF_WAKE_WORD, CONF_WAKE_WORD_ENABLED, CONF_ALWAYS_VOICE_MODE, + CONF_ALLOW_BRAVE_WEBSPEECH, CONTEXT_STRATEGY_TRUNCATE, DEFAULT_CONTEXT_MAX_CHARS, DEFAULT_CONTEXT_STRATEGY, @@ -54,6 +55,7 @@ from .const import ( DEFAULT_WAKE_WORD, DEFAULT_WAKE_WORD_ENABLED, DEFAULT_ALWAYS_VOICE_MODE, + DEFAULT_ALLOW_BRAVE_WEBSPEECH, DOMAIN, EVENT_MESSAGE_RECEIVED, OPENCLAW_CONFIG_REL_PATH, @@ -74,7 +76,7 @@ _CARD_PATH = Path(__file__).parent / "www" / _CARD_FILENAME # URL at which the card JS is served (registered via register_static_path) _CARD_STATIC_URL = f"/openclaw/{_CARD_FILENAME}" # Versioned URL used for Lovelace resource registration to avoid stale browser cache -_CARD_URL = f"{_CARD_STATIC_URL}?v=0.1.24" +_CARD_URL = f"{_CARD_STATIC_URL}?v=0.1.26" type OpenClawConfigEntry = ConfigEntry @@ -683,6 +685,11 @@ def _async_register_websocket_api(hass: HomeAssistant) -> None: CONF_ALWAYS_VOICE_MODE, DEFAULT_ALWAYS_VOICE_MODE, ), + CONF_ALLOW_BRAVE_WEBSPEECH: options.get( + CONF_ALLOW_BRAVE_WEBSPEECH, + DEFAULT_ALLOW_BRAVE_WEBSPEECH, + ), + "language": hass.config.language, }, ) diff --git a/custom_components/openclaw/config_flow.py b/custom_components/openclaw/config_flow.py index 2727791..50463e1 100644 --- a/custom_components/openclaw/config_flow.py +++ b/custom_components/openclaw/config_flow.py @@ -35,6 +35,7 @@ from .const import ( CONF_WAKE_WORD, CONF_WAKE_WORD_ENABLED, CONF_ALWAYS_VOICE_MODE, + CONF_ALLOW_BRAVE_WEBSPEECH, CONTEXT_STRATEGY_CLEAR, CONTEXT_STRATEGY_TRUNCATE, DEFAULT_GATEWAY_HOST, @@ -46,6 +47,7 @@ from .const import ( DEFAULT_WAKE_WORD, DEFAULT_WAKE_WORD_ENABLED, DEFAULT_ALWAYS_VOICE_MODE, + DEFAULT_ALLOW_BRAVE_WEBSPEECH, DOMAIN, OPENCLAW_CONFIG_REL_PATH, ) @@ -455,6 +457,13 @@ class OpenClawOptionsFlow(OptionsFlow): DEFAULT_ALWAYS_VOICE_MODE, ), ): bool, + vol.Optional( + CONF_ALLOW_BRAVE_WEBSPEECH, + default=options.get( + CONF_ALLOW_BRAVE_WEBSPEECH, + DEFAULT_ALLOW_BRAVE_WEBSPEECH, + ), + ): bool, } ), ) diff --git a/custom_components/openclaw/const.py b/custom_components/openclaw/const.py index f93c167..b9fe3d2 100644 --- a/custom_components/openclaw/const.py +++ b/custom_components/openclaw/const.py @@ -31,6 +31,7 @@ CONF_ENABLE_TOOL_CALLS = "enable_tool_calls" CONF_WAKE_WORD_ENABLED = "wake_word_enabled" CONF_WAKE_WORD = "wake_word" CONF_ALWAYS_VOICE_MODE = "always_voice_mode" +CONF_ALLOW_BRAVE_WEBSPEECH = "allow_brave_webspeech" DEFAULT_INCLUDE_EXPOSED_CONTEXT = True DEFAULT_CONTEXT_MAX_CHARS = 13000 @@ -39,6 +40,7 @@ DEFAULT_ENABLE_TOOL_CALLS = False DEFAULT_WAKE_WORD_ENABLED = False DEFAULT_WAKE_WORD = "hey openclaw" DEFAULT_ALWAYS_VOICE_MODE = False +DEFAULT_ALLOW_BRAVE_WEBSPEECH = False CONTEXT_STRATEGY_TRUNCATE = "truncate" CONTEXT_STRATEGY_CLEAR = "clear" diff --git a/custom_components/openclaw/manifest.json b/custom_components/openclaw/manifest.json index d302bbd..cd429af 100644 --- a/custom_components/openclaw/manifest.json +++ b/custom_components/openclaw/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/techartdev/OpenClawHomeAssistant/issues", "requirements": [], - "version": "0.1.24", + "version": "0.1.26", "dependencies": ["conversation"], "after_dependencies": ["hassio", "lovelace"] } diff --git a/custom_components/openclaw/strings.json b/custom_components/openclaw/strings.json index 2a7e497..c39a3f1 100644 --- a/custom_components/openclaw/strings.json +++ b/custom_components/openclaw/strings.json @@ -43,7 +43,8 @@ "enable_tool_calls": "Enable tool calls (execute services)", "wake_word_enabled": "Require wake word", "wake_word": "Wake word", - "always_voice_mode": "Always-on voice mode" + "always_voice_mode": "Always-on voice mode", + "allow_brave_webspeech": "Allow Web Speech in Brave (experimental)" } } } diff --git a/custom_components/openclaw/translations/en.json b/custom_components/openclaw/translations/en.json index 2a7e497..c39a3f1 100644 --- a/custom_components/openclaw/translations/en.json +++ b/custom_components/openclaw/translations/en.json @@ -43,7 +43,8 @@ "enable_tool_calls": "Enable tool calls (execute services)", "wake_word_enabled": "Require wake word", "wake_word": "Wake word", - "always_voice_mode": "Always-on voice mode" + "always_voice_mode": "Always-on voice mode", + "allow_brave_webspeech": "Allow Web Speech in Brave (experimental)" } } } diff --git a/custom_components/openclaw/www/openclaw-chat-card.js b/custom_components/openclaw/www/openclaw-chat-card.js index eb4dbf4..440a74d 100644 --- a/custom_components/openclaw/www/openclaw-chat-card.js +++ b/custom_components/openclaw/www/openclaw-chat-card.js @@ -13,7 +13,7 @@ * + subscribes to openclaw_message_received events. */ -const CARD_VERSION = "0.2.4"; +const CARD_VERSION = "0.2.6"; // Max time (ms) to show the thinking indicator before falling back to an error const THINKING_TIMEOUT_MS = 120_000; @@ -67,7 +67,10 @@ class OpenClawChatCard extends HTMLElement { this._voiceRetryTimer = null; this._voiceRetryCount = 0; this._voiceNetworkErrorCount = 0; + this._pendingResponses = 0; this._speechLangOverride = null; + this._integrationVoiceLanguage = null; + this._allowBraveWebSpeechIntegration = false; this._voiceBackendBlocked = false; } @@ -162,25 +165,27 @@ class OpenClawChatCard extends HTMLElement { const sessionId = this._getSessionId(); if (data.session_id && data.session_id !== sessionId) return; - // If we're waiting for a response, update the last "thinking" message - if (this._isProcessing) { - this._isProcessing = false; - this._clearThinkingTimer(); - // Replace the thinking indicator with the actual response - const thinkingIdx = this._messages.findIndex((m) => m._thinking); - if (thinkingIdx >= 0) { - this._messages[thinkingIdx] = { - role: "assistant", - content: data.message, - timestamp: data.timestamp || new Date().toISOString(), - }; - } else { - this._addMessage("assistant", data.message); + const thinkingIdx = this._messages.findIndex((m) => m._thinking); + if (thinkingIdx >= 0) { + this._messages[thinkingIdx] = { + role: "assistant", + content: data.message, + timestamp: data.timestamp || new Date().toISOString(), + }; + if (this._pendingResponses > 0) { + this._pendingResponses -= 1; } } else { this._addMessage("assistant", data.message); } + this._isProcessing = this._pendingResponses > 0; + if (!this._isProcessing) { + this._clearThinkingTimer(); + } else { + this._startThinkingTimer(); + } + this._persistMessages(); this._render(); this._scrollToBottom(); @@ -194,6 +199,7 @@ class OpenClawChatCard extends HTMLElement { _clearChat() { this._messages = []; this._isProcessing = false; + this._pendingResponses = 0; this._clearThinkingTimer(); this._persistMessages(); this._render(); @@ -273,6 +279,7 @@ class OpenClawChatCard extends HTMLElement { if (shouldReplace) { this._messages = validMessages; this._isProcessing = false; + this._pendingResponses = 0; this._clearThinkingTimer(); this._persistMessages(); this._render(); @@ -307,6 +314,10 @@ class OpenClawChatCard extends HTMLElement { this._wakeWordEnabled = !!result?.wake_word_enabled; this._wakeWord = (result?.wake_word || "hey openclaw").toString().trim().toLowerCase(); this._alwaysVoiceMode = !!result?.always_voice_mode; + this._allowBraveWebSpeechIntegration = !!result?.allow_brave_webspeech; + this._integrationVoiceLanguage = result?.language + ? this._normalizeSpeechLanguage(result.language) + : null; if (this._alwaysVoiceMode && !this._isVoiceMode) { this._isVoiceMode = true; @@ -321,9 +332,10 @@ class OpenClawChatCard extends HTMLElement { // ── Thinking timer ────────────────────────────────────────────────── _startThinkingTimer() { - this._clearThinkingTimer(); + if (this._thinkingTimer) return; this._thinkingTimer = setTimeout(() => { - if (!this._isProcessing) return; + this._thinkingTimer = null; + if (this._pendingResponses <= 0) return; const idx = this._messages.findIndex((m) => m._thinking); if (idx >= 0) { this._messages[idx] = { @@ -332,8 +344,13 @@ class OpenClawChatCard extends HTMLElement { timestamp: new Date().toISOString(), _error: true, }; + this._pendingResponses = Math.max(0, this._pendingResponses - 1); } - this._isProcessing = false; + this._isProcessing = this._pendingResponses > 0; + if (this._isProcessing) { + this._startThinkingTimer(); + } + this._persistMessages(); this._render(); }, THINKING_TIMEOUT_MS); } @@ -364,6 +381,7 @@ class OpenClawChatCard extends HTMLElement { // Add thinking indicator with timeout safeguard this._isProcessing = true; + this._pendingResponses += 1; this._messages.push({ role: "assistant", content: "", @@ -383,7 +401,13 @@ class OpenClawChatCard extends HTMLElement { } catch (err) { console.error("OpenClaw: Failed to send message:", err); // Replace thinking with error - const thinkingIdx = this._messages.findIndex((m) => m._thinking); + let thinkingIdx = -1; + for (let idx = this._messages.length - 1; idx >= 0; idx -= 1) { + if (this._messages[idx]?._thinking) { + thinkingIdx = idx; + break; + } + } if (thinkingIdx >= 0) { this._messages[thinkingIdx] = { role: "assistant", @@ -392,7 +416,14 @@ class OpenClawChatCard extends HTMLElement { _error: true, }; } - this._isProcessing = false; + if (this._pendingResponses > 0) { + this._pendingResponses -= 1; + } + this._isProcessing = this._pendingResponses > 0; + if (!this._isProcessing) { + this._clearThinkingTimer(); + } + this._persistMessages(); this._render(); } } @@ -439,9 +470,11 @@ class OpenClawChatCard extends HTMLElement { } const configuredLang = this._config.voice_language; - const hassLang = this._hass?.selectedLanguage || this._hass?.language; + const integrationLang = this._integrationVoiceLanguage; + const hassLang = + this._hass?.locale?.language || this._hass?.selectedLanguage || this._hass?.language; const browserLang = navigator.language; - const preferred = configuredLang || hassLang || browserLang || "en-US"; + const preferred = configuredLang || integrationLang || hassLang || browserLang || "en-US"; return this._normalizeSpeechLanguage(preferred); } @@ -458,8 +491,10 @@ class OpenClawChatCard extends HTMLElement { _startVoiceRecognition() { this._voiceBackendBlocked = false; + const allowBraveWebSpeech = + this._config.allow_brave_webspeech || this._allowBraveWebSpeechIntegration; - if (this._isLikelyBraveBrowser() && !this._config.allow_brave_webspeech) { + if (this._isLikelyBraveBrowser() && !allowBraveWebSpeech) { this._voiceStatus = "Voice input disabled on Brave by default due browser SpeechRecognition network failures. Use Chrome/Edge, or set allow_brave_webspeech: true in card config to force-enable experimental mode."; this._render(); @@ -631,9 +666,54 @@ class OpenClawChatCard extends HTMLElement { if (!("speechSynthesis" in window)) return; // Strip markdown for TTS const plain = text.replace(/[*_`#\[\]()]/g, ""); - const utterance = new SpeechSynthesisUtterance(plain); - utterance.lang = this._hass?.language || "en-US"; - speechSynthesis.speak(utterance); + const language = this._getSpeechRecognitionLanguage(); + + const speakNow = () => { + const utterance = new SpeechSynthesisUtterance(plain); + utterance.lang = language; + + const voices = speechSynthesis.getVoices() || []; + if (voices.length) { + const exactVoice = voices.find( + (voice) => String(voice.lang || "").toLowerCase() === language.toLowerCase() + ); + const prefix = language.split("-")[0]?.toLowerCase(); + const languageVoice = + exactVoice || + voices.find((voice) => String(voice.lang || "").toLowerCase().startsWith(`${prefix}-`)); + if (languageVoice) { + utterance.voice = languageVoice; + } + } + + utterance.onerror = () => { + this._voiceStatus = `TTS error for language ${language}`; + this._render(); + }; + + try { + speechSynthesis.cancel(); + } catch (e) { + // ignore + } + speechSynthesis.speak(utterance); + }; + + const loadedVoices = speechSynthesis.getVoices() || []; + if (loadedVoices.length) { + speakNow(); + return; + } + + const handleVoicesChanged = () => { + speechSynthesis.removeEventListener("voiceschanged", handleVoicesChanged); + speakNow(); + }; + speechSynthesis.addEventListener("voiceschanged", handleVoicesChanged); + setTimeout(() => { + speechSynthesis.removeEventListener("voiceschanged", handleVoicesChanged); + speakNow(); + }, 800); } // ── File attachments ──────────────────────────────────────────────── diff --git a/www/openclaw-chat-card.js b/www/openclaw-chat-card.js index 7d71b67..c44948b 100644 --- a/www/openclaw-chat-card.js +++ b/www/openclaw-chat-card.js @@ -1,7 +1,7 @@ (async () => { try { if (!customElements.get("openclaw-chat-card")) { - const src = "/openclaw/openclaw-chat-card.js?v=0.1.24"; + const src = "/openclaw/openclaw-chat-card.js?v=0.1.26"; console.info("OpenClaw loader importing", src); await import(src); }