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 00000000..09226adc Binary files /dev/null and b/src/langbot/pkg/platform/sources/webpage.webp differ 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 00000000..09226adc Binary files /dev/null and b/src/langbot/templates/embed/logo.webp differ 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 = `