Update changelog for version 0.1.5, add automatic Lovelace resource registration, and enhance chat card functionality

This commit is contained in:
techartdev
2026-02-20 14:37:00 +02:00
parent 793ea1488f
commit 2308ddfc45
5 changed files with 971 additions and 23 deletions
+20
View File
@@ -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
+2 -1
View File
@@ -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
+91 -18
View File
@@ -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."""
# 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, 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 ──────────────────────────────────────────────────────
+2 -2
View File
@@ -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, "&quot;").replace(/</g, "&lt;");
}
}
// ── 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;"
);