/**
* 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._wakeWordEnabled = false;
this._wakeWord = "hey openclaw";
this._alwaysVoiceMode = false;
}
// ── 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();
}
}
getCardSize() {
return 6;
}
connectedCallback() {
this._syncHistoryFromBackend();
this._loadIntegrationSettings();
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._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() {
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;
if (validMessages.length > this._messages.length) {
this._messages = validMessages;
this._isProcessing = false;
this._clearThinkingTimer();
this._persistMessages();
this._render();
this._scrollToBottom();
}
} catch (err) {
console.debug("OpenClaw: history sync skipped:", err);
}
}
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");
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?.trim();
if (!text) return;
if (this._wakeWordEnabled) {
const wake = this._wakeWord || "hey openclaw";
const lower = text.toLowerCase();
const wakePos = lower.indexOf(wake);
if (wakePos < 0) {
return;
}
let command = text.slice(wakePos + wake.length).trim();
command = command.replace(/^[,:;.!?\-]+\s*/, "");
if (!command) {
return;
}
this._sendMessage(command);
return;
}
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 = `
`;
} 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
}
`;
// ── 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;"
);