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:
fdc310
2026-04-24 15:36:14 +08:00
committed by GitHub
parent 58c1916712
commit 564d829e25
11 changed files with 2067 additions and 21 deletions

View File

@@ -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]:
"""获取消息历史"""