Update changelog for version 0.1.16, add configurable wake-word support, optional always-on voice mode, and websocket settings endpoint; enhance chat card voice recognition and context handling.

This commit is contained in:
techartdev
2026-02-20 17:21:14 +02:00
parent c0f9e5c42e
commit 0445c595ef
12 changed files with 691 additions and 24 deletions
+232 -4
View File
@@ -6,12 +6,14 @@ Sets up the OpenClaw integration: API client, coordinator, platforms, and servic
from __future__ import annotations
import asyncio
import json
from datetime import datetime, timezone
import logging
from pathlib import Path
from typing import Any
import voluptuous as vol
from homeassistant.components import websocket_api
try:
from homeassistant.components.lovelace.const import LOVELACE_DATA
@@ -36,6 +38,21 @@ from .const import (
CONF_GATEWAY_PORT,
CONF_GATEWAY_TOKEN,
CONF_USE_SSL,
CONF_CONTEXT_MAX_CHARS,
CONF_CONTEXT_STRATEGY,
CONF_ENABLE_TOOL_CALLS,
CONF_INCLUDE_EXPOSED_CONTEXT,
CONF_WAKE_WORD,
CONF_WAKE_WORD_ENABLED,
CONF_ALWAYS_VOICE_MODE,
CONTEXT_STRATEGY_TRUNCATE,
DEFAULT_CONTEXT_MAX_CHARS,
DEFAULT_CONTEXT_STRATEGY,
DEFAULT_ENABLE_TOOL_CALLS,
DEFAULT_INCLUDE_EXPOSED_CONTEXT,
DEFAULT_WAKE_WORD,
DEFAULT_WAKE_WORD_ENABLED,
DEFAULT_ALWAYS_VOICE_MODE,
DOMAIN,
EVENT_MESSAGE_RECEIVED,
OPENCLAW_CONFIG_REL_PATH,
@@ -44,10 +61,12 @@ from .const import (
SERVICE_SEND_MESSAGE,
)
from .coordinator import OpenClawCoordinator
from .exposure import build_exposed_entities_context
from .exposure import apply_context_policy, build_exposed_entities_context
_LOGGER = logging.getLogger(__name__)
_MAX_CHAT_HISTORY = 200
# Path to the chat card JS inside the integration package (custom_components/openclaw/www/)
_CARD_FILENAME = "openclaw-chat-card.js"
_CARD_PATH = Path(__file__).parent / "www" / _CARD_FILENAME
@@ -98,6 +117,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenClawConfigEntry) ->
"client": client,
"coordinator": coordinator,
"addon_config_path": addon_config_path,
"entry": entry,
}
# First data fetch — if it fails the coordinator marks entities unavailable
@@ -107,6 +127,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenClawConfigEntry) ->
# Register services (once, idempotent)
_async_register_services(hass)
_async_register_websocket_api(hass)
# Register the frontend card resource
hass.async_create_task(_async_register_frontend(hass))
@@ -303,7 +324,7 @@ def _async_register_services(hass: HomeAssistant) -> None:
async def handle_send_message(call: ServiceCall) -> None:
"""Handle the openclaw.send_message service call."""
message: str = call.data[ATTR_MESSAGE]
session_id: str | None = call.data.get(ATTR_SESSION_ID)
session_id: str = call.data.get(ATTR_SESSION_ID) or "default"
entry_data = _get_first_entry_data(hass)
if not entry_data:
@@ -312,15 +333,48 @@ def _async_register_services(hass: HomeAssistant) -> None:
client: OpenClawApiClient = entry_data["client"]
coordinator: OpenClawCoordinator = entry_data["coordinator"]
entry: ConfigEntry = entry_data["entry"]
options = entry.options
try:
system_prompt = build_exposed_entities_context(hass, assistant="assist")
include_context = options.get(
CONF_INCLUDE_EXPOSED_CONTEXT,
DEFAULT_INCLUDE_EXPOSED_CONTEXT,
)
max_chars = int(
options.get(CONF_CONTEXT_MAX_CHARS, DEFAULT_CONTEXT_MAX_CHARS)
)
strategy = options.get(CONF_CONTEXT_STRATEGY, DEFAULT_CONTEXT_STRATEGY)
if strategy not in {"clear", CONTEXT_STRATEGY_TRUNCATE}:
strategy = DEFAULT_CONTEXT_STRATEGY
raw_context = (
build_exposed_entities_context(hass, assistant="conversation")
if include_context
else None
)
system_prompt = apply_context_policy(raw_context, max_chars, strategy)
_append_chat_history(hass, session_id, "user", message)
response = await client.async_send_message(
message=message,
session_id=session_id,
system_prompt=system_prompt,
)
if options.get(CONF_ENABLE_TOOL_CALLS, DEFAULT_ENABLE_TOOL_CALLS):
tool_results = await _async_execute_tool_calls(hass, response)
if tool_results:
response = await client.async_send_message(
message=(
"Tool execution results:\n"
+ "\n".join(f"- {line}" for line in tool_results)
+ "\nRespond to the user based on these results."
),
session_id=session_id,
system_prompt=system_prompt,
)
assistant_message = _extract_assistant_message(response)
model_used = response.get("model", "unknown")
@@ -331,6 +385,7 @@ def _async_register_services(hass: HomeAssistant) -> None:
list(response.keys()),
)
_append_chat_history(hass, session_id, "assistant", assistant_message)
hass.bus.async_fire(
EVENT_MESSAGE_RECEIVED,
{
@@ -344,6 +399,7 @@ def _async_register_services(hass: HomeAssistant) -> None:
except OpenClawApiError as err:
_LOGGER.error("Failed to send message to OpenClaw: %s", err)
_append_chat_history(hass, session_id, "assistant", f"OpenClaw error: {err}")
hass.bus.async_fire(
EVENT_MESSAGE_RECEIVED,
{
@@ -358,7 +414,11 @@ def _async_register_services(hass: HomeAssistant) -> None:
"""Handle the openclaw.clear_history service call."""
session_id: str | None = call.data.get(ATTR_SESSION_ID)
_LOGGER.info("Clear history requested (session=%s)", session_id or "all")
# TODO: Implement when gateway exposes a session-clear endpoint
store = _get_chat_history_store(hass)
if session_id:
store.pop(session_id, None)
else:
store.clear()
hass.services.async_register(
DOMAIN,
@@ -433,3 +493,171 @@ def _extract_text_recursive(value: Any, depth: int = 0) -> str | None:
def _extract_assistant_message(response: dict[str, Any]) -> str | None:
"""Extract assistant text from modern/legacy OpenAI-compatible responses."""
return _extract_text_recursive(response)
def _extract_tool_calls(response: dict[str, Any]) -> list[dict[str, Any]]:
"""Extract OpenAI-style tool calls from a response payload."""
choices = response.get("choices")
if not isinstance(choices, list) or not choices:
return []
message = choices[0].get("message") if isinstance(choices[0], dict) else None
if not isinstance(message, dict):
return []
tool_calls = message.get("tool_calls")
if not isinstance(tool_calls, list):
return []
valid_calls: list[dict[str, Any]] = []
for call in tool_calls:
if isinstance(call, dict):
valid_calls.append(call)
return valid_calls
async def _async_execute_tool_calls(
hass: HomeAssistant,
response: dict[str, Any],
) -> list[str]:
"""Execute supported tool calls and return result lines."""
results: list[str] = []
tool_calls = _extract_tool_calls(response)
for call in tool_calls:
function_data = call.get("function")
if not isinstance(function_data, dict):
continue
function_name = function_data.get("name")
arguments = function_data.get("arguments")
if function_name not in {"execute_service", "execute_services"}:
results.append(f"Skipped unsupported tool '{function_name}'")
continue
if not isinstance(arguments, str):
results.append("Skipped tool call with invalid arguments format")
continue
try:
parsed = json.loads(arguments)
except json.JSONDecodeError:
results.append("Skipped tool call due to invalid JSON arguments")
continue
services_list = parsed.get("list") if isinstance(parsed, dict) else None
if not isinstance(services_list, list):
results.append("Skipped tool call without 'list' payload")
continue
for item in services_list:
if not isinstance(item, dict):
continue
domain = item.get("domain")
service = item.get("service")
service_data = item.get("service_data", {})
if not isinstance(domain, str) or not isinstance(service, str):
results.append("Skipped invalid service item (missing domain/service)")
continue
if not isinstance(service_data, dict):
service_data = {}
try:
await hass.services.async_call(
domain,
service,
service_data,
blocking=True,
)
results.append(f"Executed {domain}.{service}")
except Exception as err: # noqa: BLE001
results.append(f"Failed {domain}.{service}: {err}")
return results
def _get_chat_history_store(hass: HomeAssistant) -> dict[str, list[dict[str, str]]]:
"""Return in-memory per-session chat history store."""
store_key = f"{DOMAIN}_chat_history"
store = hass.data.get(store_key)
if store is None:
store = {}
hass.data[store_key] = store
return store
def _append_chat_history(hass: HomeAssistant, session_id: str, role: str, content: str) -> None:
"""Append a message to in-memory chat history."""
store = _get_chat_history_store(hass)
history = store.setdefault(session_id, [])
history.append(
{
"role": role,
"content": content,
"timestamp": datetime.now(timezone.utc).isoformat(),
}
)
if len(history) > _MAX_CHAT_HISTORY:
del history[:-_MAX_CHAT_HISTORY]
@callback
def _async_register_websocket_api(hass: HomeAssistant) -> None:
"""Register websocket API for chat history retrieval."""
key = f"{DOMAIN}_ws_registered"
if hass.data.get(key):
return
hass.data[key] = True
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/get_history",
vol.Optional("session_id"): cv.string,
}
)
@callback
def websocket_get_history(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Return chat history for a session."""
session_id = msg.get("session_id") or "default"
history = _get_chat_history_store(hass).get(session_id, [])
connection.send_result(msg["id"], {"session_id": session_id, "messages": history})
websocket_api.async_register_command(hass, websocket_get_history)
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/get_settings",
}
)
@callback
def websocket_get_settings(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Return frontend-related integration settings."""
entry_data = _get_first_entry_data(hass)
options = entry_data.get("entry").options if entry_data and entry_data.get("entry") else {}
connection.send_result(
msg["id"],
{
CONF_WAKE_WORD_ENABLED: options.get(
CONF_WAKE_WORD_ENABLED,
DEFAULT_WAKE_WORD_ENABLED,
),
CONF_WAKE_WORD: options.get(CONF_WAKE_WORD, DEFAULT_WAKE_WORD),
CONF_ALWAYS_VOICE_MODE: options.get(
CONF_ALWAYS_VOICE_MODE,
DEFAULT_ALWAYS_VOICE_MODE,
),
},
)
websocket_api.async_register_command(hass, websocket_get_settings)
+95 -1
View File
@@ -14,7 +14,7 @@ from typing import Any
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -28,8 +28,24 @@ from .const import (
CONF_GATEWAY_PORT,
CONF_GATEWAY_TOKEN,
CONF_USE_SSL,
CONF_CONTEXT_MAX_CHARS,
CONF_CONTEXT_STRATEGY,
CONF_ENABLE_TOOL_CALLS,
CONF_INCLUDE_EXPOSED_CONTEXT,
CONF_WAKE_WORD,
CONF_WAKE_WORD_ENABLED,
CONF_ALWAYS_VOICE_MODE,
CONTEXT_STRATEGY_CLEAR,
CONTEXT_STRATEGY_TRUNCATE,
DEFAULT_GATEWAY_HOST,
DEFAULT_GATEWAY_PORT,
DEFAULT_CONTEXT_MAX_CHARS,
DEFAULT_CONTEXT_STRATEGY,
DEFAULT_ENABLE_TOOL_CALLS,
DEFAULT_INCLUDE_EXPOSED_CONTEXT,
DEFAULT_WAKE_WORD,
DEFAULT_WAKE_WORD_ENABLED,
DEFAULT_ALWAYS_VOICE_MODE,
DOMAIN,
OPENCLAW_CONFIG_REL_PATH,
)
@@ -237,6 +253,11 @@ class OpenClawConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
@staticmethod
def async_get_options_flow(config_entry: ConfigEntry) -> OpenClawOptionsFlow:
"""Get options flow for this handler."""
return OpenClawOptionsFlow(config_entry)
def __init__(self) -> None:
"""Initialize the config flow."""
self._discovered: dict[str, Any] | None = None
@@ -364,3 +385,76 @@ class OpenClawConfigFlow(ConfigFlow, domain=DOMAIN):
),
errors=errors,
)
class OpenClawOptionsFlow(OptionsFlow):
"""Handle OpenClaw options."""
def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize options flow."""
self._config_entry = config_entry
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Manage integration options."""
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
options = self._config_entry.options
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(
{
vol.Optional(
CONF_INCLUDE_EXPOSED_CONTEXT,
default=options.get(
CONF_INCLUDE_EXPOSED_CONTEXT,
DEFAULT_INCLUDE_EXPOSED_CONTEXT,
),
): bool,
vol.Optional(
CONF_CONTEXT_MAX_CHARS,
default=options.get(
CONF_CONTEXT_MAX_CHARS,
DEFAULT_CONTEXT_MAX_CHARS,
),
): vol.All(int, vol.Range(min=1000, max=200000)),
vol.Optional(
CONF_CONTEXT_STRATEGY,
default=options.get(
CONF_CONTEXT_STRATEGY,
DEFAULT_CONTEXT_STRATEGY,
),
): vol.In([CONTEXT_STRATEGY_TRUNCATE, CONTEXT_STRATEGY_CLEAR]),
vol.Optional(
CONF_ENABLE_TOOL_CALLS,
default=options.get(
CONF_ENABLE_TOOL_CALLS,
DEFAULT_ENABLE_TOOL_CALLS,
),
): bool,
vol.Optional(
CONF_WAKE_WORD_ENABLED,
default=options.get(
CONF_WAKE_WORD_ENABLED,
DEFAULT_WAKE_WORD_ENABLED,
),
): bool,
vol.Optional(
CONF_WAKE_WORD,
default=options.get(
CONF_WAKE_WORD,
DEFAULT_WAKE_WORD,
),
): str,
vol.Optional(
CONF_ALWAYS_VOICE_MODE,
default=options.get(
CONF_ALWAYS_VOICE_MODE,
DEFAULT_ALWAYS_VOICE_MODE,
),
): bool,
}
),
)
+20
View File
@@ -23,6 +23,26 @@ CONF_GATEWAY_TOKEN = "gateway_token"
CONF_USE_SSL = "use_ssl"
CONF_ADDON_CONFIG_PATH = "addon_config_path"
# Options
CONF_INCLUDE_EXPOSED_CONTEXT = "include_exposed_context"
CONF_CONTEXT_MAX_CHARS = "context_max_chars"
CONF_CONTEXT_STRATEGY = "context_strategy"
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"
DEFAULT_INCLUDE_EXPOSED_CONTEXT = True
DEFAULT_CONTEXT_MAX_CHARS = 13000
DEFAULT_CONTEXT_STRATEGY = "truncate"
DEFAULT_ENABLE_TOOL_CALLS = False
DEFAULT_WAKE_WORD_ENABLED = False
DEFAULT_WAKE_WORD = "hey openclaw"
DEFAULT_ALWAYS_VOICE_MODE = False
CONTEXT_STRATEGY_TRUNCATE = "truncate"
CONTEXT_STRATEGY_CLEAR = "clear"
# Coordinator data keys
DATA_STATUS = "status"
DATA_MODEL = "model"
+24 -5
View File
@@ -21,12 +21,18 @@ from .const import (
ATTR_MODEL,
ATTR_SESSION_ID,
ATTR_TIMESTAMP,
CONF_CONTEXT_MAX_CHARS,
CONF_CONTEXT_STRATEGY,
CONF_INCLUDE_EXPOSED_CONTEXT,
DEFAULT_CONTEXT_MAX_CHARS,
DEFAULT_CONTEXT_STRATEGY,
DEFAULT_INCLUDE_EXPOSED_CONTEXT,
DATA_MODEL,
DOMAIN,
EVENT_MESSAGE_RECEIVED,
)
from .coordinator import OpenClawCoordinator
from .exposure import build_exposed_entities_context
from .exposure import apply_context_policy, build_exposed_entities_context
_LOGGER = logging.getLogger(__name__)
@@ -101,11 +107,24 @@ class OpenClawConversationAgent(conversation.AbstractConversationAgent):
message = user_input.text
conversation_id = user_input.conversation_id or "default"
assistant_id = getattr(user_input, "assistant", None) or "assist"
exposed_context = build_exposed_entities_context(
self.hass,
assistant=assistant_id,
assistant_id = "conversation"
options = self.entry.options
include_context = options.get(
CONF_INCLUDE_EXPOSED_CONTEXT,
DEFAULT_INCLUDE_EXPOSED_CONTEXT,
)
max_chars = int(options.get(CONF_CONTEXT_MAX_CHARS, DEFAULT_CONTEXT_MAX_CHARS))
strategy = options.get(CONF_CONTEXT_STRATEGY, DEFAULT_CONTEXT_STRATEGY)
raw_context = (
build_exposed_entities_context(
self.hass,
assistant=assistant_id,
)
if include_context
else None
)
exposed_context = apply_context_policy(raw_context, max_chars, strategy)
extra_system_prompt = getattr(user_input, "extra_system_prompt", None)
system_prompt = "\n\n".join(
part for part in (exposed_context, extra_system_prompt) if part
+38 -7
View File
@@ -10,20 +10,26 @@ from homeassistant.core import HomeAssistant
def build_exposed_entities_context(
hass: HomeAssistant,
assistant: str | None = "assist",
assistant: str | None = "conversation",
max_entities: int = 250,
) -> str | None:
"""Build a compact prompt block of entities exposed to an assistant.
Uses Home Assistant's built-in expose rules (Settings -> Voice assistants -> Expose).
"""
assistant_id = assistant or "assist"
assistant_id = assistant or "conversation"
exposed_states = [
state
for state in hass.states.async_all()
if async_should_expose(hass, assistant_id, state.entity_id)
]
def _collect_for(assistant_value: str) -> list:
return [
state
for state in hass.states.async_all()
if async_should_expose(hass, assistant_value, state.entity_id)
]
exposed_states = _collect_for(assistant_id)
if not exposed_states and assistant_id != "conversation":
exposed_states = _collect_for("conversation")
if not exposed_states:
return None
@@ -55,3 +61,28 @@ def build_exposed_entities_context(
)
return "\n".join(lines)
def apply_context_policy(
context_text: str | None,
max_chars: int,
strategy: str,
) -> str | None:
"""Apply context truncation policy to an optional prompt block."""
if not context_text:
return None
if max_chars <= 0:
return None
if len(context_text) <= max_chars:
return context_text
if strategy == "clear":
return None
marker = "\n[Context truncated to fit configured max length]\n"
available = max_chars - len(marker)
if available <= 0:
return context_text[-max_chars:]
return marker + context_text[-available:]
+1 -1
View File
@@ -8,7 +8,7 @@
"iot_class": "local_polling",
"issue_tracker": "https://github.com/techartdev/OpenClawHomeAssistant/issues",
"requirements": [],
"version": "0.1.13",
"version": "0.1.16",
"dependencies": ["conversation"],
"after_dependencies": ["hassio", "lovelace"]
}
+17
View File
@@ -31,6 +31,23 @@
"already_configured": "OpenClaw is already configured."
}
},
"options": {
"step": {
"init": {
"title": "OpenClaw Options",
"description": "Configure context and tool-calling behavior.",
"data": {
"include_exposed_context": "Include exposed entities context",
"context_max_chars": "Max context characters",
"context_strategy": "When context exceeds max",
"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"
}
}
}
},
"entity": {
"sensor": {
"status": {
@@ -31,6 +31,23 @@
"already_configured": "OpenClaw is already configured."
}
},
"options": {
"step": {
"init": {
"title": "OpenClaw Options",
"description": "Configure context and tool-calling behavior.",
"data": {
"include_exposed_context": "Include exposed entities context",
"context_max_chars": "Max context characters",
"context_strategy": "When context exceeds max",
"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"
}
}
}
},
"entity": {
"sensor": {
"status": {
@@ -59,6 +59,9 @@ class OpenClawChatCard extends HTMLElement {
this._recognition = null;
this._eventUnsubscribe = null;
this._thinkingTimer = null;
this._wakeWordEnabled = false;
this._wakeWord = "hey openclaw";
this._alwaysVoiceMode = false;
}
// ── HA card interface ───────────────────────────────────────────────
@@ -91,6 +94,8 @@ class OpenClawChatCard extends HTMLElement {
this._hass = hass;
if (firstSet) {
this._subscribeToEvents();
this._syncHistoryFromBackend();
this._loadIntegrationSettings();
this._render();
}
}
@@ -100,6 +105,8 @@ class OpenClawChatCard extends HTMLElement {
}
connectedCallback() {
this._syncHistoryFromBackend();
this._loadIntegrationSettings();
this._render();
}
@@ -136,7 +143,7 @@ class OpenClawChatCard extends HTMLElement {
if (!data || !data.message) return;
// Check if this event is for our session
const sessionId = this._config.session_id || "default";
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
@@ -179,10 +186,14 @@ class OpenClawChatCard extends HTMLElement {
// ── Message persistence ──────────────────────────────────────────────
_getStorageKey() {
const session = this._config.session_id || "default";
const session = this._getSessionId();
return `${STORAGE_PREFIX}${session}`;
}
_getSessionId() {
return this._config.session_id || "default";
}
_persistMessages() {
try {
const toSave = this._messages.filter((m) => !m._thinking);
@@ -206,6 +217,73 @@ class OpenClawChatCard extends HTMLElement {
}
}
async _syncHistoryFromBackend() {
if (!this._hass) return;
const sessionId = this._getSessionId();
try {
let result;
if (typeof this._hass.callWS === "function") {
result = await this._hass.callWS({
type: "openclaw/get_history",
session_id: sessionId,
});
} else {
result = await this._hass.connection.sendMessagePromise({
type: "openclaw/get_history",
session_id: sessionId,
});
}
const serverMessages = Array.isArray(result?.messages) ? result.messages : [];
if (!serverMessages.length) return;
const validMessages = serverMessages.filter(
(m) => m && (m.role === "user" || m.role === "assistant") && typeof m.content === "string"
);
if (!validMessages.length) return;
if (validMessages.length > this._messages.length) {
this._messages = validMessages;
this._isProcessing = false;
this._clearThinkingTimer();
this._persistMessages();
this._render();
this._scrollToBottom();
}
} catch (err) {
console.debug("OpenClaw: history sync skipped:", err);
}
}
async _loadIntegrationSettings() {
if (!this._hass) return;
try {
let result;
if (typeof this._hass.callWS === "function") {
result = await this._hass.callWS({ type: "openclaw/get_settings" });
} else {
result = await this._hass.connection.sendMessagePromise({
type: "openclaw/get_settings",
});
}
this._wakeWordEnabled = !!result?.wake_word_enabled;
this._wakeWord = (result?.wake_word || "hey openclaw").toString().trim().toLowerCase();
this._alwaysVoiceMode = !!result?.always_voice_mode;
if (this._alwaysVoiceMode && !this._isVoiceMode) {
this._isVoiceMode = true;
this._startVoiceRecognition();
}
this._render();
} catch (err) {
console.debug("OpenClaw: settings sync skipped:", err);
}
}
// ── Thinking timer ──────────────────────────────────────────────────
_startThinkingTimer() {
@@ -302,7 +380,26 @@ class OpenClawChatCard extends HTMLElement {
this._recognition.onresult = (event) => {
const result = event.results[event.results.length - 1];
if (result.isFinal) {
const text = result[0].transcript;
const text = result[0].transcript?.trim();
if (!text) return;
if (this._wakeWordEnabled) {
const wake = this._wakeWord || "hey openclaw";
const lower = text.toLowerCase();
const wakePos = lower.indexOf(wake);
if (wakePos < 0) {
return;
}
let command = text.slice(wakePos + wake.length).trim();
command = command.replace(/^[,:;.!?\-]+\s*/, "");
if (!command) {
return;
}
this._sendMessage(command);
return;
}
this._sendMessage(text);
}
};