Compare commits

...

9 Commits

Author SHA1 Message Date
RockChinQ
b34ebf85a6 fix: update version to 4.8.7 in pyproject.toml, __init__.py, and uv.lock 2026-03-04 18:30:53 +08:00
RockChinQ
06d3298cde fix: update pnpm-lock.yaml for rehype-sanitize 2026-03-01 04:12:27 -05:00
Junyan Chin
614621ab7b Merge commit from fork
Add rehype-sanitize after rehypeRaw in all ReactMarkdown usages:
- PluginReadme.tsx (plugin README rendering)
- DebugDialog.tsx (debug chat message rendering)
- NewVersionDialog.tsx (release notes rendering)

This prevents injection of raw HTML (e.g. <iframe srcdoc>) that
could steal session tokens and API credentials from localStorage.

Fixes GHSA-w8gq-g4pc-xh3h
2026-03-01 17:01:23 +08:00
Junyan Qin
8600d0a8e7 chore: add botocore dependency to pyproject.toml and uv.lock
- Included botocore>=1.42.39 in dependencies to ensure compatibility with boto3.
- Updated lock file to reflect the new botocore dependency.
2026-02-28 19:26:50 +08:00
RockChinQ
b83e6a53be fix(storage): lazy import s3storage to avoid boto3 dependency for local storage
Fixes #2014

When using default local storage, the s3storage module was imported
at the top level, which triggered boto3/botocore import and caused
ModuleNotFoundError if those packages weren't installed.

