Update version to 0.1.49 and enhance voice mode stability and chat functionality
This commit is contained in:
@@ -92,7 +92,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.48"
|
||||
_CARD_URL = f"{_CARD_STATIC_URL}?v=0.1.49"
|
||||
|
||||
OpenClawConfigEntry = ConfigEntry
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"iot_class": "local_polling",
|
||||
"issue_tracker": "https://github.com/techartdev/OpenClawHomeAssistant/issues",
|
||||
"requirements": [],
|
||||
"version": "0.1.48",
|
||||
"version": "0.1.49",
|
||||
"dependencies": ["conversation"],
|
||||
"after_dependencies": ["hassio", "lovelace"]
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* + subscribes to openclaw_message_received events.
|
||||
*/
|
||||
|
||||
const CARD_VERSION = "0.3.7";
|
||||
const CARD_VERSION = "0.3.8";
|
||||
|
||||
// Max time (ms) to show the thinking indicator before falling back to an error
|
||||
const THINKING_TIMEOUT_MS = 120_000;
|
||||
@@ -64,8 +64,11 @@ class OpenClawChatCard extends HTMLElement {
|
||||
this._wakeWord = "hey openclaw";
|
||||
this._voiceStatus = "";
|
||||
this._voiceRetryTimer = null;
|
||||
this._voiceIdleRestartTimer = null;
|
||||
this._voiceRetryCount = 0;
|
||||
this._voiceNetworkErrorCount = 0;
|
||||
this._speechDetectedInCycle = false;
|
||||
this._lastRecognitionError = null;
|
||||
this._pendingResponses = 0;
|
||||
this._speechLangOverride = null;
|
||||
this._integrationBrowserVoiceLanguage = null;
|
||||
@@ -90,6 +93,7 @@ class OpenClawChatCard extends HTMLElement {
|
||||
this._assistInputSampleRate = 16000;
|
||||
this._assistAutoStopTimer = null;
|
||||
this._lastAssistantEventSignature = null;
|
||||
this._autoScrollPinned = true;
|
||||
}
|
||||
|
||||
// ── HA card interface ───────────────────────────────────────────────
|
||||
@@ -251,8 +255,10 @@ class OpenClawChatCard extends HTMLElement {
|
||||
this._render();
|
||||
this._scrollToBottom();
|
||||
|
||||
this._syncHistoryFromBackend(1);
|
||||
|
||||
// In voice mode, speak the response
|
||||
if (this._isVoiceMode && this._recognition && data.message) {
|
||||
if (this._isVoiceMode && data.message) {
|
||||
this._speak(data.message);
|
||||
}
|
||||
}
|
||||
@@ -1050,6 +1056,12 @@ class OpenClawChatCard extends HTMLElement {
|
||||
_startBrowserVoiceRecognition() {
|
||||
this._voiceBackendBlocked = false;
|
||||
this._voiceStopRequested = false;
|
||||
this._speechDetectedInCycle = false;
|
||||
this._lastRecognitionError = null;
|
||||
if (this._voiceIdleRestartTimer) {
|
||||
clearTimeout(this._voiceIdleRestartTimer);
|
||||
this._voiceIdleRestartTimer = null;
|
||||
}
|
||||
const allowBraveWebSpeech =
|
||||
this._config.allow_brave_webspeech || this._allowBraveWebSpeechIntegration;
|
||||
|
||||
@@ -1069,7 +1081,7 @@ class OpenClawChatCard extends HTMLElement {
|
||||
|
||||
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
this._recognition = new SpeechRecognition();
|
||||
this._recognition.continuous = false;
|
||||
this._recognition.continuous = this._isVoiceMode;
|
||||
this._recognition.interimResults = false;
|
||||
this._recognition.maxAlternatives = 1;
|
||||
this._recognition.lang = this._getSpeechRecognitionLanguage();
|
||||
@@ -1084,6 +1096,8 @@ class OpenClawChatCard extends HTMLElement {
|
||||
if (result.isFinal) {
|
||||
const text = result[0].transcript?.trim();
|
||||
if (!text) return;
|
||||
this._speechDetectedInCycle = true;
|
||||
this._lastRecognitionError = null;
|
||||
|
||||
const requireWakeWord = this._wakeWordEnabled && this._isVoiceMode;
|
||||
|
||||
@@ -1120,6 +1134,9 @@ class OpenClawChatCard extends HTMLElement {
|
||||
if (this._voiceStopRequested) {
|
||||
return;
|
||||
}
|
||||
if (this._isVoiceMode && !this._speechDetectedInCycle) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this._recognition.stop();
|
||||
} catch (e) {
|
||||
@@ -1129,6 +1146,7 @@ class OpenClawChatCard extends HTMLElement {
|
||||
|
||||
this._recognition.onerror = (event) => {
|
||||
const err = event?.error || "unknown";
|
||||
this._lastRecognitionError = err;
|
||||
if (err === "aborted") {
|
||||
if (this._voiceStopRequested) {
|
||||
return;
|
||||
@@ -1191,6 +1209,29 @@ class OpenClawChatCard extends HTMLElement {
|
||||
}
|
||||
|
||||
if (this._isVoiceMode) {
|
||||
const previousError = this._lastRecognitionError;
|
||||
const hadSpeech = this._speechDetectedInCycle;
|
||||
this._speechDetectedInCycle = false;
|
||||
this._lastRecognitionError = null;
|
||||
|
||||
if (previousError === "no-speech" && !hadSpeech) {
|
||||
if (this._voiceIdleRestartTimer) {
|
||||
clearTimeout(this._voiceIdleRestartTimer);
|
||||
}
|
||||
this._voiceIdleRestartTimer = setTimeout(() => {
|
||||
this._voiceIdleRestartTimer = null;
|
||||
if (!this._isVoiceMode || !this._recognition) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this._recognition.start();
|
||||
} catch (e) {
|
||||
// Ignore — may already be started
|
||||
}
|
||||
}, 1500);
|
||||
return;
|
||||
}
|
||||
|
||||
// Restart recognition in voice mode
|
||||
try {
|
||||
this._recognition.start();
|
||||
@@ -1232,8 +1273,14 @@ class OpenClawChatCard extends HTMLElement {
|
||||
clearTimeout(this._voiceRetryTimer);
|
||||
this._voiceRetryTimer = null;
|
||||
}
|
||||
if (this._voiceIdleRestartTimer) {
|
||||
clearTimeout(this._voiceIdleRestartTimer);
|
||||
this._voiceIdleRestartTimer = null;
|
||||
}
|
||||
this._voiceRetryCount = 0;
|
||||
this._voiceNetworkErrorCount = 0;
|
||||
this._speechDetectedInCycle = false;
|
||||
this._lastRecognitionError = null;
|
||||
this._isVoiceMode = false;
|
||||
this._voiceStatus = "";
|
||||
}
|
||||
@@ -1264,6 +1311,28 @@ class OpenClawChatCard extends HTMLElement {
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
_pauseVoiceInputForTts() {
|
||||
if (!this._isVoiceMode) return;
|
||||
if (!this._recognition) return;
|
||||
|
||||
try {
|
||||
this._voiceStopRequested = true;
|
||||
this._recognition.abort();
|
||||
} catch (err) {
|
||||
console.debug("OpenClaw: could not pause voice recognition during TTS", err);
|
||||
}
|
||||
this._recognition = null;
|
||||
}
|
||||
|
||||
_resumeVoiceInputAfterTts() {
|
||||
if (!this._isVoiceMode) return;
|
||||
if (this._recognition) return;
|
||||
|
||||
this._startVoiceRecognition().catch((err) => {
|
||||
console.error("OpenClaw: failed to resume voice recognition after TTS", err);
|
||||
});
|
||||
}
|
||||
|
||||
async _toggleVoiceMode() {
|
||||
await this._loadIntegrationSettings(false);
|
||||
this._isVoiceMode = !this._isVoiceMode;
|
||||
@@ -1280,6 +1349,7 @@ class OpenClawChatCard extends HTMLElement {
|
||||
|
||||
_speak(text) {
|
||||
if (!("speechSynthesis" in window)) return;
|
||||
this._pauseVoiceInputForTts();
|
||||
// Strip markdown for TTS
|
||||
const plain = text.replace(/[*_`#\[\]()]/g, "");
|
||||
const preferredLanguage = this._getSpeechSynthesisLanguage();
|
||||
@@ -1342,6 +1412,7 @@ class OpenClawChatCard extends HTMLElement {
|
||||
if (ok) {
|
||||
this._voiceStatus = "";
|
||||
this._render();
|
||||
this._resumeVoiceInputAfterTts();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1368,6 +1439,7 @@ class OpenClawChatCard extends HTMLElement {
|
||||
if (ok) {
|
||||
this._voiceStatus = "";
|
||||
this._render();
|
||||
this._resumeVoiceInputAfterTts();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1379,6 +1451,7 @@ class OpenClawChatCard extends HTMLElement {
|
||||
const reason = event?.error ? ` (${event.error})` : "";
|
||||
this._voiceStatus = `TTS error for language ${targetLanguage}${reason}`;
|
||||
this._render();
|
||||
this._resumeVoiceInputAfterTts();
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -1391,6 +1464,11 @@ class OpenClawChatCard extends HTMLElement {
|
||||
const reason = event?.error ? ` (${event.error})` : "";
|
||||
this._voiceStatus = `TTS error for language ${targetLanguage}${reason}`;
|
||||
this._render();
|
||||
this._resumeVoiceInputAfterTts();
|
||||
};
|
||||
|
||||
utterance.onend = () => {
|
||||
this._resumeVoiceInputAfterTts();
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -1433,6 +1511,12 @@ class OpenClawChatCard extends HTMLElement {
|
||||
if (!engineCandidates.includes(normalized)) {
|
||||
engineCandidates.push(normalized);
|
||||
}
|
||||
if (normalized.startsWith("tts.")) {
|
||||
const alias = normalized.slice(4);
|
||||
if (alias && !engineCandidates.includes(alias)) {
|
||||
engineCandidates.push(alias);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!engineCandidates.length) {
|
||||
@@ -1446,63 +1530,99 @@ class OpenClawChatCard extends HTMLElement {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
|
||||
const languageCandidates = [];
|
||||
const normalizedLanguage = typeof language === "string" ? language.trim() : "";
|
||||
if (normalizedLanguage) {
|
||||
languageCandidates.push(normalizedLanguage);
|
||||
const baseLanguage = normalizedLanguage.split("-")[0];
|
||||
if (baseLanguage && baseLanguage !== normalizedLanguage) {
|
||||
languageCandidates.push(baseLanguage);
|
||||
}
|
||||
}
|
||||
languageCandidates.push(null);
|
||||
|
||||
let audioPath = null;
|
||||
let selectedEngine = null;
|
||||
let selectedLanguage = null;
|
||||
let lastError = "unknown_error";
|
||||
|
||||
for (const engine of engineCandidates) {
|
||||
console.info("OpenClaw: trying HA TTS engine", engine, "language", language);
|
||||
const payloads = [
|
||||
{
|
||||
platform: engine,
|
||||
message: text,
|
||||
language,
|
||||
cache: true,
|
||||
},
|
||||
{
|
||||
engine_id: engine,
|
||||
message: text,
|
||||
language,
|
||||
cache: true,
|
||||
},
|
||||
{
|
||||
tts_engine_id: engine,
|
||||
message: text,
|
||||
language,
|
||||
cache: true,
|
||||
},
|
||||
];
|
||||
for (const candidateLanguage of languageCandidates) {
|
||||
const payloads = [
|
||||
{
|
||||
platform: engine,
|
||||
message: text,
|
||||
cache: true,
|
||||
...(candidateLanguage ? { language: candidateLanguage } : {}),
|
||||
},
|
||||
{
|
||||
engine_id: engine,
|
||||
message: text,
|
||||
cache: true,
|
||||
...(candidateLanguage ? { language: candidateLanguage } : {}),
|
||||
},
|
||||
{
|
||||
tts_engine_id: engine,
|
||||
message: text,
|
||||
cache: true,
|
||||
...(candidateLanguage ? { language: candidateLanguage } : {}),
|
||||
},
|
||||
];
|
||||
|
||||
for (const payload of payloads) {
|
||||
try {
|
||||
const response = await fetch("/api/tts_get_url", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
console.info(
|
||||
"OpenClaw: trying HA TTS engine",
|
||||
engine,
|
||||
"language",
|
||||
candidateLanguage || "auto"
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
lastError = `engine=${engine}, tts_get_url_http=${response.status}`;
|
||||
continue;
|
||||
}
|
||||
for (const payload of payloads) {
|
||||
try {
|
||||
const response = await fetch("/api/tts_get_url", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const body = await response.json();
|
||||
const candidate = body?.url || body?.path || body?.audio_url || body?.audioUrl;
|
||||
if (!candidate) {
|
||||
lastError = `engine=${engine}, no_audio_url_in_response`;
|
||||
continue;
|
||||
}
|
||||
if (!response.ok) {
|
||||
let responseSnippet = "";
|
||||
try {
|
||||
responseSnippet = (await response.text()).trim().slice(0, 120);
|
||||
} catch (_err) {
|
||||
// ignore response body parsing failure
|
||||
}
|
||||
lastError =
|
||||
`engine=${engine}, lang=${candidateLanguage || "auto"}, ` +
|
||||
`tts_get_url_http=${response.status}` +
|
||||
(responseSnippet ? `, detail=${responseSnippet}` : "");
|
||||
continue;
|
||||
}
|
||||
|
||||
audioPath = String(candidate).startsWith("http")
|
||||
? String(candidate)
|
||||
: String(candidate).startsWith("/")
|
||||
const body = await response.json();
|
||||
const candidate = body?.url || body?.path || body?.audio_url || body?.audioUrl;
|
||||
if (!candidate) {
|
||||
lastError =
|
||||
`engine=${engine}, lang=${candidateLanguage || "auto"}, no_audio_url_in_response`;
|
||||
continue;
|
||||
}
|
||||
|
||||
audioPath = String(candidate).startsWith("http")
|
||||
? String(candidate)
|
||||
: `/${String(candidate)}`;
|
||||
selectedEngine = engine;
|
||||
: String(candidate).startsWith("/")
|
||||
? String(candidate)
|
||||
: `/${String(candidate)}`;
|
||||
selectedEngine = engine;
|
||||
selectedLanguage = candidateLanguage;
|
||||
break;
|
||||
} catch (err) {
|
||||
console.debug("OpenClaw: HA TTS URL request failed", err);
|
||||
lastError =
|
||||
`engine=${engine}, lang=${candidateLanguage || "auto"}, tts_get_url_request_failed`;
|
||||
}
|
||||
}
|
||||
|
||||
if (audioPath) {
|
||||
break;
|
||||
} catch (err) {
|
||||
console.debug("OpenClaw: HA TTS URL request failed", err);
|
||||
lastError = `engine=${engine}, tts_get_url_request_failed`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1521,7 +1641,9 @@ class OpenClawChatCard extends HTMLElement {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
if (!audioResponse.ok) {
|
||||
this._lastHaTtsAttempt = `engine=${selectedEngine}, audio_fetch_http=${audioResponse.status}`;
|
||||
this._lastHaTtsAttempt =
|
||||
`engine=${selectedEngine}, lang=${selectedLanguage || "auto"}, ` +
|
||||
`audio_fetch_http=${audioResponse.status}`;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1549,11 +1671,12 @@ class OpenClawChatCard extends HTMLElement {
|
||||
}
|
||||
});
|
||||
|
||||
this._lastHaTtsAttempt = `engine=${selectedEngine}, ok`;
|
||||
this._lastHaTtsAttempt = `engine=${selectedEngine}, lang=${selectedLanguage || "auto"}, ok`;
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.debug("OpenClaw: HA TTS playback failed", err);
|
||||
this._lastHaTtsAttempt = `engine=${selectedEngine}, audio_playback_failed`;
|
||||
this._lastHaTtsAttempt =
|
||||
`engine=${selectedEngine}, lang=${selectedLanguage || "auto"}, audio_playback_failed`;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1579,10 +1702,16 @@ class OpenClawChatCard extends HTMLElement {
|
||||
|
||||
// ── UI helpers ──────────────────────────────────────────────────────
|
||||
|
||||
_scrollToBottom() {
|
||||
_isNearBottom(container, threshold = 48) {
|
||||
if (!container) return true;
|
||||
const distance = container.scrollHeight - (container.scrollTop + container.clientHeight);
|
||||
return distance <= threshold;
|
||||
}
|
||||
|
||||
_scrollToBottom(force = false) {
|
||||
requestAnimationFrame(() => {
|
||||
const container = this.shadowRoot?.querySelector(".messages");
|
||||
if (container) {
|
||||
if (container && (force || this._autoScrollPinned)) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
});
|
||||
@@ -1601,6 +1730,11 @@ class OpenClawChatCard extends HTMLElement {
|
||||
// ── Render ──────────────────────────────────────────────────────────
|
||||
|
||||
_render() {
|
||||
const previousContainer = this.shadowRoot?.querySelector(".messages");
|
||||
if (previousContainer) {
|
||||
this._autoScrollPinned = this._isNearBottom(previousContainer);
|
||||
}
|
||||
|
||||
const config = this._config;
|
||||
const messages = this._messages;
|
||||
|
||||
@@ -1969,6 +2103,13 @@ class OpenClawChatCard extends HTMLElement {
|
||||
});
|
||||
}
|
||||
|
||||
const messagesContainer = this.shadowRoot.getElementById("messages");
|
||||
if (messagesContainer) {
|
||||
messagesContainer.addEventListener("scroll", () => {
|
||||
this._autoScrollPinned = this._isNearBottom(messagesContainer);
|
||||
});
|
||||
}
|
||||
|
||||
this._scrollToBottom();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user