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 = ` - - - -
-

${this._escapeHtml(config.title)}

-
- ${ - config.show_voice_button - ? ` - ` - : "" - } - - ${config.show_clear_button ? '' : ''} -
-
- -
- ${ - 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;" -); +})();