mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-14 03:33:43 +08:00
356 lines
9.6 KiB
TypeScript
356 lines
9.6 KiB
TypeScript
/**
|
|
* MateClaw WebChat — Embeddable Chat Widget
|
|
*
|
|
* Usage:
|
|
* <script src="https://your-server/mateclaw-webchat.umd.js"></script>
|
|
* <script>
|
|
* MateClawWebChat.init({ apiKey: 'your-key', server: 'https://your-server' })
|
|
* </script>
|
|
*/
|
|
|
|
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 (hex) */
|
|
primaryColor?: string
|
|
/** Widget title */
|
|
title?: string
|
|
/** Placeholder text */
|
|
placeholder?: string
|
|
}
|
|
|
|
interface Message {
|
|
role: 'user' | 'assistant'
|
|
content: string
|
|
}
|
|
|
|
const DEFAULT_CONFIG: Partial<WebChatConfig> = {
|
|
position: 'bottom-right',
|
|
primaryColor: '#409eff',
|
|
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 {
|
|
position: fixed;
|
|
${config.position === 'bottom-left' ? 'left: 20px' : 'right: 20px'};
|
|
bottom: 20px;
|
|
width: 56px;
|
|
height: 56px;
|
|
border-radius: 50%;
|
|
background: ${config.primaryColor};
|
|
color: white;
|
|
border: none;
|
|
cursor: pointer;
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
|
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: white;
|
|
border-radius: 12px;
|
|
box-shadow: 0 8px 32px rgba(0,0,0,0.12);
|
|
display: flex;
|
|
flex-direction: column;
|
|
z-index: 99999;
|
|
overflow: hidden;
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
}
|
|
.mc-webchat-header {
|
|
padding: 14px 16px;
|
|
background: ${config.primaryColor};
|
|
color: white;
|
|
font-weight: 600;
|
|
font-size: 15px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
.mc-webchat-close {
|
|
background: none;
|
|
border: none;
|
|
color: white;
|
|
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;
|
|
}
|
|
.mc-webchat-msg {
|
|
max-width: 85%;
|
|
padding: 8px 12px;
|
|
border-radius: 12px;
|
|
font-size: 14px;
|
|
line-height: 1.5;
|
|
word-break: break-word;
|
|
white-space: pre-wrap;
|
|
}
|
|
.mc-webchat-msg--user {
|
|
align-self: flex-end;
|
|
background: ${config.primaryColor};
|
|
color: white;
|
|
border-bottom-right-radius: 4px;
|
|
}
|
|
.mc-webchat-msg--assistant {
|
|
align-self: flex-start;
|
|
background: #f0f2f5;
|
|
color: #333;
|
|
border-bottom-left-radius: 4px;
|
|
}
|
|
.mc-webchat-input-area {
|
|
padding: 10px 12px;
|
|
border-top: 1px solid #eee;
|
|
display: flex;
|
|
gap: 8px;
|
|
}
|
|
.mc-webchat-input {
|
|
flex: 1;
|
|
padding: 8px 12px;
|
|
border: 1px solid #ddd;
|
|
border-radius: 20px;
|
|
font-size: 14px;
|
|
outline: none;
|
|
}
|
|
.mc-webchat-input:focus { border-color: ${config.primaryColor}; }
|
|
.mc-webchat-send {
|
|
width: 36px;
|
|
height: 36px;
|
|
border-radius: 50%;
|
|
background: ${config.primaryColor};
|
|
color: white;
|
|
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 = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>`
|
|
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 = `
|
|
<div class="mc-webchat-header">
|
|
<span>${config.title}</span>
|
|
<button class="mc-webchat-close">×</button>
|
|
</div>
|
|
<div class="mc-webchat-messages" id="mc-messages"></div>
|
|
<div class="mc-webchat-input-area">
|
|
<input class="mc-webchat-input" placeholder="${config.placeholder}" id="mc-input" />
|
|
<button class="mc-webchat-send" id="mc-send">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
|
</button>
|
|
</div>
|
|
`
|
|
|
|
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 }
|
|
}
|