Now s3storage is only imported when S3 storage is actually configured.
2026-02-28 06:02:41 -05:00
Junyan Chin
88132dff8a perf: reduce memory usage by ~200MB+ at startup (#2013)
* perf: reduce memory usage by ~200MB+ at startup

Two key optimizations:

1. Use importlib.util.find_spec() instead of __import__() in dependency
   checking. find_spec() only locates modules without executing them,
   avoiding loading all 36 dependencies (~222MB) into memory at startup.

2. Introduce shared aiohttp.ClientSession via httpclient module.
   Previously, every HTTP request created a new ClientSession, which
   creates a new TCPConnector and SSL context, loading system root
   certificates each time (~270MB total allocations observed via memray).
   Now all HTTP client code reuses shared sessions.

   - satori.py and coze_server_api/client.py are left unchanged as they
     create one session per adapter lifecycle (not per-request).

Profiling data (memray):
- Peak memory: 403MB
- SSL context creation: 270MB / 6.7M allocations (67% of total)
- Dependency import: 222MB (55% of peak)
- Expected reduction: 150-350MB at startup

* fix: remove unused aiohttp imports (ruff F401)

* style: ruff format
2026-02-27 20:09:03 +08:00
Junyan Qin
2dc5999583 fix: handle undefined values in DynamicFormItemComponent
- Updated BOOLEAN case to default to false when field.value is undefined.
- Updated SELECT case to default to an empty string when field.value is undefined.
2026-02-27 10:55:28 +08:00
Junyan Qin
73461814c9 fix: prevent infinite re-render loop in BotForm and DynamicFormComponent
- Updated BotForm to serialize adapter_config for stable useEffect dependency.
- Refactored DynamicFormComponent to track last emitted values, avoiding unnecessary re-renders when form values remain unchanged.
2026-02-27 10:52:19 +08:00
Guanchao Wang
210e5e50d3 fix: telegram send messsage (#2010) 2026-02-27 00:40:19 +08:00
26 changed files with 503 additions and 355 deletions

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "langbot" name = "langbot"
version = "4.8.6" version = "4.8.7"
description = "Production-grade platform for building agentic IM bots" description = "Production-grade platform for building agentic IM bots"
readme = "README.md" readme = "README.md"
license-files = ["LICENSE"] license-files = ["LICENSE"]
@@ -71,6 +71,7 @@ dependencies = [
"boto3>=1.35.0", "boto3>=1.35.0",
"pymilvus>=2.6.4", "pymilvus>=2.6.4",
"pgvector>=0.4.1", "pgvector>=0.4.1",
"botocore>=1.42.39",
] ]
keywords = [ keywords = [
"bot", "bot",

View File

@@ -1,3 +1,3 @@
"""LangBot - Production-grade platform for building agentic IM bots""" """LangBot - Production-grade platform for building agentic IM bots"""
__version__ = '4.8.6' __version__ = '4.8.7'

View File

@@ -1,5 +1,5 @@
import requests import requests
import aiohttp from langbot.pkg.utils import httpclient
def post_json(base_url, token, data=None): def post_json(base_url, token, data=None):
@@ -63,7 +63,7 @@ async def async_request(
""" """
headers = {'Content-Type': 'application/json'} headers = {'Content-Type': 'application/json'}
url = f'{base_url}?key={token_key}' url = f'{base_url}?key={token_key}'
async with aiohttp.ClientSession() as session: session = httpclient.get_session()
async with session.request( async with session.request(
method=method, url=url, params=params, headers=headers, data=data, json=json method=method, url=url, params=params, headers=headers, data=data, json=json
) as response: ) as response:

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
import aiohttp from langbot.pkg.utils import httpclient
import typing import typing
import datetime import datetime
import time import time
@@ -99,7 +99,7 @@ class SpaceService:
space_config = self._get_space_config() space_config = self._get_space_config()
space_url = space_config['url'] space_url = space_config['url']
async with aiohttp.ClientSession() as session: session = httpclient.get_session()
async with session.post( async with session.post(
f'{space_url}/api/v1/accounts/oauth/token', f'{space_url}/api/v1/accounts/oauth/token',
json={'code': code, 'instance_id': constants.instance_id}, json={'code': code, 'instance_id': constants.instance_id},
@@ -116,7 +116,7 @@ class SpaceService:
space_config = self._get_space_config() space_config = self._get_space_config()
space_url = space_config['url'] space_url = space_config['url']
async with aiohttp.ClientSession() as session: session = httpclient.get_session()
async with session.post( async with session.post(
f'{space_url}/api/v1/accounts/token/refresh', json={'refresh_token': refresh_token} f'{space_url}/api/v1/accounts/token/refresh', json={'refresh_token': refresh_token}
) as response: ) as response:
@@ -132,7 +132,7 @@ class SpaceService:
space_config = self._get_space_config() space_config = self._get_space_config()
space_url = space_config['url'] space_url = space_config['url']
async with aiohttp.ClientSession() as session: session = httpclient.get_session()
async with session.get( async with session.get(
f'{space_url}/api/v1/accounts/me', headers={'Authorization': f'Bearer {access_token}'} f'{space_url}/api/v1/accounts/me', headers={'Authorization': f'Bearer {access_token}'}
) as response: ) as response:
@@ -178,7 +178,7 @@ class SpaceService:
space_config = self._get_space_config() space_config = self._get_space_config()
space_url = space_config['url'] space_url = space_config['url']
async with aiohttp.ClientSession() as session: session = httpclient.get_session()
async with session.get(f'{space_url}/api/v1/models') as response: async with session.get(f'{space_url}/api/v1/models') as response:
if response.status != 200: if response.status != 200:
raise ValueError(f'Failed to get models: {await response.text()}') raise ValueError(f'Failed to get models: {await response.text()}')

View File

@@ -1,3 +1,4 @@
import importlib.util
import pip import pip
import os import os
from ...utils import pkgmgr from ...utils import pkgmgr
@@ -49,9 +50,10 @@ async def check_deps() -> list[str]:
missing_deps = [] missing_deps = []
for dep in required_deps: for dep in required_deps:
try: # Use find_spec instead of __import__ to avoid actually loading
__import__(dep) # all modules into memory. find_spec only checks if the module
except ImportError: # can be found, without executing module-level code.
if importlib.util.find_spec(dep) is None:
missing_deps.append(dep) missing_deps.append(dep)
return missing_deps return missing_deps

View File

@@ -1,10 +1,9 @@
from __future__ import annotations from __future__ import annotations
import aiohttp
from .. import entities from .. import entities
from .. import filter as filter_model from .. import filter as filter_model
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
from langbot.pkg.utils import httpclient
BAIDU_EXAMINE_URL = 'https://aip.baidubce.com/rest/2.0/solution/v1/text_censor/v2/user_defined?access_token={}' BAIDU_EXAMINE_URL = 'https://aip.baidubce.com/rest/2.0/solution/v1/text_censor/v2/user_defined?access_token={}'
BAIDU_EXAMINE_TOKEN_URL = 'https://aip.baidubce.com/oauth/2.0/token' BAIDU_EXAMINE_TOKEN_URL = 'https://aip.baidubce.com/oauth/2.0/token'
@@ -15,7 +14,7 @@ class BaiduCloudExamine(filter_model.ContentFilter):
"""百度云内容审核""" """百度云内容审核"""
async def _get_token(self) -> str: async def _get_token(self) -> str:
async with aiohttp.ClientSession() as session: session = httpclient.get_session()
async with session.post( async with session.post(
BAIDU_EXAMINE_TOKEN_URL, BAIDU_EXAMINE_TOKEN_URL,
params={ params={
@@ -27,7 +26,7 @@ class BaiduCloudExamine(filter_model.ContentFilter):
return (await resp.json())['access_token'] return (await resp.json())['access_token']
async def process(self, query: pipeline_query.Query, message: str) -> entities.FilterResult: async def process(self, query: pipeline_query.Query, message: str) -> entities.FilterResult:
async with aiohttp.ClientSession() as session: session = httpclient.get_session()
async with session.post( async with session.post(
BAIDU_EXAMINE_URL.format(await self._get_token()), BAIDU_EXAMINE_URL.format(await self._get_token()),
headers={ headers={

View File

@@ -14,7 +14,7 @@ import io
import asyncio import asyncio
from enum import Enum from enum import Enum
import aiohttp from langbot.pkg.utils import httpclient
import pydantic import pydantic
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
@@ -622,7 +622,7 @@ class DiscordMessageConverter(abstract_platform_adapter.AbstractMessageConverter
image_bytes = base64.b64decode(base64_data) image_bytes = base64.b64decode(base64_data)
elif ele.url: elif ele.url:
# 从URL下载图片 # 从URL下载图片
async with aiohttp.ClientSession() as session: session = httpclient.get_session()
async with session.get(ele.url) as response: async with session.get(ele.url) as response:
image_bytes = await response.read() image_bytes = await response.read()
# 从URL或Content-Type推断文件类型 # 从URL或Content-Type推断文件类型
@@ -702,7 +702,7 @@ class DiscordMessageConverter(abstract_platform_adapter.AbstractMessageConverter
file_base64 = ele.base64.split(',')[-1] file_base64 = ele.base64.split(',')[-1]
file_bytes = base64.b64decode(file_base64) file_bytes = base64.b64decode(file_base64)
elif ele.url: elif ele.url:
async with aiohttp.ClientSession() as session: session = httpclient.get_session()
async with session.get(ele.url) as response: async with session.get(ele.url) as response:
file_bytes = await response.read() file_bytes = await response.read()
if file_bytes: if file_bytes:
@@ -717,7 +717,7 @@ class DiscordMessageConverter(abstract_platform_adapter.AbstractMessageConverter
else: else:
file_bytes = base64.b64decode(ele.base64) file_bytes = base64.b64decode(ele.base64)
elif ele.url: elif ele.url:
async with aiohttp.ClientSession() as session: session = httpclient.get_session()
async with session.get(ele.url) as response: async with session.get(ele.url) as response:
file_bytes = await response.read() file_bytes = await response.read()
if file_bytes: if file_bytes:
@@ -775,7 +775,7 @@ class DiscordMessageConverter(abstract_platform_adapter.AbstractMessageConverter
# attachments # attachments
for attachment in message.attachments: for attachment in message.attachments:
async with aiohttp.ClientSession(trust_env=True) as session: session = httpclient.get_session(trust_env=True)
async with session.get(attachment.url) as response: async with session.get(attachment.url) as response:
image_data = await response.read() image_data = await response.read()
image_base64 = base64.b64encode(image_data).decode('utf-8') image_base64 = base64.b64encode(image_data).decode('utf-8')

View File

@@ -9,6 +9,8 @@ import traceback
import time import time
import aiohttp import aiohttp
from langbot.pkg.utils import httpclient
import websockets import websockets
import pydantic import pydantic
@@ -120,7 +122,7 @@ class KookMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
if content: if content:
# Download image and convert to base64 # Download image and convert to base64
try: try:
async with aiohttp.ClientSession() as session: session = httpclient.get_session()
async with session.get(content) as response: async with session.get(content) as response:
if response.status == 200: if response.status == 200:
image_bytes = await response.read() image_bytes = await response.read()
@@ -295,7 +297,7 @@ class KookAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
'Authorization': f'Bot {self.config["token"]}', 'Authorization': f'Bot {self.config["token"]}',
} }
async with aiohttp.ClientSession() as session: session = httpclient.get_session()
async with session.get(base_url, params=params, headers=headers) as response: async with session.get(base_url, params=params, headers=headers) as response:
if response.status == 200: if response.status == 200:
data = await response.json() data = await response.json()
@@ -315,7 +317,7 @@ class KookAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
'Authorization': f'Bot {self.config["token"]}', 'Authorization': f'Bot {self.config["token"]}',
} }
async with aiohttp.ClientSession() as session: session = httpclient.get_session()
async with session.get(base_url, headers=headers) as response: async with session.get(base_url, headers=headers) as response:
if response.status == 200: if response.status == 200:
data = await response.json() data = await response.json()
@@ -510,7 +512,7 @@ class KookAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
try: try:
if not self.http_session: if not self.http_session:
self.http_session = aiohttp.ClientSession() self.http_session = httpclient.get_session()
async with self.http_session.post(url, json=payload, headers=headers) as response: async with self.http_session.post(url, json=payload, headers=headers) as response:
if response.status == 200: if response.status == 200:
@@ -576,7 +578,7 @@ class KookAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
try: try:
if not self.http_session: if not self.http_session:
self.http_session = aiohttp.ClientSession() self.http_session = httpclient.get_session()
async with self.http_session.post(url, json=payload, headers=headers) as response: async with self.http_session.post(url, json=payload, headers=headers) as response:
if response.status == 200: if response.status == 200:
@@ -624,7 +626,7 @@ class KookAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
try: try:
# Create HTTP session # Create HTTP session
self.http_session = aiohttp.ClientSession() self.http_session = httpclient.get_session()
await self.logger.info('Starting KOOK adapter') await self.logger.info('Starting KOOK adapter')

View File

@@ -17,7 +17,7 @@ import tempfile
import os import os
import mimetypes import mimetypes
import aiohttp from langbot.pkg.utils import httpclient
import lark_oapi.ws.exception import lark_oapi.ws.exception
import quart import quart
from lark_oapi.api.im.v1 import * from lark_oapi.api.im.v1 import *
@@ -78,7 +78,7 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
return None return None
elif msg.url: elif msg.url:
try: try:
async with aiohttp.ClientSession() as session: session = httpclient.get_session()
async with session.get(msg.url) as response: async with session.get(msg.url) as response:
if response.status == 200: if response.status == 200:
image_bytes = await response.read() image_bytes = await response.read()
@@ -208,7 +208,7 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
pass pass
elif msg.url: elif msg.url:
try: try:
async with aiohttp.ClientSession() as session: session = httpclient.get_session()
async with session.get(msg.url) as resp: async with session.get(msg.url) as resp:
if resp.status == 200: if resp.status == 200:
data = await resp.read() data = await resp.read()

View File

@@ -9,7 +9,7 @@ import copy
import threading import threading
import quart import quart
import aiohttp from langbot.pkg.utils import httpclient
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
from ....core import app from ....core import app
@@ -639,7 +639,7 @@ class GeWeChatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
async def run_async(self): async def run_async(self):
if not self.config['token']: if not self.config['token']:
async with aiohttp.ClientSession() as session: session = httpclient.get_session()
async with session.post( async with session.post(
f'{self.config["gewechat_url"]}/v2/api/tools/getTokenId', f'{self.config["gewechat_url"]}/v2/api/tools/getTokenId',
json={'app_id': self.config['app_id']}, json={'app_id': self.config['app_id']},

View File

@@ -9,9 +9,9 @@ import telegramify_markdown
import typing import typing
import traceback import traceback
import base64 import base64
import aiohttp
import pydantic import pydantic
from langbot.pkg.utils import httpclient
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter 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.message as platform_message
import langbot_plugin.api.entities.builtin.platform.events as platform_events import langbot_plugin.api.entities.builtin.platform.events as platform_events
@@ -33,7 +33,7 @@ class TelegramMessageConverter(abstract_platform_adapter.AbstractMessageConverte
if component.base64: if component.base64:
photo_bytes = base64.b64decode(component.base64) photo_bytes = base64.b64decode(component.base64)
elif component.url: elif component.url:
async with aiohttp.ClientSession() as session: session = httpclient.get_session()
async with session.get(component.url) as response: async with session.get(component.url) as response:
photo_bytes = await response.read() photo_bytes = await response.read()
elif component.path: elif component.path:
@@ -74,8 +74,7 @@ class TelegramMessageConverter(abstract_platform_adapter.AbstractMessageConverte
file_bytes = None file_bytes = None
file_format = '' file_format = ''
async with aiohttp.ClientSession(trust_env=True) as session: async with httpclient.get_session(trust_env=True).get(file.file_path) as response:
async with session.get(file.file_path) as response:
file_bytes = await response.read() file_bytes = await response.read()
file_format = 'image/jpeg' file_format = 'image/jpeg'
@@ -94,8 +93,7 @@ class TelegramMessageConverter(abstract_platform_adapter.AbstractMessageConverte
file_bytes = None file_bytes = None
file_format = message.voice.mime_type or 'audio/ogg' file_format = message.voice.mime_type or 'audio/ogg'
async with aiohttp.ClientSession(trust_env=True) as session: async with httpclient.get_session(trust_env=True).get(file.file_path) as response:
async with session.get(file.file_path) as response:
file_bytes = await response.read() file_bytes = await response.read()
message_components.append( message_components.append(
@@ -194,7 +192,31 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
) )
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
pass components = await TelegramMessageConverter.yiri2target(message, self.bot)
chat_id_str, _, thread_id_str = str(target_id).partition('#')
chat_id: int | str = int(chat_id_str) if chat_id_str.lstrip('-').isdigit() else chat_id_str
message_thread_id = int(thread_id_str) if thread_id_str and thread_id_str.isdigit() else None
for component in components:
component_type = component.get('type')
args = {'chat_id': chat_id}
if message_thread_id is not None:
args['message_thread_id'] = message_thread_id
if component_type == 'text':
text = component.get('text', '')
if self.config['markdown_card'] is True:
text = telegramify_markdown.markdownify(content=text)
args['parse_mode'] = 'MarkdownV2'
args['text'] = text
await self.bot.send_message(**args)
elif component_type == 'photo':
photo = component.get('photo')
if photo is None:
continue
args['photo'] = telegram.InputFile(photo)
await self.bot.send_photo(**args)
async def reply_message( async def reply_message(
self, self,

View File

@@ -3,6 +3,8 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import aiohttp import aiohttp
from langbot.pkg.utils import httpclient
import uuid import uuid
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -119,7 +121,7 @@ class WebhookPusher:
dict | None: The response JSON if successful, None otherwise dict | None: The response JSON if successful, None otherwise
""" """
try: try:
async with aiohttp.ClientSession() as session: session = httpclient.get_session()
async with session.post( async with session.post(
url, url,
json=payload, json=payload,

View File

@@ -5,6 +5,8 @@ import json
import uuid import uuid
import aiohttp import aiohttp
from langbot.pkg.utils import httpclient
from .. import runner from .. import runner
from ...core import app from ...core import app
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
@@ -217,7 +219,7 @@ class N8nServiceAPIRunner(runner.RequestRunner):
self.ap.logger.debug('no auth') self.ap.logger.debug('no auth')
# 调用webhook # 调用webhook
async with aiohttp.ClientSession() as session: session = httpclient.get_session()
if is_stream: if is_stream:
# 流式请求 # 流式请求
async with session.post( async with session.post(

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from ..core import app from ..core import app
from . import provider from . import provider
from .providers import localstorage, s3storage from .providers import localstorage
class StorageMgr: class StorageMgr:
@@ -21,6 +21,8 @@ class StorageMgr:
storage_type = storage_config.get('use', 'local') storage_type = storage_config.get('use', 'local')
if storage_type == 's3': if storage_type == 's3':
from .providers import s3storage
self.storage_provider = s3storage.S3StorageProvider(self.ap) self.storage_provider = s3storage.S3StorageProvider(self.ap)
self.ap.logger.info('Initialized S3 storage backend.') self.ap.logger.info('Initialized S3 storage backend.')
else: else:

View File

@@ -0,0 +1,43 @@
"""Shared aiohttp.ClientSession to avoid repeated SSL context creation.
Each call to `aiohttp.ClientSession()` creates a new `TCPConnector` which in turn
creates a new `ssl.SSLContext` and loads all system root certificates. This is
extremely expensive in both CPU and memory (~270MB total allocations observed via
memray profiling).
This module provides a shared session pool so that all HTTP client code in LangBot
reuses the same underlying SSL context and connection pool.
"""
from __future__ import annotations
import aiohttp
_sessions: dict[str, aiohttp.ClientSession] = {}
def get_session(*, trust_env: bool = False) -> aiohttp.ClientSession:
"""Get or create a shared aiohttp.ClientSession.
Args:
trust_env: Whether to trust environment variables for proxy settings.
Returns:
A shared aiohttp.ClientSession instance.
"""
key = f'trust_env={trust_env}'
session = _sessions.get(key)
if session is None or session.closed:
session = aiohttp.ClientSession(trust_env=trust_env)
_sessions[key] = session
return session
async def close_all():
"""Close all shared sessions. Call on application shutdown."""
for session in _sessions.values():
if not session.closed:
await session.close()
_sessions.clear()

View File

@@ -5,6 +5,8 @@ from urllib.parse import urlparse, parse_qs
import ssl import ssl
import aiohttp import aiohttp
from langbot.pkg.utils import httpclient
import PIL.Image import PIL.Image
import httpx import httpx
@@ -47,13 +49,14 @@ async def get_gewechat_image_base64(
) )
try: try:
async with aiohttp.ClientSession(timeout=timeout) as session: session = httpclient.get_session()
# 获取图片下载链接 # 获取图片下载链接
try: try:
async with session.post( async with session.post(
f'{gewechat_url}/v2/api/message/downloadImage', f'{gewechat_url}/v2/api/message/downloadImage',
headers=headers, headers=headers,
json={'appId': app_id, 'type': image_type, 'xml': xml_content}, json={'appId': app_id, 'type': image_type, 'xml': xml_content},
timeout=timeout,
) as response: ) as response:
if response.status != 200: if response.status != 200:
# print(response) # print(response)
@@ -104,7 +107,7 @@ async def get_wecom_image_base64(pic_url: str) -> tuple[str, str]:
:param pic_url: 企业微信图片URL :param pic_url: 企业微信图片URL
:return: (base64_str, image_format) :return: (base64_str, image_format)
""" """
async with aiohttp.ClientSession() as session: session = httpclient.get_session()
async with session.get(pic_url) as response: async with session.get(pic_url) as response:
if response.status != 200: if response.status != 200:
raise Exception(f'Failed to download image: {response.status}') raise Exception(f'Failed to download image: {response.status}')
@@ -152,10 +155,8 @@ async def get_qq_image_bytes(image_url: str, query: dict = {}) -> tuple[bytes, s
ssl_context = ssl.create_default_context() ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE ssl_context.verify_mode = ssl.CERT_NONE
async with aiohttp.ClientSession(trust_env=False) as session: session = httpclient.get_session()
async with session.get( async with session.get(image_url, params=query, ssl=ssl_context, timeout=aiohttp.ClientTimeout(total=30.0)) as resp:
image_url, params=query, ssl=ssl_context, timeout=aiohttp.ClientTimeout(total=30.0)
) as resp:
resp.raise_for_status() resp.raise_for_status()
file_bytes = await resp.read() file_bytes = await resp.read()
content_type = resp.headers.get('Content-Type') content_type = resp.headers.get('Content-Type')
@@ -204,7 +205,7 @@ async def extract_b64_and_format(image_base64_data: str) -> typing.Tuple[str, st
async def get_slack_image_to_base64(pic_url: str, bot_token: str): async def get_slack_image_to_base64(pic_url: str, bot_token: str):
headers = {'Authorization': f'Bearer {bot_token}'} headers = {'Authorization': f'Bearer {bot_token}'}
try: try:
async with aiohttp.ClientSession() as session: session = httpclient.get_session()
async with session.get(pic_url, headers=headers) as resp: async with session.get(pic_url, headers=headers) as resp:
mime_type = resp.headers.get('Content-Type', 'application/octet-stream') mime_type = resp.headers.get('Content-Type', 'application/octet-stream')
file_bytes = await resp.read() file_bytes = await resp.read()

6
uv.lock generated
View File

@@ -1,5 +1,5 @@
version = 1 version = 1
revision = 3 revision = 2
requires-python = ">=3.11, <4.0" requires-python = ">=3.11, <4.0"
resolution-markers = [ resolution-markers = [
"python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform == 'win32'",
@@ -1799,7 +1799,7 @@ wheels = [
[[package]] [[package]]
name = "langbot" name = "langbot"
version = "4.8.6" version = "4.8.7"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiocqhttp" }, { name = "aiocqhttp" },
@@ -1813,6 +1813,7 @@ dependencies = [
{ name = "asyncpg" }, { name = "asyncpg" },
{ name = "beautifulsoup4" }, { name = "beautifulsoup4" },
{ name = "boto3" }, { name = "boto3" },
{ name = "botocore" },
{ name = "certifi" }, { name = "certifi" },
{ name = "chardet" }, { name = "chardet" },
{ name = "chromadb" }, { name = "chromadb" },
@@ -1891,6 +1892,7 @@ requires-dist = [
{ name = "asyncpg", specifier = ">=0.30.0" }, { name = "asyncpg", specifier = ">=0.30.0" },
{ name = "beautifulsoup4", specifier = ">=4.12.3" }, { name = "beautifulsoup4", specifier = ">=4.12.3" },
{ name = "boto3", specifier = ">=1.35.0" }, { name = "boto3", specifier = ">=1.35.0" },
{ name = "botocore", specifier = ">=1.42.39" },
{ name = "certifi", specifier = ">=2025.4.26" }, { name = "certifi", specifier = ">=2025.4.26" },
{ name = "chardet", specifier = ">=5.2.0" }, { name = "chardet", specifier = ">=5.2.0" },
{ name = "chromadb", specifier = ">=0.4.24" }, { name = "chromadb", specifier = ">=0.4.24" },

42
web/package-lock.json generated
View File

@@ -32,7 +32,7 @@
"@radix-ui/react-tooltip": "^1.2.7", "@radix-ui/react-tooltip": "^1.2.7",
"@tailwindcss/postcss": "^4.1.5", "@tailwindcss/postcss": "^4.1.5",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"axios": "^1.12.0", "axios": "^1.13.5",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
@@ -56,6 +56,7 @@
"rehype-autolink-headings": "^7.1.0", "rehype-autolink-headings": "^7.1.0",
"rehype-highlight": "^7.0.2", "rehype-highlight": "^7.0.2",
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"rehype-slug": "^6.0.0", "rehype-slug": "^6.0.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"sonner": "^2.0.3", "sonner": "^2.0.3",
@@ -3798,13 +3799,13 @@
} }
}, },
"node_modules/axios": { "node_modules/axios": {
"version": "1.13.4", "version": "1.13.6",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
"integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==", "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"follow-redirects": "^1.15.6", "follow-redirects": "^1.15.11",
"form-data": "^4.0.4", "form-data": "^4.0.5",
"proxy-from-env": "^1.1.0" "proxy-from-env": "^1.1.0"
} }
}, },
@@ -5970,6 +5971,21 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/hast-util-sanitize": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz",
"integrity": "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@ungap/structured-clone": "^1.0.0",
"unist-util-position": "^5.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-to-jsx-runtime": { "node_modules/hast-util-to-jsx-runtime": {
"version": "2.3.6", "version": "2.3.6",
"resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
@@ -9392,6 +9408,20 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/rehype-sanitize": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz",
"integrity": "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"hast-util-sanitize": "^5.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/rehype-slug": { "node_modules/rehype-slug": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/rehype-slug/-/rehype-slug-6.0.0.tgz", "resolved": "https://registry.npmjs.org/rehype-slug/-/rehype-slug-6.0.0.tgz",

View File

@@ -68,6 +68,7 @@
"rehype-autolink-headings": "^7.1.0", "rehype-autolink-headings": "^7.1.0",
"rehype-highlight": "^7.0.2", "rehype-highlight": "^7.0.2",
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"rehype-slug": "^6.0.0", "rehype-slug": "^6.0.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"sonner": "^2.0.3", "sonner": "^2.0.3",

18
web/pnpm-lock.yaml generated
View File

@@ -149,6 +149,9 @@ dependencies:
rehype-raw: rehype-raw:
specifier: ^7.0.0 specifier: ^7.0.0
version: 7.0.0 version: 7.0.0
rehype-sanitize:
specifier: ^6.0.0
version: 6.0.0
rehype-slug: rehype-slug:
specifier: ^6.0.0 specifier: ^6.0.0
version: 6.0.0 version: 6.0.0
@@ -3873,6 +3876,14 @@ packages:
zwitch: 2.0.4 zwitch: 2.0.4
dev: false dev: false
/hast-util-sanitize@5.0.2:
resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==}
dependencies:
'@types/hast': 3.0.4
'@ungap/structured-clone': 1.3.0
unist-util-position: 5.0.0
dev: false
/hast-util-to-jsx-runtime@2.3.6: /hast-util-to-jsx-runtime@2.3.6:
resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
dependencies: dependencies:
@@ -5713,6 +5724,13 @@ packages:
vfile: 6.0.3 vfile: 6.0.3
dev: false dev: false
/rehype-sanitize@6.0.0:
resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==}
dependencies:
'@types/hast': 3.0.4
hast-util-sanitize: 5.0.2
dev: false
/rehype-slug@6.0.0: /rehype-slug@6.0.0:
resolution: {integrity: sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==} resolution: {integrity: sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==}
dependencies: dependencies:

View File

@@ -124,6 +124,12 @@ export default function BotForm({
const currentAdapter = form.watch('adapter'); const currentAdapter = form.watch('adapter');
const currentAdapterConfig = form.watch('adapter_config'); const currentAdapterConfig = form.watch('adapter_config');
// Serialize adapter_config to a stable string so it can be used as a
// useEffect dependency without triggering on every render. form.watch()
// returns a new object reference each time, which would otherwise cause
// the filtering effect below to loop indefinitely.
const adapterConfigJson = JSON.stringify(currentAdapterConfig);
useEffect(() => { useEffect(() => {
setBotFormValues(); setBotFormValues();
}, []); }, []);
@@ -147,7 +153,7 @@ export default function BotForm({
// For non-Lark adapters, show all fields // For non-Lark adapters, show all fields
setFilteredDynamicFormConfigList(dynamicFormConfigList); setFilteredDynamicFormConfigList(dynamicFormConfigList);
} }
}, [currentAdapter, currentAdapterConfig, dynamicFormConfigList]); }, [currentAdapter, adapterConfigJson, dynamicFormConfigList]);
// 复制到剪贴板的辅助函数 - 使用页面上的真实input元素 // 复制到剪贴板的辅助函数 - 使用页面上的真实input元素
const copyToClipboard = () => { const copyToClipboard = () => {

View File

@@ -11,7 +11,7 @@ import {
FormMessage, FormMessage,
} from '@/components/ui/form'; } from '@/components/ui/form';
import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent'; import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent';
import { useEffect, useRef } from 'react'; import { useCallback, useEffect, useRef } from 'react';
import { extractI18nObject } from '@/i18n/I18nProvider'; import { extractI18nObject } from '@/i18n/I18nProvider';
export default function DynamicFormComponent({ export default function DynamicFormComponent({
@@ -146,22 +146,12 @@ export default function DynamicFormComponent({
const onSubmitRef = useRef(onSubmit); const onSubmitRef = useRef(onSubmit);
onSubmitRef.current = onSubmit; onSubmitRef.current = onSubmit;
// 监听表单值变化 // Track the last emitted values to avoid emitting identical snapshots,
useEffect(() => { // which would cause the parent to call setValue with an equivalent object,
// Emit initial form values immediately so the parent always has a valid snapshot, // triggering a re-render loop.
// even if the user saves without modifying any field. const lastEmittedRef = useRef<string>('');
// form.watch(callback) only fires on subsequent changes, not on mount.
const formValues = form.getValues();
const initialFinalValues = itemConfigList.reduce(
(acc, item) => {
acc[item.name] = formValues[item.name] ?? item.default;
return acc;
},
{} as Record<string, object>,
);
onSubmitRef.current?.(initialFinalValues);
const subscription = form.watch(() => { const emitValues = useCallback(() => {
const formValues = form.getValues(); const formValues = form.getValues();
const finalValues = itemConfigList.reduce( const finalValues = itemConfigList.reduce(
(acc, item) => { (acc, item) => {
@@ -170,10 +160,25 @@ export default function DynamicFormComponent({
}, },
{} as Record<string, object>, {} as Record<string, object>,
); );
const serialized = JSON.stringify(finalValues);
if (serialized !== lastEmittedRef.current) {
lastEmittedRef.current = serialized;
onSubmitRef.current?.(finalValues); onSubmitRef.current?.(finalValues);
}
}, [form, itemConfigList]);
// 监听表单值变化
useEffect(() => {
// Emit initial form values immediately so the parent always has a valid snapshot,
// even if the user saves without modifying any field.
// form.watch(callback) only fires on subsequent changes, not on mount.
emitValues();
const subscription = form.watch(() => {
emitValues();
}); });
return () => subscription.unsubscribe(); return () => subscription.unsubscribe();
}, [form, itemConfigList]); }, [form, itemConfigList, emitValues]);
return ( return (
<Form {...form}> <Form {...form}>

View File

@@ -191,7 +191,12 @@ export default function DynamicFormItemComponent({
return <Textarea {...field} className="min-h-[120px]" />; return <Textarea {...field} className="min-h-[120px]" />;
case DynamicFormItemType.BOOLEAN: case DynamicFormItemType.BOOLEAN:
return <Switch checked={field.value} onCheckedChange={field.onChange} />; return (
<Switch
checked={field.value ?? false}
onCheckedChange={field.onChange}
/>
);
case DynamicFormItemType.STRING_ARRAY: case DynamicFormItemType.STRING_ARRAY:
return ( return (
@@ -242,7 +247,7 @@ export default function DynamicFormItemComponent({
case DynamicFormItemType.SELECT: case DynamicFormItemType.SELECT:
return ( return (
<Select value={field.value} onValueChange={field.onChange}> <Select value={field.value ?? ''} onValueChange={field.onChange}>
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]"> <SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue placeholder={t('common.select')} /> <SelectValue placeholder={t('common.select')} />
</SelectTrigger> </SelectTrigger>

View File

@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw'; import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize';
import rehypeHighlight from 'rehype-highlight'; import rehypeHighlight from 'rehype-highlight';
import i18n from 'i18next'; import i18n from 'i18next';
import { ExternalLink } from 'lucide-react'; import { ExternalLink } from 'lucide-react';
@@ -62,7 +63,7 @@ export default function NewVersionDialog({
<div className="markdown-body max-w-none text-sm"> <div className="markdown-body max-w-none text-sm">
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeHighlight]} rehypePlugins={[rehypeRaw, rehypeSanitize, rehypeHighlight]}
components={{ components={{
ul: ({ children }) => <ul className="list-disc">{children}</ul>, ul: ({ children }) => <ul className="list-disc">{children}</ul>,
ol: ({ children }) => ( ol: ({ children }) => (

View File

@@ -25,6 +25,7 @@ import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import rehypeHighlight from 'rehype-highlight'; import rehypeHighlight from 'rehype-highlight';
import rehypeRaw from 'rehype-raw'; import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize';
import rehypeSlug from 'rehype-slug'; import rehypeSlug from 'rehype-slug';
import rehypeAutolinkHeadings from 'rehype-autolink-headings'; import rehypeAutolinkHeadings from 'rehype-autolink-headings';
import '@/styles/github-markdown.css'; import '@/styles/github-markdown.css';
@@ -622,6 +623,7 @@ export default function DebugDialog({
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
rehypePlugins={[ rehypePlugins={[
rehypeRaw, rehypeRaw,
rehypeSanitize,
rehypeHighlight, rehypeHighlight,
rehypeSlug, rehypeSlug,
[ [

View File

@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw'; import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize';
import rehypeHighlight from 'rehype-highlight'; import rehypeHighlight from 'rehype-highlight';
import rehypeSlug from 'rehype-slug'; import rehypeSlug from 'rehype-slug';
import rehypeAutolinkHeadings from 'rehype-autolink-headings'; import rehypeAutolinkHeadings from 'rehype-autolink-headings';
@@ -51,6 +52,7 @@ export default function PluginReadme({
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
rehypePlugins={[ rehypePlugins={[
rehypeRaw, rehypeRaw,
rehypeSanitize,
rehypeHighlight, rehypeHighlight,
rehypeSlug, rehypeSlug,
[ [