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.
## [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
+8 -1
View File
@@ -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,
},
)
@@ -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,
}
),
)
+2
View File
@@ -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"
+1 -1
View File
@@ -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"]
}
+2 -1
View File
@@ -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)"
}
}
}
@@ -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)"
}
}
}
@@ -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,11 +165,6 @@ 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] = {
@@ -174,13 +172,20 @@ class OpenClawChatCard extends HTMLElement {
content: data.message,
timestamp: data.timestamp || new Date().toISOString(),
};
} else {
this._addMessage("assistant", data.message);
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 language = this._getSpeechRecognitionLanguage();
const speakNow = () => {
const utterance = new SpeechSynthesisUtterance(plain);
utterance.lang = this._hass?.language || "en-US";
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 ────────────────────────────────────────────────
+1 -1
View File
@@ -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);
}