Route OpenClaw chat by HA user
This commit is contained in:
@@ -47,9 +47,9 @@ from .const import (
|
|||||||
ATTR_ACCOUNT_ID,
|
ATTR_ACCOUNT_ID,
|
||||||
ATTR_SOURCE,
|
ATTR_SOURCE,
|
||||||
ATTR_TIMESTAMP,
|
ATTR_TIMESTAMP,
|
||||||
|
ATTR_HA_USER_ID,
|
||||||
CONF_ADDON_CONFIG_PATH,
|
CONF_ADDON_CONFIG_PATH,
|
||||||
CONF_AGENT_ID,
|
CONF_AGENT_ID,
|
||||||
CONF_VOICE_AGENT_ID,
|
|
||||||
CONF_GATEWAY_HOST,
|
CONF_GATEWAY_HOST,
|
||||||
CONF_GATEWAY_PORT,
|
CONF_GATEWAY_PORT,
|
||||||
CONF_GATEWAY_TOKEN,
|
CONF_GATEWAY_TOKEN,
|
||||||
@@ -67,7 +67,6 @@ from .const import (
|
|||||||
CONF_THINKING_TIMEOUT,
|
CONF_THINKING_TIMEOUT,
|
||||||
CONTEXT_STRATEGY_TRUNCATE,
|
CONTEXT_STRATEGY_TRUNCATE,
|
||||||
DEFAULT_AGENT_ID,
|
DEFAULT_AGENT_ID,
|
||||||
DEFAULT_VOICE_AGENT_ID,
|
|
||||||
DEFAULT_CONTEXT_MAX_CHARS,
|
DEFAULT_CONTEXT_MAX_CHARS,
|
||||||
DEFAULT_CONTEXT_STRATEGY,
|
DEFAULT_CONTEXT_STRATEGY,
|
||||||
DEFAULT_ENABLE_TOOL_CALLS,
|
DEFAULT_ENABLE_TOOL_CALLS,
|
||||||
@@ -90,6 +89,13 @@ from .const import (
|
|||||||
from .coordinator import OpenClawCoordinator
|
from .coordinator import OpenClawCoordinator
|
||||||
from .exposure import apply_context_policy, build_exposed_entities_context
|
from .exposure import apply_context_policy, build_exposed_entities_context
|
||||||
from .helpers import extract_text_recursive
|
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__)
|
_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)
|
# URL at which the card JS is served (registered via register_static_path)
|
||||||
_CARD_STATIC_URL = f"/openclaw/{_CARD_FILENAME}"
|
_CARD_STATIC_URL = f"/openclaw/{_CARD_FILENAME}"
|
||||||
# Versioned URL used for Lovelace resource registration to avoid stale browser cache
|
# 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
|
OpenClawConfigEntry = ConfigEntry
|
||||||
|
|
||||||
@@ -125,6 +131,7 @@ SEND_MESSAGE_SCHEMA = vol.Schema(
|
|||||||
CLEAR_HISTORY_SCHEMA = vol.Schema(
|
CLEAR_HISTORY_SCHEMA = vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Optional(ATTR_SESSION_ID): cv.string,
|
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:
|
def _async_register_services(hass: HomeAssistant) -> None:
|
||||||
"""Register openclaw.send_message and openclaw.clear_history services."""
|
"""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:
|
async def handle_send_message(call: ServiceCall) -> None:
|
||||||
"""Handle the openclaw.send_message service call."""
|
"""Handle the openclaw.send_message service call."""
|
||||||
message: str = call.data[ATTR_MESSAGE]
|
message: str = call.data[ATTR_MESSAGE]
|
||||||
source: str | None = call.data.get(ATTR_SOURCE)
|
source: str | None = call.data.get(ATTR_SOURCE)
|
||||||
session_id: str = call.data.get(ATTR_SESSION_ID) or "default"
|
raw_session_id: str = call.data.get(ATTR_SESSION_ID) or "default"
|
||||||
call_agent_id = _normalize_optional_text(call.data.get(ATTR_AGENT_ID))
|
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
|
extra_headers = _VOICE_REQUEST_HEADERS if source == "voice" else None
|
||||||
|
|
||||||
entry_data = _get_first_entry_data(hass)
|
entry_data = _get_first_entry_data(hass)
|
||||||
@@ -421,12 +423,12 @@ def _async_register_services(hass: HomeAssistant) -> None:
|
|||||||
client: OpenClawApiClient = entry_data["client"]
|
client: OpenClawApiClient = entry_data["client"]
|
||||||
coordinator: OpenClawCoordinator = entry_data["coordinator"]
|
coordinator: OpenClawCoordinator = entry_data["coordinator"]
|
||||||
options = _get_entry_options(hass, entry_data)
|
options = _get_entry_options(hass, entry_data)
|
||||||
voice_agent_id = _normalize_optional_text(
|
resolved_agent_id = resolve_agent_id(ha_user_id, call_agent_id)
|
||||||
options.get(CONF_VOICE_AGENT_ID, DEFAULT_VOICE_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:
|
try:
|
||||||
include_context = options.get(
|
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)
|
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(
|
response = await client.async_send_message(
|
||||||
message=message,
|
message=message,
|
||||||
session_id=session_id,
|
session_id=scoped_session_id,
|
||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
agent_id=resolved_agent_id,
|
agent_id=resolved_agent_id,
|
||||||
model=active_model,
|
model=active_model,
|
||||||
@@ -469,7 +471,7 @@ def _async_register_services(hass: HomeAssistant) -> None:
|
|||||||
+ "\n".join(f"- {line}" for line in tool_results)
|
+ "\n".join(f"- {line}" for line in tool_results)
|
||||||
+ "\nRespond to the user based on these results."
|
+ "\nRespond to the user based on these results."
|
||||||
),
|
),
|
||||||
session_id=session_id,
|
session_id=scoped_session_id,
|
||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
agent_id=resolved_agent_id,
|
agent_id=resolved_agent_id,
|
||||||
model=active_model,
|
model=active_model,
|
||||||
@@ -486,12 +488,15 @@ def _async_register_services(hass: HomeAssistant) -> None:
|
|||||||
list(response.keys()),
|
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(
|
hass.bus.async_fire(
|
||||||
EVENT_MESSAGE_RECEIVED,
|
EVENT_MESSAGE_RECEIVED,
|
||||||
{
|
{
|
||||||
ATTR_MESSAGE: assistant_message,
|
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_MODEL: model_used,
|
||||||
ATTR_TIMESTAMP: datetime.now(timezone.utc).isoformat(),
|
ATTR_TIMESTAMP: datetime.now(timezone.utc).isoformat(),
|
||||||
},
|
},
|
||||||
@@ -500,12 +505,20 @@ def _async_register_services(hass: HomeAssistant) -> None:
|
|||||||
|
|
||||||
except OpenClawApiError as err:
|
except OpenClawApiError as err:
|
||||||
_LOGGER.error("Failed to send message to OpenClaw: %s", 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(
|
hass.bus.async_fire(
|
||||||
EVENT_MESSAGE_RECEIVED,
|
EVENT_MESSAGE_RECEIVED,
|
||||||
{
|
{
|
||||||
ATTR_MESSAGE: f"OpenClaw error: {err}",
|
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_MODEL: "unknown",
|
||||||
ATTR_TIMESTAMP: datetime.now(timezone.utc).isoformat(),
|
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:
|
async def handle_clear_history(call: ServiceCall) -> None:
|
||||||
"""Handle the openclaw.clear_history service call."""
|
"""Handle the openclaw.clear_history service call."""
|
||||||
session_id: str | None = call.data.get(ATTR_SESSION_ID)
|
raw_session_id: str | None = call.data.get(ATTR_SESSION_ID)
|
||||||
_LOGGER.info("Clear history requested (session=%s)", session_id or "all")
|
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)
|
store = _get_chat_history_store(hass)
|
||||||
if session_id:
|
if raw_session_id:
|
||||||
store.pop(session_id, None)
|
scoped_session_id = build_scoped_session_id(
|
||||||
|
raw_session_id,
|
||||||
|
ha_user_id,
|
||||||
|
agent_id,
|
||||||
|
)
|
||||||
|
store.pop(scoped_session_id, None)
|
||||||
else:
|
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:
|
async def handle_invoke_tool(call: ServiceCall) -> None:
|
||||||
"""Handle the openclaw.invoke_tool service call."""
|
"""Handle the openclaw.invoke_tool service call."""
|
||||||
@@ -793,9 +825,26 @@ def _async_register_websocket_api(hass: HomeAssistant) -> None:
|
|||||||
msg: dict[str, Any],
|
msg: dict[str, Any],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Return chat history for a session."""
|
"""Return chat history for a session."""
|
||||||
session_id = msg.get("session_id") or "default"
|
raw_session_id = msg.get("session_id") or "default"
|
||||||
history = _get_chat_history_store(hass).get(session_id, [])
|
connection_user = getattr(connection, "user", None)
|
||||||
connection.send_result(msg["id"], {"session_id": session_id, "messages": history})
|
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)
|
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."""
|
"""Return frontend-related integration settings."""
|
||||||
entry_data = _get_first_entry_data(hass)
|
entry_data = _get_first_entry_data(hass)
|
||||||
options = _get_entry_options(hass, entry_data) if entry_data else {}
|
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(
|
connection.send_result(
|
||||||
msg["id"],
|
msg["id"],
|
||||||
{
|
{
|
||||||
@@ -837,6 +889,8 @@ def _async_register_websocket_api(hass: HomeAssistant) -> None:
|
|||||||
CONF_THINKING_TIMEOUT,
|
CONF_THINKING_TIMEOUT,
|
||||||
DEFAULT_THINKING_TIMEOUT,
|
DEFAULT_THINKING_TIMEOUT,
|
||||||
),
|
),
|
||||||
|
ATTR_HA_USER_ID: ha_user_id,
|
||||||
|
ATTR_AGENT_ID: agent_id,
|
||||||
"language": hass.config.language,
|
"language": hass.config.language,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ ATTR_DRY_RUN = "dry_run"
|
|||||||
ATTR_MESSAGE_CHANNEL = "message_channel"
|
ATTR_MESSAGE_CHANNEL = "message_channel"
|
||||||
ATTR_ACCOUNT_ID = "account_id"
|
ATTR_ACCOUNT_ID = "account_id"
|
||||||
ATTR_AGENT_ID = "agent_id"
|
ATTR_AGENT_ID = "agent_id"
|
||||||
|
ATTR_HA_USER_ID = "ha_user_id"
|
||||||
ATTR_OK = "ok"
|
ATTR_OK = "ok"
|
||||||
ATTR_RESULT = "result"
|
ATTR_RESULT = "result"
|
||||||
ATTR_ERROR = "error"
|
ATTR_ERROR = "error"
|
||||||
|
|||||||
@@ -19,18 +19,18 @@ from homeassistant.helpers import intent
|
|||||||
|
|
||||||
from .api import OpenClawApiClient, OpenClawApiError
|
from .api import OpenClawApiClient, OpenClawApiError
|
||||||
from .const import (
|
from .const import (
|
||||||
|
ATTR_AGENT_ID,
|
||||||
|
ATTR_HA_USER_ID,
|
||||||
ATTR_MESSAGE,
|
ATTR_MESSAGE,
|
||||||
ATTR_MODEL,
|
ATTR_MODEL,
|
||||||
ATTR_SESSION_ID,
|
ATTR_SESSION_ID,
|
||||||
|
ATTR_SESSION_KEY,
|
||||||
ATTR_TIMESTAMP,
|
ATTR_TIMESTAMP,
|
||||||
CONF_ASSIST_SESSION_ID,
|
CONF_ASSIST_SESSION_ID,
|
||||||
CONF_AGENT_ID,
|
|
||||||
CONF_CONTEXT_MAX_CHARS,
|
CONF_CONTEXT_MAX_CHARS,
|
||||||
CONF_CONTEXT_STRATEGY,
|
CONF_CONTEXT_STRATEGY,
|
||||||
CONF_INCLUDE_EXPOSED_CONTEXT,
|
CONF_INCLUDE_EXPOSED_CONTEXT,
|
||||||
CONF_VOICE_AGENT_ID,
|
|
||||||
DEFAULT_ASSIST_SESSION_ID,
|
DEFAULT_ASSIST_SESSION_ID,
|
||||||
DEFAULT_AGENT_ID,
|
|
||||||
DEFAULT_CONTEXT_MAX_CHARS,
|
DEFAULT_CONTEXT_MAX_CHARS,
|
||||||
DEFAULT_CONTEXT_STRATEGY,
|
DEFAULT_CONTEXT_STRATEGY,
|
||||||
DEFAULT_INCLUDE_EXPOSED_CONTEXT,
|
DEFAULT_INCLUDE_EXPOSED_CONTEXT,
|
||||||
@@ -41,6 +41,7 @@ from .const import (
|
|||||||
from .coordinator import OpenClawCoordinator
|
from .coordinator import OpenClawCoordinator
|
||||||
from .exposure import apply_context_policy, build_exposed_entities_context
|
from .exposure import apply_context_policy, build_exposed_entities_context
|
||||||
from .helpers import extract_text_recursive
|
from .helpers import extract_text_recursive
|
||||||
|
from .routing import normalize_ha_user_id, resolve_agent_id, resolve_model_override
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -121,19 +122,12 @@ class OpenClawConversationAgent(conversation.AbstractConversationAgent):
|
|||||||
|
|
||||||
message = user_input.text
|
message = user_input.text
|
||||||
assistant_id = "conversation"
|
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
|
options = self.entry.options
|
||||||
voice_agent_id = self._normalize_optional_text(
|
resolved_agent_id = resolve_agent_id(ha_user_id)
|
||||||
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
|
|
||||||
conversation_id = self._resolve_conversation_id(user_input, resolved_agent_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(
|
include_context = options.get(
|
||||||
CONF_INCLUDE_EXPOSED_CONTEXT,
|
CONF_INCLUDE_EXPOSED_CONTEXT,
|
||||||
DEFAULT_INCLUDE_EXPOSED_CONTEXT,
|
DEFAULT_INCLUDE_EXPOSED_CONTEXT,
|
||||||
@@ -203,6 +197,9 @@ class OpenClawConversationAgent(conversation.AbstractConversationAgent):
|
|||||||
{
|
{
|
||||||
ATTR_MESSAGE: full_response,
|
ATTR_MESSAGE: full_response,
|
||||||
ATTR_SESSION_ID: conversation_id,
|
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_MODEL: coordinator.data.get(DATA_MODEL) if coordinator.data else None,
|
||||||
ATTR_TIMESTAMP: datetime.now(timezone.utc).isoformat(),
|
ATTR_TIMESTAMP: datetime.now(timezone.utc).isoformat(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"issue_tracker": "https://github.com/techartdev/OpenClawHomeAssistantIntegration/issues",
|
"issue_tracker": "https://github.com/techartdev/OpenClawHomeAssistantIntegration/issues",
|
||||||
"requirements": [],
|
"requirements": [],
|
||||||
"version": "0.1.62",
|
"version": "0.1.63",
|
||||||
"dependencies": ["conversation"],
|
"dependencies": ["conversation"],
|
||||||
"after_dependencies": ["hassio", "lovelace"]
|
"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
|
# Initialise from coordinator cache
|
||||||
models = coordinator.available_models
|
models = coordinator.available_models
|
||||||
self._attr_options = models if models else ["unknown"]
|
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_current_option = current if current in self._attr_options else (
|
||||||
self._attr_options[0] if self._attr_options else None
|
self._attr_options[0] if self._attr_options else None
|
||||||
)
|
)
|
||||||
@@ -83,7 +84,8 @@ class OpenClawModelSelect(CoordinatorEntity[OpenClawCoordinator], SelectEntity):
|
|||||||
models = self.coordinator.available_models
|
models = self.coordinator.available_models
|
||||||
if models:
|
if models:
|
||||||
self._attr_options = 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:
|
if current and current in self._attr_options:
|
||||||
self._attr_current_option = current
|
self._attr_current_option = current
|
||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ class OpenClawSensor(CoordinatorEntity[OpenClawCoordinator], SensorEntity):
|
|||||||
"""Initialize the sensor."""
|
"""Initialize the sensor."""
|
||||||
super().__init__(coordinator)
|
super().__init__(coordinator)
|
||||||
self.entity_description = description
|
self.entity_description = description
|
||||||
|
self._entry = entry
|
||||||
self._attr_unique_id = f"{entry.entry_id}_{description.key}"
|
self._attr_unique_id = f"{entry.entry_id}_{description.key}"
|
||||||
self._attr_device_info = {
|
self._attr_device_info = {
|
||||||
"identifiers": {(DOMAIN, entry.entry_id)},
|
"identifiers": {(DOMAIN, entry.entry_id)},
|
||||||
@@ -134,6 +135,10 @@ class OpenClawSensor(CoordinatorEntity[OpenClawCoordinator], SensorEntity):
|
|||||||
"""Return the state of the sensor."""
|
"""Return the state of the sensor."""
|
||||||
if not self.coordinator.data:
|
if not self.coordinator.data:
|
||||||
return None
|
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)
|
return self.coordinator.data.get(self.entity_description.key)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ send_message:
|
|||||||
agent_id:
|
agent_id:
|
||||||
name: Agent ID
|
name: Agent ID
|
||||||
description: >
|
description: >
|
||||||
Optional OpenClaw agent ID to route this message to. Overrides the
|
Optional OpenClaw agent ID to route this message to. When omitted,
|
||||||
agent ID configured in the integration options. Defaults to "main".
|
the integration routes by the Home Assistant user who called the service.
|
||||||
required: false
|
required: false
|
||||||
example: "main"
|
example: "main"
|
||||||
selector:
|
selector:
|
||||||
@@ -29,11 +29,17 @@ send_message:
|
|||||||
|
|
||||||
clear_history:
|
clear_history:
|
||||||
name: 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:
|
fields:
|
||||||
session_id:
|
session_id:
|
||||||
name: 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
|
required: false
|
||||||
selector:
|
selector:
|
||||||
text:
|
text:
|
||||||
|
|||||||
@@ -127,17 +127,21 @@
|
|||||||
},
|
},
|
||||||
"agent_id": {
|
"agent_id": {
|
||||||
"name": "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": {
|
"clear_history": {
|
||||||
"name": "Clear History",
|
"name": "Clear History",
|
||||||
"description": "Clear conversation history.",
|
"description": "Clear scoped conversation history for the current Home Assistant user.",
|
||||||
"fields": {
|
"fields": {
|
||||||
"session_id": {
|
"session_id": {
|
||||||
"name": "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": {
|
"agent_id": {
|
||||||
"name": "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": {
|
"clear_history": {
|
||||||
"name": "Clear History",
|
"name": "Clear History",
|
||||||
"description": "Clear conversation history.",
|
"description": "Clear scoped conversation history for the current Home Assistant user.",
|
||||||
"fields": {
|
"fields": {
|
||||||
"session_id": {
|
"session_id": {
|
||||||
"name": "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.
|
* + 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)
|
// 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;
|
const THINKING_TIMEOUT_MS = 120_000;
|
||||||
@@ -96,6 +96,9 @@ class OpenClawChatCard extends HTMLElement {
|
|||||||
this._autoScrollPinned = true;
|
this._autoScrollPinned = true;
|
||||||
this._lastHassRenderSignature = null;
|
this._lastHassRenderSignature = null;
|
||||||
this._integrationThinkingTimeout = null;
|
this._integrationThinkingTimeout = null;
|
||||||
|
this._backendHaUserId = null;
|
||||||
|
this._activeAgentId = null;
|
||||||
|
this._lastStorageIdentity = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── HA card interface ───────────────────────────────────────────────
|
// ── HA card interface ───────────────────────────────────────────────
|
||||||
@@ -128,8 +131,15 @@ class OpenClawChatCard extends HTMLElement {
|
|||||||
|
|
||||||
set hass(hass) {
|
set hass(hass) {
|
||||||
const firstSet = !this._hass;
|
const firstSet = !this._hass;
|
||||||
|
const previousStorageIdentity = this._getStorageIdentity();
|
||||||
this._hass = hass;
|
this._hass = hass;
|
||||||
const currentSignature = this._getHassRenderSignature(hass);
|
const currentSignature = this._getHassRenderSignature(hass);
|
||||||
|
const currentStorageIdentity = this._getStorageIdentity();
|
||||||
|
if (currentStorageIdentity !== previousStorageIdentity) {
|
||||||
|
this._lastStorageIdentity = currentStorageIdentity;
|
||||||
|
this._resetTransientChatState();
|
||||||
|
this._restoreMessages();
|
||||||
|
}
|
||||||
if (firstSet) {
|
if (firstSet) {
|
||||||
this._subscribeToEvents();
|
this._subscribeToEvents();
|
||||||
this._syncHistoryFromBackend();
|
this._syncHistoryFromBackend();
|
||||||
@@ -252,11 +262,19 @@ class OpenClawChatCard extends HTMLElement {
|
|||||||
const data = event.data;
|
const data = event.data;
|
||||||
if (!data || !data.message) return;
|
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
|
// Check if this event is for our session
|
||||||
const sessionId = this._getSessionId();
|
const sessionId = this._getSessionId();
|
||||||
if (data.session_id && data.session_id !== sessionId) return;
|
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) {
|
if (signature === this._lastAssistantEventSignature) {
|
||||||
return;
|
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._messages = [];
|
||||||
this._isProcessing = false;
|
this._isProcessing = false;
|
||||||
this._pendingResponses = 0;
|
this._pendingResponses = 0;
|
||||||
@@ -304,11 +331,31 @@ class OpenClawChatCard extends HTMLElement {
|
|||||||
this._render();
|
this._render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_resetTransientChatState() {
|
||||||
|
this._isProcessing = false;
|
||||||
|
this._pendingResponses = 0;
|
||||||
|
this._clearThinkingTimer();
|
||||||
|
this._lastAssistantEventSignature = null;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Message persistence ──────────────────────────────────────────────
|
// ── Message persistence ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
_getHaUserId() {
|
||||||
|
return (
|
||||||
|
this._hass?.user?.id ||
|
||||||
|
this._backendHaUserId ||
|
||||||
|
"unknown"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_getStorageIdentity() {
|
||||||
|
return `${this._getHaUserId()}|${this._getSessionId()}`;
|
||||||
|
}
|
||||||
|
|
||||||
_getStorageKey() {
|
_getStorageKey() {
|
||||||
|
const userId = this._getHaUserId();
|
||||||
const session = this._getSessionId();
|
const session = this._getSessionId();
|
||||||
return `${STORAGE_PREFIX}${session}`;
|
return `${STORAGE_PREFIX}${userId}_${session}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
_getSessionId() {
|
_getSessionId() {
|
||||||
@@ -329,9 +376,8 @@ class OpenClawChatCard extends HTMLElement {
|
|||||||
_restoreMessages() {
|
_restoreMessages() {
|
||||||
try {
|
try {
|
||||||
const stored = sessionStorage.getItem(this._getStorageKey());
|
const stored = sessionStorage.getItem(this._getStorageKey());
|
||||||
if (stored) {
|
const parsed = stored ? JSON.parse(stored) : [];
|
||||||
this._messages = JSON.parse(stored);
|
this._messages = Array.isArray(parsed) ? parsed : [];
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Corrupted data — start fresh
|
// Corrupted data — start fresh
|
||||||
this._messages = [];
|
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 : [];
|
const serverMessages = Array.isArray(result?.messages) ? result.messages : [];
|
||||||
if (!serverMessages.length) return;
|
if (!serverMessages.length) return;
|
||||||
|
|
||||||
@@ -426,6 +484,17 @@ class OpenClawChatCard extends HTMLElement {
|
|||||||
typeof result?.thinking_timeout === "number" && result.thinking_timeout >= 10
|
typeof result?.thinking_timeout === "number" && result.thinking_timeout >= 10
|
||||||
? result.thinking_timeout * 1000
|
? result.thinking_timeout * 1000
|
||||||
: null;
|
: 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) {
|
if (includePipeline) {
|
||||||
try {
|
try {
|
||||||
@@ -2185,7 +2254,11 @@ class OpenClawChatCard extends HTMLElement {
|
|||||||
const clearBtn = this.shadowRoot.getElementById("clear-btn");
|
const clearBtn = this.shadowRoot.getElementById("clear-btn");
|
||||||
if (clearBtn) {
|
if (clearBtn) {
|
||||||
clearBtn.addEventListener("click", () => {
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
if (!customElements.get("openclaw-chat-card")) {
|
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);
|
console.info("OpenClaw loader importing", src);
|
||||||
await import(src);
|
await import(src);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user