diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1ea45e2..e627cee 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,13 @@
All notable changes to the OpenClaw Home Assistant Integration will be documented in this file.
+## [0.1.19] - 2026-02-20
+
+### Changed
+- Made `custom_components/openclaw/www/openclaw-chat-card.js` the single source of truth for card implementation.
+- Replaced root `www/openclaw-chat-card.js` with a tiny loader shim that imports `/openclaw/openclaw-chat-card.js`.
+- Removed manual-maintenance duplication between two full card script files.
+
## [0.1.18] - 2026-02-20
### Fixed
diff --git a/README.md b/README.md
index fb2a9ec..bdd89b3 100644
--- a/README.md
+++ b/README.md
@@ -24,9 +24,8 @@ A native Home Assistant integration for communicating with the
### Manual
1. Copy `custom_components/openclaw/` into your HA `config/custom_components/` directory
-2. Copy `www/openclaw-chat-card.js` into `config/www/`
-3. Restart Home Assistant
-4. Add the integration via Settings → Devices & Services
+2. Restart Home Assistant
+3. Add the integration via Settings → Devices & Services
## Prerequisites
diff --git a/custom_components/openclaw/manifest.json b/custom_components/openclaw/manifest.json
index 27cc361..6543396 100644
--- a/custom_components/openclaw/manifest.json
+++ b/custom_components/openclaw/manifest.json
@@ -8,7 +8,7 @@
"iot_class": "local_polling",
"issue_tracker": "https://github.com/techartdev/OpenClawHomeAssistant/issues",
"requirements": [],
- "version": "0.1.18",
+ "version": "0.1.19",
"dependencies": ["conversation"],
"after_dependencies": ["hassio", "lovelace"]
}
diff --git a/custom_components/openclaw/www/openclaw-chat-card.js b/custom_components/openclaw/www/openclaw-chat-card.js
index ca83f74..f00fa06 100644
--- a/custom_components/openclaw/www/openclaw-chat-card.js
+++ b/custom_components/openclaw/www/openclaw-chat-card.js
@@ -64,6 +64,8 @@ class OpenClawChatCard extends HTMLElement {
this._wakeWord = "hey openclaw";
this._alwaysVoiceMode = false;
this._voiceStatus = "";
+ this._voiceRetryTimer = null;
+ this._voiceRetryCount = 0;
}
// ── HA card interface ───────────────────────────────────────────────
@@ -120,6 +122,10 @@ class OpenClawChatCard extends HTMLElement {
clearTimeout(this._historySyncRetryTimer);
this._historySyncRetryTimer = null;
}
+ if (this._voiceRetryTimer) {
+ clearTimeout(this._voiceRetryTimer);
+ this._voiceRetryTimer = null;
+ }
}
// ── Event subscription ──────────────────────────────────────────────
@@ -445,7 +451,21 @@ class OpenClawChatCard extends HTMLElement {
this._recognition.onerror = (event) => {
console.error("OpenClaw: Speech recognition error:", event.error);
- this._voiceStatus = `Voice error: ${event.error}`;
+ const err = event?.error || "unknown";
+ if (err === "network") {
+ this._voiceStatus =
+ "Voice network error: browser speech service unavailable. Check internet and retry.";
+ } else if (err === "not-allowed") {
+ this._voiceStatus = "Microphone access denied. Allow mic permission for this site.";
+ } else if (err === "audio-capture") {
+ this._voiceStatus = "No microphone available.";
+ } else {
+ this._voiceStatus = `Voice error: ${err}`;
+ }
+
+ if (this._isVoiceMode && ["network", "audio-capture", "no-speech"].includes(err)) {
+ this._scheduleVoiceRetry();
+ }
this._render();
};
@@ -472,10 +492,40 @@ class OpenClawChatCard extends HTMLElement {
this._recognition.abort();
this._recognition = null;
}
+ if (this._voiceRetryTimer) {
+ clearTimeout(this._voiceRetryTimer);
+ this._voiceRetryTimer = null;
+ }
+ this._voiceRetryCount = 0;
this._isVoiceMode = false;
this._voiceStatus = "";
}
+ _scheduleVoiceRetry() {
+ if (!this._isVoiceMode) return;
+ if (this._voiceRetryCount >= 6) {
+ this._voiceStatus =
+ "Voice retry stopped after repeated errors. Toggle voice mode to try again.";
+ this._render();
+ return;
+ }
+
+ if (this._voiceRetryTimer) {
+ clearTimeout(this._voiceRetryTimer);
+ }
+
+ const delayMs = Math.min(1000 * (this._voiceRetryCount + 1), 6000);
+ this._voiceRetryCount += 1;
+ this._voiceStatus = `Voice reconnecting in ${Math.ceil(delayMs / 1000)}s…`;
+ this._render();
+
+ this._voiceRetryTimer = setTimeout(() => {
+ this._voiceRetryTimer = null;
+ if (!this._isVoiceMode) return;
+ this._startVoiceRecognition();
+ }, delayMs);
+ }
+
_toggleVoiceMode() {
this._isVoiceMode = !this._isVoiceMode;
if (this._isVoiceMode) {
diff --git a/www/openclaw-chat-card.js b/www/openclaw-chat-card.js
index ca83f74..b6f3e50 100644
--- a/www/openclaw-chat-card.js
+++ b/www/openclaw-chat-card.js
@@ -1,1007 +1,9 @@
-/**
- * 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, '
$2
')
- // Inline code
- .replace(/`([^`]+)`/g, "$1")
- // Bold
- .replace(/\*\*(.+?)\*\*/g, "$1")
- // Italic
- .replace(/\*(.+?)\*/g, "$1")
- // Links
- .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1')
- // Headers
- .replace(/^### (.+)$/gm, "$1
")
- .replace(/^## (.+)$/gm, "$1
")
- .replace(/^# (.+)$/gm, "$1
")
- // Line breaks
- .replace(/\n/g, "
");
- 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;
- this._historySyncRetryTimer = null;
- this._wakeWordEnabled = false;
- this._wakeWord = "hey openclaw";
- this._alwaysVoiceMode = false;
- this._voiceStatus = "";
- }
-
- // ── 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._syncHistoryFromBackend();
- this._loadIntegrationSettings();
- this._render();
+(async () => {
+ try {
+ if (!customElements.get("openclaw-chat-card")) {
+ await import("/openclaw/openclaw-chat-card.js");
}
+ } catch (err) {
+ console.error("OpenClaw: failed to load chat card bundle from /openclaw/openclaw-chat-card.js", err);
}
-
- getCardSize() {
- return 6;
- }
-
- connectedCallback() {
- this._syncHistoryFromBackend();
- this._loadIntegrationSettings();
- this._render();
- }
-
- disconnectedCallback() {
- this._unsubscribeEvents();
- this._stopVoiceRecognition();
- this._clearThinkingTimer();
- if (this._historySyncRetryTimer) {
- clearTimeout(this._historySyncRetryTimer);
- this._historySyncRetryTimer = null;
- }
- }
-
- // ── 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._getSessionId();
- 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._getSessionId();
- return `${STORAGE_PREFIX}${session}`;
- }
-
- _getSessionId() {
- return this._config.session_id || "default";
- }
-
- _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 = [];
- }
- }
-
- async _syncHistoryFromBackend(retries = 6) {
- if (!this._hass) return;
-
- const sessionId = this._getSessionId();
-
- try {
- let result;
- if (typeof this._hass.callWS === "function") {
- result = await this._hass.callWS({
- type: "openclaw/get_history",
- session_id: sessionId,
- });
- } else {
- result = await this._hass.connection.sendMessagePromise({
- type: "openclaw/get_history",
- session_id: sessionId,
- });
- }
-
- const serverMessages = Array.isArray(result?.messages) ? result.messages : [];
- if (!serverMessages.length) return;
-
- const validMessages = serverMessages.filter(
- (m) => m && (m.role === "user" || m.role === "assistant") && typeof m.content === "string"
- );
- if (!validMessages.length) return;
-
- const shouldReplace =
- validMessages.length > this._messages.length ||
- (validMessages.length === this._messages.length &&
- validMessages.length > 0 &&
- this._messages.length > 0 &&
- (validMessages[validMessages.length - 1].content !==
- this._messages[this._messages.length - 1].content ||
- validMessages[validMessages.length - 1].timestamp !==
- this._messages[this._messages.length - 1].timestamp));
-
- if (shouldReplace) {
- this._messages = validMessages;
- this._isProcessing = false;
- this._clearThinkingTimer();
- this._persistMessages();
- this._render();
- this._scrollToBottom();
- }
- } catch (err) {
- console.debug("OpenClaw: history sync skipped:", err);
- if (retries > 0) {
- if (this._historySyncRetryTimer) {
- clearTimeout(this._historySyncRetryTimer);
- }
- this._historySyncRetryTimer = setTimeout(() => {
- this._syncHistoryFromBackend(retries - 1);
- }, 1500);
- }
- }
- }
-
- async _loadIntegrationSettings() {
- if (!this._hass) return;
-
- try {
- let result;
- if (typeof this._hass.callWS === "function") {
- result = await this._hass.callWS({ type: "openclaw/get_settings" });
- } else {
- result = await this._hass.connection.sendMessagePromise({
- type: "openclaw/get_settings",
- });
- }
-
- this._wakeWordEnabled = !!result?.wake_word_enabled;
- this._wakeWord = (result?.wake_word || "hey openclaw").toString().trim().toLowerCase();
- this._alwaysVoiceMode = !!result?.always_voice_mode;
-
- if (this._alwaysVoiceMode && !this._isVoiceMode) {
- this._isVoiceMode = true;
- this._startVoiceRecognition();
- }
- this._render();
- } catch (err) {
- console.debug("OpenClaw: settings sync skipped:", err);
- }
- }
-
- // ── 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");
- this._voiceStatus = "Speech recognition not supported by this browser.";
- this._render();
- 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._voiceStatus = this._isVoiceMode
- ? `Listening (wake word: ${this._wakeWord || "hey openclaw"})`
- : "Listening…";
-
- this._recognition.onresult = (event) => {
- const result = event.results[event.results.length - 1];
- if (result.isFinal) {
- const text = result[0].transcript?.trim();
- if (!text) return;
-
- const requireWakeWord = this._wakeWordEnabled && this._isVoiceMode;
-
- if (requireWakeWord) {
- const wake = this._wakeWord || "hey openclaw";
- const lower = text.toLowerCase();
- const wakePos = lower.indexOf(wake);
- if (wakePos < 0) {
- this._voiceStatus = `Heard: \"${text}\" (waiting for wake word)`;
- this._render();
- return;
- }
-
- let command = text.slice(wakePos + wake.length).trim();
- command = command.replace(/^[,:;.!?\-]+\s*/, "");
- if (!command) {
- this._voiceStatus = "Wake word detected. Say command after wake word.";
- this._render();
- return;
- }
- this._voiceStatus = "Sending…";
- this._render();
- this._sendMessage(command);
- return;
- }
-
- this._voiceStatus = "Sending…";
- this._render();
- this._sendMessage(text);
- }
- };
-
- this._recognition.onerror = (event) => {
- console.error("OpenClaw: Speech recognition error:", event.error);
- this._voiceStatus = `Voice 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._voiceStatus = "";
- this._render();
- }
- };
-
- this._recognition.start();
- this._render();
- }
-
- _stopVoiceRecognition() {
- if (this._recognition) {
- this._recognition.abort();
- this._recognition = null;
- }
- this._isVoiceMode = false;
- this._voiceStatus = "";
- }
-
- _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 = `
`;
- } else if (isError) {
- contentHtml = `${this._escapeHtml(msg.content)}
`;
- } else if (isUser) {
- contentHtml = this._escapeHtml(msg.content);
- } else {
- contentHtml = renderMarkdown(msg.content);
- }
-
- const timeHtml =
- config.show_timestamps && msg.timestamp
- ? `${this._formatTime(msg.timestamp)}
`
- : "";
-
- return `
-
-
${contentHtml}
- ${timeHtml}
-
`;
- })
- .join("");
-
- const voiceActive = this._recognition !== null;
-
- this.shadowRoot.innerHTML = `
-
-
-
-
-
-
- ${
- messages.length === 0
- ? '
Send a message to start a conversation
'
- : messagesHtml
- }
-
-
- ${this._voiceStatus ? `${this._escapeHtml(this._voiceStatus)}
` : ""}
-
-
-
-
-
-
- `;
-
- // ── 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 = `
-
-
- `;
-
- // 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(/ card?.type === "openclaw-chat-card")) {
- 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;"
-);
+})();