mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-22 18:27:12 +00:00
e1ac5e0fc8
* Document multi-tenant workspace architecture * Add OSS and commercial workspace boundaries * docs: redesign multi-tenant workspace architecture * feat(tenancy): implement workspace isolation * docs(tenancy): record verification evidence * docs(tenancy): revise single-instance SaaS topology * docs(tenancy): refine architecture options * docs: finalize cloud v2 multi-tenant decisions * feat(tenancy): establish cloud isolation foundations * feat(tenancy): harden shared cloud runtime boundaries * docs(tenancy): record final isolation verification * fix(tenancy): close isolation and permission gaps * docs(tenancy): record final isolation verification * feat(tenancy): connect cloud workspace control plane * fix(build): install git for pinned SDK * docs(cloud): update control plane verification * chore: update multi-tenant SDK pin * fix(cloud): skip legacy model sync during startup * test(cloud): preserve minimal model manager fixtures * fix(cloud): preserve authenticated account context * fix(cloud): reuse authenticated account for user info * feat(cloud): complete Workspace settings navigation * test(web): cover Workspace dropdown menu * feat(web): place workspace controls in sidebar * refactor(web): streamline workspace controls * style(web): format workspace layout test * fix(cloud): surface runtime and workspace plan status * fix(plugin): keep runtime identity stable across restarts * fix(ui): widen and center workspace switcher * fix(ui): hide roles from workspace switcher * fix(ui): align workspace switcher with sidebar entries * feat(workspace): add in-product collaboration and direct Cloud launch * style: format collaboration changes * fix(workspace): bind collaboration APIs to tenant UoW * fix(cloud): preserve Core-owned collaboration state * test(cloud): require Space identity for invite registration * feat(cloud): complete secure invitation experience * style(web): format invitation flows * fix(cloud): recover box runtime without unscoped skill reload * feat(oss): enforce invitation account and owner billing flows * style: format OSS account service * test(oss): cover invitation logout handoff * fix(oss): resolve workspace owner in scoped session * feat(cloud): harden multi-tenant runtime resources * fix(cloud): bound runtime restart storms * fix(cloud): eliminate periodic runtime CPU spikes * fix(cloud): enforce instance capacity ceilings * fix(cloud): scope public login capability discovery * fix(cloud): bound tenant maintenance and monitoring work * fix(runtime): bound tenant resource amplification * fix(deps): pin green multi-tenant plugin SDK * fix(cloud): handle unavailable skill capability * fix(security): require authentication for image file endpoint (H-2) - Changed /api/v1/files/image from AuthType.NONE to USER_TOKEN_OR_API_KEY - Added Permission.RESOURCE_VIEW requirement - Prevents unauthenticated cross-tenant file access via leaked keys - Fixes HIGH severity finding from multi-tenant security review docs: add comprehensive database migration guide - Complete migration steps for OSS → multi-tenant - Backup, execution, verification procedures - Rollback scenarios and recovery plans - Performance tuning recommendations * test: add comprehensive cross-tenant isolation tests Added 7 critical test scenarios for multi-tenant boundaries: - Cross-tenant bot access prevention - Viewer role read-only enforcement - Removed member immediate access revocation - Model provider credential isolation - WebSocket message isolation - Invitation token workspace scoping - Multi-workspace context validation These tests address P0-2 coverage gaps for: - workspaces.py (membership & invitation flows) - user.py (authentication & authorization) - websocket_chat.py (real-time isolation) - plugins.py (resource access control) docs: finalize database migration guide * fix(security): resolve M-1, M-2, M-3 security findings M-1: WebSocket authorization TOCTOU race (FIXED) - Changed _revalidate_websocket_authorization to return RequestContext - Ensures validated context is used immediately without race window - Prevents removed members from sending messages during revalidation gap M-2: Model Manager cache workspace isolation (VERIFIED) - Confirmed _CacheKey already uses 4-tuple: (instance, workspace, generation, resource) - Cache is properly scoped per workspace, no cross-tenant leakage possible - No code change needed, documented as working correctly M-3: Invitation lock workspace scoping (FIXED) - Changed lock key from token_digest to workspace_uuid:token_digest - Prevents DoS where attacker locks token in Workspace A to block Workspace B - Locks now isolated per workspace All MEDIUM severity findings from security review now resolved. * fix(cloud): unblock tenant CI and enforce knowledge quotas * fix(tenancy): scope rerank model sync --------- Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
1398 lines
51 KiB
JavaScript
1398 lines
51 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 ==========
|
|
function createSessionId() {
|
|
if (window.crypto && typeof window.crypto.randomUUID === "function") {
|
|
return window.crypto.randomUUID();
|
|
}
|
|
|
|
var bytes = new Uint8Array(16);
|
|
if (!window.crypto || typeof window.crypto.getRandomValues !== "function") {
|
|
throw new Error("Secure random number generation is unavailable");
|
|
}
|
|
window.crypto.getRandomValues(bytes);
|
|
bytes[6] = (bytes[6] & 15) | 64;
|
|
bytes[8] = (bytes[8] & 63) | 128;
|
|
var hex = Array.prototype.map
|
|
.call(bytes, function (value) {
|
|
return value.toString(16).padStart(2, "0");
|
|
})
|
|
.join("");
|
|
return (
|
|
hex.slice(0, 8) +
|
|
"-" +
|
|
hex.slice(8, 12) +
|
|
"-" +
|
|
hex.slice(12, 16) +
|
|
"-" +
|
|
hex.slice(16, 20) +
|
|
"-" +
|
|
hex.slice(20)
|
|
);
|
|
}
|
|
|
|
function getOrCreateSessionId() {
|
|
var storageKey = "langbot_embed_session_" + CONFIG.botUuid;
|
|
try {
|
|
var stored = window.sessionStorage.getItem(storageKey);
|
|
if (
|
|
/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(
|
|
stored || "",
|
|
)
|
|
)
|
|
return stored;
|
|
var created = createSessionId();
|
|
window.sessionStorage.setItem(storageKey, created);
|
|
return created;
|
|
} catch (e) {
|
|
return createSessionId();
|
|
}
|
|
}
|
|
|
|
var state = {
|
|
isOpen: false,
|
|
isConnected: false,
|
|
ws: null,
|
|
connectionId: null,
|
|
sessionToken: null,
|
|
sessionId: getOrCreateSessionId(),
|
|
reconnectAttempts: 0,
|
|
heartbeatTimer: null,
|
|
messages: [],
|
|
nextLocalId: 1,
|
|
isStreaming: false,
|
|
streamingMsgId: null,
|
|
historyLoaded: false,
|
|
hasConnected: false,
|
|
messageVersion: 0,
|
|
historyReloadTimer: null,
|
|
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 +
|
|
"&session_id=" +
|
|
encodeURIComponent(state.sessionId);
|
|
|
|
try {
|
|
state.ws = new WebSocket(url);
|
|
} catch (e) {
|
|
showError(t("failedToConnect"));
|
|
return;
|
|
}
|
|
|
|
state.ws.onopen = function () {
|
|
state.reconnectAttempts = 0;
|
|
state.ws.send(
|
|
JSON.stringify({
|
|
type: "authenticate",
|
|
token: state.sessionToken || "",
|
|
}),
|
|
);
|
|
};
|
|
|
|
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;
|
|
if (state.hasConnected) loadHistory(true);
|
|
state.hasConnected = true;
|
|
startHeartbeat();
|
|
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;
|
|
state.messageVersion++;
|
|
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 scheduleHistoryReload() {
|
|
if (state.historyReloadTimer) clearTimeout(state.historyReloadTimer);
|
|
state.historyReloadTimer = setTimeout(function () {
|
|
state.historyReloadTimer = null;
|
|
loadHistory(true);
|
|
}, 100);
|
|
}
|
|
|
|
function loadHistory(force) {
|
|
if (state.historyLoaded && !force) return;
|
|
state.historyLoaded = true;
|
|
var messageVersion = state.messageVersion;
|
|
|
|
var url =
|
|
CONFIG.baseUrl +
|
|
"/api/v1/embed/" +
|
|
CONFIG.botUuid +
|
|
"/messages/" +
|
|
CONFIG.sessionType +
|
|
"?session_id=" +
|
|
encodeURIComponent(state.sessionId);
|
|
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) {
|
|
if (force && messageVersion !== state.messageVersion) {
|
|
scheduleHistoryReload();
|
|
return;
|
|
}
|
|
if (force) {
|
|
state.messages = [];
|
|
state.isStreaming = false;
|
|
state.streamingMsgId = null;
|
|
renderMessages();
|
|
}
|
|
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 +
|
|
"?session_id=" +
|
|
encodeURIComponent(state.sessionId);
|
|
var headers = {};
|
|
if (state.sessionToken)
|
|
headers["Authorization"] = "Bearer " + state.sessionToken;
|
|
fetch(url, { method: "POST", headers: headers })
|
|
.then(function () {
|
|
state.messages = [];
|
|
state.messageVersion++;
|
|
state.isStreaming = false;
|
|
state.streamingMsgId = null;
|
|
state.historyLoaded = true;
|
|
renderMessages();
|
|
})
|
|
.catch(function () {
|
|
// ignore
|
|
});
|
|
}
|
|
|
|
// ========== UI Rendering ==========
|
|
function addMessage(msg, silent) {
|
|
state.messages.push(msg);
|
|
if (!silent) state.messageVersion++;
|
|
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();
|
|
}
|
|
})();
|