mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-10 07:46:02 +00:00
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) <noreply@anthropic.com> * refactor(embed): use bot_uuid instead of pipeline_uuid in all embed URLs Replace pipeline_uuid with bot_uuid in all user-facing embed widget URLs so internal pipeline identifiers are never exposed. The server resolves bot_uuid to the owning web_page_bot, validates it is enabled and has a pipeline bound, then routes internally using pipeline_uuid. Add a dedicated WebSocket endpoint at /api/v1/embed/<bot_uuid>/ws/connect instead of reusing the pipeline debug path. Wire WebPageBotAdapter to proxy reply_message calls through the WebSocket adapter so dashboard shows the correct adapter name while replies are still delivered. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs(embed): improve Turnstile config field descriptions Add guidance on where to obtain the keys (Cloudflare dashboard) and clarify that leaving them empty disables the feature. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(embed): add multi-language support for embed widget Add a language selector to the web_page_bot config with 8 locales (en, zh-Hans, zh-Hant, ja, es, ru, th, vi). The backend injects the locale into widget.js which uses a built-in i18n dictionary for all user-facing strings (welcome message, placeholder, aria labels, error messages, powered-by footer). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(embed): use correct select option format for language selector Options must use name/label (i18n object) format, not value/label (plain string), to match the dynamic form renderer. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style(embed): adjust footer padding and link to langbot.app Increase footer padding for more breathing room from the bottom edge. Change powered-by link from GitHub repo to langbot.app. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(embed): ignore Enter key during IME composition Check e.isComposing before treating Enter as send, so confirming an IME candidate (e.g. Chinese/Japanese input) does not also fire the message. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(embed): center bubble icon and fill entire circle Make .lb-chat-icon span fill the full bubble area so the logo image covers the circle completely without exposing the blue background. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(embed): add bubble icon presets selector Add 6 bubble icon options (LangBot logo, chat bubble, robot, headset, sparkle, message) configurable in the bot settings. Icons are inline SVGs in widget.js, selected via a config field injected by the backend. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: RockChinQ <rockchinq@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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]:
|
||||
"""获取消息历史"""
|
||||
|
||||
Reference in New Issue
Block a user