Route OpenClaw chat by HA user
This commit is contained in:
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user