diff --git a/custom_components/openclaw/__init__.py b/custom_components/openclaw/__init__.py index 99e5e46..d1e2db7 100644 --- a/custom_components/openclaw/__init__.py +++ b/custom_components/openclaw/__init__.py @@ -47,9 +47,9 @@ from .const import ( ATTR_ACCOUNT_ID, ATTR_SOURCE, ATTR_TIMESTAMP, + ATTR_HA_USER_ID, CONF_ADDON_CONFIG_PATH, CONF_AGENT_ID, - CONF_VOICE_AGENT_ID, CONF_GATEWAY_HOST, CONF_GATEWAY_PORT, CONF_GATEWAY_TOKEN, @@ -67,7 +67,6 @@ from .const import ( CONF_THINKING_TIMEOUT, CONTEXT_STRATEGY_TRUNCATE, DEFAULT_AGENT_ID, - DEFAULT_VOICE_AGENT_ID, DEFAULT_CONTEXT_MAX_CHARS, DEFAULT_CONTEXT_STRATEGY, DEFAULT_ENABLE_TOOL_CALLS, @@ -90,6 +89,13 @@ from .const import ( from .coordinator import OpenClawCoordinator from .exposure import apply_context_policy, build_exposed_entities_context from .helpers import extract_text_recursive +from .routing import ( + build_scoped_session_id, + normalize_ha_user_id, + normalize_optional_text, + resolve_agent_id, + resolve_model_override, +) _LOGGER = logging.getLogger(__name__) @@ -107,7 +113,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.62" +_CARD_URL = f"{_CARD_STATIC_URL}?v=0.1.63" OpenClawConfigEntry = ConfigEntry @@ -125,6 +131,7 @@ SEND_MESSAGE_SCHEMA = vol.Schema( CLEAR_HISTORY_SCHEMA = vol.Schema( { vol.Optional(ATTR_SESSION_ID): cv.string, + vol.Optional(ATTR_AGENT_ID): cv.string, } ) @@ -399,18 +406,13 @@ async def _async_add_lovelace_resource(hass: HomeAssistant, url: str) -> bool: def _async_register_services(hass: HomeAssistant) -> None: """Register openclaw.send_message and openclaw.clear_history services.""" - def _normalize_optional_text(value: Any) -> str | None: - if not isinstance(value, str): - return None - cleaned = value.strip() - return cleaned or None - async def handle_send_message(call: ServiceCall) -> None: """Handle the openclaw.send_message service call.""" message: str = call.data[ATTR_MESSAGE] source: str | None = call.data.get(ATTR_SOURCE) - session_id: str = call.data.get(ATTR_SESSION_ID) or "default" - call_agent_id = _normalize_optional_text(call.data.get(ATTR_AGENT_ID)) + raw_session_id: str = call.data.get(ATTR_SESSION_ID) or "default" + ha_user_id = normalize_ha_user_id(call.context.user_id) + call_agent_id = normalize_optional_text(call.data.get(ATTR_AGENT_ID)) extra_headers = _VOICE_REQUEST_HEADERS if source == "voice" else None entry_data = _get_first_entry_data(hass) @@ -421,12 +423,12 @@ def _async_register_services(hass: HomeAssistant) -> None: client: OpenClawApiClient = entry_data["client"] coordinator: OpenClawCoordinator = entry_data["coordinator"] options = _get_entry_options(hass, entry_data) - voice_agent_id = _normalize_optional_text( - options.get(CONF_VOICE_AGENT_ID, DEFAULT_VOICE_AGENT_ID) + resolved_agent_id = resolve_agent_id(ha_user_id, call_agent_id) + scoped_session_id = build_scoped_session_id( + raw_session_id, + ha_user_id, + resolved_agent_id, ) - resolved_agent_id = call_agent_id - if resolved_agent_id is None and source == "voice": - resolved_agent_id = voice_agent_id try: include_context = options.get( @@ -447,13 +449,13 @@ def _async_register_services(hass: HomeAssistant) -> None: ) system_prompt = apply_context_policy(raw_context, max_chars, strategy) - active_model = _normalize_optional_text(options.get("active_model")) + active_model = resolve_model_override(options.get("active_model")) - _append_chat_history(hass, session_id, "user", message) + _append_chat_history(hass, scoped_session_id, "user", message) response = await client.async_send_message( message=message, - session_id=session_id, + session_id=scoped_session_id, system_prompt=system_prompt, agent_id=resolved_agent_id, model=active_model, @@ -469,7 +471,7 @@ def _async_register_services(hass: HomeAssistant) -> None: + "\n".join(f"- {line}" for line in tool_results) + "\nRespond to the user based on these results." ), - session_id=session_id, + session_id=scoped_session_id, system_prompt=system_prompt, agent_id=resolved_agent_id, model=active_model, @@ -486,12 +488,15 @@ def _async_register_services(hass: HomeAssistant) -> None: list(response.keys()), ) - _append_chat_history(hass, session_id, "assistant", assistant_message) + _append_chat_history(hass, scoped_session_id, "assistant", assistant_message) hass.bus.async_fire( EVENT_MESSAGE_RECEIVED, { ATTR_MESSAGE: assistant_message, - ATTR_SESSION_ID: session_id, + ATTR_SESSION_ID: raw_session_id, + ATTR_SESSION_KEY: scoped_session_id, + ATTR_HA_USER_ID: ha_user_id, + ATTR_AGENT_ID: resolved_agent_id, ATTR_MODEL: model_used, ATTR_TIMESTAMP: datetime.now(timezone.utc).isoformat(), }, @@ -500,12 +505,20 @@ 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}") + _append_chat_history( + hass, + scoped_session_id, + "assistant", + f"OpenClaw error: {err}", + ) hass.bus.async_fire( EVENT_MESSAGE_RECEIVED, { ATTR_MESSAGE: f"OpenClaw error: {err}", - ATTR_SESSION_ID: session_id, + ATTR_SESSION_ID: raw_session_id, + ATTR_SESSION_KEY: scoped_session_id, + ATTR_HA_USER_ID: ha_user_id, + ATTR_AGENT_ID: resolved_agent_id, ATTR_MODEL: "unknown", ATTR_TIMESTAMP: datetime.now(timezone.utc).isoformat(), }, @@ -513,13 +526,32 @@ def _async_register_services(hass: HomeAssistant) -> None: async def handle_clear_history(call: ServiceCall) -> 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") + raw_session_id: str | None = call.data.get(ATTR_SESSION_ID) + ha_user_id = normalize_ha_user_id(call.context.user_id) + requested_agent_id = normalize_optional_text(call.data.get(ATTR_AGENT_ID)) + agent_id = resolve_agent_id(ha_user_id, requested_agent_id) + _LOGGER.info( + "Clear history requested (user=%s, agent=%s, session=%s)", + ha_user_id, + agent_id, + raw_session_id or "all", + ) store = _get_chat_history_store(hass) - if session_id: - store.pop(session_id, None) + if raw_session_id: + scoped_session_id = build_scoped_session_id( + raw_session_id, + ha_user_id, + agent_id, + ) + store.pop(scoped_session_id, None) else: - store.clear() + scoped_prefix = build_scoped_session_id("", ha_user_id, agent_id).rsplit( + "__session_", + 1, + )[0] + "__session_" + for key in list(store): + if key.startswith(scoped_prefix): + store.pop(key, None) async def handle_invoke_tool(call: ServiceCall) -> None: """Handle the openclaw.invoke_tool service call.""" @@ -793,9 +825,26 @@ def _async_register_websocket_api(hass: HomeAssistant) -> None: 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}) + raw_session_id = msg.get("session_id") or "default" + connection_user = getattr(connection, "user", None) + ha_user_id = normalize_ha_user_id(getattr(connection_user, "id", None)) + agent_id = resolve_agent_id(ha_user_id) + scoped_session_id = build_scoped_session_id( + raw_session_id, + ha_user_id, + agent_id, + ) + history = _get_chat_history_store(hass).get(scoped_session_id, []) + connection.send_result( + msg["id"], + { + "session_id": raw_session_id, + ATTR_SESSION_KEY: scoped_session_id, + ATTR_HA_USER_ID: ha_user_id, + ATTR_AGENT_ID: agent_id, + "messages": history, + }, + ) websocket_api.async_register_command(hass, websocket_get_history) @@ -813,6 +862,9 @@ def _async_register_websocket_api(hass: HomeAssistant) -> None: """Return frontend-related integration settings.""" entry_data = _get_first_entry_data(hass) options = _get_entry_options(hass, entry_data) if entry_data else {} + connection_user = getattr(connection, "user", None) + ha_user_id = normalize_ha_user_id(getattr(connection_user, "id", None)) + agent_id = resolve_agent_id(ha_user_id) connection.send_result( msg["id"], { @@ -837,6 +889,8 @@ def _async_register_websocket_api(hass: HomeAssistant) -> None: CONF_THINKING_TIMEOUT, DEFAULT_THINKING_TIMEOUT, ), + ATTR_HA_USER_ID: ha_user_id, + ATTR_AGENT_ID: agent_id, "language": hass.config.language, }, ) diff --git a/custom_components/openclaw/const.py b/custom_components/openclaw/const.py index b96a69c..adbe079 100644 --- a/custom_components/openclaw/const.py +++ b/custom_components/openclaw/const.py @@ -138,6 +138,7 @@ ATTR_DRY_RUN = "dry_run" ATTR_MESSAGE_CHANNEL = "message_channel" ATTR_ACCOUNT_ID = "account_id" ATTR_AGENT_ID = "agent_id" +ATTR_HA_USER_ID = "ha_user_id" ATTR_OK = "ok" ATTR_RESULT = "result" ATTR_ERROR = "error" diff --git a/custom_components/openclaw/conversation.py b/custom_components/openclaw/conversation.py index 633a7d1..8eef923 100644 --- a/custom_components/openclaw/conversation.py +++ b/custom_components/openclaw/conversation.py @@ -19,18 +19,18 @@ from homeassistant.helpers import intent from .api import OpenClawApiClient, OpenClawApiError from .const import ( + ATTR_AGENT_ID, + ATTR_HA_USER_ID, ATTR_MESSAGE, ATTR_MODEL, ATTR_SESSION_ID, + ATTR_SESSION_KEY, ATTR_TIMESTAMP, CONF_ASSIST_SESSION_ID, - CONF_AGENT_ID, CONF_CONTEXT_MAX_CHARS, CONF_CONTEXT_STRATEGY, CONF_INCLUDE_EXPOSED_CONTEXT, - CONF_VOICE_AGENT_ID, DEFAULT_ASSIST_SESSION_ID, - DEFAULT_AGENT_ID, DEFAULT_CONTEXT_MAX_CHARS, DEFAULT_CONTEXT_STRATEGY, DEFAULT_INCLUDE_EXPOSED_CONTEXT, @@ -41,6 +41,7 @@ from .const import ( from .coordinator import OpenClawCoordinator from .exposure import apply_context_policy, build_exposed_entities_context from .helpers import extract_text_recursive +from .routing import normalize_ha_user_id, resolve_agent_id, resolve_model_override _LOGGER = logging.getLogger(__name__) @@ -121,19 +122,12 @@ class OpenClawConversationAgent(conversation.AbstractConversationAgent): message = user_input.text assistant_id = "conversation" + context = getattr(user_input, "context", None) + ha_user_id = normalize_ha_user_id(getattr(context, "user_id", None)) options = self.entry.options - voice_agent_id = self._normalize_optional_text( - options.get(CONF_VOICE_AGENT_ID) - ) - configured_agent_id = self._normalize_optional_text( - options.get( - CONF_AGENT_ID, - self.entry.data.get(CONF_AGENT_ID, DEFAULT_AGENT_ID), - ) - ) - resolved_agent_id = voice_agent_id or configured_agent_id + resolved_agent_id = resolve_agent_id(ha_user_id) conversation_id = self._resolve_conversation_id(user_input, resolved_agent_id) - active_model = self._normalize_optional_text(options.get("active_model")) + active_model = resolve_model_override(options.get("active_model")) include_context = options.get( CONF_INCLUDE_EXPOSED_CONTEXT, DEFAULT_INCLUDE_EXPOSED_CONTEXT, @@ -203,6 +197,9 @@ class OpenClawConversationAgent(conversation.AbstractConversationAgent): { ATTR_MESSAGE: full_response, ATTR_SESSION_ID: conversation_id, + ATTR_SESSION_KEY: conversation_id, + ATTR_HA_USER_ID: ha_user_id, + ATTR_AGENT_ID: resolved_agent_id, ATTR_MODEL: coordinator.data.get(DATA_MODEL) if coordinator.data else None, ATTR_TIMESTAMP: datetime.now(timezone.utc).isoformat(), }, diff --git a/custom_components/openclaw/manifest.json b/custom_components/openclaw/manifest.json index 7f6650d..e5ba780 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/OpenClawHomeAssistantIntegration/issues", "requirements": [], - "version": "0.1.62", + "version": "0.1.63", "dependencies": ["conversation"], "after_dependencies": ["hassio", "lovelace"] } diff --git a/custom_components/openclaw/routing.py b/custom_components/openclaw/routing.py new file mode 100644 index 0000000..9f16488 --- /dev/null +++ b/custom_components/openclaw/routing.py @@ -0,0 +1,71 @@ +"""Routing helpers for Home Assistant user-aware OpenClaw chat sessions.""" + +from __future__ import annotations + +import re +from typing import Any + +DEFAULT_CHAT_AGENT_ID = "tetra-home" +SYSTEM_HA_USER_ID = "system" + +HA_USER_AGENT_MAP: dict[str, str] = { + "bd3f666f43f941e886ef007a794eefe8": "main", + "1d2be9a622584cf0abb1c243d12a9959": "family-krzysztof", + "eca5887898544556840878ab37a35fbc": "family-ewa", + "2f5bf9f09478400082380bf7f26966d9": "family-tosia", + "f613d90efe8b4afea63c4a5a7ef7b559": "family-domi", +} + + +def normalize_optional_text(value: Any) -> str | None: + """Return a stripped string, or None for blank/non-string values.""" + if not isinstance(value, str): + return None + cleaned = value.strip() + return cleaned or None + + +def normalize_ha_user_id(value: Any) -> str: + """Return a stable HA user id for routing/history scope.""" + return normalize_optional_text(value) or SYSTEM_HA_USER_ID + + +def resolve_agent_id( + ha_user_id: str | None, + explicit_agent_id: str | None = None, +) -> str: + """Resolve the OpenClaw agent for a Home Assistant user.""" + if explicit_agent_id: + return explicit_agent_id + normalized_user_id = normalize_ha_user_id(ha_user_id) + return HA_USER_AGENT_MAP.get(normalized_user_id, DEFAULT_CHAT_AGENT_ID) + + +def resolve_model_override(value: Any) -> str | None: + """Return a provider model override, excluding OpenClaw pseudo-model ids.""" + model = normalize_optional_text(value) + if model and model.startswith("openclaw/"): + return None + return model + + +def build_scoped_session_id( + raw_session_id: str | None, + ha_user_id: str | None, + agent_id: str | None, +) -> str: + """Build the backend/gateway session id scoped by user, agent, and raw session.""" + raw_session = normalize_optional_text(raw_session_id) or "default" + user = normalize_ha_user_id(ha_user_id) + agent = normalize_optional_text(agent_id) or DEFAULT_CHAT_AGENT_ID + return ( + f"ha_user_{_sanitize_session_part(user)}" + f"__agent_{_sanitize_session_part(agent)}" + f"__session_{_sanitize_session_part(raw_session)}" + ) + + +def _sanitize_session_part(value: str) -> str: + """Keep scoped session ids header-safe and readable.""" + cleaned = re.sub(r"[^A-Za-z0-9_.-]+", "_", value.strip()) + return cleaned.strip("_") or "default" diff --git a/custom_components/openclaw/select.py b/custom_components/openclaw/select.py index b330a92..899e42d 100644 --- a/custom_components/openclaw/select.py +++ b/custom_components/openclaw/select.py @@ -72,7 +72,8 @@ class OpenClawModelSelect(CoordinatorEntity[OpenClawCoordinator], SelectEntity): # Initialise from coordinator cache models = coordinator.available_models self._attr_options = models if models else ["unknown"] - current = (coordinator.data or {}).get(DATA_MODEL) + selected = entry.options.get(description.key) + current = selected or (coordinator.data or {}).get(DATA_MODEL) self._attr_current_option = current if current in self._attr_options else ( self._attr_options[0] if self._attr_options else None ) @@ -83,7 +84,8 @@ class OpenClawModelSelect(CoordinatorEntity[OpenClawCoordinator], SelectEntity): models = self.coordinator.available_models if models: self._attr_options = models - current = (self.coordinator.data or {}).get(DATA_MODEL) + selected = self._entry.options.get(self.entity_description.key) + current = selected or (self.coordinator.data or {}).get(DATA_MODEL) if current and current in self._attr_options: self._attr_current_option = current self.async_write_ha_state() diff --git a/custom_components/openclaw/sensor.py b/custom_components/openclaw/sensor.py index 4cae0bf..83d6b8b 100644 --- a/custom_components/openclaw/sensor.py +++ b/custom_components/openclaw/sensor.py @@ -120,6 +120,7 @@ class OpenClawSensor(CoordinatorEntity[OpenClawCoordinator], SensorEntity): """Initialize the sensor.""" super().__init__(coordinator) self.entity_description = description + self._entry = entry self._attr_unique_id = f"{entry.entry_id}_{description.key}" self._attr_device_info = { "identifiers": {(DOMAIN, entry.entry_id)}, @@ -134,6 +135,10 @@ class OpenClawSensor(CoordinatorEntity[OpenClawCoordinator], SensorEntity): """Return the state of the sensor.""" if not self.coordinator.data: return None + if self.entity_description.key == DATA_MODEL: + selected = self._entry.options.get("active_model") + if selected: + return selected return self.coordinator.data.get(self.entity_description.key) @property diff --git a/custom_components/openclaw/services.yaml b/custom_components/openclaw/services.yaml index 9b9f255..52849ed 100644 --- a/custom_components/openclaw/services.yaml +++ b/custom_components/openclaw/services.yaml @@ -20,8 +20,8 @@ send_message: agent_id: name: Agent ID description: > - Optional OpenClaw agent ID to route this message to. Overrides the - agent ID configured in the integration options. Defaults to "main". + Optional OpenClaw agent ID to route this message to. When omitted, + the integration routes by the Home Assistant user who called the service. required: false example: "main" selector: @@ -29,11 +29,17 @@ send_message: clear_history: name: Clear History - description: Clear the conversation history for a session. + description: Clear scoped conversation history for the current Home Assistant user. fields: session_id: name: Session ID - description: Session ID to clear. If omitted, clears the default session. + description: Session ID to clear. If omitted, clears this user's sessions for the resolved agent. + required: false + selector: + text: + agent_id: + name: Agent ID + description: Optional OpenClaw agent ID override for the history scope. required: false selector: text: diff --git a/custom_components/openclaw/strings.json b/custom_components/openclaw/strings.json index 8246532..32fc8cb 100644 --- a/custom_components/openclaw/strings.json +++ b/custom_components/openclaw/strings.json @@ -127,17 +127,21 @@ }, "agent_id": { "name": "Agent ID", - "description": "Optional OpenClaw agent ID to route this message to. Overrides the configured default. Defaults to \"main\"." + "description": "Optional OpenClaw agent ID to route this message to. When omitted, OpenClaw routes by the Home Assistant user who called the service." } } }, "clear_history": { "name": "Clear History", - "description": "Clear conversation history.", + "description": "Clear scoped conversation history for the current Home Assistant user.", "fields": { "session_id": { "name": "Session ID", - "description": "Session to clear." + "description": "Session to clear. If omitted, clears this user's sessions for the resolved agent." + }, + "agent_id": { + "name": "Agent ID", + "description": "Optional OpenClaw agent ID override for the history scope." } } }, diff --git a/custom_components/openclaw/translations/en.json b/custom_components/openclaw/translations/en.json index fc8df9d..6690599 100644 --- a/custom_components/openclaw/translations/en.json +++ b/custom_components/openclaw/translations/en.json @@ -129,17 +129,21 @@ }, "agent_id": { "name": "Agent ID", - "description": "Optional OpenClaw agent ID to route this message to. Overrides the configured default. Defaults to \"main\"." + "description": "Optional OpenClaw agent ID to route this message to. When omitted, OpenClaw routes by the Home Assistant user who called the service." } } }, "clear_history": { "name": "Clear History", - "description": "Clear conversation history.", + "description": "Clear scoped conversation history for the current Home Assistant user.", "fields": { "session_id": { "name": "Session ID", - "description": "Session to clear." + "description": "Session to clear. If omitted, clears this user's sessions for the resolved agent." + }, + "agent_id": { + "name": "Agent ID", + "description": "Optional OpenClaw agent ID override for the history scope." } } }, diff --git a/custom_components/openclaw/www/openclaw-chat-card.js b/custom_components/openclaw/www/openclaw-chat-card.js index a9ab1c4..664d4f0 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.3.13"; +const CARD_VERSION = "0.3.14"; // Max time (ms) to show the thinking indicator before falling back to an error (default; overridable via card config `thinking_timeout` in seconds) const THINKING_TIMEOUT_MS = 120_000; @@ -96,6 +96,9 @@ class OpenClawChatCard extends HTMLElement { this._autoScrollPinned = true; this._lastHassRenderSignature = null; this._integrationThinkingTimeout = null; + this._backendHaUserId = null; + this._activeAgentId = null; + this._lastStorageIdentity = null; } // ── HA card interface ─────────────────────────────────────────────── @@ -128,8 +131,15 @@ class OpenClawChatCard extends HTMLElement { set hass(hass) { const firstSet = !this._hass; + const previousStorageIdentity = this._getStorageIdentity(); this._hass = hass; const currentSignature = this._getHassRenderSignature(hass); + const currentStorageIdentity = this._getStorageIdentity(); + if (currentStorageIdentity !== previousStorageIdentity) { + this._lastStorageIdentity = currentStorageIdentity; + this._resetTransientChatState(); + this._restoreMessages(); + } if (firstSet) { this._subscribeToEvents(); this._syncHistoryFromBackend(); @@ -252,11 +262,19 @@ class OpenClawChatCard extends HTMLElement { const data = event.data; if (!data || !data.message) return; + const haUserId = this._getHaUserId(); + if (data.ha_user_id && haUserId === "unknown") { + this._syncHistoryFromBackend(1); + return; + } + if (data.ha_user_id && haUserId !== "unknown" && data.ha_user_id !== haUserId) return; + if (data.agent_id && this._activeAgentId && data.agent_id !== this._activeAgentId) return; + // Check if this event is for our session const sessionId = this._getSessionId(); if (data.session_id && data.session_id !== sessionId) return; - const signature = `${sessionId}|${data.timestamp || ""}|${String(data.message)}`; + const signature = `${haUserId}|${data.agent_id || ""}|${sessionId}|${data.timestamp || ""}|${String(data.message)}`; if (signature === this._lastAssistantEventSignature) { return; } @@ -295,7 +313,16 @@ class OpenClawChatCard extends HTMLElement { } } - _clearChat() { + async _clearChat() { + if (this._hass) { + try { + await this._hass.callService("openclaw", "clear_history", { + session_id: this._getSessionId(), + }); + } catch (err) { + console.warn("OpenClaw: backend history clear failed:", err); + } + } this._messages = []; this._isProcessing = false; this._pendingResponses = 0; @@ -304,11 +331,31 @@ class OpenClawChatCard extends HTMLElement { this._render(); } + _resetTransientChatState() { + this._isProcessing = false; + this._pendingResponses = 0; + this._clearThinkingTimer(); + this._lastAssistantEventSignature = null; + } + // ── Message persistence ────────────────────────────────────────────── + _getHaUserId() { + return ( + this._hass?.user?.id || + this._backendHaUserId || + "unknown" + ); + } + + _getStorageIdentity() { + return `${this._getHaUserId()}|${this._getSessionId()}`; + } + _getStorageKey() { + const userId = this._getHaUserId(); const session = this._getSessionId(); - return `${STORAGE_PREFIX}${session}`; + return `${STORAGE_PREFIX}${userId}_${session}`; } _getSessionId() { @@ -329,9 +376,8 @@ class OpenClawChatCard extends HTMLElement { _restoreMessages() { try { const stored = sessionStorage.getItem(this._getStorageKey()); - if (stored) { - this._messages = JSON.parse(stored); - } + const parsed = stored ? JSON.parse(stored) : []; + this._messages = Array.isArray(parsed) ? parsed : []; } catch (e) { // Corrupted data — start fresh this._messages = []; @@ -357,6 +403,18 @@ class OpenClawChatCard extends HTMLElement { }); } + const previousStorageIdentity = this._getStorageIdentity(); + if (result?.ha_user_id) { + this._backendHaUserId = result.ha_user_id; + } + if (result?.agent_id) { + this._activeAgentId = result.agent_id; + } + if (this._getStorageIdentity() !== previousStorageIdentity) { + this._resetTransientChatState(); + this._restoreMessages(); + } + const serverMessages = Array.isArray(result?.messages) ? result.messages : []; if (!serverMessages.length) return; @@ -426,6 +484,17 @@ class OpenClawChatCard extends HTMLElement { typeof result?.thinking_timeout === "number" && result.thinking_timeout >= 10 ? result.thinking_timeout * 1000 : null; + const previousStorageIdentity = this._getStorageIdentity(); + if (result?.ha_user_id) { + this._backendHaUserId = result.ha_user_id; + } + if (result?.agent_id) { + this._activeAgentId = result.agent_id; + } + if (this._getStorageIdentity() !== previousStorageIdentity) { + this._resetTransientChatState(); + this._restoreMessages(); + } if (includePipeline) { try { @@ -2185,7 +2254,11 @@ class OpenClawChatCard extends HTMLElement { const clearBtn = this.shadowRoot.getElementById("clear-btn"); if (clearBtn) { clearBtn.addEventListener("click", () => { - if (confirm("Clear chat history?")) this._clearChat(); + if (confirm("Clear chat history?")) { + this._clearChat().catch((err) => { + console.error("OpenClaw: clear chat failed", err); + }); + } }); } diff --git a/www/openclaw-chat-card.js b/www/openclaw-chat-card.js index 5796959..eb7489d 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.62"; + const src = "/openclaw/openclaw-chat-card.js?v=0.1.63"; console.info("OpenClaw loader importing", src); await import(src); }