/** * MateClaw WebChat — Embeddable Chat Widget * * Usage: * * */ export interface WebChatConfig { /** API Key (from MateClaw channel config) */ apiKey: string /** MateClaw server URL (e.g., https://your-server.com) */ server: string /** Widget position */ position?: 'bottom-right' | 'bottom-left' /** Primary color (CSS color). Defaults to MateClaw UI token. */ primaryColor?: string /** Widget title */ title?: string /** Placeholder text */ placeholder?: string } interface Message { role: 'user' | 'assistant' content: string } const DEFAULT_CONFIG: Partial = { position: 'bottom-right', primaryColor: 'var(--mc-primary, #D97757)', title: 'MateClaw', placeholder: 'Type a message...', } let config: WebChatConfig let container: HTMLDivElement let visitorId: string let messages: Message[] = [] let isOpen = false let isStreaming = false export function init(userConfig: WebChatConfig) { config = { ...DEFAULT_CONFIG, ...userConfig } visitorId = localStorage.getItem('mc-webchat-visitor') || generateId() localStorage.setItem('mc-webchat-visitor', visitorId) injectStyles() createWidget() } function generateId(): string { return 'v_' + Math.random().toString(36).substring(2, 10) + Date.now().toString(36) } function injectStyles() { const style = document.createElement('style') style.textContent = ` .mc-webchat-bubble, .mc-webchat-panel, .mc-webchat-panel * { box-sizing: border-box; font-family: var(--mc-font-body, 'Inter', 'Avenir Next', 'SF Pro Display', 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif); letter-spacing: 0; } .mc-webchat-bubble { position: fixed; ${config.position === 'bottom-left' ? 'left: 20px' : 'right: 20px'}; bottom: 20px; width: 56px; height: 56px; border-radius: 50%; background: ${config.primaryColor}; color: var(--mc-text-inverse, #ffffff); border: none; cursor: pointer; box-shadow: var(--mc-shadow-medium, 0 18px 48px rgba(58, 32, 19, 0.12)); display: flex; align-items: center; justify-content: center; z-index: 99999; transition: transform 0.2s; } .mc-webchat-bubble:hover { transform: scale(1.1); } .mc-webchat-panel { position: fixed; ${config.position === 'bottom-left' ? 'left: 20px' : 'right: 20px'}; bottom: 88px; width: 380px; height: 520px; background: var(--mc-bg-elevated, #ffffff); color: var(--mc-text-primary, #1d1612); border: 1px solid var(--mc-border-light, #ebe3db); border-radius: var(--mc-radius-md, 12px); box-shadow: var(--mc-shadow-strong, 0 24px 70px rgba(58, 32, 19, 0.16)); display: flex; flex-direction: column; z-index: 99999; overflow: hidden; } .mc-webchat-header { padding: 14px 16px; background: var(--mc-chat-header-bg, #ffffff); color: var(--mc-text-primary, #1d1612); font-weight: 600; font-size: 15px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--mc-border-light, #ebe3db); } .mc-webchat-close { background: none; border: none; color: var(--mc-text-secondary, #665245); cursor: pointer; font-size: 18px; padding: 0 4px; opacity: 0.8; } .mc-webchat-close:hover { opacity: 1; } .mc-webchat-messages { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; background: var(--mc-chat-bg, #FAFAF8); } .mc-webchat-msg { max-width: 85%; padding: 8px 12px; border-radius: var(--mc-radius-md, 12px); font-size: var(--mc-text-sm, 13px); line-height: 1.5; word-break: break-word; white-space: pre-wrap; } .mc-webchat-msg--user { align-self: flex-end; background: var(--mc-user-bubble-bg, ${config.primaryColor}); color: var(--mc-user-bubble-color, #ffffff); border-bottom-right-radius: 4px; } .mc-webchat-msg--assistant { align-self: flex-start; background: var(--mc-assistant-bubble-bg, #ffffff); color: var(--mc-assistant-bubble-color, #1C1410); border: 1px solid var(--mc-assistant-bubble-border, #DDD5CC); border-bottom-left-radius: 4px; } .mc-webchat-input-area { padding: 10px 12px; border-top: 1px solid var(--mc-border-light, #ebe3db); display: flex; gap: 8px; background: var(--mc-bg-elevated, #ffffff); } .mc-webchat-input { flex: 1; padding: 8px 12px; border: 1px solid var(--mc-input-border, #DDD5CC); border-radius: var(--mc-radius-full, 9999px); background: var(--mc-input-bg, #FAFAF8); color: var(--mc-input-text, #1C1410); font-size: var(--mc-text-sm, 13px); outline: none; } .mc-webchat-input:focus { border-color: ${config.primaryColor}; } .mc-webchat-send { width: 36px; height: 36px; border-radius: 50%; background: ${config.primaryColor}; color: var(--mc-text-inverse, #ffffff); border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; } .mc-webchat-send:disabled { opacity: 0.5; cursor: not-allowed; } @media (max-width: 480px) { .mc-webchat-panel { width: calc(100vw - 24px); left: 12px; right: 12px; bottom: 80px; height: 60vh; } } ` document.head.appendChild(style) } function createWidget() { container = document.createElement('div') container.id = 'mc-webchat-root' // Bubble button const bubble = document.createElement('button') bubble.className = 'mc-webchat-bubble' bubble.innerHTML = `` bubble.onclick = () => togglePanel() container.appendChild(bubble) document.body.appendChild(container) } function togglePanel() { isOpen = !isOpen const existing = container.querySelector('.mc-webchat-panel') if (isOpen && !existing) { createPanel() } else if (!isOpen && existing) { existing.remove() } } function createPanel() { const panel = document.createElement('div') panel.className = 'mc-webchat-panel' panel.innerHTML = `
${config.title}
` panel.querySelector('.mc-webchat-close')!.addEventListener('click', togglePanel) const input = panel.querySelector('#mc-input') as HTMLInputElement const sendBtn = panel.querySelector('#mc-send') as HTMLButtonElement input.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() sendMessage(input.value) } }) sendBtn.addEventListener('click', () => sendMessage(input.value)) container.appendChild(panel) // Render existing messages renderMessages() } function renderMessages() { const el = document.getElementById('mc-messages') if (!el) return el.innerHTML = '' messages.forEach((msg) => { const div = document.createElement('div') div.className = `mc-webchat-msg mc-webchat-msg--${msg.role}` div.textContent = msg.content el.appendChild(div) }) el.scrollTop = el.scrollHeight } function appendMessage(role: 'user' | 'assistant', content: string) { messages.push({ role, content }) const el = document.getElementById('mc-messages') if (!el) return const div = document.createElement('div') div.className = `mc-webchat-msg mc-webchat-msg--${role}` div.textContent = content el.appendChild(div) el.scrollTop = el.scrollHeight return div } function updateLastAssistant(content: string) { const el = document.getElementById('mc-messages') if (!el) return const last = el.querySelector('.mc-webchat-msg--assistant:last-child') if (last) { last.textContent = content el.scrollTop = el.scrollHeight } } async function sendMessage(text: string) { text = text.trim() if (!text || isStreaming) return const input = document.getElementById('mc-input') as HTMLInputElement if (input) input.value = '' appendMessage('user', text) isStreaming = true // Create assistant message placeholder appendMessage('assistant', '...') let fullContent = '' try { const response = await fetch(`${config.server}/api/v1/channels/webchat/stream`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-MC-Key': config.apiKey, Accept: 'text/event-stream', }, body: JSON.stringify({ message: text, visitorId }), }) if (!response.ok) { throw new Error(`HTTP ${response.status}`) } const reader = response.body?.getReader() const decoder = new TextDecoder() if (!reader) throw new Error('No response body') let buffer = '' while (true) { const { done, value } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) const lines = buffer.split('\n') buffer = lines.pop() || '' for (const line of lines) { if (line.startsWith('data:')) { const data = line.slice(5).trim() if (!data) continue try { const parsed = JSON.parse(data) if (parsed.text) { fullContent += parsed.text updateLastAssistant(fullContent) } } catch { // not JSON, might be raw text } } else if (line.startsWith('event:')) { const eventType = line.slice(6).trim() if (eventType === 'done') { break } } } } // Update the stored message if (messages.length > 0) { messages[messages.length - 1].content = fullContent || '(no response)' } } catch (e: any) { updateLastAssistant(`Error: ${e.message}`) if (messages.length > 0) { messages[messages.length - 1].content = `Error: ${e.message}` } } finally { isStreaming = false } } // Auto-export for UMD if (typeof window !== 'undefined') { ;(window as any).MateClawWebChat = { init } }