breve webspeech config, type animation fixes, voice language fixes

This commit is contained in:
techartdev
2026-02-20 20:16:48 +02:00
parent ab76d093e1
commit d1b3fb1d57
9 changed files with 147 additions and 31 deletions
+16
View File
@@ -2,6 +2,22 @@
All notable changes to the OpenClaw Home Assistant Integration will be documented in this file. 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 ## [0.1.24] - 2026-02-20
### Fixed ### Fixed
+8 -1
View File
@@ -46,6 +46,7 @@ from .const import (
CONF_WAKE_WORD, CONF_WAKE_WORD,
CONF_WAKE_WORD_ENABLED, CONF_WAKE_WORD_ENABLED,
CONF_ALWAYS_VOICE_MODE, CONF_ALWAYS_VOICE_MODE,
CONF_ALLOW_BRAVE_WEBSPEECH,
CONTEXT_STRATEGY_TRUNCATE, CONTEXT_STRATEGY_TRUNCATE,
DEFAULT_CONTEXT_MAX_CHARS, DEFAULT_CONTEXT_MAX_CHARS,
DEFAULT_CONTEXT_STRATEGY, DEFAULT_CONTEXT_STRATEGY,
@@ -54,6 +55,7 @@ from .const import (
DEFAULT_WAKE_WORD, DEFAULT_WAKE_WORD,
DEFAULT_WAKE_WORD_ENABLED, DEFAULT_WAKE_WORD_ENABLED,
DEFAULT_ALWAYS_VOICE_MODE, DEFAULT_ALWAYS_VOICE_MODE,
DEFAULT_ALLOW_BRAVE_WEBSPEECH,
DOMAIN, DOMAIN,
EVENT_MESSAGE_RECEIVED, EVENT_MESSAGE_RECEIVED,
OPENCLAW_CONFIG_REL_PATH, 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) # URL at which the card JS is served (registered via register_static_path)
_CARD_STATIC_URL = f"/openclaw/{_CARD_FILENAME}" _CARD_STATIC_URL = f"/openclaw/{_CARD_FILENAME}"
# Versioned URL used for Lovelace resource registration to avoid stale browser cache # 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 type OpenClawConfigEntry = ConfigEntry
@@ -683,6 +685,11 @@ def _async_register_websocket_api(hass: HomeAssistant) -> None:
CONF_ALWAYS_VOICE_MODE, CONF_ALWAYS_VOICE_MODE,
DEFAULT_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,
}, },
) )
@@ -35,6 +35,7 @@ from .const import (
CONF_WAKE_WORD, CONF_WAKE_WORD,
CONF_WAKE_WORD_ENABLED, CONF_WAKE_WORD_ENABLED,
CONF_ALWAYS_VOICE_MODE, CONF_ALWAYS_VOICE_MODE,
CONF_ALLOW_BRAVE_WEBSPEECH,
CONTEXT_STRATEGY_CLEAR, CONTEXT_STRATEGY_CLEAR,
CONTEXT_STRATEGY_TRUNCATE, CONTEXT_STRATEGY_TRUNCATE,
DEFAULT_GATEWAY_HOST, DEFAULT_GATEWAY_HOST,
@@ -46,6 +47,7 @@ from .const import (
DEFAULT_WAKE_WORD, DEFAULT_WAKE_WORD,
DEFAULT_WAKE_WORD_ENABLED, DEFAULT_WAKE_WORD_ENABLED,
DEFAULT_ALWAYS_VOICE_MODE, DEFAULT_ALWAYS_VOICE_MODE,
DEFAULT_ALLOW_BRAVE_WEBSPEECH,
DOMAIN, DOMAIN,
OPENCLAW_CONFIG_REL_PATH, OPENCLAW_CONFIG_REL_PATH,
) )
@@ -455,6 +457,13 @@ class OpenClawOptionsFlow(OptionsFlow):
DEFAULT_ALWAYS_VOICE_MODE, DEFAULT_ALWAYS_VOICE_MODE,
), ),
): bool, ): bool,
vol.Optional(
CONF_ALLOW_BRAVE_WEBSPEECH,
default=options.get(
CONF_ALLOW_BRAVE_WEBSPEECH,
DEFAULT_ALLOW_BRAVE_WEBSPEECH,
),
): bool,
} }
), ),
) )
+2
View File
@@ -31,6 +31,7 @@ CONF_ENABLE_TOOL_CALLS = "enable_tool_calls"
CONF_WAKE_WORD_ENABLED = "wake_word_enabled" CONF_WAKE_WORD_ENABLED = "wake_word_enabled"
CONF_WAKE_WORD = "wake_word" CONF_WAKE_WORD = "wake_word"
CONF_ALWAYS_VOICE_MODE = "always_voice_mode" CONF_ALWAYS_VOICE_MODE = "always_voice_mode"
CONF_ALLOW_BRAVE_WEBSPEECH = "allow_brave_webspeech"
DEFAULT_INCLUDE_EXPOSED_CONTEXT = True DEFAULT_INCLUDE_EXPOSED_CONTEXT = True
DEFAULT_CONTEXT_MAX_CHARS = 13000 DEFAULT_CONTEXT_MAX_CHARS = 13000
@@ -39,6 +40,7 @@ DEFAULT_ENABLE_TOOL_CALLS = False
DEFAULT_WAKE_WORD_ENABLED = False DEFAULT_WAKE_WORD_ENABLED = False
DEFAULT_WAKE_WORD = "hey openclaw" DEFAULT_WAKE_WORD = "hey openclaw"
DEFAULT_ALWAYS_VOICE_MODE = False DEFAULT_ALWAYS_VOICE_MODE = False
DEFAULT_ALLOW_BRAVE_WEBSPEECH = False
CONTEXT_STRATEGY_TRUNCATE = "truncate" CONTEXT_STRATEGY_TRUNCATE = "truncate"
CONTEXT_STRATEGY_CLEAR = "clear" CONTEXT_STRATEGY_CLEAR = "clear"
+1 -1
View File
@@ -8,7 +8,7 @@
"iot_class": "local_polling", "iot_class": "local_polling",
"issue_tracker": "https://github.com/techartdev/OpenClawHomeAssistant/issues", "issue_tracker": "https://github.com/techartdev/OpenClawHomeAssistant/issues",
"requirements": [], "requirements": [],
"version": "0.1.24", "version": "0.1.26",
"dependencies": ["conversation"], "dependencies": ["conversation"],
"after_dependencies": ["hassio", "lovelace"] "after_dependencies": ["hassio", "lovelace"]
} }
+2 -1
View File
@@ -43,7 +43,8 @@
"enable_tool_calls": "Enable tool calls (execute services)", "enable_tool_calls": "Enable tool calls (execute services)",
"wake_word_enabled": "Require wake word", "wake_word_enabled": "Require wake word",
"wake_word": "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)"
} }
} }
} }
@@ -43,7 +43,8 @@
"enable_tool_calls": "Enable tool calls (execute services)", "enable_tool_calls": "Enable tool calls (execute services)",
"wake_word_enabled": "Require wake word", "wake_word_enabled": "Require wake word",
"wake_word": "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)"
} }
} }
} }
@@ -13,7 +13,7 @@
* + subscribes to openclaw_message_received events. * + 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 // Max time (ms) to show the thinking indicator before falling back to an error
const THINKING_TIMEOUT_MS = 120_000; const THINKING_TIMEOUT_MS = 120_000;
@@ -67,7 +67,10 @@ class OpenClawChatCard extends HTMLElement {
this._voiceRetryTimer = null; this._voiceRetryTimer = null;
this._voiceRetryCount = 0; this._voiceRetryCount = 0;
this._voiceNetworkErrorCount = 0; this._voiceNetworkErrorCount = 0;
this._pendingResponses = 0;
this._speechLangOverride = null; this._speechLangOverride = null;
this._integrationVoiceLanguage = null;
this._allowBraveWebSpeechIntegration = false;
this._voiceBackendBlocked = false; this._voiceBackendBlocked = false;
} }
@@ -162,25 +165,27 @@ class OpenClawChatCard extends HTMLElement {
const sessionId = this._getSessionId(); const sessionId = this._getSessionId();
if (data.session_id && data.session_id !== sessionId) return; if (data.session_id && data.session_id !== sessionId) return;
// If we're waiting for a response, update the last "thinking" message const thinkingIdx = this._messages.findIndex((m) => m._thinking);
if (this._isProcessing) { if (thinkingIdx >= 0) {
this._isProcessing = false; this._messages[thinkingIdx] = {
this._clearThinkingTimer(); role: "assistant",
// Replace the thinking indicator with the actual response content: data.message,
const thinkingIdx = this._messages.findIndex((m) => m._thinking); timestamp: data.timestamp || new Date().toISOString(),
if (thinkingIdx >= 0) { };
this._messages[thinkingIdx] = { if (this._pendingResponses > 0) {
role: "assistant", this._pendingResponses -= 1;
content: data.message,
timestamp: data.timestamp || new Date().toISOString(),
};
} else {
this._addMessage("assistant", data.message);
} }
} else { } else {
this._addMessage("assistant", data.message); this._addMessage("assistant", data.message);
} }
this._isProcessing = this._pendingResponses > 0;
if (!this._isProcessing) {
this._clearThinkingTimer();
} else {
this._startThinkingTimer();
}
this._persistMessages(); this._persistMessages();
this._render(); this._render();
this._scrollToBottom(); this._scrollToBottom();
@@ -194,6 +199,7 @@ class OpenClawChatCard extends HTMLElement {
_clearChat() { _clearChat() {
this._messages = []; this._messages = [];
this._isProcessing = false; this._isProcessing = false;
this._pendingResponses = 0;
this._clearThinkingTimer(); this._clearThinkingTimer();
this._persistMessages(); this._persistMessages();
this._render(); this._render();
@@ -273,6 +279,7 @@ class OpenClawChatCard extends HTMLElement {
if (shouldReplace) { if (shouldReplace) {
this._messages = validMessages; this._messages = validMessages;
this._isProcessing = false; this._isProcessing = false;
this._pendingResponses = 0;
this._clearThinkingTimer(); this._clearThinkingTimer();
this._persistMessages(); this._persistMessages();
this._render(); this._render();
@@ -307,6 +314,10 @@ class OpenClawChatCard extends HTMLElement {
this._wakeWordEnabled = !!result?.wake_word_enabled; this._wakeWordEnabled = !!result?.wake_word_enabled;
this._wakeWord = (result?.wake_word || "hey openclaw").toString().trim().toLowerCase(); this._wakeWord = (result?.wake_word || "hey openclaw").toString().trim().toLowerCase();
this._alwaysVoiceMode = !!result?.always_voice_mode; 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) { if (this._alwaysVoiceMode && !this._isVoiceMode) {
this._isVoiceMode = true; this._isVoiceMode = true;
@@ -321,9 +332,10 @@ class OpenClawChatCard extends HTMLElement {
// ── Thinking timer ────────────────────────────────────────────────── // ── Thinking timer ──────────────────────────────────────────────────
_startThinkingTimer() { _startThinkingTimer() {
this._clearThinkingTimer(); if (this._thinkingTimer) return;
this._thinkingTimer = setTimeout(() => { this._thinkingTimer = setTimeout(() => {
if (!this._isProcessing) return; this._thinkingTimer = null;
if (this._pendingResponses <= 0) return;
const idx = this._messages.findIndex((m) => m._thinking); const idx = this._messages.findIndex((m) => m._thinking);
if (idx >= 0) { if (idx >= 0) {
this._messages[idx] = { this._messages[idx] = {
@@ -332,8 +344,13 @@ class OpenClawChatCard extends HTMLElement {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
_error: true, _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(); this._render();
}, THINKING_TIMEOUT_MS); }, THINKING_TIMEOUT_MS);
} }
@@ -364,6 +381,7 @@ class OpenClawChatCard extends HTMLElement {
// Add thinking indicator with timeout safeguard // Add thinking indicator with timeout safeguard
this._isProcessing = true; this._isProcessing = true;
this._pendingResponses += 1;
this._messages.push({ this._messages.push({
role: "assistant", role: "assistant",
content: "", content: "",
@@ -383,7 +401,13 @@ class OpenClawChatCard extends HTMLElement {
} catch (err) { } catch (err) {
console.error("OpenClaw: Failed to send message:", err); console.error("OpenClaw: Failed to send message:", err);
// Replace thinking with error // 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) { if (thinkingIdx >= 0) {
this._messages[thinkingIdx] = { this._messages[thinkingIdx] = {
role: "assistant", role: "assistant",
@@ -392,7 +416,14 @@ class OpenClawChatCard extends HTMLElement {
_error: true, _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(); this._render();
} }
} }
@@ -439,9 +470,11 @@ class OpenClawChatCard extends HTMLElement {
} }
const configuredLang = this._config.voice_language; 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 browserLang = navigator.language;
const preferred = configuredLang || hassLang || browserLang || "en-US"; const preferred = configuredLang || integrationLang || hassLang || browserLang || "en-US";
return this._normalizeSpeechLanguage(preferred); return this._normalizeSpeechLanguage(preferred);
} }
@@ -458,8 +491,10 @@ class OpenClawChatCard extends HTMLElement {
_startVoiceRecognition() { _startVoiceRecognition() {
this._voiceBackendBlocked = false; 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 = 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."; "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(); this._render();
@@ -631,9 +666,54 @@ class OpenClawChatCard extends HTMLElement {
if (!("speechSynthesis" in window)) return; if (!("speechSynthesis" in window)) return;
// Strip markdown for TTS // Strip markdown for TTS
const plain = text.replace(/[*_`#\[\]()]/g, ""); const plain = text.replace(/[*_`#\[\]()]/g, "");
const utterance = new SpeechSynthesisUtterance(plain); const language = this._getSpeechRecognitionLanguage();
utterance.lang = this._hass?.language || "en-US";
speechSynthesis.speak(utterance); 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 ──────────────────────────────────────────────── // ── File attachments ────────────────────────────────────────────────
+1 -1
View File
@@ -1,7 +1,7 @@
(async () => { (async () => {
try { try {
if (!customElements.get("openclaw-chat-card")) { 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); console.info("OpenClaw loader importing", src);
await import(src); await import(src);
} }