Update changelog for version 0.1.5, add automatic Lovelace resource registration, and enhance chat card functionality
This commit is contained in:
@@ -2,6 +2,26 @@
|
|||||||
|
|
||||||
All notable changes to the OpenClaw Home Assistant Integration will be documented in this file.
|
All notable changes to the OpenClaw Home Assistant Integration will be documented in this file.
|
||||||
|
|
||||||
|
## [0.1.5] - 2026-02-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Automatic Lovelace resource registration** — the chat card JS is now served
|
||||||
|
directly from inside the integration package (`custom_components/openclaw/www/`)
|
||||||
|
via a registered static HTTP path at `/openclaw/openclaw-chat-card.js`. The
|
||||||
|
integration also adds it to Lovelace's resource store automatically on first
|
||||||
|
setup. No manual "Add resource" step is required.
|
||||||
|
- `async_setup()` registered so the static path is available before any config
|
||||||
|
entry setup runs.
|
||||||
|
- `lovelace` added to `after_dependencies` so Lovelace is ready when we attempt
|
||||||
|
to register the resource.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `openclaw-chat-card.js` is now shipped inside `custom_components/openclaw/www/`
|
||||||
|
(in addition to the top-level `www/` for backward compatibility with HACS installs
|
||||||
|
that copy files to `config/www/`).
|
||||||
|
- If programmatic Lovelace registration fails (e.g. Lovelace not loaded), a clear
|
||||||
|
warning with manual fallback instructions is logged instead of silently doing nothing.
|
||||||
|
|
||||||
## [0.1.4] - 2026-02-20
|
## [0.1.4] - 2026-02-20
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ The integration will auto-detect it — no manual configuration needed.
|
|||||||
|
|
||||||
## Chat Card
|
## Chat Card
|
||||||
|
|
||||||
Add the card to any dashboard:
|
The chat card is registered automatically when the integration loads — no
|
||||||
|
manual resource configuration needed. Just add it to any dashboard:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
type: custom:openclaw-chat-card
|
type: custom:openclaw-chat-card
|
||||||
|
|||||||
@@ -40,8 +40,25 @@ from .coordinator import OpenClawCoordinator
|
|||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 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
|
||||||
|
# URL at which the card JS will be served (registered via register_static_path)
|
||||||
|
_CARD_URL = f"/openclaw/{_CARD_FILENAME}"
|
||||||
|
|
||||||
type OpenClawConfigEntry = ConfigEntry
|
type OpenClawConfigEntry = ConfigEntry
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
|
||||||
|
"""Global integration setup — runs once before any config entries.
|
||||||
|
|
||||||
|
Registers the static HTTP path for the chat card so the JS file is served
|
||||||
|
directly from inside the integration package, regardless of whether the
|
||||||
|
user installed via HACS or manually.
|
||||||
|
"""
|
||||||
|
_async_register_static_path(hass)
|
||||||
|
return True
|
||||||
|
|
||||||
# Service call schemas
|
# Service call schemas
|
||||||
SEND_MESSAGE_SCHEMA = vol.Schema(
|
SEND_MESSAGE_SCHEMA = vol.Schema(
|
||||||
{
|
{
|
||||||
@@ -57,9 +74,6 @@ CLEAR_HISTORY_SCHEMA = vol.Schema(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Path to the chat card JS inside the integration
|
|
||||||
_CARD_FILENAME = "openclaw-chat-card.js"
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: OpenClawConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: OpenClawConfigEntry) -> bool:
|
||||||
"""Set up OpenClaw from a config entry.
|
"""Set up OpenClaw from a config entry.
|
||||||
@@ -164,31 +178,90 @@ def _async_setup_token_refresh(
|
|||||||
# ── Frontend registration ─────────────────────────────────────────────────────
|
# ── Frontend registration ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _async_register_frontend(hass: HomeAssistant) -> None:
|
def _async_register_static_path(hass: HomeAssistant) -> None:
|
||||||
"""Register the Lovelace custom card resource.
|
"""Register the integration's www/ folder as a static HTTP path.
|
||||||
|
|
||||||
Attempts to register as a Lovelace module resource so the card
|
After this the card JS is always available at /openclaw/openclaw-chat-card.js
|
||||||
is available without manual YAML edits. Falls back to a log hint
|
regardless of how the integration was installed (HACS or manual).
|
||||||
if programmatic registration isn't possible.
|
"""
|
||||||
|
static_key = f"{DOMAIN}_static_registered"
|
||||||
|
if hass.data.get(static_key):
|
||||||
|
return
|
||||||
|
hass.data[static_key] = True
|
||||||
|
|
||||||
|
if not _CARD_PATH.exists():
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Chat card JS not found at %s — frontend resource will not be available",
|
||||||
|
_CARD_PATH,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
hass.http.register_static_path(
|
||||||
|
f"/openclaw/{_CARD_FILENAME}",
|
||||||
|
str(_CARD_PATH),
|
||||||
|
cache_headers=True,
|
||||||
|
)
|
||||||
|
_LOGGER.debug("Registered static path: /openclaw/%s", _CARD_FILENAME)
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _async_register_frontend(hass: HomeAssistant) -> None:
|
||||||
|
"""Register the Lovelace custom card resource (called once per setup).
|
||||||
|
|
||||||
|
Adds the card URL to Lovelace's resource list so it loads on every
|
||||||
|
dashboard automatically. No manual step required.
|
||||||
"""
|
"""
|
||||||
frontend_key = f"{DOMAIN}_frontend_registered"
|
frontend_key = f"{DOMAIN}_frontend_registered"
|
||||||
if hass.data.get(frontend_key):
|
if hass.data.get(frontend_key):
|
||||||
return
|
return
|
||||||
hass.data[frontend_key] = True
|
hass.data[frontend_key] = True
|
||||||
|
|
||||||
# The card JS is distributed in www/ alongside the integration.
|
# Ensure static path is registered (may have been missed if async_setup
|
||||||
# Users install via HACS which symlinks it into config/www/community/openclaw/
|
# didn't run, e.g. during a config entry reload).
|
||||||
# or manually copy to config/www/.
|
_async_register_static_path(hass)
|
||||||
# We register the resource URL so it auto-loads.
|
|
||||||
url = f"/local/community/openclaw/{_CARD_FILENAME}"
|
|
||||||
|
|
||||||
# Static path registration is handled by HACS for HACS installs,
|
hass.async_create_task(_async_add_lovelace_resource(hass, _CARD_URL))
|
||||||
# or manually by the user. We just log the expected URL.
|
|
||||||
_LOGGER.info(
|
|
||||||
"OpenClaw chat card: add as a Lovelace resource at '%s' "
|
async def _async_add_lovelace_resource(hass: HomeAssistant, url: str) -> None:
|
||||||
"(type: JavaScript Module). HACS does this automatically.",
|
"""Add the card URL to Lovelace's resource store if not already present."""
|
||||||
url,
|
# Lovelace stores resources in hass.data["lovelace"]["resources"].
|
||||||
)
|
# It is a ResourceStorageCollection with:
|
||||||
|
# .async_items() → list of {"id", "res_type", "url"} dicts
|
||||||
|
# .async_create_item(data) → persists a new resource
|
||||||
|
lovelace_data = hass.data.get("lovelace")
|
||||||
|
if not lovelace_data:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Lovelace not loaded; resource '%s' must be added manually if needed",
|
||||||
|
url,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
resource_collection = lovelace_data.get("resources")
|
||||||
|
if resource_collection is None:
|
||||||
|
_LOGGER.debug("Lovelace resource store not available")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
existing_urls = {item["url"] for item in resource_collection.async_items()}
|
||||||
|
if url in existing_urls:
|
||||||
|
_LOGGER.debug("Lovelace resource already registered: %s", url)
|
||||||
|
return
|
||||||
|
|
||||||
|
await resource_collection.async_create_item(
|
||||||
|
{"res_type": "module", "url": url}
|
||||||
|
)
|
||||||
|
_LOGGER.info(
|
||||||
|
"Auto-registered Lovelace resource: %s — the chat card is ready to use.",
|
||||||
|
url,
|
||||||
|
)
|
||||||
|
except Exception as err: # noqa: BLE001
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Could not auto-register Lovelace resource '%s': %s. "
|
||||||
|
"Add it manually: Settings → Dashboards → Resources.",
|
||||||
|
url,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── Service registration ──────────────────────────────────────────────────────
|
# ── Service registration ──────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"issue_tracker": "https://github.com/techartdev/OpenClawHomeAssistant/issues",
|
"issue_tracker": "https://github.com/techartdev/OpenClawHomeAssistant/issues",
|
||||||
"requirements": [],
|
"requirements": [],
|
||||||
"version": "0.1.4",
|
"version": "0.1.5",
|
||||||
"dependencies": ["conversation"],
|
"dependencies": ["conversation"],
|
||||||
"after_dependencies": ["hassio"]
|
"after_dependencies": ["hassio", "lovelace"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,854 @@
|
|||||||
|
/**
|
||||||
|
* OpenClaw Chat Card — Lovelace custom card for Home Assistant.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Message history with timestamps
|
||||||
|
* - Streaming AI response display (typing indicator)
|
||||||
|
* - Markdown rendering
|
||||||
|
* - File/image attachment support
|
||||||
|
* - Voice input (WebSpeech / MediaRecorder)
|
||||||
|
* - Voice mode toggle (continuous conversation)
|
||||||
|
*
|
||||||
|
* Communication: uses HA WebSocket API → openclaw.send_message service
|
||||||
|
* + subscribes to openclaw_message_received events.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const CARD_VERSION = "0.2.0";
|
||||||
|
|
||||||
|
// Max time (ms) to show the thinking indicator before falling back to an error
|
||||||
|
const THINKING_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
|
// Session storage key prefix for message persistence across dashboard navigations
|
||||||
|
const STORAGE_PREFIX = "openclaw_chat_";
|
||||||
|
|
||||||
|
// ─── Minimal Markdown renderer ──────────────────────────────────────────────
|
||||||
|
// Handles: **bold**, *italic*, `code`, ```code blocks```, [links](url), headers
|
||||||
|
function renderMarkdown(text) {
|
||||||
|
if (!text) return "";
|
||||||
|
let html = text
|
||||||
|
// Code blocks (``` ... ```)
|
||||||
|
.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code class="lang-$1">$2</code></pre>')
|
||||||
|
// Inline code
|
||||||
|
.replace(/`([^`]+)`/g, "<code>$1</code>")
|
||||||
|
// Bold
|
||||||
|
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
|
||||||
|
// Italic
|
||||||
|
.replace(/\*(.+?)\*/g, "<em>$1</em>")
|
||||||
|
// Links
|
||||||
|
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
|
||||||
|
// Headers
|
||||||
|
.replace(/^### (.+)$/gm, "<h4>$1</h4>")
|
||||||
|
.replace(/^## (.+)$/gm, "<h3>$1</h3>")
|
||||||
|
.replace(/^# (.+)$/gm, "<h2>$1</h2>")
|
||||||
|
// Line breaks
|
||||||
|
.replace(/\n/g, "<br>");
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Card class ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class OpenClawChatCard extends HTMLElement {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.attachShadow({ mode: "open" });
|
||||||
|
this._hass = null;
|
||||||
|
this._config = {};
|
||||||
|
this._messages = [];
|
||||||
|
this._isProcessing = false;
|
||||||
|
this._isVoiceMode = false;
|
||||||
|
this._recognition = null;
|
||||||
|
this._eventUnsubscribe = null;
|
||||||
|
this._thinkingTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HA card interface ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
static getConfigElement() {
|
||||||
|
return document.createElement("openclaw-chat-card-editor");
|
||||||
|
}
|
||||||
|
|
||||||
|
static getStubConfig() {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
setConfig(config) {
|
||||||
|
this._config = {
|
||||||
|
title: config.title || "OpenClaw Chat",
|
||||||
|
height: config.height || "500px",
|
||||||
|
show_timestamps: config.show_timestamps !== false,
|
||||||
|
show_voice_button: config.show_voice_button !== false,
|
||||||
|
show_clear_button: config.show_clear_button !== false,
|
||||||
|
session_id: config.session_id || null,
|
||||||
|
...config,
|
||||||
|
};
|
||||||
|
// Restore messages from sessionStorage if available
|
||||||
|
this._restoreMessages();
|
||||||
|
this._render();
|
||||||
|
}
|
||||||
|
|
||||||
|
set hass(hass) {
|
||||||
|
const firstSet = !this._hass;
|
||||||
|
this._hass = hass;
|
||||||
|
if (firstSet) {
|
||||||
|
this._subscribeToEvents();
|
||||||
|
this._render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getCardSize() {
|
||||||
|
return 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this._render();
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
this._unsubscribeEvents();
|
||||||
|
this._stopVoiceRecognition();
|
||||||
|
this._clearThinkingTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Event subscription ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
async _subscribeToEvents() {
|
||||||
|
if (!this._hass || this._eventUnsubscribe) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
this._eventUnsubscribe = await this._hass.connection.subscribeEvents(
|
||||||
|
(event) => this._handleOpenClawEvent(event),
|
||||||
|
"openclaw_message_received"
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("OpenClaw: Failed to subscribe to events:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_unsubscribeEvents() {
|
||||||
|
if (this._eventUnsubscribe) {
|
||||||
|
this._eventUnsubscribe();
|
||||||
|
this._eventUnsubscribe = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleOpenClawEvent(event) {
|
||||||
|
const data = event.data;
|
||||||
|
if (!data || !data.message) return;
|
||||||
|
|
||||||
|
// Check if this event is for our session
|
||||||
|
const sessionId = this._config.session_id || "default";
|
||||||
|
if (data.session_id && data.session_id !== sessionId) return;
|
||||||
|
|
||||||
|
// If we're waiting for a response, update the last "thinking" message
|
||||||
|
if (this._isProcessing) {
|
||||||
|
this._isProcessing = false;
|
||||||
|
this._clearThinkingTimer();
|
||||||
|
// Replace the thinking indicator with the actual response
|
||||||
|
const thinkingIdx = this._messages.findIndex((m) => m._thinking);
|
||||||
|
if (thinkingIdx >= 0) {
|
||||||
|
this._messages[thinkingIdx] = {
|
||||||
|
role: "assistant",
|
||||||
|
content: data.message,
|
||||||
|
timestamp: data.timestamp || new Date().toISOString(),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
this._addMessage("assistant", data.message);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this._addMessage("assistant", data.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._persistMessages();
|
||||||
|
this._render();
|
||||||
|
this._scrollToBottom();
|
||||||
|
|
||||||
|
// In voice mode, speak the response
|
||||||
|
if (this._isVoiceMode && data.message) {
|
||||||
|
this._speak(data.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_clearChat() {
|
||||||
|
this._messages = [];
|
||||||
|
this._isProcessing = false;
|
||||||
|
this._clearThinkingTimer();
|
||||||
|
this._persistMessages();
|
||||||
|
this._render();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Message persistence ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
_getStorageKey() {
|
||||||
|
const session = this._config.session_id || "default";
|
||||||
|
return `${STORAGE_PREFIX}${session}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_persistMessages() {
|
||||||
|
try {
|
||||||
|
const toSave = this._messages.filter((m) => !m._thinking);
|
||||||
|
// Keep last 100 messages to avoid storage bloat
|
||||||
|
const trimmed = toSave.slice(-100);
|
||||||
|
sessionStorage.setItem(this._getStorageKey(), JSON.stringify(trimmed));
|
||||||
|
} catch (e) {
|
||||||
|
// sessionStorage full or unavailable — ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_restoreMessages() {
|
||||||
|
try {
|
||||||
|
const stored = sessionStorage.getItem(this._getStorageKey());
|
||||||
|
if (stored) {
|
||||||
|
this._messages = JSON.parse(stored);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Corrupted data — start fresh
|
||||||
|
this._messages = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Thinking timer ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_startThinkingTimer() {
|
||||||
|
this._clearThinkingTimer();
|
||||||
|
this._thinkingTimer = setTimeout(() => {
|
||||||
|
if (!this._isProcessing) return;
|
||||||
|
const idx = this._messages.findIndex((m) => m._thinking);
|
||||||
|
if (idx >= 0) {
|
||||||
|
this._messages[idx] = {
|
||||||
|
role: "assistant",
|
||||||
|
content: "Response timed out. The model may still be processing — try again.",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
_error: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
this._isProcessing = false;
|
||||||
|
this._render();
|
||||||
|
}, THINKING_TIMEOUT_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
_clearThinkingTimer() {
|
||||||
|
if (this._thinkingTimer) {
|
||||||
|
clearTimeout(this._thinkingTimer);
|
||||||
|
this._thinkingTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Message handling ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_addMessage(role, content) {
|
||||||
|
this._messages.push({
|
||||||
|
role,
|
||||||
|
content,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
this._persistMessages();
|
||||||
|
}
|
||||||
|
|
||||||
|
async _sendMessage(text) {
|
||||||
|
if (!text || !text.trim() || !this._hass) return;
|
||||||
|
|
||||||
|
const message = text.trim();
|
||||||
|
this._addMessage("user", message);
|
||||||
|
|
||||||
|
// Add thinking indicator with timeout safeguard
|
||||||
|
this._isProcessing = true;
|
||||||
|
this._messages.push({
|
||||||
|
role: "assistant",
|
||||||
|
content: "",
|
||||||
|
_thinking: true,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
this._startThinkingTimer();
|
||||||
|
|
||||||
|
this._render();
|
||||||
|
this._scrollToBottom();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this._hass.callService("openclaw", "send_message", {
|
||||||
|
message: message,
|
||||||
|
session_id: this._config.session_id || undefined,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("OpenClaw: Failed to send message:", err);
|
||||||
|
// Replace thinking with error
|
||||||
|
const thinkingIdx = this._messages.findIndex((m) => m._thinking);
|
||||||
|
if (thinkingIdx >= 0) {
|
||||||
|
this._messages[thinkingIdx] = {
|
||||||
|
role: "assistant",
|
||||||
|
content: `Error: ${err.message || "Failed to send message"}`,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
_error: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
this._isProcessing = false;
|
||||||
|
this._render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Voice ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_startVoiceRecognition() {
|
||||||
|
if (!("webkitSpeechRecognition" in window) && !("SpeechRecognition" in window)) {
|
||||||
|
console.warn("OpenClaw: Speech recognition not supported in this browser");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||||
|
this._recognition = new SpeechRecognition();
|
||||||
|
this._recognition.continuous = this._isVoiceMode;
|
||||||
|
this._recognition.interimResults = true;
|
||||||
|
this._recognition.lang = this._hass?.language || "en-US";
|
||||||
|
|
||||||
|
this._recognition.onresult = (event) => {
|
||||||
|
const result = event.results[event.results.length - 1];
|
||||||
|
if (result.isFinal) {
|
||||||
|
const text = result[0].transcript;
|
||||||
|
this._sendMessage(text);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this._recognition.onerror = (event) => {
|
||||||
|
console.error("OpenClaw: Speech recognition error:", event.error);
|
||||||
|
this._render();
|
||||||
|
};
|
||||||
|
|
||||||
|
this._recognition.onend = () => {
|
||||||
|
if (this._isVoiceMode) {
|
||||||
|
// Restart recognition in voice mode
|
||||||
|
try {
|
||||||
|
this._recognition.start();
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore — may already be started
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this._render();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this._recognition.start();
|
||||||
|
this._render();
|
||||||
|
}
|
||||||
|
|
||||||
|
_stopVoiceRecognition() {
|
||||||
|
if (this._recognition) {
|
||||||
|
this._recognition.abort();
|
||||||
|
this._recognition = null;
|
||||||
|
}
|
||||||
|
this._isVoiceMode = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_toggleVoiceMode() {
|
||||||
|
this._isVoiceMode = !this._isVoiceMode;
|
||||||
|
if (this._isVoiceMode) {
|
||||||
|
this._startVoiceRecognition();
|
||||||
|
} else {
|
||||||
|
this._stopVoiceRecognition();
|
||||||
|
}
|
||||||
|
this._render();
|
||||||
|
}
|
||||||
|
|
||||||
|
_speak(text) {
|
||||||
|
if (!("speechSynthesis" in window)) return;
|
||||||
|
// Strip markdown for TTS
|
||||||
|
const plain = text.replace(/[*_`#\[\]()]/g, "");
|
||||||
|
const utterance = new SpeechSynthesisUtterance(plain);
|
||||||
|
utterance.lang = this._hass?.language || "en-US";
|
||||||
|
speechSynthesis.speak(utterance);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── File attachments ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_handleFileAttachment() {
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "file";
|
||||||
|
input.multiple = true;
|
||||||
|
input.accept = "image/*,.pdf,.txt,.md,.json,.csv";
|
||||||
|
input.onchange = (e) => {
|
||||||
|
const files = Array.from(e.target.files);
|
||||||
|
if (files.length > 0) {
|
||||||
|
// TODO: Implement file upload via service call
|
||||||
|
const names = files.map((f) => f.name).join(", ");
|
||||||
|
this._addMessage("user", `📎 Attached: ${names}`);
|
||||||
|
this._render();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
input.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── UI helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_scrollToBottom() {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const container = this.shadowRoot?.querySelector(".messages");
|
||||||
|
if (container) {
|
||||||
|
container.scrollTop = container.scrollHeight;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_formatTime(isoString) {
|
||||||
|
if (!isoString) return "";
|
||||||
|
try {
|
||||||
|
const d = new Date(isoString);
|
||||||
|
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_render() {
|
||||||
|
const config = this._config;
|
||||||
|
const messages = this._messages;
|
||||||
|
|
||||||
|
const messagesHtml = messages
|
||||||
|
.map((msg) => {
|
||||||
|
const isUser = msg.role === "user";
|
||||||
|
const isThinking = msg._thinking;
|
||||||
|
const isError = msg._error;
|
||||||
|
|
||||||
|
let contentHtml;
|
||||||
|
if (isThinking) {
|
||||||
|
contentHtml = `<div class="thinking"><span></span><span></span><span></span></div>`;
|
||||||
|
} else if (isError) {
|
||||||
|
contentHtml = `<div class="error">${this._escapeHtml(msg.content)}</div>`;
|
||||||
|
} else if (isUser) {
|
||||||
|
contentHtml = this._escapeHtml(msg.content);
|
||||||
|
} else {
|
||||||
|
contentHtml = renderMarkdown(msg.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeHtml =
|
||||||
|
config.show_timestamps && msg.timestamp
|
||||||
|
? `<div class="time">${this._formatTime(msg.timestamp)}</div>`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="msg ${isUser ? "user" : "assistant"}">
|
||||||
|
<div class="bubble">${contentHtml}</div>
|
||||||
|
${timeHtml}
|
||||||
|
</div>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const voiceActive = this._recognition !== null;
|
||||||
|
|
||||||
|
this.shadowRoot.innerHTML = `
|
||||||
|
<style>
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
--oc-bg: var(--card-background-color, #1c1c1c);
|
||||||
|
--oc-msg-user: var(--primary-color, #2563eb);
|
||||||
|
--oc-msg-assistant: var(--secondary-background-color, #2a2a2a);
|
||||||
|
--oc-text: var(--primary-text-color, #e6edf3);
|
||||||
|
--oc-text-secondary: var(--secondary-text-color, #9ca3af);
|
||||||
|
--oc-border: var(--divider-color, #333);
|
||||||
|
--oc-radius: 12px;
|
||||||
|
}
|
||||||
|
ha-card {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--oc-border);
|
||||||
|
}
|
||||||
|
.header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--oc-text);
|
||||||
|
}
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.icon-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--oc-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.icon-btn:hover { color: var(--oc-text); background: var(--oc-border); }
|
||||||
|
.icon-btn.active { color: var(--oc-msg-user); }
|
||||||
|
.messages {
|
||||||
|
height: ${config.height || "500px"};
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.msg {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-width: 85%;
|
||||||
|
}
|
||||||
|
.msg.user {
|
||||||
|
align-self: flex-end;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
.msg.assistant {
|
||||||
|
align-self: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.bubble {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: var(--oc-radius);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--oc-text);
|
||||||
|
word-wrap: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
.msg.user .bubble {
|
||||||
|
background: var(--oc-msg-user);
|
||||||
|
color: white;
|
||||||
|
border-bottom-right-radius: 4px;
|
||||||
|
}
|
||||||
|
.msg.assistant .bubble {
|
||||||
|
background: var(--oc-msg-assistant);
|
||||||
|
border-bottom-left-radius: 4px;
|
||||||
|
}
|
||||||
|
.bubble code {
|
||||||
|
background: rgba(0,0,0,0.3);
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.bubble pre {
|
||||||
|
background: rgba(0,0,0,0.3);
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
.bubble pre code {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.bubble a { color: #60a5fa; }
|
||||||
|
.time {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--oc-text-secondary);
|
||||||
|
margin-top: 2px;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
.error { color: #ef4444; }
|
||||||
|
.thinking {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
.thinking span {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background: var(--oc-text-secondary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: bounce 1.4s infinite ease-in-out both;
|
||||||
|
}
|
||||||
|
.thinking span:nth-child(1) { animation-delay: -0.32s; }
|
||||||
|
.thinking span:nth-child(2) { animation-delay: -0.16s; }
|
||||||
|
@keyframes bounce {
|
||||||
|
0%, 80%, 100% { transform: scale(0); }
|
||||||
|
40% { transform: scale(1); }
|
||||||
|
}
|
||||||
|
.input-area {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-top: 1px solid var(--oc-border);
|
||||||
|
}
|
||||||
|
.input-area textarea {
|
||||||
|
flex: 1;
|
||||||
|
resize: none;
|
||||||
|
border: 1px solid var(--oc-border);
|
||||||
|
border-radius: var(--oc-radius);
|
||||||
|
background: var(--oc-bg);
|
||||||
|
color: var(--oc-text);
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
min-height: 20px;
|
||||||
|
max-height: 120px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.input-area textarea:focus {
|
||||||
|
border-color: var(--oc-msg-user);
|
||||||
|
}
|
||||||
|
.send-btn {
|
||||||
|
background: var(--oc-msg-user);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
.send-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: var(--oc-text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.voice-indicator {
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
background: #ef4444;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: pulse 1.5s infinite;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.3; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<ha-card>
|
||||||
|
<div class="header">
|
||||||
|
<h3>${this._escapeHtml(config.title)}</h3>
|
||||||
|
<div class="header-actions">
|
||||||
|
${
|
||||||
|
config.show_voice_button
|
||||||
|
? `<button class="icon-btn ${voiceActive ? "active" : ""}"
|
||||||
|
id="voice-btn"
|
||||||
|
title="${voiceActive ? "Stop voice" : "Voice input"}">
|
||||||
|
${voiceActive ? '<span class="voice-indicator"></span>🎙️' : "🎙️"}
|
||||||
|
</button>
|
||||||
|
<button class="icon-btn ${this._isVoiceMode ? "active" : ""}"
|
||||||
|
id="voice-mode-btn"
|
||||||
|
title="Toggle voice mode (continuous)">
|
||||||
|
🔊
|
||||||
|
</button>`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
<button class="icon-btn" id="attach-btn" title="Attach file">📎</button>
|
||||||
|
${config.show_clear_button ? '<button class="icon-btn" id="clear-btn" title="Clear chat">🗑️</button>' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="messages" id="messages">
|
||||||
|
${
|
||||||
|
messages.length === 0
|
||||||
|
? '<div class="empty-state">Send a message to start a conversation</div>'
|
||||||
|
: messagesHtml
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-area">
|
||||||
|
<textarea
|
||||||
|
id="input"
|
||||||
|
rows="1"
|
||||||
|
placeholder="Type a message..."
|
||||||
|
${this._isProcessing ? "disabled" : ""}
|
||||||
|
></textarea>
|
||||||
|
<button class="send-btn" id="send-btn" ${this._isProcessing ? "disabled" : ""}>➤</button>
|
||||||
|
</div>
|
||||||
|
</ha-card>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ── Event listeners ────────────────────────────────────────────
|
||||||
|
const input = this.shadowRoot.getElementById("input");
|
||||||
|
const sendBtn = this.shadowRoot.getElementById("send-btn");
|
||||||
|
const attachBtn = this.shadowRoot.getElementById("attach-btn");
|
||||||
|
const voiceBtn = this.shadowRoot.getElementById("voice-btn");
|
||||||
|
const voiceModeBtn = this.shadowRoot.getElementById("voice-mode-btn");
|
||||||
|
|
||||||
|
if (input) {
|
||||||
|
input.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
this._sendMessage(input.value);
|
||||||
|
input.value = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Auto-resize textarea
|
||||||
|
input.addEventListener("input", () => {
|
||||||
|
input.style.height = "auto";
|
||||||
|
input.style.height = Math.min(input.scrollHeight, 120) + "px";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sendBtn) {
|
||||||
|
sendBtn.addEventListener("click", () => {
|
||||||
|
if (input) {
|
||||||
|
this._sendMessage(input.value);
|
||||||
|
input.value = "";
|
||||||
|
input.style.height = "auto";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attachBtn) {
|
||||||
|
attachBtn.addEventListener("click", () => this._handleFileAttachment());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (voiceBtn) {
|
||||||
|
voiceBtn.addEventListener("click", () => {
|
||||||
|
if (this._recognition) {
|
||||||
|
this._stopVoiceRecognition();
|
||||||
|
this._render();
|
||||||
|
} else {
|
||||||
|
this._startVoiceRecognition();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (voiceModeBtn) {
|
||||||
|
voiceModeBtn.addEventListener("click", () => this._toggleVoiceMode());
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearBtn = this.shadowRoot.getElementById("clear-btn");
|
||||||
|
if (clearBtn) {
|
||||||
|
clearBtn.addEventListener("click", () => {
|
||||||
|
if (confirm("Clear chat history?")) this._clearChat();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this._scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
_escapeHtml(text) {
|
||||||
|
if (!text) return "";
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Card editor element ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class OpenClawChatCardEditor extends HTMLElement {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.attachShadow({ mode: "open" });
|
||||||
|
this._config = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
setConfig(config) {
|
||||||
|
this._config = { ...config };
|
||||||
|
this._render();
|
||||||
|
}
|
||||||
|
|
||||||
|
set hass(_hass) {
|
||||||
|
// We don't need hass reference in the editor
|
||||||
|
}
|
||||||
|
|
||||||
|
_render() {
|
||||||
|
const c = this._config;
|
||||||
|
this.shadowRoot.innerHTML = `
|
||||||
|
<style>
|
||||||
|
:host { display: block; }
|
||||||
|
.row { display: flex; align-items: center; padding: 8px 0; }
|
||||||
|
.row label { flex: 1; font-size: 14px; color: var(--primary-text-color); }
|
||||||
|
.row input[type="text"] {
|
||||||
|
flex: 1;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid var(--divider-color, #ccc);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--card-background-color, #fff);
|
||||||
|
color: var(--primary-text-color);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.row input[type="checkbox"] {
|
||||||
|
width: 18px; height: 18px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div>
|
||||||
|
<div class="row">
|
||||||
|
<label for="title">Title</label>
|
||||||
|
<input type="text" id="title" value="${this._esc(c.title || "OpenClaw Chat")}" />
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<label for="height">Height</label>
|
||||||
|
<input type="text" id="height" value="${this._esc(c.height || "500px")}" />
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<label for="session_id">Session ID (optional)</label>
|
||||||
|
<input type="text" id="session_id" value="${this._esc(c.session_id || "")}" />
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<label for="show_timestamps">Show timestamps</label>
|
||||||
|
<input type="checkbox" id="show_timestamps" ${c.show_timestamps !== false ? "checked" : ""} />
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<label for="show_voice_button">Show voice button</label>
|
||||||
|
<input type="checkbox" id="show_voice_button" ${c.show_voice_button !== false ? "checked" : ""} />
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<label for="show_clear_button">Show clear button</label>
|
||||||
|
<input type="checkbox" id="show_clear_button" ${c.show_clear_button !== false ? "checked" : ""} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Bind events
|
||||||
|
for (const id of ["title", "height", "session_id"]) {
|
||||||
|
const el = this.shadowRoot.getElementById(id);
|
||||||
|
if (el) {
|
||||||
|
el.addEventListener("change", (e) => this._fireChanged(id, e.target.value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const id of ["show_timestamps", "show_voice_button", "show_clear_button"]) {
|
||||||
|
const el = this.shadowRoot.getElementById(id);
|
||||||
|
if (el) {
|
||||||
|
el.addEventListener("change", (e) => this._fireChanged(id, e.target.checked));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_fireChanged(key, value) {
|
||||||
|
this._config = { ...this._config, [key]: value };
|
||||||
|
const event = new CustomEvent("config-changed", {
|
||||||
|
detail: { config: this._config },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
});
|
||||||
|
this.dispatchEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
_esc(str) {
|
||||||
|
return String(str).replace(/"/g, """).replace(/</g, "<");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Card registration ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
customElements.define("openclaw-chat-card-editor", OpenClawChatCardEditor);
|
||||||
|
customElements.define("openclaw-chat-card", OpenClawChatCard);
|
||||||
|
|
||||||
|
window.customCards = window.customCards || [];
|
||||||
|
window.customCards.push({
|
||||||
|
type: "openclaw-chat-card",
|
||||||
|
name: "OpenClaw Chat",
|
||||||
|
description: "Chat interface for OpenClaw AI Assistant with streaming, voice, and file support.",
|
||||||
|
preview: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.info(
|
||||||
|
`%c OPENCLAW-CHAT-CARD %c v${CARD_VERSION} `,
|
||||||
|
"color: white; background: #2563eb; font-weight: bold; padding: 2px 6px; border-radius: 4px 0 0 4px;",
|
||||||
|
"color: #2563eb; background: #e5e7eb; font-weight: bold; padding: 2px 6px; border-radius: 0 4px 4px 0;"
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user