(function () { "use strict"; // Prevent duplicate initialization if (document.getElementById("langbot-widget-root")) return; // Read config from script tag data attributes var scriptEl = document.currentScript; var scriptTitle = scriptEl ? scriptEl.getAttribute("data-title") : null; // ========== i18n ========== var I18N = { en_US: { welcomeMessage: "Send a message to start the conversation", inputPlaceholder: "Type a message...", openChat: "Open chat", resetConversation: "Reset conversation", minimize: "Minimize", uploadFile: "Upload file", send: "Send", failedToConnect: "Failed to connect", imageTooLarge: "Image must be under 5MB", onlyImages: "Only image files are supported", botVerificationFailed: "Bot verification failed", botVerificationNetworkError: "Bot verification network error", botVerificationError: "Bot verification error", poweredBy: 'Powered by LangBot', }, zh_Hans: { welcomeMessage: "发送消息开始对话", inputPlaceholder: "输入消息...", openChat: "打开聊天", resetConversation: "重置对话", minimize: "最小化", uploadFile: "上传文件", send: "发送", failedToConnect: "连接失败", imageTooLarge: "图片大小不能超过 5MB", onlyImages: "仅支持图片文件", botVerificationFailed: "机器人验证失败", botVerificationNetworkError: "机器人验证网络错误", botVerificationError: "机器人验证错误", poweredBy: '由 LangBot 提供支持', }, zh_Hant: { welcomeMessage: "傳送訊息開始對話", inputPlaceholder: "輸入訊息...", openChat: "開啟聊天", resetConversation: "重置對話", minimize: "最小化", uploadFile: "上傳檔案", send: "傳送", failedToConnect: "連線失敗", imageTooLarge: "圖片大小不能超過 5MB", onlyImages: "僅支援圖片檔案", botVerificationFailed: "機器人驗證失敗", botVerificationNetworkError: "機器人驗證網路錯誤", botVerificationError: "機器人驗證錯誤", poweredBy: '由 LangBot 提供支持', }, ja_JP: { welcomeMessage: "メッセージを送信して会話を始めましょう", inputPlaceholder: "メッセージを入力...", openChat: "チャットを開く", resetConversation: "会話をリセット", minimize: "最小化", uploadFile: "ファイルをアップロード", send: "送信", failedToConnect: "接続に失敗しました", imageTooLarge: "画像は5MB以下にしてください", onlyImages: "画像ファイルのみ対応しています", botVerificationFailed: "ボット認証に失敗しました", botVerificationNetworkError: "ボット認証のネットワークエラー", botVerificationError: "ボット認証エラー", poweredBy: 'LangBot で動作', }, es_ES: { welcomeMessage: "Envía un mensaje para iniciar la conversación", inputPlaceholder: "Escribe un mensaje...", openChat: "Abrir chat", resetConversation: "Reiniciar conversación", minimize: "Minimizar", uploadFile: "Subir archivo", send: "Enviar", failedToConnect: "Error de conexión", imageTooLarge: "La imagen debe ser menor a 5MB", onlyImages: "Solo se admiten archivos de imagen", botVerificationFailed: "Verificación del bot fallida", botVerificationNetworkError: "Error de red en verificación del bot", botVerificationError: "Error de verificación del bot", poweredBy: 'Desarrollado con LangBot', }, ru_RU: { welcomeMessage: "Отправьте сообщение, чтобы начать разговор", inputPlaceholder: "Введите сообщение...", openChat: "Открыть чат", resetConversation: "Сбросить разговор", minimize: "Свернуть", uploadFile: "Загрузить файл", send: "Отправить", failedToConnect: "Ошибка подключения", imageTooLarge: "Изображение должно быть менее 5МБ", onlyImages: "Поддерживаются только изображения", botVerificationFailed: "Проверка бота не пройдена", botVerificationNetworkError: "Ошибка сети при проверке бота", botVerificationError: "Ошибка проверки бота", poweredBy: 'Работает на LangBot', }, th_TH: { welcomeMessage: "ส่งข้อความเพื่อเริ่มการสนทนา", inputPlaceholder: "พิมพ์ข้อความ...", openChat: "เปิดแชท", resetConversation: "รีเซ็ตการสนทนา", minimize: "ย่อ", uploadFile: "อัปโหลดไฟล์", send: "ส่ง", failedToConnect: "เชื่อมต่อไม่สำเร็จ", imageTooLarge: "รูปภาพต้องมีขนาดไม่เกิน 5MB", onlyImages: "รองรับเฉพาะไฟล์รูปภาพเท่านั้น", botVerificationFailed: "การยืนยันบอทล้มเหลว", botVerificationNetworkError: "เกิดข้อผิดพลาดเครือข่ายในการยืนยันบอท", botVerificationError: "เกิดข้อผิดพลาดในการยืนยันบอท", poweredBy: 'ขับเคลื่อนโดย LangBot', }, vi_VN: { welcomeMessage: "Gửi tin nhắn để bắt đầu cuộc trò chuyện", inputPlaceholder: "Nhập tin nhắn...", openChat: "Mở trò chuyện", resetConversation: "Đặt lại cuộc trò chuyện", minimize: "Thu nhỏ", uploadFile: "Tải lên tệp", send: "Gửi", failedToConnect: "Kết nối thất bại", imageTooLarge: "Hình ảnh phải nhỏ hơn 5MB", onlyImages: "Chỉ hỗ trợ tệp hình ảnh", botVerificationFailed: "Xác minh bot thất bại", botVerificationNetworkError: "Lỗi mạng khi xác minh bot", botVerificationError: "Lỗi xác minh bot", poweredBy: 'Được hỗ trợ bởi LangBot', }, }; var _locale = "__LANGBOT_LOCALE__"; var _strings = I18N[_locale] || I18N.en_US; function t(key) { return _strings[key] || I18N.en_US[key] || key; } // ========== Configuration (injected by backend) ========== var CONFIG = { botUuid: "__LANGBOT_BOT_UUID__", baseUrl: "__LANGBOT_BASE_URL__", sessionType: "person", title: scriptTitle || "LangBot", logoUrl: "__LANGBOT_BASE_URL__" + "/api/v1/embed/logo", maxReconnectAttempts: 5, reconnectDelay: 3000, heartbeatInterval: 30000, turnstileSiteKey: "__LANGBOT_TURNSTILE_SITE_KEY__", bubbleIcon: "__LANGBOT_BUBBLE_ICON__", }; // ========== Styles ========== var STYLES = '\ :host { all: initial; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-size: 14px; line-height: 1.5; color: #1a1a1a; }\ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }\ .lb-bubble { position: fixed; bottom: 20px; right: 20px; width: 56px; height: 56px; border-radius: 50%; background: #2563eb; color: #fff; border: none; cursor: pointer; box-shadow: 0 4px 12px rgba(37,99,235,0.4); display: flex; align-items: center; justify-content: center; z-index: 2147483646; transition: transform 0.2s ease, box-shadow 0.2s ease; overflow: hidden; }\ .lb-bubble:hover { transform: scale(1.08); box-shadow: 0 6px 20px rgba(37,99,235,0.5); }\ .lb-bubble svg { width: 28px; height: 28px; fill: currentColor; }\ .lb-chat-icon { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }\ .lb-bubble .lb-close-icon { display: none; }\ .lb-bubble.lb-open .lb-chat-icon { display: none; }\ .lb-bubble.lb-open .lb-close-icon { display: block; }\ .lb-panel { position: fixed; bottom: 88px; right: 20px; width: 400px; height: 600px; max-height: calc(100vh - 108px); background: #fff; border-radius: 16px; box-shadow: 0 8px 40px rgba(0,0,0,0.15); display: flex; flex-direction: column; z-index: 2147483646; overflow: hidden; opacity: 0; transform: translateY(16px) scale(0.95); pointer-events: none; transition: opacity 0.25s ease, transform 0.25s ease; }\ .lb-panel.lb-visible { opacity: 1; transform: translateY(0) scale(1); pointer-events: auto; }\ .lb-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; background: #2563eb; color: #fff; flex-shrink: 0; }\ .lb-header-left { display: flex; align-items: center; gap: 10px; }\ .lb-header-logo { width: 28px; height: 28px; border-radius: 6px; object-fit: cover; }\ .lb-header-title { font-size: 16px; font-weight: 600; }\ .lb-status-dot { width: 8px; height: 8px; border-radius: 50%; background: #fbbf24; flex-shrink: 0; }\ .lb-status-dot.lb-connected { background: #34d399; }\ .lb-header-actions { display: flex; align-items: center; gap: 8px; }\ .lb-header-btn { background: none; border: none; color: #fff; cursor: pointer; padding: 4px; border-radius: 6px; display: flex; align-items: center; justify-content: center; opacity: 0.8; transition: opacity 0.15s; }\ .lb-header-btn:hover { opacity: 1; }\ .lb-header-btn svg { width: 18px; height: 18px; fill: currentColor; }\ .lb-messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 16px; scroll-behavior: smooth; }\ .lb-messages::-webkit-scrollbar { width: 6px; }\ .lb-messages::-webkit-scrollbar-track { background: transparent; }\ .lb-messages::-webkit-scrollbar-thumb { background: #d1d5db; border-radius: 3px; }\ .lb-msg { display: flex; gap: 10px; animation: lb-fade-in 0.2s ease; max-width: 100%; }\ @keyframes lb-fade-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }\ .lb-msg-user { flex-direction: row-reverse; }\ .lb-msg-assistant { flex-direction: row; }\ .lb-avatar { width: 32px; height: 32px; border-radius: 50%; flex-shrink: 0; display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: 600; overflow: hidden; }\ .lb-avatar svg { width: 18px; height: 18px; fill: #fff; }\ .lb-avatar img { width: 100%; height: 100%; object-fit: cover; }\ .lb-avatar-user { background: #6366f1; color: #fff; }\ .lb-avatar-bot { background: #5b9bd5; color: #fff; }\ .lb-msg-body { display: flex; flex-direction: column; max-width: calc(100% - 42px); min-width: 0; }\ .lb-msg-user .lb-msg-body { align-items: flex-end; }\ .lb-msg-assistant .lb-msg-body { align-items: flex-start; }\ .lb-msg-bubble { padding: 10px 14px; border-radius: 12px; word-break: break-word; white-space: pre-wrap; font-size: 14px; line-height: 1.6; max-width: 100%; }\ .lb-msg-user .lb-msg-bubble { background: #2563eb; color: #fff; border-bottom-right-radius: 4px; }\ .lb-msg-assistant .lb-msg-bubble { background: #f3f4f6; color: #1a1a1a; border-bottom-left-radius: 4px; }\ .lb-msg-bubble code { font-family: "SF Mono", Monaco, Consolas, monospace; font-size: 0.9em; background: rgba(0,0,0,0.06); padding: 1px 4px; border-radius: 3px; }\ .lb-msg-user .lb-msg-bubble code { background: rgba(255,255,255,0.2); }\ .lb-msg-bubble pre { background: #1e293b; color: #e2e8f0; padding: 12px; border-radius: 8px; overflow-x: auto; margin: 8px 0; font-size: 13px; }\ .lb-msg-bubble pre code { background: none; padding: 0; color: inherit; }\ .lb-msg-bubble a { color: #2563eb; text-decoration: underline; }\ .lb-msg-user .lb-msg-bubble a { color: #bfdbfe; }\ .lb-msg-bubble h3 { font-size: 15px; font-weight: 600; margin: 8px 0 4px; }\ .lb-msg-bubble h4 { font-size: 14px; font-weight: 600; margin: 6px 0 3px; }\ .lb-msg-bubble blockquote { border-left: 3px solid #d1d5db; padding-left: 10px; margin: 6px 0; color: #6b7280; }\ .lb-msg-bubble ul, .lb-msg-bubble ol { padding-left: 20px; margin: 4px 0; }\ .lb-msg-bubble li { margin: 2px 0; }\ .lb-msg-bubble table { border-collapse: collapse; margin: 8px 0; font-size: 13px; width: 100%; }\ .lb-msg-bubble th, .lb-msg-bubble td { border: 1px solid #d1d5db; padding: 4px 8px; text-align: left; }\ .lb-msg-bubble th { background: #f3f4f6; font-weight: 600; }\ .lb-msg-bubble hr { border: none; border-top: 1px solid #d1d5db; margin: 8px 0; }\ .lb-msg-bubble del { text-decoration: line-through; opacity: 0.7; }\ .lb-msg-bubble img { max-width: 100%; border-radius: 8px; margin: 4px 0; cursor: pointer; }\ .lb-msg-actions { display: flex; align-items: center; gap: 4px; margin-top: 6px; padding-top: 6px; border-top: 1px solid rgba(0,0,0,0.06); }\ .lb-msg-actions-hidden { display: none; }\ .lb-act-btn { background: none; border: 1px solid #e5e7eb; color: #9ca3af; cursor: pointer; padding: 3px 6px; border-radius: 6px; display: flex; align-items: center; gap: 3px; font-size: 11px; transition: all 0.15s; }\ .lb-act-btn:hover { background: #f3f4f6; color: #6b7280; border-color: #d1d5db; }\ .lb-act-btn.lb-active { color: #2563eb; border-color: #93c5fd; background: #eff6ff; }\ .lb-act-btn svg { width: 14px; height: 14px; fill: currentColor; }\ .lb-img-upload-btn { background: none; border: none; color: #9ca3af; cursor: pointer; padding: 6px; border-radius: 8px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; transition: color 0.15s; }\ .lb-img-upload-btn:hover { color: #6b7280; }\ .lb-img-upload-btn svg { width: 20px; height: 20px; fill: currentColor; }\ .lb-img-preview { display: flex; align-items: center; gap: 8px; padding: 8px 16px; border-top: 1px solid #e5e7eb; background: #fafafa; flex-shrink: 0; }\ .lb-img-preview img { width: 48px; height: 48px; object-fit: cover; border-radius: 6px; }\ .lb-img-preview-remove { background: none; border: none; color: #9ca3af; cursor: pointer; font-size: 18px; padding: 0 4px; }\ .lb-img-preview-remove:hover { color: #ef4444; }\ .lb-msg-meta { display: flex; align-items: center; gap: 8px; margin-top: 4px; padding: 0 2px; }\ .lb-msg-time { font-size: 11px; color: #9ca3af; }\ .lb-footer { text-align: right; padding: 6px 12px; font-size: 9px; color: #d1d5db; font-style: italic; flex-shrink: 0; }\ .lb-footer a { color: #d1d5db; text-decoration: none; }\ .lb-footer a:hover { color: #9ca3af; }\ .lb-typing { display: inline-flex; gap: 4px; padding: 10px 14px; background: #f3f4f6; border-radius: 12px; border-bottom-left-radius: 4px; margin-left: 42px; }\ .lb-typing span { width: 6px; height: 6px; background: #9ca3af; border-radius: 50%; animation: lb-bounce 1.4s infinite both; }\ .lb-typing span:nth-child(2) { animation-delay: 0.16s; }\ .lb-typing span:nth-child(3) { animation-delay: 0.32s; }\ @keyframes lb-bounce { 0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; } 40% { transform: scale(1); opacity: 1; } }\ .lb-welcome { text-align: center; color: #9ca3af; padding: 40px 20px; font-size: 14px; }\ .lb-welcome-logo { width: 48px; height: 48px; border-radius: 12px; margin: 0 auto 12px; }\ .lb-input-area { display: flex; align-items: flex-end; gap: 8px; padding: 12px 16px; border-top: 1px solid #e5e7eb; background: #fff; flex-shrink: 0; }\ .lb-input { flex: 1; border: 1px solid #d1d5db; border-radius: 10px; padding: 10px 14px; font-size: 14px; font-family: inherit; line-height: 1.4; resize: none; outline: none; max-height: 120px; min-height: 40px; transition: border-color 0.15s; overflow-y: auto; }\ .lb-input:focus { border-color: #2563eb; }\ .lb-input::placeholder { color: #9ca3af; }\ .lb-send-btn { width: 40px; height: 40px; border-radius: 10px; background: #2563eb; color: #fff; border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; flex-shrink: 0; transition: background 0.15s, opacity 0.15s; }\ .lb-send-btn:hover { background: #1d4ed8; }\ .lb-send-btn:disabled { opacity: 0.4; cursor: not-allowed; }\ .lb-send-btn svg { width: 20px; height: 20px; fill: currentColor; }\ .lb-error { text-align: center; color: #ef4444; padding: 8px; font-size: 12px; background: #fef2f2; border-radius: 8px; margin: 4px 16px; }\ @media (max-width: 480px) {\ .lb-panel { bottom: 0; right: 0; width: 100vw; height: 100vh; max-height: 100vh; border-radius: 0; }\ .lb-bubble { bottom: 16px; right: 16px; }\ }\ '; // ========== Bubble Icon Presets ========== var BUBBLE_ICONS = { logo: null, chat: '', robot: '', headset: '', sparkle: '', message: '', }; // ========== SVG Icons ========== var ICON_CLOSE = ''; var ICON_SEND = ''; var ICON_RESET = ''; var ICON_USER = ''; var ICON_THUMB_UP = ''; var ICON_THUMB_DOWN = ''; var ICON_COPY = ''; var ICON_CHECK = ''; var ICON_IMAGE = ''; // ========== State ========== var state = { isOpen: false, isConnected: false, ws: null, connectionId: null, reconnectAttempts: 0, heartbeatTimer: null, messages: [], nextLocalId: 1, isStreaming: false, streamingMsgId: null, historyLoaded: false, pendingImage: null, feedbackState: {}, }; // ========== DOM References ========== var els = {}; // ========== Utility Functions ========== function esc(str) { var div = document.createElement("div"); div.textContent = str; return div.innerHTML; } function formatTime(ts) { try { var d = new Date(ts); return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } catch (e) { return ""; } } function renderMarkdown(text) { if (!text) return ""; // Preserve code blocks first var codeBlocks = []; text = text.replace(/```(\w*)\n?([\s\S]*?)```/g, function (m, lang, code) { codeBlocks.push("
" + esc(code.trim()) + "
"); return "\x00CB" + (codeBlocks.length - 1) + "\x00"; }); var html = esc(text); // Restore code blocks html = html.replace(/\x00CB(\d+)\x00/g, function (m, i) { return codeBlocks[parseInt(i)]; }); // Inline code html = html.replace(/`([^`]+)`/g, "$1"); // Headings html = html.replace(/^### (.+)$/gm, "

$1

"); html = html.replace(/^## (.+)$/gm, "

$1

"); html = html.replace(/^# (.+)$/gm, "

$1

"); // Horizontal rules html = html.replace(/^---$/gm, "
"); // Blockquotes html = html.replace(/^> (.+)$/gm, "
$1
"); // Tables html = html.replace(/((?:\|.+\|\n?)+)/g, function (table) { var rows = table.trim().split("\n"); if (rows.length < 2) return table; var out = ""; for (var r = 0; r < rows.length; r++) { if (r === 1 && /^\|[\s\-:|]+\|$/.test(rows[r])) continue; var cells = rows[r].split("|").filter(function (c, i, a) { return i > 0 && i < a.length - 1; }); var tag = r === 0 ? "th" : "td"; out += "" + cells .map(function (c) { return "<" + tag + ">" + c.trim() + ""; }) .join("") + ""; } return out + "
"; }); // Strikethrough html = html.replace(/~~([^~]+)~~/g, "$1"); // Bold html = html.replace(/\*\*([^*]+)\*\*/g, "$1"); // Italic html = html.replace(/\*([^*]+)\*/g, "$1"); // Links html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function (match, p1, p2) { if (/^https?:\/\//i.test(p2)) { return ( '' + p1 + "" ); } return p1; }); // Unordered lists html = html.replace(/((?:^[\-\*] .+(?:
)?)+)/gm, function (block) { var items = block.split(/
|\\n/).filter(function (l) { return /^[\-\*] /.test(l.trim()); }); return ( "" ); }); // Ordered lists html = html.replace(/((?:^\d+\. .+(?:
)?)+)/gm, function (block) { var items = block.split(/
|\\n/).filter(function (l) { return /^\d+\. /.test(l.trim()); }); return ( "
    " + items .map(function (l) { return "
  1. " + l.replace(/^\d+\. /, "") + "
  2. "; }) .join("") + "
" ); }); // Line breaks (but not inside block elements) html = html.replace(/\n/g, "
"); // Clean up excessive
around block elements html = html.replace( /
\s*(<(?:h[34]|pre|table|ul|ol|blockquote|hr))/g, "$1", ); html = html.replace( /(<\/(?:h[34]|pre|table|ul|ol|blockquote)>)\s*
/g, "$1", ); return html; } function scrollToBottom() { if (els.messages) { requestAnimationFrame(function () { els.messages.scrollTop = els.messages.scrollHeight; }); } } // ========== WebSocket Client ========== function wsConnect() { if ( state.ws && (state.ws.readyState === WebSocket.OPEN || state.ws.readyState === WebSocket.CONNECTING) ) { return; } var protocol = CONFIG.baseUrl.indexOf("https") === 0 ? "wss:" : "ws:"; var host = CONFIG.baseUrl.replace(/^https?:\/\//, ""); var url = protocol + "//" + host + "/api/v1/embed/" + CONFIG.botUuid + "/ws/connect?session_type=" + CONFIG.sessionType; try { state.ws = new WebSocket(url); } catch (e) { showError(t("failedToConnect")); return; } state.ws.onopen = function () { state.reconnectAttempts = 0; startHeartbeat(); }; state.ws.onmessage = function (event) { try { var data = JSON.parse(event.data); handleWsMessage(data); } catch (e) { // ignore parse errors } }; state.ws.onclose = function () { state.isConnected = false; updateStatusDot(); updateSendBtn(); stopHeartbeat(); if (state.reconnectAttempts < CONFIG.maxReconnectAttempts) { state.reconnectAttempts++; setTimeout(wsConnect, CONFIG.reconnectDelay * state.reconnectAttempts); } }; state.ws.onerror = function () { state.isConnected = false; updateStatusDot(); updateSendBtn(); }; } function handleWsMessage(data) { switch (data.type) { case "connected": state.isConnected = true; state.connectionId = data.connection_id; updateStatusDot(); updateSendBtn(); break; case "response": if (data.session_type && data.session_type !== CONFIG.sessionType) break; if (data.data) handleAssistantMessage(data.data); break; case "user_message": if (data.session_type && data.session_type !== CONFIG.sessionType) break; // Only show messages from OTHER connections (own messages are added locally) if (data.data && data.data.connection_id !== state.connectionId) { addMessage(data.data); } break; case "pong": break; case "error": showError(data.message || "Unknown error"); break; } } function handleAssistantMessage(msg) { // Streaming: update existing message with same id var existingIdx = -1; for (var i = state.messages.length - 1; i >= 0; i--) { if ( state.messages[i].id === msg.id && state.messages[i].role === "assistant" ) { existingIdx = i; break; } } // Deduplicate: if any assistant message since last user message has the same content, skip if (existingIdx < 0) { var content = (msg.content || extractText(msg)) .replace(/\s+/g, " ") .trim(); if (content) { for (var j = state.messages.length - 1; j >= 0; j--) { var prev = state.messages[j]; if (prev.role === "user") break; if (prev.role === "assistant") { var prevContent = (prev.content || extractText(prev)) .replace(/\s+/g, " ") .trim(); if ( prevContent === content || prevContent.indexOf(content) >= 0 || content.indexOf(prevContent) >= 0 ) return; } } } } if (existingIdx >= 0) { state.messages[existingIdx] = msg; updateMessageEl(existingIdx, msg); } else { addMessage(msg); } state.isStreaming = !msg.is_final; state.streamingMsgId = msg.is_final ? null : msg.id; if (msg.is_final) { removeTypingIndicator(); } scrollToBottom(); } function sendMessage(text, imageBase64) { if (!state.ws || state.ws.readyState !== WebSocket.OPEN) return; if (!text.trim() && !imageBase64) return; var chain = []; if (text.trim()) chain.push({ type: "Plain", text: text.trim() }); if (imageBase64) chain.push({ type: "Image", base64: imageBase64 }); var localMsg = { id: "local_" + state.nextLocalId++, role: "user", content: text.trim(), message_chain: chain, timestamp: new Date().toISOString(), is_final: true, }; addMessage(localMsg); state.ws.send( JSON.stringify({ type: "message", message: chain, stream: true }), ); } function startHeartbeat() { stopHeartbeat(); state.heartbeatTimer = setInterval(function () { if (state.ws && state.ws.readyState === WebSocket.OPEN) { state.ws.send(JSON.stringify({ type: "ping" })); } }, CONFIG.heartbeatInterval); } function stopHeartbeat() { if (state.heartbeatTimer) { clearInterval(state.heartbeatTimer); state.heartbeatTimer = null; } } function wsDisconnect() { stopHeartbeat(); state.reconnectAttempts = CONFIG.maxReconnectAttempts; if (state.ws) { if (state.ws.readyState === WebSocket.OPEN) { state.ws.send(JSON.stringify({ type: "disconnect" })); } state.ws.close(); state.ws = null; } state.isConnected = false; state.connectionId = null; } // ========== Message History ========== function loadHistory() { if (state.historyLoaded) return; state.historyLoaded = true; var url = CONFIG.baseUrl + "/api/v1/embed/" + CONFIG.botUuid + "/messages/" + CONFIG.sessionType; var headers = {}; if (state.sessionToken) headers["Authorization"] = "Bearer " + state.sessionToken; fetch(url, { headers: headers }) .then(function (res) { return res.json(); }) .then(function (json) { if (json.code === 0 && json.data && json.data.messages) { var msgs = json.data.messages; for (var i = 0; i < msgs.length; i++) { addMessage(msgs[i], true); } scrollToBottom(); } }) .catch(function () { // silently ignore history load errors }); } function resetSession() { var url = CONFIG.baseUrl + "/api/v1/embed/" + CONFIG.botUuid + "/reset/" + CONFIG.sessionType; var headers = {}; if (state.sessionToken) headers["Authorization"] = "Bearer " + state.sessionToken; fetch(url, { method: "POST", headers: headers }) .then(function () { state.messages = []; state.isStreaming = false; state.streamingMsgId = null; state.historyLoaded = true; renderMessages(); }) .catch(function () { // ignore }); } // ========== UI Rendering ========== function addMessage(msg, silent) { state.messages.push(msg); var el = createMessageEl(msg); if (els.welcome) { els.welcome.style.display = "none"; } els.messages.appendChild(el); if (!silent) scrollToBottom(); } function createMessageEl(msg) { var isUser = msg.role === "user"; var div = document.createElement("div"); div.className = "lb-msg " + (isUser ? "lb-msg-user" : "lb-msg-assistant"); div.dataset.msgId = msg.id; // Avatar var avatar = document.createElement("div"); avatar.className = "lb-avatar " + (isUser ? "lb-avatar-user" : "lb-avatar-bot"); if (isUser) { avatar.innerHTML = ICON_USER; } else { var logoImg = document.createElement("img"); logoImg.src = CONFIG.logoUrl; logoImg.alt = "Bot"; avatar.appendChild(logoImg); } // Message body (bubble + meta) var body = document.createElement("div"); body.className = "lb-msg-body"; var bubble = document.createElement("div"); bubble.className = "lb-msg-bubble"; var textContent = msg.content || extractText(msg); bubble.innerHTML = isUser ? esc(textContent) : renderMarkdown(textContent); // Render images from message chain var images = extractImages(msg); for (var ii = 0; ii < images.length; ii++) { var img = document.createElement("img"); img.src = images[ii]; img.alt = "Image"; bubble.appendChild(img); } // Meta row: time var meta = document.createElement("div"); meta.className = "lb-msg-meta"; var time = document.createElement("span"); time.className = "lb-msg-time"; time.textContent = formatTime(msg.timestamp); meta.appendChild(time); body.appendChild(bubble); body.appendChild(meta); // Action buttons for assistant messages (copy, like, dislike) — inside bubble, hidden during streaming if (!isUser) { var actions = document.createElement("div"); actions.className = "lb-msg-actions" + (msg.is_final === false ? " lb-msg-actions-hidden" : ""); // Copy button var copyBtn = document.createElement("button"); copyBtn.className = "lb-act-btn"; copyBtn.innerHTML = ICON_COPY; copyBtn.addEventListener( "click", (function (t) { return function () { var currentText = bubble.textContent || t; navigator.clipboard.writeText(currentText).then(function () { copyBtn.innerHTML = ICON_CHECK; setTimeout(function () { copyBtn.innerHTML = ICON_COPY; }, 1500); }); }; })(textContent), ); actions.appendChild(copyBtn); // Like & Dislike buttons var likeBtn = document.createElement("button"); var dislikeBtn = document.createElement("button"); likeBtn.className = "lb-act-btn" + (state.feedbackState[msg.id] === 1 ? " lb-active" : ""); likeBtn.innerHTML = ICON_THUMB_UP; dislikeBtn.className = "lb-act-btn" + (state.feedbackState[msg.id] === 2 ? " lb-active" : ""); dislikeBtn.innerHTML = ICON_THUMB_DOWN; (function (id, lBtn, dBtn) { lBtn.addEventListener("click", function () { submitFeedback(id, 1); lBtn.classList.toggle("lb-active", state.feedbackState[id] === 1); dBtn.classList.remove("lb-active"); }); dBtn.addEventListener("click", function () { submitFeedback(id, 2); dBtn.classList.toggle("lb-active", state.feedbackState[id] === 2); lBtn.classList.remove("lb-active"); }); })(msg.id, likeBtn, dislikeBtn); actions.appendChild(likeBtn); actions.appendChild(dislikeBtn); bubble.appendChild(actions); } div.appendChild(avatar); div.appendChild(body); return div; } function extractText(msg) { if (msg.content) return msg.content; if (msg.message_chain) { var texts = []; for (var i = 0; i < msg.message_chain.length; i++) { if (msg.message_chain[i].text) texts.push(msg.message_chain[i].text); } return texts.join(""); } return ""; } function extractImages(msg) { var images = []; if (msg.message_chain) { for (var i = 0; i < msg.message_chain.length; i++) { var c = msg.message_chain[i]; if (c.type === "Image" && (c.base64 || c.url)) { var imgUrl = c.base64 || c.url; if (/^(https?:\/\/|data:)/i.test(imgUrl)) { images.push(imgUrl); } } } } return images; } function submitFeedback(msgId, feedbackType) { var prev = state.feedbackState[msgId]; var actualType = prev === feedbackType ? 3 : feedbackType; // toggle = cancel state.feedbackState[msgId] = actualType === 3 ? 0 : actualType; var headers = { "Content-Type": "application/json" }; if (state.sessionToken) headers["Authorization"] = "Bearer " + state.sessionToken; fetch(CONFIG.baseUrl + "/api/v1/embed/" + CONFIG.botUuid + "/feedback", { method: "POST", headers: headers, body: JSON.stringify({ message_id: msgId, feedback_type: actualType }), }).catch(function () {}); } function updateMessageEl(idx, msg) { var allMsgs = els.messages.querySelectorAll(".lb-msg"); if (allMsgs[idx]) { var bubble = allMsgs[idx].querySelector(".lb-msg-bubble"); if (bubble) { // Preserve action buttons if present var actionsEl = bubble.querySelector(".lb-msg-actions"); bubble.innerHTML = renderMarkdown(msg.content || extractText(msg)); // Re-append or show action buttons when streaming finishes if (actionsEl) { if (msg.is_final) actionsEl.classList.remove("lb-msg-actions-hidden"); bubble.appendChild(actionsEl); } } } } function renderMessages() { // Clear all messages from DOM while (els.messages.firstChild) { els.messages.removeChild(els.messages.firstChild); } // Re-add welcome if no messages if (state.messages.length === 0) { els.messages.appendChild(createWelcomeEl()); return; } for (var i = 0; i < state.messages.length; i++) { els.messages.appendChild(createMessageEl(state.messages[i])); } scrollToBottom(); } function createWelcomeEl() { var div = document.createElement("div"); div.className = "lb-welcome"; els.welcome = div; var logo = document.createElement("img"); logo.className = "lb-welcome-logo"; logo.src = CONFIG.logoUrl; logo.alt = "LangBot"; var text = document.createElement("div"); text.textContent = t("welcomeMessage"); div.appendChild(logo); div.appendChild(text); return div; } function showTypingIndicator() { if (els.messages.querySelector(".lb-typing")) return; var div = document.createElement("div"); div.className = "lb-typing"; div.innerHTML = ""; els.messages.appendChild(div); scrollToBottom(); } function removeTypingIndicator() { var el = els.messages.querySelector(".lb-typing"); if (el) el.remove(); } function showError(msg) { var div = document.createElement("div"); div.className = "lb-error"; div.textContent = msg; els.messages.appendChild(div); setTimeout(function () { if (div.parentNode) div.remove(); }, 5000); scrollToBottom(); } function updateStatusDot() { if (els.statusDot) { if (state.isConnected) { els.statusDot.classList.add("lb-connected"); } else { els.statusDot.classList.remove("lb-connected"); } } } function updateSendBtn() { if (els.sendBtn) { els.sendBtn.disabled = !state.isConnected; } } function togglePanel() { state.isOpen = !state.isOpen; if (state.isOpen) { els.panel.classList.add("lb-visible"); els.bubble.classList.add("lb-open"); ensureTurnstileVerified(function () { loadHistory(); wsConnect(); }); setTimeout(function () { if (els.input) els.input.focus(); }, 300); } else { els.panel.classList.remove("lb-visible"); els.bubble.classList.remove("lb-open"); } } function ensureTurnstileVerified(callback) { if ( state.sessionToken || !CONFIG.turnstileSiteKey || CONFIG.turnstileSiteKey.indexOf("__LANGBOT") === 0 ) { return callback(); } if (state.turnstileQueue) { state.turnstileQueue.push(callback); return; } state.turnstileQueue = [callback]; var flushQueue = function (success) { var q = state.turnstileQueue; state.turnstileQueue = null; if (success && q) { for (var i = 0; i < q.length; i++) q[i](); } }; var doRender = function () { var container = document.createElement("div"); document.body.appendChild(container); turnstile.render(container, { sitekey: CONFIG.turnstileSiteKey, size: "invisible", callback: function (token) { fetch( CONFIG.baseUrl + "/api/v1/embed/" + CONFIG.botUuid + "/turnstile/verify", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token: token }), }, ) .then(function (res) { return res.json(); }) .then(function (data) { if (data && data.data && data.data.token) { state.sessionToken = data.data.token; flushQueue(true); } else { showError(t("botVerificationFailed")); flushQueue(false); } }) .catch(function () { showError(t("botVerificationNetworkError")); flushQueue(false); }); }, "error-callback": function () { showError(t("botVerificationError")); flushQueue(false); }, }); }; if (window.turnstile) { doRender(); } else { window.onloadTurnstileCallback = doRender; var script = document.createElement("script"); script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit&onload=onloadTurnstileCallback"; script.async = true; script.defer = true; document.head.appendChild(script); } } function handleSend() { var text = els.input.value; var img = state.pendingImage; if ((!text.trim() && !img) || !state.isConnected) return; sendMessage(text, img); els.input.value = ""; els.input.style.height = "auto"; clearPendingAttachment(); els.input.focus(); } function handleInputKeydown(e) { if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); handleSend(); } } function autoResizeInput() { els.input.style.height = "auto"; els.input.style.height = Math.min(els.input.scrollHeight, 120) + "px"; } function handleImageSelect(e) { var file = e.target.files && e.target.files[0]; if (!file) return; if (file.size > 5 * 1024 * 1024) { showError(t("imageTooLarge")); return; } if (!/^image\//.test(file.type)) { showError(t("onlyImages")); return; } var reader = new FileReader(); reader.onload = function (ev) { showImagePreview(ev.target.result); state.pendingImage = ev.target.result; }; reader.readAsDataURL(file); e.target.value = ""; } function showImagePreview(src) { removePreviewDom(); var preview = document.createElement("div"); preview.className = "lb-img-preview"; preview.id = "lb-img-preview"; var img = document.createElement("img"); img.src = src; var removeBtn = document.createElement("button"); removeBtn.className = "lb-img-preview-remove"; removeBtn.textContent = "\u00d7"; removeBtn.addEventListener("click", clearPendingAttachment); preview.appendChild(img); preview.appendChild(removeBtn); // Insert before footer var footer = els.panel.querySelector(".lb-footer"); if (footer) { footer.parentNode.insertBefore(preview, footer); } } function removePreviewDom() { var existing = els.panel ? els.panel.querySelector("#lb-img-preview") : null; if (existing) existing.remove(); } function clearPendingAttachment() { state.pendingImage = null; state.pendingFile = null; removePreviewDom(); } // ========== Build DOM ========== function buildWidget() { // Root container var root = document.createElement("div"); root.id = "langbot-widget-root"; document.body.appendChild(root); var shadow = root.attachShadow({ mode: "open" }); // Styles var style = document.createElement("style"); style.textContent = STYLES; shadow.appendChild(style); // Chat bubble button var bubble = document.createElement("button"); bubble.className = "lb-bubble"; bubble.setAttribute("aria-label", t("openChat")); var chatIcon = document.createElement("span"); chatIcon.className = "lb-chat-icon"; var selectedBubbleSvg = BUBBLE_ICONS[CONFIG.bubbleIcon]; if (selectedBubbleSvg) { chatIcon.innerHTML = selectedBubbleSvg; } else { var bubbleLogo = document.createElement("img"); bubbleLogo.src = CONFIG.logoUrl; bubbleLogo.alt = CONFIG.title; bubbleLogo.style.cssText = "width:100%;height:100%;object-fit:cover;"; chatIcon.appendChild(bubbleLogo); } var closeIcon = document.createElement("span"); closeIcon.className = "lb-close-icon"; closeIcon.innerHTML = ICON_CLOSE; bubble.appendChild(chatIcon); bubble.appendChild(closeIcon); bubble.addEventListener("click", togglePanel); els.bubble = bubble; shadow.appendChild(bubble); // Chat panel var panel = document.createElement("div"); panel.className = "lb-panel"; els.panel = panel; // Header var header = document.createElement("div"); header.className = "lb-header"; var headerLeft = document.createElement("div"); headerLeft.className = "lb-header-left"; var headerLogo = document.createElement("img"); headerLogo.className = "lb-header-logo"; headerLogo.src = CONFIG.logoUrl; headerLogo.alt = CONFIG.title; var title = document.createElement("span"); title.className = "lb-header-title"; title.textContent = CONFIG.title; var statusDot = document.createElement("span"); statusDot.className = "lb-status-dot"; els.statusDot = statusDot; headerLeft.appendChild(headerLogo); headerLeft.appendChild(title); headerLeft.appendChild(statusDot); var headerActions = document.createElement("div"); headerActions.className = "lb-header-actions"; var resetBtn = document.createElement("button"); resetBtn.className = "lb-header-btn"; resetBtn.setAttribute("aria-label", t("resetConversation")); resetBtn.innerHTML = ICON_RESET; resetBtn.addEventListener("click", resetSession); var minimizeBtn = document.createElement("button"); minimizeBtn.className = "lb-header-btn"; minimizeBtn.setAttribute("aria-label", t("minimize")); minimizeBtn.innerHTML = ICON_CLOSE; minimizeBtn.addEventListener("click", togglePanel); headerActions.appendChild(resetBtn); headerActions.appendChild(minimizeBtn); header.appendChild(headerLeft); header.appendChild(headerActions); panel.appendChild(header); // Messages area var messages = document.createElement("div"); messages.className = "lb-messages"; els.messages = messages; messages.appendChild(createWelcomeEl()); panel.appendChild(messages); // Input area var inputArea = document.createElement("div"); inputArea.className = "lb-input-area"; // Hidden file input var fileInput = document.createElement("input"); fileInput.type = "file"; fileInput.accept = "image/*"; fileInput.style.cssText = "position:absolute;width:0;height:0;overflow:hidden;opacity:0;"; fileInput.addEventListener("change", handleImageSelect); // Image upload button var imgBtn = document.createElement("button"); imgBtn.className = "lb-img-upload-btn"; imgBtn.setAttribute("aria-label", t("uploadFile")); imgBtn.innerHTML = ICON_IMAGE; imgBtn.addEventListener("click", function () { fileInput.click(); }); var input = document.createElement("textarea"); input.className = "lb-input"; input.placeholder = t("inputPlaceholder"); input.rows = 1; input.addEventListener("keydown", handleInputKeydown); input.addEventListener("input", autoResizeInput); els.input = input; var sendBtn = document.createElement("button"); sendBtn.className = "lb-send-btn"; sendBtn.disabled = true; sendBtn.setAttribute("aria-label", t("send")); sendBtn.innerHTML = ICON_SEND; sendBtn.addEventListener("click", handleSend); els.sendBtn = sendBtn; inputArea.appendChild(fileInput); inputArea.appendChild(imgBtn); inputArea.appendChild(input); inputArea.appendChild(sendBtn); // Footer: Powered by LangBot (above input area) var footer = document.createElement("div"); footer.className = "lb-footer"; footer.innerHTML = t("poweredBy"); panel.appendChild(footer); panel.appendChild(inputArea); shadow.appendChild(panel); } // ========== Initialize ========== if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", buildWidget); } else { buildWidget(); } })();