mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-04 21:06:03 +00:00
* feat: add web_page_bot adapter and embed widget - Implemented a new `web_page_bot` adapter for embedding chat widgets on websites. - Created a new YAML configuration file for `web_page_bot` with necessary metadata and execution details. - Developed the `WebPageBotAdapter` class to handle message events and manage listeners. - Added a JavaScript widget for embedding the chat interface, including styles and functionality for user interaction. - Updated WebSocket handling to support the new bot adapter and manage connections. - Enhanced the bot form to include pipeline UUID and adapter configuration in the system context. - Introduced a new dynamic form item type for embed code in the form entity. * feat(embed): add feedback submission and image upload functionality to embed widget * feat(embed): add reset session endpoint for embed widget and improve WebSocket image handling * feat(widget): remove typing indicator display logic from message handling * fix(embed): security hardening for embed widget - Add UUID format validation for pipeline_uuid parameters - Add Cloudflare Turnstile integration for bot protection (optional) - Add HMAC-signed session tokens for /messages, /reset, /feedback endpoints - Sanitize error responses (remove internal exception details) - Sanitize base_url before JS injection - Fix XSS in markdown link rendering (only allow http/https protocols) - Fix XSS in image URL extraction (only allow http/https/data protocols) - Escape widget title in embed code snippet (HTML entity encoding) - Remove class-level mutable default in WebPageBotAdapter - Remove duplicate config line and console.log in widget.js - Add turnstile_site_key and turnstile_secret_key config fields * style: fix prettier formatting for chained replace calls * fix(embed): declare listeners as Pydantic field in WebPageBotAdapter The base class is a Pydantic BaseModel, so listeners must be declared as a field (with default_factory) rather than assigned in __init__. Also keep the __init__ to convert positional args to keyword args for Pydantic compatibility with botmgr's calling convention. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(embed): use bot_uuid instead of pipeline_uuid in all embed URLs Replace pipeline_uuid with bot_uuid in all user-facing embed widget URLs so internal pipeline identifiers are never exposed. The server resolves bot_uuid to the owning web_page_bot, validates it is enabled and has a pipeline bound, then routes internally using pipeline_uuid. Add a dedicated WebSocket endpoint at /api/v1/embed/<bot_uuid>/ws/connect instead of reusing the pipeline debug path. Wire WebPageBotAdapter to proxy reply_message calls through the WebSocket adapter so dashboard shows the correct adapter name while replies are still delivered. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs(embed): improve Turnstile config field descriptions Add guidance on where to obtain the keys (Cloudflare dashboard) and clarify that leaving them empty disables the feature. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(embed): add multi-language support for embed widget Add a language selector to the web_page_bot config with 8 locales (en, zh-Hans, zh-Hant, ja, es, ru, th, vi). The backend injects the locale into widget.js which uses a built-in i18n dictionary for all user-facing strings (welcome message, placeholder, aria labels, error messages, powered-by footer). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(embed): use correct select option format for language selector Options must use name/label (i18n object) format, not value/label (plain string), to match the dynamic form renderer. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style(embed): adjust footer padding and link to langbot.app Increase footer padding for more breathing room from the bottom edge. Change powered-by link from GitHub repo to langbot.app. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(embed): ignore Enter key during IME composition Check e.isComposing before treating Enter as send, so confirming an IME candidate (e.g. Chinese/Japanese input) does not also fire the message. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(embed): center bubble icon and fill entire circle Make .lb-chat-icon span fill the full bubble area so the logo image covers the circle completely without exposing the blue background. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(embed): add bubble icon presets selector Add 6 bubble icon options (LangBot logo, chat bubble, robot, headset, sparkle, message) configurable in the bot settings. Icons are inline SVGs in widget.js, selected via a config field injected by the backend. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: RockChinQ <rockchinq@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1309 lines
49 KiB
JavaScript
1309 lines
49 KiB
JavaScript
(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 <a href="https://langbot.app" target="_blank" rel="noopener noreferrer">LangBot</a>',
|
||
},
|
||
zh_Hans: {
|
||
welcomeMessage: "发送消息开始对话",
|
||
inputPlaceholder: "输入消息...",
|
||
openChat: "打开聊天",
|
||
resetConversation: "重置对话",
|
||
minimize: "最小化",
|
||
uploadFile: "上传文件",
|
||
send: "发送",
|
||
failedToConnect: "连接失败",
|
||
imageTooLarge: "图片大小不能超过 5MB",
|
||
onlyImages: "仅支持图片文件",
|
||
botVerificationFailed: "机器人验证失败",
|
||
botVerificationNetworkError: "机器人验证网络错误",
|
||
botVerificationError: "机器人验证错误",
|
||
poweredBy:
|
||
'由 <a href="https://langbot.app" target="_blank" rel="noopener noreferrer">LangBot</a> 提供支持',
|
||
},
|
||
zh_Hant: {
|
||
welcomeMessage: "傳送訊息開始對話",
|
||
inputPlaceholder: "輸入訊息...",
|
||
openChat: "開啟聊天",
|
||
resetConversation: "重置對話",
|
||
minimize: "最小化",
|
||
uploadFile: "上傳檔案",
|
||
send: "傳送",
|
||
failedToConnect: "連線失敗",
|
||
imageTooLarge: "圖片大小不能超過 5MB",
|
||
onlyImages: "僅支援圖片檔案",
|
||
botVerificationFailed: "機器人驗證失敗",
|
||
botVerificationNetworkError: "機器人驗證網路錯誤",
|
||
botVerificationError: "機器人驗證錯誤",
|
||
poweredBy:
|
||
'由 <a href="https://langbot.app" target="_blank" rel="noopener noreferrer">LangBot</a> 提供支持',
|
||
},
|
||
ja_JP: {
|
||
welcomeMessage: "メッセージを送信して会話を始めましょう",
|
||
inputPlaceholder: "メッセージを入力...",
|
||
openChat: "チャットを開く",
|
||
resetConversation: "会話をリセット",
|
||
minimize: "最小化",
|
||
uploadFile: "ファイルをアップロード",
|
||
send: "送信",
|
||
failedToConnect: "接続に失敗しました",
|
||
imageTooLarge: "画像は5MB以下にしてください",
|
||
onlyImages: "画像ファイルのみ対応しています",
|
||
botVerificationFailed: "ボット認証に失敗しました",
|
||
botVerificationNetworkError: "ボット認証のネットワークエラー",
|
||
botVerificationError: "ボット認証エラー",
|
||
poweredBy:
|
||
'<a href="https://langbot.app" target="_blank" rel="noopener noreferrer">LangBot</a> で動作',
|
||
},
|
||
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 <a href="https://langbot.app" target="_blank" rel="noopener noreferrer">LangBot</a>',
|
||
},
|
||
ru_RU: {
|
||
welcomeMessage: "Отправьте сообщение, чтобы начать разговор",
|
||
inputPlaceholder: "Введите сообщение...",
|
||
openChat: "Открыть чат",
|
||
resetConversation: "Сбросить разговор",
|
||
minimize: "Свернуть",
|
||
uploadFile: "Загрузить файл",
|
||
send: "Отправить",
|
||
failedToConnect: "Ошибка подключения",
|
||
imageTooLarge: "Изображение должно быть менее 5МБ",
|
||
onlyImages: "Поддерживаются только изображения",
|
||
botVerificationFailed: "Проверка бота не пройдена",
|
||
botVerificationNetworkError: "Ошибка сети при проверке бота",
|
||
botVerificationError: "Ошибка проверки бота",
|
||
poweredBy:
|
||
'Работает на <a href="https://langbot.app" target="_blank" rel="noopener noreferrer">LangBot</a>',
|
||
},
|
||
th_TH: {
|
||
welcomeMessage: "ส่งข้อความเพื่อเริ่มการสนทนา",
|
||
inputPlaceholder: "พิมพ์ข้อความ...",
|
||
openChat: "เปิดแชท",
|
||
resetConversation: "รีเซ็ตการสนทนา",
|
||
minimize: "ย่อ",
|
||
uploadFile: "อัปโหลดไฟล์",
|
||
send: "ส่ง",
|
||
failedToConnect: "เชื่อมต่อไม่สำเร็จ",
|
||
imageTooLarge: "รูปภาพต้องมีขนาดไม่เกิน 5MB",
|
||
onlyImages: "รองรับเฉพาะไฟล์รูปภาพเท่านั้น",
|
||
botVerificationFailed: "การยืนยันบอทล้มเหลว",
|
||
botVerificationNetworkError: "เกิดข้อผิดพลาดเครือข่ายในการยืนยันบอท",
|
||
botVerificationError: "เกิดข้อผิดพลาดในการยืนยันบอท",
|
||
poweredBy:
|
||
'ขับเคลื่อนโดย <a href="https://langbot.app" target="_blank" rel="noopener noreferrer">LangBot</a>',
|
||
},
|
||
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 <a href="https://langbot.app" target="_blank" rel="noopener noreferrer">LangBot</a>',
|
||
},
|
||
};
|
||
|
||
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: '<svg viewBox="0 0 24 24" fill="white" width="28" height="28"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6l-2 2V4h16v12z"/></svg>',
|
||
robot:
|
||
'<svg viewBox="0 0 24 24" fill="white" width="28" height="28"><path d="M20 9V7c0-1.1-.9-2-2-2h-3c0-1.66-1.34-3-3-3S9 3.34 9 5H6c-1.1 0-2 .9-2 2v2c-1.66 0-3 1.34-3 3s1.34 3 3 3v4c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2v-4c1.66 0 3-1.34 3-3s-1.34-3-3-3zM7.5 11.5c0-.83.67-1.5 1.5-1.5s1.5.67 1.5 1.5S9.83 13 9 13s-1.5-.67-1.5-1.5zM16 17H8v-2h8v2zm-1-4c-.83 0-1.5-.67-1.5-1.5S14.17 10 15 10s1.5.67 1.5 1.5S15.83 13 15 13z"/></svg>',
|
||
headset:
|
||
'<svg viewBox="0 0 24 24" fill="white" width="28" height="28"><path d="M12 1a9 9 0 00-9 9v7c0 1.66 1.34 3 3 3h3v-8H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-4v8h3c1.66 0 3-1.34 3-3v-7a9 9 0 00-9-9z"/></svg>',
|
||
sparkle:
|
||
'<svg viewBox="0 0 24 24" fill="white" width="28" height="28"><path d="M12 2L9.19 8.63 2 9.24l5.46 4.73L5.82 21 12 17.27 18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2z"/></svg>',
|
||
message:
|
||
'<svg viewBox="0 0 24 24" fill="white" width="28" height="28"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 12H6v-2h12v2zm0-3H6V9h12v2zm0-3H6V6h12v2z"/></svg>',
|
||
};
|
||
|
||
// ========== SVG Icons ==========
|
||
var ICON_CLOSE =
|
||
'<svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>';
|
||
var ICON_SEND =
|
||
'<svg viewBox="0 0 24 24"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>';
|
||
var ICON_RESET =
|
||
'<svg viewBox="0 0 24 24"><path d="M17.65 6.35A7.96 7.96 0 0012 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0112 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>';
|
||
var ICON_USER =
|
||
'<svg viewBox="0 0 24 24"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>';
|
||
var ICON_THUMB_UP =
|
||
'<svg viewBox="0 0 24 24"><path d="M2 20h2c.55 0 1-.45 1-1v-9c0-.55-.45-1-1-1H2v11zm19.83-7.12c.11-.25.17-.52.17-.8V11c0-1.1-.9-2-2-2h-5.5l.92-4.65c.05-.22.02-.46-.08-.66-.23-.45-.52-.86-.88-1.22L14 2 7.59 8.41C7.21 8.79 7 9.3 7 9.83v7.84C7 18.95 8.05 20 9.34 20h8.11c.7 0 1.36-.37 1.72-.97l2.66-6.15z"/></svg>';
|
||
var ICON_THUMB_DOWN =
|
||
'<svg viewBox="0 0 24 24"><path d="M22 4h-2c-.55 0-1 .45-1 1v9c0 .55.45 1 1 1h2V4zM2.17 11.12c-.11.25-.17.52-.17.8V13c0 1.1.9 2 2 2h5.5l-.92 4.65c-.05.22-.02.46.08.66.23.45.52.86.88 1.22L10 22l6.41-6.41c.38-.38.59-.89.59-1.42V6.34C17 5.05 15.95 4 14.66 4h-8.1c-.71 0-1.36.37-1.72.97l-2.67 6.15z"/></svg>';
|
||
var ICON_COPY =
|
||
'<svg viewBox="0 0 24 24"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>';
|
||
var ICON_CHECK =
|
||
'<svg viewBox="0 0 24 24"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>';
|
||
var ICON_IMAGE =
|
||
'<svg viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zM6 20V4h7v5h5v11H6z"/><path d="M8 17l2.5-3.5L13 17l2-2.5L18 17H8z"/></svg>';
|
||
|
||
// ========== 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("<pre><code>" + esc(code.trim()) + "</code></pre>");
|
||
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, "<code>$1</code>");
|
||
// Headings
|
||
html = html.replace(/^### (.+)$/gm, "<h4>$1</h4>");
|
||
html = html.replace(/^## (.+)$/gm, "<h4>$1</h4>");
|
||
html = html.replace(/^# (.+)$/gm, "<h3>$1</h3>");
|
||
// Horizontal rules
|
||
html = html.replace(/^---$/gm, "<hr>");
|
||
// Blockquotes
|
||
html = html.replace(/^> (.+)$/gm, "<blockquote>$1</blockquote>");
|
||
// Tables
|
||
html = html.replace(/((?:\|.+\|\n?)+)/g, function (table) {
|
||
var rows = table.trim().split("\n");
|
||
if (rows.length < 2) return table;
|
||
var out = "<table>";
|
||
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 +=
|
||
"<tr>" +
|
||
cells
|
||
.map(function (c) {
|
||
return "<" + tag + ">" + c.trim() + "</" + tag + ">";
|
||
})
|
||
.join("") +
|
||
"</tr>";
|
||
}
|
||
return out + "</table>";
|
||
});
|
||
// Strikethrough
|
||
html = html.replace(/~~([^~]+)~~/g, "<del>$1</del>");
|
||
// Bold
|
||
html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
||
// Italic
|
||
html = html.replace(/\*([^*]+)\*/g, "<em>$1</em>");
|
||
// Links
|
||
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function (match, p1, p2) {
|
||
if (/^https?:\/\//i.test(p2)) {
|
||
return (
|
||
'<a href="' +
|
||
p2 +
|
||
'" target="_blank" rel="noopener noreferrer">' +
|
||
p1 +
|
||
"</a>"
|
||
);
|
||
}
|
||
return p1;
|
||
});
|
||
// Unordered lists
|
||
html = html.replace(/((?:^[\-\*] .+(?:<br>)?)+)/gm, function (block) {
|
||
var items = block.split(/<br>|\\n/).filter(function (l) {
|
||
return /^[\-\*] /.test(l.trim());
|
||
});
|
||
return (
|
||
"<ul>" +
|
||
items
|
||
.map(function (l) {
|
||
return "<li>" + l.replace(/^[\-\*] /, "") + "</li>";
|
||
})
|
||
.join("") +
|
||
"</ul>"
|
||
);
|
||
});
|
||
// Ordered lists
|
||
html = html.replace(/((?:^\d+\. .+(?:<br>)?)+)/gm, function (block) {
|
||
var items = block.split(/<br>|\\n/).filter(function (l) {
|
||
return /^\d+\. /.test(l.trim());
|
||
});
|
||
return (
|
||
"<ol>" +
|
||
items
|
||
.map(function (l) {
|
||
return "<li>" + l.replace(/^\d+\. /, "") + "</li>";
|
||
})
|
||
.join("") +
|
||
"</ol>"
|
||
);
|
||
});
|
||
// Line breaks (but not inside block elements)
|
||
html = html.replace(/\n/g, "<br>");
|
||
// Clean up excessive <br> around block elements
|
||
html = html.replace(
|
||
/<br>\s*(<(?:h[34]|pre|table|ul|ol|blockquote|hr))/g,
|
||
"$1",
|
||
);
|
||
html = html.replace(
|
||
/(<\/(?:h[34]|pre|table|ul|ol|blockquote)>)\s*<br>/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 = "<span></span><span></span><span></span>";
|
||
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();
|
||
}
|
||
})();
|