From 564d829e256b89fbc74cb09787b00dc2f95bdfca Mon Sep 17 00:00:00 2001 From: fdc310 <82008029+fdc310@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:36:14 +0800 Subject: [PATCH] Feat/webpage adapter (#2135) * 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) * 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//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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) --------- Co-authored-by: RockChinQ Co-authored-by: Claude Opus 4.6 (1M context) --- .../http/controller/groups/pipelines/embed.py | 384 +++++ .../groups/pipelines/websocket_chat.py | 16 +- .../pkg/platform/sources/web_page_bot.yaml | 177 +++ .../platform/sources/web_page_bot_adapter.py | 97 ++ src/langbot/pkg/platform/sources/webpage.webp | Bin 0 -> 13922 bytes .../pkg/platform/sources/websocket_adapter.py | 50 +- src/langbot/templates/embed/logo.webp | Bin 0 -> 13922 bytes src/langbot/templates/embed/widget.js | 1308 +++++++++++++++++ .../home/bots/components/bot-form/BotForm.tsx | 2 + .../dynamic-form/DynamicFormComponent.tsx | 53 +- web/src/app/infra/entities/form/dynamic.ts | 1 + 11 files changed, 2067 insertions(+), 21 deletions(-) create mode 100644 src/langbot/pkg/api/http/controller/groups/pipelines/embed.py create mode 100644 src/langbot/pkg/platform/sources/web_page_bot.yaml create mode 100644 src/langbot/pkg/platform/sources/web_page_bot_adapter.py create mode 100644 src/langbot/pkg/platform/sources/webpage.webp create mode 100644 src/langbot/templates/embed/logo.webp create mode 100644 src/langbot/templates/embed/widget.js diff --git a/src/langbot/pkg/api/http/controller/groups/pipelines/embed.py b/src/langbot/pkg/api/http/controller/groups/pipelines/embed.py new file mode 100644 index 00000000..99c00944 --- /dev/null +++ b/src/langbot/pkg/api/http/controller/groups/pipelines/embed.py @@ -0,0 +1,384 @@ +"""Embed widget routes - serve embeddable chat widget for external websites. + +All user-facing URLs are keyed by **bot_uuid** (not pipeline_uuid) so that +internal pipeline identifiers are never exposed to end-users. Each handler +resolves the bot_uuid to the owning ``web_page_bot`` RuntimeBot and extracts +the bound pipeline_uuid for internal routing. +""" + +import asyncio +import datetime +import json +import logging +import uuid +import hmac +import hashlib +import time +import re +import httpx + +import quart + +from ... import group +from ......utils import paths +from ......platform.sources.websocket_manager import ws_connection_manager + +logger = logging.getLogger(__name__) + +# Cache the widget template content +_widget_template_cache: str | None = None +_logo_bytes_cache: bytes | None = None + + +def _is_valid_uuid(s: str) -> bool: + return bool(re.match(r'^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$', s)) + + +def _get_widget_template() -> str: + """Load and cache the widget JS template.""" + global _widget_template_cache + if _widget_template_cache is None: + template_path = paths.get_resource_path('templates/embed/widget.js') + with open(template_path, 'r', encoding='utf-8') as f: + _widget_template_cache = f.read() + return _widget_template_cache + + +def _get_logo_bytes() -> bytes: + """Load and cache the logo image.""" + global _logo_bytes_cache + if _logo_bytes_cache is None: + logo_path = paths.get_resource_path('templates/embed/logo.webp') + with open(logo_path, 'rb') as f: + _logo_bytes_cache = f.read() + return _logo_bytes_cache + + +@group.group_class('embed', '/api/v1/embed') +class EmbedRouterGroup(group.RouterGroup): + # -- helpers ------------------------------------------------------------- + + def _resolve_bot(self, bot_uuid: str): + """Resolve *bot_uuid* to ``(runtime_bot, pipeline_uuid)``. + + Returns ``(None, None)`` when the bot does not exist, is not a + ``web_page_bot``, is disabled, or has no pipeline bound. + """ + for bot in self.ap.platform_mgr.bots: + if ( + bot.bot_entity.uuid == bot_uuid + and bot.bot_entity.adapter == 'web_page_bot' + and bot.bot_entity.enable + and bot.bot_entity.use_pipeline_uuid + ): + return bot, bot.bot_entity.use_pipeline_uuid + return None, None + + def _get_bot_config(self, bot_uuid: str) -> dict: + for bot in self.ap.platform_mgr.bots: + if bot.bot_entity.uuid == bot_uuid and bot.bot_entity.adapter == 'web_page_bot': + return bot.bot_entity.adapter_config + return {} + + async def _verify_session_token(self, request, bot_uuid: str) -> bool: + config = self._get_bot_config(bot_uuid) + secret = config.get('turnstile_secret_key', '') + if not secret: + return True + auth_header = request.headers.get('Authorization', '') + if not auth_header.startswith('Bearer '): + return False + token = auth_header[7:] + try: + ts_str, mac = token.split('.', 1) + ts = float(ts_str) + if time.time() - ts > 86400: + return False + expected_mac = hmac.new(secret.encode(), f'{ts_str}'.encode(), hashlib.sha256).hexdigest() + return hmac.compare_digest(mac, expected_mac) + except Exception: + return False + + # -- routes -------------------------------------------------------------- + + async def initialize(self) -> None: + @self.route('//turnstile/verify', methods=['POST'], auth_type=group.AuthType.NONE) + async def verify_turnstile(bot_uuid: str) -> str: + if not _is_valid_uuid(bot_uuid): + return self.http_status(400, -1, 'Invalid bot_uuid format') + runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid) + if runtime_bot is None: + return self.http_status(404, -1, 'Bot not found or not available') + try: + data = await quart.request.get_json() + token = data.get('token') + if not token: + return self.http_status(400, -1, 'Token is required') + + config = self._get_bot_config(bot_uuid) + secret = config.get('turnstile_secret_key', '') + if not secret: + ts = time.time() + return self.success(data={'token': f'{ts}.dummy'}) + + async with httpx.AsyncClient() as client: + resp = await client.post( + 'https://challenges.cloudflare.com/turnstile/v0/siteverify', + data={'secret': secret, 'response': token}, + ) + result = resp.json() + + if not result.get('success'): + return self.http_status(403, -1, 'Turnstile verification failed') + + ts = time.time() + mac = hmac.new(secret.encode(), f'{ts}'.encode(), hashlib.sha256).hexdigest() + session_token = f'{ts}.{mac}' + + return self.success(data={'token': session_token}) + + except Exception as e: + logger.error(f'Turnstile verify failed: {e}', exc_info=True) + return self.http_status(500, -1, 'Internal server error') + + @self.route('//widget.js', methods=['GET'], auth_type=group.AuthType.NONE) + async def serve_widget(bot_uuid: str) -> quart.Response: + """Serve the embed widget JavaScript with injected configuration.""" + if not _is_valid_uuid(bot_uuid): + return self.http_status(400, -1, 'Invalid bot_uuid format') + runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid) + if runtime_bot is None: + return quart.Response( + '// Bot not found or not available', status=404, content_type='application/javascript' + ) + try: + template = _get_widget_template() + except FileNotFoundError: + return quart.Response('// Widget template not found', status=404, content_type='application/javascript') + + base_url = quart.request.host_url.rstrip('/') + webhook_prefix = self.ap.instance_config.data.get('api', {}).get('webhook_prefix', '') + if webhook_prefix: + base_url = webhook_prefix.rstrip('/') + + if not re.match(r'^https?://[a-zA-Z0-9._:/-]+$', base_url): + base_url = quart.request.host_url.rstrip('/') + + config = self._get_bot_config(bot_uuid) + site_key = config.get('turnstile_site_key', '') + locale = config.get('language', 'en_US') or 'en_US' + bubble_icon = config.get('bubble_icon', 'logo') or 'logo' + widget_js = template.replace('__LANGBOT_TURNSTILE_SITE_KEY__', site_key) + widget_js = widget_js.replace('__LANGBOT_BOT_UUID__', bot_uuid) + widget_js = widget_js.replace('__LANGBOT_BASE_URL__', base_url) + widget_js = widget_js.replace('__LANGBOT_LOCALE__', locale) + widget_js = widget_js.replace('__LANGBOT_BUBBLE_ICON__', bubble_icon) + + response = quart.Response(widget_js, content_type='application/javascript; charset=utf-8') + response.headers['Cache-Control'] = 'public, max-age=300' + return response + + @self.route('/logo', methods=['GET'], auth_type=group.AuthType.NONE) + async def serve_logo() -> quart.Response: + """Serve the LangBot logo for the embed widget.""" + try: + logo_data = _get_logo_bytes() + except FileNotFoundError: + return quart.Response('', status=404) + + response = quart.Response(logo_data, content_type='image/webp') + response.headers['Cache-Control'] = 'public, max-age=86400' + return response + + @self.route('//messages/', methods=['GET'], auth_type=group.AuthType.NONE) + async def get_embed_messages(bot_uuid: str, session_type: str) -> str: + if not _is_valid_uuid(bot_uuid): + return self.http_status(400, -1, 'Invalid bot_uuid format') + runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid) + if runtime_bot is None: + return self.http_status(404, -1, 'Bot not found or not available') + if not await self._verify_session_token(quart.request, bot_uuid): + return self.http_status(403, -1, 'Unauthorized or session expired') + try: + if session_type not in ['person', 'group']: + return self.http_status(400, -1, 'session_type must be person or group') + + websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter + if not websocket_adapter: + return self.http_status(404, -1, 'WebSocket adapter not found') + + messages = websocket_adapter.get_websocket_messages(pipeline_uuid, session_type) + return self.success(data={'messages': messages}) + + except Exception as e: + logger.error(f'Failed to get embed messages: {e}', exc_info=True) + return self.http_status(500, -1, 'Internal server error') + + @self.route('//reset/', methods=['POST'], auth_type=group.AuthType.NONE) + async def reset_embed_session(bot_uuid: str, session_type: str) -> str: + if not _is_valid_uuid(bot_uuid): + return self.http_status(400, -1, 'Invalid bot_uuid format') + runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid) + if runtime_bot is None: + return self.http_status(404, -1, 'Bot not found or not available') + if not await self._verify_session_token(quart.request, bot_uuid): + return self.http_status(403, -1, 'Unauthorized or session expired') + try: + if session_type not in ['person', 'group']: + return self.http_status(400, -1, 'session_type must be person or group') + + websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter + if not websocket_adapter: + return self.http_status(404, -1, 'WebSocket adapter not found') + + websocket_adapter.reset_session(pipeline_uuid, session_type) + return self.success(data={'message': 'Session reset successfully'}) + + except Exception as e: + logger.error(f'Failed to reset embed session: {e}', exc_info=True) + return self.http_status(500, -1, 'Internal server error') + + @self.route('//feedback', methods=['POST'], auth_type=group.AuthType.NONE) + async def submit_feedback(bot_uuid: str) -> str: + if not _is_valid_uuid(bot_uuid): + return self.http_status(400, -1, 'Invalid bot_uuid format') + runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid) + if runtime_bot is None: + return self.http_status(404, -1, 'Bot not found or not available') + if not await self._verify_session_token(quart.request, bot_uuid): + return self.http_status(403, -1, 'Unauthorized or session expired') + try: + data = await quart.request.get_json() + message_id = data.get('message_id', '') + feedback_type = data.get('feedback_type') + + if feedback_type not in (1, 2, 3): + return self.http_status(400, -1, 'feedback_type must be 1 (like), 2 (dislike), or 3 (cancel)') + + feedback_id = f'embed_{uuid.uuid4().hex[:12]}' + + await self.ap.monitoring_service.record_feedback( + feedback_id=feedback_id, + feedback_type=feedback_type, + bot_id=runtime_bot.bot_entity.uuid, + bot_name=runtime_bot.bot_entity.name or bot_uuid, + pipeline_id=pipeline_uuid, + message_id=str(message_id), + platform='web_page_bot', + ) + + return self.success(data={'feedback_id': feedback_id}) + + except Exception as e: + logger.error(f'Failed to record feedback: {e}', exc_info=True) + return self.http_status(500, -1, 'Internal server error') + + # -- Embed WebSocket endpoint ---------------------------------------- + + @self.quart_app.websocket(self.path + '//ws/connect') + async def embed_websocket_connect(bot_uuid: str): + """WebSocket connection for embed widget, keyed by bot_uuid.""" + if not _is_valid_uuid(bot_uuid): + await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Invalid bot_uuid format'})) + return + + runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid) + if runtime_bot is None: + await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Bot not found or not available'})) + return + + session_type = quart.websocket.args.get('session_type', 'person') + if session_type not in ['person', 'group']: + await quart.websocket.send( + json.dumps({'type': 'error', 'message': 'session_type must be person or group'}) + ) + return + + websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter + if not websocket_adapter: + await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'})) + return + + try: + connection = await ws_connection_manager.add_connection( + websocket=quart.websocket._get_current_object(), + pipeline_uuid=pipeline_uuid, + session_type=session_type, + metadata={'user_agent': quart.websocket.headers.get('User-Agent', '')}, + ) + + await quart.websocket.send( + json.dumps( + { + 'type': 'connected', + 'connection_id': connection.connection_id, + 'bot_uuid': bot_uuid, + 'session_type': session_type, + 'timestamp': connection.created_at.isoformat(), + } + ) + ) + + logger.debug( + f'Embed WebSocket connected: {connection.connection_id} ' + f'(bot={bot_uuid}, pipeline={pipeline_uuid}, session_type={session_type})' + ) + + receive_task = asyncio.create_task(self._handle_receive(connection, websocket_adapter, runtime_bot)) + send_task = asyncio.create_task(self._handle_send(connection)) + + try: + await asyncio.gather(receive_task, send_task) + except Exception as e: + logger.error(f'Embed WebSocket task error: {e}') + finally: + await ws_connection_manager.remove_connection(connection.connection_id) + + except Exception as e: + logger.error(f'Embed WebSocket connection error: {e}', exc_info=True) + try: + await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Internal server error'})) + except Exception: + pass + + # -- WebSocket receive/send helpers -------------------------------------- + + async def _handle_receive(self, connection, websocket_adapter, owner_bot): + try: + while connection.is_active: + message = await quart.websocket.receive() + await ws_connection_manager.update_activity(connection.connection_id) + + try: + data = json.loads(message) + message_type = data.get('type', 'message') + + if message_type == 'ping': + await connection.send_queue.put( + {'type': 'pong', 'timestamp': datetime.datetime.now().isoformat()} + ) + elif message_type == 'message': + await websocket_adapter.handle_websocket_message(connection, data, owner_bot=owner_bot) + elif message_type == 'disconnect': + break + + except json.JSONDecodeError: + await connection.send_queue.put({'type': 'error', 'message': 'Invalid JSON format'}) + + except Exception as e: + logger.error(f'Embed receive error: {e}', exc_info=True) + finally: + connection.is_active = False + + async def _handle_send(self, connection): + try: + while connection.is_active: + try: + message = await asyncio.wait_for(connection.send_queue.get(), timeout=1.0) + await quart.websocket.send(json.dumps(message)) + except asyncio.TimeoutError: + continue + except Exception as e: + logger.error(f'Embed send error: {e}', exc_info=True) + finally: + connection.is_active = False diff --git a/src/langbot/pkg/api/http/controller/groups/pipelines/websocket_chat.py b/src/langbot/pkg/api/http/controller/groups/pipelines/websocket_chat.py index d03790b1..c85ecc77 100644 --- a/src/langbot/pkg/api/http/controller/groups/pipelines/websocket_chat.py +++ b/src/langbot/pkg/api/http/controller/groups/pipelines/websocket_chat.py @@ -43,6 +43,9 @@ class WebSocketChatRouterGroup(group.RouterGroup): await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'})) return + # Find the owning bot for this pipeline (e.g. a web_page_bot) + owner_bot = self._find_owner_bot(pipeline_uuid) + # 注册连接 connection = await ws_connection_manager.add_connection( websocket=quart.websocket._get_current_object(), @@ -70,7 +73,7 @@ class WebSocketChatRouterGroup(group.RouterGroup): ) # 创建接收和发送任务 - receive_task = asyncio.create_task(self._handle_receive(connection, websocket_adapter)) + receive_task = asyncio.create_task(self._handle_receive(connection, websocket_adapter, owner_bot)) send_task = asyncio.create_task(self._handle_send(connection)) # 等待任务完成 @@ -178,7 +181,14 @@ class WebSocketChatRouterGroup(group.RouterGroup): except Exception as e: return self.http_status(500, -1, f'Internal server error: {str(e)}') - async def _handle_receive(self, connection, websocket_adapter): + def _find_owner_bot(self, pipeline_uuid: str): + """Find a user-created bot (e.g. web_page_bot) that owns this pipeline.""" + for bot in self.ap.platform_mgr.bots: + if bot.bot_entity.adapter == 'web_page_bot' and bot.bot_entity.use_pipeline_uuid == pipeline_uuid: + return bot + return None + + async def _handle_receive(self, connection, websocket_adapter, owner_bot=None): """处理接收消息的任务""" try: while connection.is_active: @@ -203,7 +213,7 @@ class WebSocketChatRouterGroup(group.RouterGroup): logger.debug(f'收到消息: {data} from {connection.connection_id}') # 处理消息(不等待响应,响应会通过broadcast异步发送) - await websocket_adapter.handle_websocket_message(connection, data) + await websocket_adapter.handle_websocket_message(connection, data, owner_bot=owner_bot) elif message_type == 'disconnect': # 客户端主动断开 diff --git a/src/langbot/pkg/platform/sources/web_page_bot.yaml b/src/langbot/pkg/platform/sources/web_page_bot.yaml new file mode 100644 index 00000000..d4f4d9a5 --- /dev/null +++ b/src/langbot/pkg/platform/sources/web_page_bot.yaml @@ -0,0 +1,177 @@ +apiVersion: v1 +kind: MessagePlatformAdapter +metadata: + name: web_page_bot + label: + en_US: "Page Bot" + zh_Hans: "页面机器人" + zh_Hant: "頁面機器人" + ja_JP: "ページボット" + th_TH: "บอทหน้าเว็บ" + vi_VN: "Bot trang web" + es_ES: "Bot de página" + description: + en_US: "Embed a chat widget on any website with a simple script tag" + zh_Hans: "通过一行脚本标签将聊天组件嵌入到任何网站" + zh_Hant: "透過一行腳本標籤將聊天元件嵌入到任何網站" + ja_JP: "シンプルなスクリプトタグで任意のウェブサイトにチャットウィジェットを埋め込みます" + th_TH: "ฝังวิดเจ็ตแชทในเว็บไซต์ใดก็ได้ด้วยแท็กสคริปต์" + vi_VN: "Nhúng widget trò chuyện vào bất kỳ trang web nào bằng thẻ script" + es_ES: "Incrusta un widget de chat en cualquier sitio web con una etiqueta de script" + icon: "webpage.webp" +spec: + categories: + - popular + config: + - name: title + label: + en_US: Widget Title + zh_Hans: 组件标题 + zh_Hant: 元件標題 + ja_JP: ウィジェットタイトル + th_TH: ชื่อวิดเจ็ต + vi_VN: Tiêu đề widget + es_ES: Título del widget + description: + en_US: The title displayed in the chat widget header + zh_Hans: 显示在聊天组件顶部的标题 + zh_Hant: 顯示在聊天元件頂部的標題 + ja_JP: チャットウィジェットのヘッダーに表示されるタイトル + th_TH: ชื่อที่แสดงในส่วนหัวของวิดเจ็ตแชท + vi_VN: Tiêu đề hiển thị trong đầu widget trò chuyện + es_ES: El título que se muestra en el encabezado del widget de chat + type: string + required: false + default: "LangBot" + - name: bubble_icon + label: + en_US: Bubble Icon + zh_Hans: 气泡图标 + zh_Hant: 氣泡圖示 + ja_JP: バブルアイコン + th_TH: ไอคอนบับเบิล + vi_VN: Biểu tượng bong bóng + es_ES: Icono de burbuja + ru_RU: Иконка пузырька + description: + en_US: "Icon displayed on the floating chat bubble" + zh_Hans: "浮动聊天气泡上显示的图标" + type: select + required: false + default: "logo" + options: + - name: "logo" + label: + en_US: "LangBot Logo" + zh_Hans: "LangBot 图标" + - name: "chat" + label: + en_US: "Chat Bubble" + zh_Hans: "聊天气泡" + - name: "robot" + label: + en_US: "Robot" + zh_Hans: "机器人" + - name: "headset" + label: + en_US: "Headset" + zh_Hans: "客服耳机" + - name: "sparkle" + label: + en_US: "Sparkle" + zh_Hans: "星光" + - name: "message" + label: + en_US: "Message" + zh_Hans: "消息" + - name: language + label: + en_US: Widget Language + zh_Hans: 组件语言 + zh_Hant: 元件語言 + ja_JP: ウィジェット言語 + th_TH: ภาษาวิดเจ็ต + vi_VN: Ngôn ngữ widget + es_ES: Idioma del widget + ru_RU: Язык виджета + description: + en_US: "Display language of the chat widget" + zh_Hans: "聊天组件的显示语言" + zh_Hant: "聊天元件的顯示語言" + ja_JP: "チャットウィジェットの表示言語" + th_TH: "ภาษาแสดงผลของวิดเจ็ตแชท" + vi_VN: "Ngôn ngữ hiển thị của widget trò chuyện" + es_ES: "Idioma de visualización del widget de chat" + ru_RU: "Язык отображения виджета чата" + type: select + required: false + default: "en_US" + options: + - name: "en_US" + label: + en_US: "English" + - name: "zh_Hans" + label: + en_US: "简体中文" + - name: "zh_Hant" + label: + en_US: "繁體中文" + - name: "ja_JP" + label: + en_US: "日本語" + - name: "es_ES" + label: + en_US: "Español" + - name: "ru_RU" + label: + en_US: "Русский" + - name: "th_TH" + label: + en_US: "ไทย" + - name: "vi_VN" + label: + en_US: "Tiếng Việt" + - name: embed_code + label: + en_US: Embed Code + zh_Hans: 嵌入代码 + zh_Hant: 嵌入代碼 + ja_JP: 埋め込みコード + th_TH: โค้ดฝังตัว + vi_VN: Mã nhúng + es_ES: Código de incrustación + description: + en_US: "Copy this code and paste it into your website HTML. The code will be generated after saving." + zh_Hans: "复制此代码并粘贴到你的网站 HTML 中。保存后将自动生成。" + zh_Hant: "複製此代碼並貼到你的網站 HTML 中。儲存後將自動生成。" + ja_JP: "このコードをコピーしてウェブサイトのHTMLに貼り付けてください。保存後に自動生成されます。" + th_TH: "คัดลอกโค้ดนี้และวางในHTML ของเว็บไซต์ของคุณ จะสร้างอัตโนมัติหลังจากบันทึก" + vi_VN: "Sao chép mã này và dán vào HTML trang web của bạn. Mã sẽ được tạo tự động sau khi lưu." + es_ES: "Copia este código y pégalo en el HTML de tu sitio web. El código se generará después de guardar." + type: embed-code + required: false + default: "" + - name: turnstile_site_key + label: + en_US: Turnstile Site Key + zh_Hans: Turnstile 站点密钥 + description: + en_US: "Cloudflare Turnstile site key for bot protection. Get it from the Cloudflare dashboard (Turnstile > Add Site). Leave empty to disable." + zh_Hans: "Cloudflare Turnstile 站点密钥,用于防止机器人滥用。在 Cloudflare 控制台(Turnstile > 添加站点)中获取。留空则不启用。" + type: string + required: false + default: "" + - name: turnstile_secret_key + label: + en_US: Turnstile Secret Key + zh_Hans: Turnstile 服务端密钥 + description: + en_US: "Cloudflare Turnstile secret key for server-side token verification. Found alongside the site key in the Cloudflare dashboard. Required if site key is set." + zh_Hans: "Cloudflare Turnstile 服务端密钥,用于服务端验证令牌。与站点密钥一起在 Cloudflare 控制台中获取。设置了站点密钥时必填。" + type: string + required: false + default: "" +execution: + python: + path: "web_page_bot_adapter.py" + attr: "WebPageBotAdapter" diff --git a/src/langbot/pkg/platform/sources/web_page_bot_adapter.py b/src/langbot/pkg/platform/sources/web_page_bot_adapter.py new file mode 100644 index 00000000..9b892a10 --- /dev/null +++ b/src/langbot/pkg/platform/sources/web_page_bot_adapter.py @@ -0,0 +1,97 @@ +"""Web Page Bot adapter - lightweight adapter for embeddable chat widget""" + +import typing + +import pydantic + +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +import langbot_plugin.api.entities.builtin.platform.message as platform_message +import langbot_plugin.api.entities.builtin.platform.events as platform_events +import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger + + +class WebPageBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): + """Lightweight adapter for the embeddable page bot. + + This adapter does not handle messages itself. The actual WebSocket + communication is handled by the singleton websocket_proxy_bot. + This adapter stores event listeners so that RuntimeBot can register + its handlers, which are then called by the websocket adapter when + a message arrives for this bot's pipeline. + + Message sending/replying is delegated to the websocket_proxy_bot's + adapter so that replies are actually delivered over the WebSocket + connection while the dashboard correctly shows this adapter's name. + """ + + listeners: dict = pydantic.Field(default_factory=dict, exclude=True) + _ws_adapter: typing.Any = None + + class Config: + arbitrary_types_allowed = True + # Allow private attributes + underscore_attrs_are_private = True + + def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs): + super().__init__(config=config, logger=logger, **kwargs) + + def set_ws_adapter(self, ws_adapter) -> None: + """Set the underlying WebSocket adapter used for actual message delivery.""" + object.__setattr__(self, '_ws_adapter', ws_adapter) + + async def send_message( + self, + target_type: str, + target_id: str, + message: platform_message.MessageChain, + ) -> dict: + if self._ws_adapter is not None: + return await self._ws_adapter.send_message(target_type, target_id, message) + return {} + + async def reply_message( + self, + message_source: platform_events.MessageEvent, + message: platform_message.MessageChain, + quote_origin: bool = False, + ) -> dict: + if self._ws_adapter is not None: + return await self._ws_adapter.reply_message(message_source, message, quote_origin) + return {} + + async def reply_message_chunk( + self, + message_source: platform_events.MessageEvent, + bot_message, + message: platform_message.MessageChain, + quote_origin: bool = False, + is_final: bool = False, + ) -> dict: + if self._ws_adapter is not None: + return await self._ws_adapter.reply_message_chunk( + message_source, bot_message, message, quote_origin, is_final + ) + return {} + + def register_listener( + self, + event_type: typing.Type[platform_events.Event], + func: typing.Callable, + ): + self.listeners[event_type] = func + + def unregister_listener( + self, + event_type: typing.Type[platform_events.Event], + func: typing.Callable, + ): + self.listeners.pop(event_type, None) + + async def is_muted(self, group_id: int) -> bool: + return False + + async def run_async(self): + pass + + async def kill(self): + pass diff --git a/src/langbot/pkg/platform/sources/webpage.webp b/src/langbot/pkg/platform/sources/webpage.webp new file mode 100644 index 0000000000000000000000000000000000000000..09226adcf9fca2b8a51ff96f383b01be37f00089 GIT binary patch literal 13922 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 DyRYH2 literal 0 HcmV?d00001 diff --git a/src/langbot/pkg/platform/sources/websocket_adapter.py b/src/langbot/pkg/platform/sources/websocket_adapter.py index 01da9f10..9ffcf04a 100644 --- a/src/langbot/pkg/platform/sources/websocket_adapter.py +++ b/src/langbot/pkg/platform/sources/websocket_adapter.py @@ -312,7 +312,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter) async def _process_image_components(self, message_chain_obj: list): """ - 处理消息链中的图片组件,将path转换为base64 + 处理消息链中的图片和文件组件,将path转换为base64 Args: message_chain_obj: 消息链对象列表 @@ -322,16 +322,18 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter) storage_mgr = self.ap.storage_mgr for component in message_chain_obj: - if component.get('type') == 'Image' and component.get('path'): - try: - # 从storage读取文件 - file_content = await storage_mgr.storage_provider.load(component['path']) + comp_type = component.get('type', '') + comp_path = component.get('path', '') - # 转换为base64 + if not comp_path: + continue + + if comp_type == 'Image': + try: + file_content = await storage_mgr.storage_provider.load(comp_path) base64_str = base64.b64encode(file_content).decode('utf-8') - # 添加data URI前缀(根据文件扩展名判断MIME类型) - file_key = component['path'] + file_key = comp_path if file_key.lower().endswith(('.jpg', '.jpeg')): mime_type = 'image/jpeg' elif file_key.lower().endswith('.png'): @@ -341,19 +343,19 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter) elif file_key.lower().endswith('.webp'): mime_type = 'image/webp' else: - mime_type = 'image/png' # 默认 + mime_type = 'image/png' component['base64'] = f'data:{mime_type};base64,{base64_str}' - await storage_mgr.storage_provider.delete(component['path']) + await storage_mgr.storage_provider.delete(comp_path) component['path'] = '' - # 保留path字段用于后端处理,前端使用base64显示 except Exception as e: - await self.logger.error(f'加载图片文件失败 {component["path"]}: {e}') + await self.logger.error(f'Failed to load image file {comp_path}: {e}') async def handle_websocket_message( self, connection: WebSocketConnection, message_data: dict, + owner_bot=None, ): """ 处理从WebSocket接收的消息 @@ -366,6 +368,8 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter) message_data: 消息数据,包含: - message: 消息链 - stream: 是否启用流式输出 (可选,默认True) + owner_bot: Optional RuntimeBot that owns this pipeline (e.g. a web_page_bot). + When provided, its identity is used for logging and session tracking. """ pipeline_uuid = connection.pipeline_uuid session_type = connection.session_type @@ -435,12 +439,26 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter) sender=sender, message_chain=message_chain, time=datetime.now().timestamp() ) - # 设置流水线UUID + # 设置流水线UUID (proxy bot always needs it for reply_message routing) self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid = pipeline_uuid + if owner_bot is not None: + owner_bot.bot_entity.use_pipeline_uuid = pipeline_uuid - # 异步触发事件处理(不等待结果) - if event.__class__ in self.listeners: - asyncio.create_task(self.listeners[event.__class__](event, self)) + # 异步触发事件处理 + # Use owner_bot's listeners if available, otherwise fall back to proxy bot + listeners = ( + owner_bot.adapter.listeners + if (owner_bot and hasattr(owner_bot.adapter, 'listeners') and owner_bot.adapter.listeners) + else self.listeners + ) + # Pass owner_bot's adapter so that downstream logging / dashboard + # attributes the message to the correct bot adapter name. + # Wire the ws adapter into the owner so replies are actually delivered. + if owner_bot and hasattr(owner_bot.adapter, 'set_ws_adapter'): + owner_bot.adapter.set_ws_adapter(self) + callback_adapter = owner_bot.adapter if (owner_bot and hasattr(owner_bot, 'adapter')) else self + if event.__class__ in listeners: + asyncio.create_task(listeners[event.__class__](event, callback_adapter)) def get_websocket_messages(self, pipeline_uuid: str, session_type: str) -> list[dict]: """获取消息历史""" diff --git a/src/langbot/templates/embed/logo.webp b/src/langbot/templates/embed/logo.webp new file mode 100644 index 0000000000000000000000000000000000000000..09226adcf9fca2b8a51ff96f383b01be37f00089 GIT binary patch literal 13922 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 DyRYH2 literal 0 HcmV?d00001 diff --git a/src/langbot/templates/embed/widget.js b/src/langbot/templates/embed/widget.js new file mode 100644 index 00000000..72be5eeb --- /dev/null +++ b/src/langbot/templates/embed/widget.js @@ -0,0 +1,1308 @@ +(function () { + "use strict"; + + // Prevent duplicate initialization + if (document.getElementById("langbot-widget-root")) return; + + // Read config from script tag data attributes + var scriptEl = document.currentScript; + var scriptTitle = scriptEl ? scriptEl.getAttribute("data-title") : null; + + // ========== i18n ========== + var I18N = { + en_US: { + welcomeMessage: "Send a message to start the conversation", + inputPlaceholder: "Type a message...", + openChat: "Open chat", + resetConversation: "Reset conversation", + minimize: "Minimize", + uploadFile: "Upload file", + send: "Send", + failedToConnect: "Failed to connect", + imageTooLarge: "Image must be under 5MB", + onlyImages: "Only image files are supported", + botVerificationFailed: "Bot verification failed", + botVerificationNetworkError: "Bot verification network error", + botVerificationError: "Bot verification error", + poweredBy: + 'Powered by LangBot', + }, + zh_Hans: { + welcomeMessage: "发送消息开始对话", + inputPlaceholder: "输入消息...", + openChat: "打开聊天", + resetConversation: "重置对话", + minimize: "最小化", + uploadFile: "上传文件", + send: "发送", + failedToConnect: "连接失败", + imageTooLarge: "图片大小不能超过 5MB", + onlyImages: "仅支持图片文件", + botVerificationFailed: "机器人验证失败", + botVerificationNetworkError: "机器人验证网络错误", + botVerificationError: "机器人验证错误", + poweredBy: + '由 LangBot 提供支持', + }, + zh_Hant: { + welcomeMessage: "傳送訊息開始對話", + inputPlaceholder: "輸入訊息...", + openChat: "開啟聊天", + resetConversation: "重置對話", + minimize: "最小化", + uploadFile: "上傳檔案", + send: "傳送", + failedToConnect: "連線失敗", + imageTooLarge: "圖片大小不能超過 5MB", + onlyImages: "僅支援圖片檔案", + botVerificationFailed: "機器人驗證失敗", + botVerificationNetworkError: "機器人驗證網路錯誤", + botVerificationError: "機器人驗證錯誤", + poweredBy: + '由 LangBot 提供支持', + }, + ja_JP: { + welcomeMessage: "メッセージを送信して会話を始めましょう", + inputPlaceholder: "メッセージを入力...", + openChat: "チャットを開く", + resetConversation: "会話をリセット", + minimize: "最小化", + uploadFile: "ファイルをアップロード", + send: "送信", + failedToConnect: "接続に失敗しました", + imageTooLarge: "画像は5MB以下にしてください", + onlyImages: "画像ファイルのみ対応しています", + botVerificationFailed: "ボット認証に失敗しました", + botVerificationNetworkError: "ボット認証のネットワークエラー", + botVerificationError: "ボット認証エラー", + poweredBy: + 'LangBot で動作', + }, + es_ES: { + welcomeMessage: "Envía un mensaje para iniciar la conversación", + inputPlaceholder: "Escribe un mensaje...", + openChat: "Abrir chat", + resetConversation: "Reiniciar conversación", + minimize: "Minimizar", + uploadFile: "Subir archivo", + send: "Enviar", + failedToConnect: "Error de conexión", + imageTooLarge: "La imagen debe ser menor a 5MB", + onlyImages: "Solo se admiten archivos de imagen", + botVerificationFailed: "Verificación del bot fallida", + botVerificationNetworkError: "Error de red en verificación del bot", + botVerificationError: "Error de verificación del bot", + poweredBy: + 'Desarrollado con LangBot', + }, + ru_RU: { + welcomeMessage: "Отправьте сообщение, чтобы начать разговор", + inputPlaceholder: "Введите сообщение...", + openChat: "Открыть чат", + resetConversation: "Сбросить разговор", + minimize: "Свернуть", + uploadFile: "Загрузить файл", + send: "Отправить", + failedToConnect: "Ошибка подключения", + imageTooLarge: "Изображение должно быть менее 5МБ", + onlyImages: "Поддерживаются только изображения", + botVerificationFailed: "Проверка бота не пройдена", + botVerificationNetworkError: "Ошибка сети при проверке бота", + botVerificationError: "Ошибка проверки бота", + poweredBy: + 'Работает на LangBot', + }, + th_TH: { + welcomeMessage: "ส่งข้อความเพื่อเริ่มการสนทนา", + inputPlaceholder: "พิมพ์ข้อความ...", + openChat: "เปิดแชท", + resetConversation: "รีเซ็ตการสนทนา", + minimize: "ย่อ", + uploadFile: "อัปโหลดไฟล์", + send: "ส่ง", + failedToConnect: "เชื่อมต่อไม่สำเร็จ", + imageTooLarge: "รูปภาพต้องมีขนาดไม่เกิน 5MB", + onlyImages: "รองรับเฉพาะไฟล์รูปภาพเท่านั้น", + botVerificationFailed: "การยืนยันบอทล้มเหลว", + botVerificationNetworkError: "เกิดข้อผิดพลาดเครือข่ายในการยืนยันบอท", + botVerificationError: "เกิดข้อผิดพลาดในการยืนยันบอท", + poweredBy: + 'ขับเคลื่อนโดย LangBot', + }, + vi_VN: { + welcomeMessage: "Gửi tin nhắn để bắt đầu cuộc trò chuyện", + inputPlaceholder: "Nhập tin nhắn...", + openChat: "Mở trò chuyện", + resetConversation: "Đặt lại cuộc trò chuyện", + minimize: "Thu nhỏ", + uploadFile: "Tải lên tệp", + send: "Gửi", + failedToConnect: "Kết nối thất bại", + imageTooLarge: "Hình ảnh phải nhỏ hơn 5MB", + onlyImages: "Chỉ hỗ trợ tệp hình ảnh", + botVerificationFailed: "Xác minh bot thất bại", + botVerificationNetworkError: "Lỗi mạng khi xác minh bot", + botVerificationError: "Lỗi xác minh bot", + poweredBy: + 'Được hỗ trợ bởi LangBot', + }, + }; + + var _locale = "__LANGBOT_LOCALE__"; + var _strings = I18N[_locale] || I18N.en_US; + function t(key) { + return _strings[key] || I18N.en_US[key] || key; + } + + // ========== Configuration (injected by backend) ========== + var CONFIG = { + botUuid: "__LANGBOT_BOT_UUID__", + baseUrl: "__LANGBOT_BASE_URL__", + sessionType: "person", + title: scriptTitle || "LangBot", + logoUrl: "__LANGBOT_BASE_URL__" + "/api/v1/embed/logo", + maxReconnectAttempts: 5, + reconnectDelay: 3000, + heartbeatInterval: 30000, + turnstileSiteKey: "__LANGBOT_TURNSTILE_SITE_KEY__", + bubbleIcon: "__LANGBOT_BUBBLE_ICON__", + }; + + // ========== Styles ========== + var STYLES = + '\ + :host { all: initial; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-size: 14px; line-height: 1.5; color: #1a1a1a; }\ + *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }\ + .lb-bubble { position: fixed; bottom: 20px; right: 20px; width: 56px; height: 56px; border-radius: 50%; background: #2563eb; color: #fff; border: none; cursor: pointer; box-shadow: 0 4px 12px rgba(37,99,235,0.4); display: flex; align-items: center; justify-content: center; z-index: 2147483646; transition: transform 0.2s ease, box-shadow 0.2s ease; overflow: hidden; }\ + .lb-bubble:hover { transform: scale(1.08); box-shadow: 0 6px 20px rgba(37,99,235,0.5); }\ + .lb-bubble svg { width: 28px; height: 28px; fill: currentColor; }\ + .lb-chat-icon { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }\ + .lb-bubble .lb-close-icon { display: none; }\ + .lb-bubble.lb-open .lb-chat-icon { display: none; }\ + .lb-bubble.lb-open .lb-close-icon { display: block; }\ + .lb-panel { position: fixed; bottom: 88px; right: 20px; width: 400px; height: 600px; max-height: calc(100vh - 108px); background: #fff; border-radius: 16px; box-shadow: 0 8px 40px rgba(0,0,0,0.15); display: flex; flex-direction: column; z-index: 2147483646; overflow: hidden; opacity: 0; transform: translateY(16px) scale(0.95); pointer-events: none; transition: opacity 0.25s ease, transform 0.25s ease; }\ + .lb-panel.lb-visible { opacity: 1; transform: translateY(0) scale(1); pointer-events: auto; }\ + .lb-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; background: #2563eb; color: #fff; flex-shrink: 0; }\ + .lb-header-left { display: flex; align-items: center; gap: 10px; }\ + .lb-header-logo { width: 28px; height: 28px; border-radius: 6px; object-fit: cover; }\ + .lb-header-title { font-size: 16px; font-weight: 600; }\ + .lb-status-dot { width: 8px; height: 8px; border-radius: 50%; background: #fbbf24; flex-shrink: 0; }\ + .lb-status-dot.lb-connected { background: #34d399; }\ + .lb-header-actions { display: flex; align-items: center; gap: 8px; }\ + .lb-header-btn { background: none; border: none; color: #fff; cursor: pointer; padding: 4px; border-radius: 6px; display: flex; align-items: center; justify-content: center; opacity: 0.8; transition: opacity 0.15s; }\ + .lb-header-btn:hover { opacity: 1; }\ + .lb-header-btn svg { width: 18px; height: 18px; fill: currentColor; }\ + .lb-messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 16px; scroll-behavior: smooth; }\ + .lb-messages::-webkit-scrollbar { width: 6px; }\ + .lb-messages::-webkit-scrollbar-track { background: transparent; }\ + .lb-messages::-webkit-scrollbar-thumb { background: #d1d5db; border-radius: 3px; }\ + .lb-msg { display: flex; gap: 10px; animation: lb-fade-in 0.2s ease; max-width: 100%; }\ + @keyframes lb-fade-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }\ + .lb-msg-user { flex-direction: row-reverse; }\ + .lb-msg-assistant { flex-direction: row; }\ + .lb-avatar { width: 32px; height: 32px; border-radius: 50%; flex-shrink: 0; display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: 600; overflow: hidden; }\ + .lb-avatar svg { width: 18px; height: 18px; fill: #fff; }\ + .lb-avatar img { width: 100%; height: 100%; object-fit: cover; }\ + .lb-avatar-user { background: #6366f1; color: #fff; }\ + .lb-avatar-bot { background: #5b9bd5; color: #fff; }\ + .lb-msg-body { display: flex; flex-direction: column; max-width: calc(100% - 42px); min-width: 0; }\ + .lb-msg-user .lb-msg-body { align-items: flex-end; }\ + .lb-msg-assistant .lb-msg-body { align-items: flex-start; }\ + .lb-msg-bubble { padding: 10px 14px; border-radius: 12px; word-break: break-word; white-space: pre-wrap; font-size: 14px; line-height: 1.6; max-width: 100%; }\ + .lb-msg-user .lb-msg-bubble { background: #2563eb; color: #fff; border-bottom-right-radius: 4px; }\ + .lb-msg-assistant .lb-msg-bubble { background: #f3f4f6; color: #1a1a1a; border-bottom-left-radius: 4px; }\ + .lb-msg-bubble code { font-family: "SF Mono", Monaco, Consolas, monospace; font-size: 0.9em; background: rgba(0,0,0,0.06); padding: 1px 4px; border-radius: 3px; }\ + .lb-msg-user .lb-msg-bubble code { background: rgba(255,255,255,0.2); }\ + .lb-msg-bubble pre { background: #1e293b; color: #e2e8f0; padding: 12px; border-radius: 8px; overflow-x: auto; margin: 8px 0; font-size: 13px; }\ + .lb-msg-bubble pre code { background: none; padding: 0; color: inherit; }\ + .lb-msg-bubble a { color: #2563eb; text-decoration: underline; }\ + .lb-msg-user .lb-msg-bubble a { color: #bfdbfe; }\ + .lb-msg-bubble h3 { font-size: 15px; font-weight: 600; margin: 8px 0 4px; }\ + .lb-msg-bubble h4 { font-size: 14px; font-weight: 600; margin: 6px 0 3px; }\ + .lb-msg-bubble blockquote { border-left: 3px solid #d1d5db; padding-left: 10px; margin: 6px 0; color: #6b7280; }\ + .lb-msg-bubble ul, .lb-msg-bubble ol { padding-left: 20px; margin: 4px 0; }\ + .lb-msg-bubble li { margin: 2px 0; }\ + .lb-msg-bubble table { border-collapse: collapse; margin: 8px 0; font-size: 13px; width: 100%; }\ + .lb-msg-bubble th, .lb-msg-bubble td { border: 1px solid #d1d5db; padding: 4px 8px; text-align: left; }\ + .lb-msg-bubble th { background: #f3f4f6; font-weight: 600; }\ + .lb-msg-bubble hr { border: none; border-top: 1px solid #d1d5db; margin: 8px 0; }\ + .lb-msg-bubble del { text-decoration: line-through; opacity: 0.7; }\ + .lb-msg-bubble img { max-width: 100%; border-radius: 8px; margin: 4px 0; cursor: pointer; }\ + .lb-msg-actions { display: flex; align-items: center; gap: 4px; margin-top: 6px; padding-top: 6px; border-top: 1px solid rgba(0,0,0,0.06); }\ + .lb-msg-actions-hidden { display: none; }\ + .lb-act-btn { background: none; border: 1px solid #e5e7eb; color: #9ca3af; cursor: pointer; padding: 3px 6px; border-radius: 6px; display: flex; align-items: center; gap: 3px; font-size: 11px; transition: all 0.15s; }\ + .lb-act-btn:hover { background: #f3f4f6; color: #6b7280; border-color: #d1d5db; }\ + .lb-act-btn.lb-active { color: #2563eb; border-color: #93c5fd; background: #eff6ff; }\ + .lb-act-btn svg { width: 14px; height: 14px; fill: currentColor; }\ + .lb-img-upload-btn { background: none; border: none; color: #9ca3af; cursor: pointer; padding: 6px; border-radius: 8px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; transition: color 0.15s; }\ + .lb-img-upload-btn:hover { color: #6b7280; }\ + .lb-img-upload-btn svg { width: 20px; height: 20px; fill: currentColor; }\ + .lb-img-preview { display: flex; align-items: center; gap: 8px; padding: 8px 16px; border-top: 1px solid #e5e7eb; background: #fafafa; flex-shrink: 0; }\ + .lb-img-preview img { width: 48px; height: 48px; object-fit: cover; border-radius: 6px; }\ + .lb-img-preview-remove { background: none; border: none; color: #9ca3af; cursor: pointer; font-size: 18px; padding: 0 4px; }\ + .lb-img-preview-remove:hover { color: #ef4444; }\ + .lb-msg-meta { display: flex; align-items: center; gap: 8px; margin-top: 4px; padding: 0 2px; }\ + .lb-msg-time { font-size: 11px; color: #9ca3af; }\ + .lb-footer { text-align: right; padding: 6px 12px; font-size: 9px; color: #d1d5db; font-style: italic; flex-shrink: 0; }\ + .lb-footer a { color: #d1d5db; text-decoration: none; }\ + .lb-footer a:hover { color: #9ca3af; }\ + .lb-typing { display: inline-flex; gap: 4px; padding: 10px 14px; background: #f3f4f6; border-radius: 12px; border-bottom-left-radius: 4px; margin-left: 42px; }\ + .lb-typing span { width: 6px; height: 6px; background: #9ca3af; border-radius: 50%; animation: lb-bounce 1.4s infinite both; }\ + .lb-typing span:nth-child(2) { animation-delay: 0.16s; }\ + .lb-typing span:nth-child(3) { animation-delay: 0.32s; }\ + @keyframes lb-bounce { 0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; } 40% { transform: scale(1); opacity: 1; } }\ + .lb-welcome { text-align: center; color: #9ca3af; padding: 40px 20px; font-size: 14px; }\ + .lb-welcome-logo { width: 48px; height: 48px; border-radius: 12px; margin: 0 auto 12px; }\ + .lb-input-area { display: flex; align-items: flex-end; gap: 8px; padding: 12px 16px; border-top: 1px solid #e5e7eb; background: #fff; flex-shrink: 0; }\ + .lb-input { flex: 1; border: 1px solid #d1d5db; border-radius: 10px; padding: 10px 14px; font-size: 14px; font-family: inherit; line-height: 1.4; resize: none; outline: none; max-height: 120px; min-height: 40px; transition: border-color 0.15s; overflow-y: auto; }\ + .lb-input:focus { border-color: #2563eb; }\ + .lb-input::placeholder { color: #9ca3af; }\ + .lb-send-btn { width: 40px; height: 40px; border-radius: 10px; background: #2563eb; color: #fff; border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; flex-shrink: 0; transition: background 0.15s, opacity 0.15s; }\ + .lb-send-btn:hover { background: #1d4ed8; }\ + .lb-send-btn:disabled { opacity: 0.4; cursor: not-allowed; }\ + .lb-send-btn svg { width: 20px; height: 20px; fill: currentColor; }\ + .lb-error { text-align: center; color: #ef4444; padding: 8px; font-size: 12px; background: #fef2f2; border-radius: 8px; margin: 4px 16px; }\ + @media (max-width: 480px) {\ + .lb-panel { bottom: 0; right: 0; width: 100vw; height: 100vh; max-height: 100vh; border-radius: 0; }\ + .lb-bubble { bottom: 16px; right: 16px; }\ + }\ + '; + + // ========== Bubble Icon Presets ========== + var BUBBLE_ICONS = { + logo: null, + chat: '', + robot: + '', + headset: + '', + sparkle: + '', + message: + '', + }; + + // ========== SVG Icons ========== + var ICON_CLOSE = + ''; + var ICON_SEND = + ''; + var ICON_RESET = + ''; + var ICON_USER = + ''; + var ICON_THUMB_UP = + ''; + var ICON_THUMB_DOWN = + ''; + var ICON_COPY = + ''; + var ICON_CHECK = + ''; + var ICON_IMAGE = + ''; + + // ========== State ========== + var state = { + isOpen: false, + isConnected: false, + ws: null, + connectionId: null, + reconnectAttempts: 0, + heartbeatTimer: null, + messages: [], + nextLocalId: 1, + isStreaming: false, + streamingMsgId: null, + historyLoaded: false, + pendingImage: null, + feedbackState: {}, + }; + + // ========== DOM References ========== + var els = {}; + + // ========== Utility Functions ========== + function esc(str) { + var div = document.createElement("div"); + div.textContent = str; + return div.innerHTML; + } + + function formatTime(ts) { + try { + var d = new Date(ts); + return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + } catch (e) { + return ""; + } + } + + function renderMarkdown(text) { + if (!text) return ""; + // Preserve code blocks first + var codeBlocks = []; + text = text.replace(/```(\w*)\n?([\s\S]*?)```/g, function (m, lang, code) { + codeBlocks.push("

" + esc(code.trim()) + "
"); + return "\x00CB" + (codeBlocks.length - 1) + "\x00"; + }); + var html = esc(text); + // Restore code blocks + html = html.replace(/\x00CB(\d+)\x00/g, function (m, i) { + return codeBlocks[parseInt(i)]; + }); + // Inline code + html = html.replace(/`([^`]+)`/g, "$1"); + // Headings + html = html.replace(/^### (.+)$/gm, "

$1

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

$1

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

$1

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