Compare commits

...

11 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
Junyan Qin
4fd488b97a chore: Bump version to 4.8.6 in pyproject.toml, uv.lock, and __init__.py 2026-02-26 22:54:13 +08:00
Junyan Qin
422a34ead4 fix: plugins in recommendation cannot be installed 2026-02-26 22:53:29 +08:00
27 changed files with 503 additions and 364 deletions

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "langbot" name = "langbot"
version = "4.8.5" 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.5' __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,16 +63,16 @@ 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:
response.raise_for_status() # 如果状态码不是200抛出异常 response.raise_for_status() # 如果状态码不是200抛出异常
result = await response.json() result = await response.json()
# print(result) # print(result)
return result return result
# if result.get('Code') == 200: # if result.get('Code') == 200:
# #
# return await result # return await result
# else: # else:
# raise RuntimeError("请求失败",response.text) # raise RuntimeError("请求失败",response.text)

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,49 +99,49 @@ 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},
) as response: ) as response:
if response.status != 200: if response.status != 200:
raise ValueError(f'Failed to exchange OAuth code: {await response.text()}') raise ValueError(f'Failed to exchange OAuth code: {await response.text()}')
data = await response.json() data = await response.json()
if data.get('code') != 0: if data.get('code') != 0:
raise ValueError(f'Failed to exchange OAuth code: {data.get("msg")}') raise ValueError(f'Failed to exchange OAuth code: {data.get("msg")}')
return data.get('data', {}) return data.get('data', {})
async def refresh_token(self, refresh_token: str) -> typing.Dict: async def refresh_token(self, refresh_token: str) -> typing.Dict:
"""Refresh Space access token""" """Refresh Space access token"""
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:
if response.status != 200: if response.status != 200:
raise ValueError(f'Failed to refresh token: {await response.text()}') raise ValueError(f'Failed to refresh token: {await response.text()}')
data = await response.json() data = await response.json()
if data.get('code') != 0: if data.get('code') != 0:
raise ValueError(f'Failed to refresh token: {data.get("msg")}') raise ValueError(f'Failed to refresh token: {data.get("msg")}')
return data.get('data', {}) return data.get('data', {})
async def get_user_info_raw(self, access_token: str) -> typing.Dict: async def get_user_info_raw(self, access_token: str) -> typing.Dict:
"""Get user info from Space using access token (no validation)""" """Get user info from Space using access token (no validation)"""
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:
if response.status != 200: if response.status != 200:
raise ValueError(f'Failed to get user info: {await response.text()}') raise ValueError(f'Failed to get user info: {await response.text()}')
data = await response.json() data = await response.json()
if data.get('code') != 0: if data.get('code') != 0:
raise ValueError(f'Failed to get user info: {data.get("msg")}') raise ValueError(f'Failed to get user info: {data.get("msg")}')
return data.get('data', {}) return data.get('data', {})
# === API calls with token validation === # === API calls with token validation ===
@@ -178,12 +178,12 @@ 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()}')
data = await response.json() data = await response.json()
if data.get('code') != 0: if data.get('code') != 0:
raise ValueError(f'Failed to get models: {data.get("msg")}') raise ValueError(f'Failed to get models: {data.get("msg")}')
models_data = data.get('data', {}).get('models', []) models_data = data.get('data', {}).get('models', [])
return [SpaceModel.model_validate(model_dict) for model_dict in models_data] return [SpaceModel.model_validate(model_dict) for model_dict in models_data]

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,50 +14,50 @@ 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={
'grant_type': 'client_credentials', 'grant_type': 'client_credentials',
'client_id': self.ap.pipeline_cfg.data['baidu-cloud-examine']['api-key'], 'client_id': self.ap.pipeline_cfg.data['baidu-cloud-examine']['api-key'],
'client_secret': self.ap.pipeline_cfg.data['baidu-cloud-examine']['api-secret'], 'client_secret': self.ap.pipeline_cfg.data['baidu-cloud-examine']['api-secret'],
}, },
) as resp: ) as resp:
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={
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json', 'Accept': 'application/json',
}, },
data=f'text={message}'.encode('utf-8'), data=f'text={message}'.encode('utf-8'),
) as resp: ) as resp:
result = await resp.json() result = await resp.json()
if 'error_code' in result: if 'error_code' in result:
return entities.FilterResult(
level=entities.ResultLevel.BLOCK,
replacement=message,
user_notice='',
console_notice=f'百度云判定出错,错误信息:{result["error_msg"]}',
)
else:
conclusion = result['conclusion']
if conclusion in ('合规'):
return entities.FilterResult(
level=entities.ResultLevel.PASS,
replacement=message,
user_notice='',
console_notice=f'百度云判定结果:{conclusion}',
)
else:
return entities.FilterResult( return entities.FilterResult(
level=entities.ResultLevel.BLOCK, level=entities.ResultLevel.BLOCK,
replacement=message, replacement=message,
user_notice='', user_notice='消息中存在不合适的内容, 请修改',
console_notice=f'百度云判定出错,错误信息:{result["error_msg"]}', console_notice=f'百度云判定结果:{conclusion}',
) )
else:
conclusion = result['conclusion']
if conclusion in ('合规'):
return entities.FilterResult(
level=entities.ResultLevel.PASS,
replacement=message,
user_notice='',
console_notice=f'百度云判定结果:{conclusion}',
)
else:
return entities.FilterResult(
level=entities.ResultLevel.BLOCK,
replacement=message,
user_notice='消息中存在不合适的内容, 请修改',
console_notice=f'百度云判定结果:{conclusion}',
)

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,23 +622,23 @@ 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推断文件类型
content_type = response.headers.get('Content-Type', '') content_type = response.headers.get('Content-Type', '')
if 'jpeg' in content_type or 'jpg' in content_type: if 'jpeg' in content_type or 'jpg' in content_type:
filename = f'{uuid.uuid4()}.jpg' filename = f'{uuid.uuid4()}.jpg'
elif 'gif' in content_type: elif 'gif' in content_type:
filename = f'{uuid.uuid4()}.gif' filename = f'{uuid.uuid4()}.gif'
elif 'webp' in content_type: elif 'webp' in content_type:
filename = f'{uuid.uuid4()}.webp' filename = f'{uuid.uuid4()}.webp'
elif ele.url.lower().endswith(('.jpg', '.jpeg')): elif ele.url.lower().endswith(('.jpg', '.jpeg')):
filename = f'{uuid.uuid4()}.jpg' filename = f'{uuid.uuid4()}.jpg'
elif ele.url.lower().endswith('.gif'): elif ele.url.lower().endswith('.gif'):
filename = f'{uuid.uuid4()}.gif' filename = f'{uuid.uuid4()}.gif'
elif ele.url.lower().endswith('.webp'): elif ele.url.lower().endswith('.webp'):
filename = f'{uuid.uuid4()}.webp' filename = f'{uuid.uuid4()}.webp'
elif ele.path: elif ele.path:
# 从文件路径读取图片 # 从文件路径读取图片
# 确保路径没有空字节 # 确保路径没有空字节
@@ -702,9 +702,9 @@ 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:
files.append(discord.File(fp=io.BytesIO(file_bytes), filename=filename)) files.append(discord.File(fp=io.BytesIO(file_bytes), filename=filename))
elif isinstance(ele, platform_message.File): elif isinstance(ele, platform_message.File):
@@ -717,9 +717,9 @@ 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:
files.append(discord.File(fp=io.BytesIO(file_bytes), filename=filename)) files.append(discord.File(fp=io.BytesIO(file_bytes), filename=filename))
elif isinstance(ele, platform_message.Forward): elif isinstance(ele, platform_message.Forward):
@@ -775,12 +775,12 @@ 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')
image_format = response.headers['Content-Type'] image_format = response.headers['Content-Type']
element_list.append(platform_message.Image(base64=f'data:{image_format};base64,{image_base64}')) element_list.append(platform_message.Image(base64=f'data:{image_format};base64,{image_base64}'))
return platform_message.MessageChain(element_list) return platform_message.MessageChain(element_list)

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,16 +122,16 @@ 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()
image_base64 = base64.b64encode(image_bytes).decode('utf-8') image_base64 = base64.b64encode(image_bytes).decode('utf-8')
# Detect image format # Detect image format
content_type = response.headers.get('Content-Type', 'image/png') content_type = response.headers.get('Content-Type', 'image/png')
components.append( components.append(
platform_message.Image(base64=f'data:{content_type};base64,{image_base64}') platform_message.Image(base64=f'data:{content_type};base64,{image_base64}')
) )
except Exception: except Exception:
# If download fails, just add as plain text # If download fails, just add as plain text
components.append(platform_message.Plain(text=f'[Image: {content}]')) components.append(platform_message.Plain(text=f'[Image: {content}]'))
@@ -295,17 +297,17 @@ 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()
if data.get('code') == 0: if data.get('code') == 0:
gateway_url = data['data']['url'] gateway_url = data['data']['url']
return gateway_url return gateway_url
else:
raise Exception(f'Failed to get gateway URL: {data.get("message")}')
else: else:
raise Exception(f'Failed to get gateway URL: HTTP {response.status}') raise Exception(f'Failed to get gateway URL: {data.get("message")}')
else:
raise Exception(f'Failed to get gateway URL: HTTP {response.status}')
async def _get_bot_user_info(self) -> dict: async def _get_bot_user_info(self) -> dict:
"""Get bot's own user information from KOOK API""" """Get bot's own user information from KOOK API"""
@@ -315,17 +317,17 @@ 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()
if data.get('code') == 0: if data.get('code') == 0:
user_info = data['data'] user_info = data['data']
return user_info return user_info
else:
raise Exception(f'Failed to get bot user info: {data.get("message")}')
else: else:
raise Exception(f'Failed to get bot user info: HTTP {response.status}') raise Exception(f'Failed to get bot user info: {data.get("message")}')
else:
raise Exception(f'Failed to get bot user info: HTTP {response.status}')
async def _handle_hello(self, data: dict): async def _handle_hello(self, data: dict):
"""Handle HELLO signal (signal 1)""" """Handle HELLO signal (signal 1)"""
@@ -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,13 +78,13 @@ 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()
else: else:
print(f'Failed to download image from {msg.url}: HTTP {response.status}') print(f'Failed to download image from {msg.url}: HTTP {response.status}')
return None return None
except Exception as e: except Exception as e:
print(f'Failed to download image from {msg.url}: {e}') print(f'Failed to download image from {msg.url}: {e}')
traceback.print_exc() traceback.print_exc()
@@ -208,10 +208,10 @@ 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()
except Exception: except Exception:
pass pass
elif msg.path: elif msg.path:

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,14 +639,14 @@ 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']},
) as response: ) as response:
if response.status != 200: if response.status != 200:
raise Exception(f'获取gewechat token失败: {await response.text()}') raise Exception(f'获取gewechat token失败: {await response.text()}')
self.config['token'] = (await response.json())['data'] self.config['token'] = (await response.json())['data']
self.bot = gewechat_client.GewechatClient(f'{self.config["gewechat_url"]}/v2/api', self.config['token']) self.bot = gewechat_client.GewechatClient(f'{self.config["gewechat_url"]}/v2/api', self.config['token'])

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,9 +33,9 @@ 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:
with open(component.path, 'rb') as f: with open(component.path, 'rb') as f:
photo_bytes = f.read() photo_bytes = f.read()
@@ -74,10 +74,9 @@ 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'
message_components.append( message_components.append(
platform_message.Image( platform_message.Image(
@@ -94,9 +93,8 @@ 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(
platform_message.Voice( platform_message.Voice(
@@ -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,23 +121,23 @@ 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,
headers={'Content-Type': 'application/json'}, headers={'Content-Type': 'application/json'},
timeout=aiohttp.ClientTimeout(total=15), timeout=aiohttp.ClientTimeout(total=15),
) as response: ) as response:
if response.status >= 400: if response.status >= 400:
self.logger.warning(f'Webhook {url} returned status {response.status}') self.logger.warning(f'Webhook {url} returned status {response.status}')
return None
else:
self.logger.debug(f'Successfully pushed to webhook {url}')
try:
return await response.json()
except Exception as json_error:
self.logger.debug(f'Failed to parse JSON response from webhook {url}: {json_error}')
return None return None
else:
self.logger.debug(f'Successfully pushed to webhook {url}')
try:
return await response.json()
except Exception as json_error:
self.logger.debug(f'Failed to parse JSON response from webhook {url}: {json_error}')
return None
except asyncio.TimeoutError: except asyncio.TimeoutError:
self.logger.warning(f'Timeout pushing to webhook {url}') self.logger.warning(f'Timeout pushing to webhook {url}')
return None return None

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,50 +219,50 @@ 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(
self.webhook_url, json=payload, headers=headers, auth=auth, timeout=self.timeout self.webhook_url, json=payload, headers=headers, auth=auth, timeout=self.timeout
) as response: ) as response:
if response.status != 200:
error_text = await response.text()
self.ap.logger.error(f'n8n webhook call failed: {response.status}, {error_text}')
raise Exception(f'n8n webhook call failed: {response.status}, {error_text}')
# 处理流式响应
async for chunk in self._process_stream_response(response):
yield chunk
else:
async with session.post(
self.webhook_url, json=payload, headers=headers, auth=auth, timeout=self.timeout
) as response:
try:
async for chunk in self._process_stream_response(response):
output_content = chunk.content if chunk.is_final else ''
except:
# 非流式请求(保持原有逻辑)
if response.status != 200: if response.status != 200:
error_text = await response.text() error_text = await response.text()
self.ap.logger.error(f'n8n webhook call failed: {response.status}, {error_text}') self.ap.logger.error(f'n8n webhook call failed: {response.status}, {error_text}')
raise Exception(f'n8n webhook call failed: {response.status}, {error_text}') raise Exception(f'n8n webhook call failed: {response.status}, {error_text}')
# 处理流式响应 # 解析响应
async for chunk in self._process_stream_response(response): response_data = await response.json()
yield chunk self.ap.logger.debug(f'n8n webhook response: {response_data}')
else:
async with session.post(
self.webhook_url, json=payload, headers=headers, auth=auth, timeout=self.timeout
) as response:
try:
async for chunk in self._process_stream_response(response):
output_content = chunk.content if chunk.is_final else ''
except:
# 非流式请求(保持原有逻辑)
if response.status != 200:
error_text = await response.text()
self.ap.logger.error(f'n8n webhook call failed: {response.status}, {error_text}')
raise Exception(f'n8n webhook call failed: {response.status}, {error_text}')
# 解析响应 # 从响应中提取输出
response_data = await response.json() if self.output_key in response_data:
self.ap.logger.debug(f'n8n webhook response: {response_data}') output_content = response_data[self.output_key]
else:
# 如果没有指定的输出键,则使用整个响应
output_content = json.dumps(response_data, ensure_ascii=False)
# 从响应中提取输出 # 返回消息
if self.output_key in response_data: yield provider_message.Message(
output_content = response_data[self.output_key] role='assistant',
else: content=output_content,
# 如果没有指定的输出键,则使用整个响应 )
output_content = json.dumps(response_data, ensure_ascii=False)
# 返回消息
yield provider_message.Message(
role='assistant',
content=output_content,
)
except Exception as e: except Exception as e:
self.ap.logger.error(f'n8n webhook call exception: {str(e)}') self.ap.logger.error(f'n8n webhook call exception: {str(e)}')
raise N8nAPIError(f'n8n webhook call exception: {str(e)}') raise N8nAPIError(f'n8n webhook call exception: {str(e)}')

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,53 +49,54 @@ 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},
) as response: timeout=timeout,
if response.status != 200: ) as response:
# print(response) if response.status != 200:
raise Exception(f'获取gewechat图片下载失败: {await response.text()}') # print(response)
raise Exception(f'获取gewechat图片下载失败: {await response.text()}')
resp_data = await response.json() resp_data = await response.json()
if resp_data.get('ret') != 200: if resp_data.get('ret') != 200:
raise Exception(f'获取gewechat图片下载链接失败: {resp_data}') raise Exception(f'获取gewechat图片下载链接失败: {resp_data}')
file_url = resp_data['data']['fileUrl'] file_url = resp_data['data']['fileUrl']
except asyncio.TimeoutError: except asyncio.TimeoutError:
raise Exception('获取图片下载链接超时') raise Exception('获取图片下载链接超时')
except aiohttp.ClientError as e: except aiohttp.ClientError as e:
raise Exception(f'获取图片下载链接网络错误: {str(e)}') raise Exception(f'获取图片下载链接网络错误: {str(e)}')
# 解析原始URL并替换端口 # 解析原始URL并替换端口
base_url = gewechat_file_url base_url = gewechat_file_url
download_url = f'{base_url}/download/{file_url}' download_url = f'{base_url}/download/{file_url}'
# 下载图片 # 下载图片
try: try:
async with session.get(download_url) as img_response: async with session.get(download_url) as img_response:
if img_response.status != 200: if img_response.status != 200:
raise Exception(f'下载图片失败: {await img_response.text()}, URL: {download_url}') raise Exception(f'下载图片失败: {await img_response.text()}, URL: {download_url}')
image_data = await img_response.read() image_data = await img_response.read()
content_type = img_response.headers.get('Content-Type', '') content_type = img_response.headers.get('Content-Type', '')
if content_type: if content_type:
image_format = content_type.split('/')[-1] image_format = content_type.split('/')[-1]
else: else:
image_format = file_url.split('.')[-1] image_format = file_url.split('.')[-1]
base64_str = base64.b64encode(image_data).decode('utf-8') base64_str = base64.b64encode(image_data).decode('utf-8')
return base64_str, image_format return base64_str, image_format
except asyncio.TimeoutError: except asyncio.TimeoutError:
raise Exception(f'下载图片超时, URL: {download_url}') raise Exception(f'下载图片超时, URL: {download_url}')
except aiohttp.ClientError as e: except aiohttp.ClientError as e:
raise Exception(f'下载图片网络错误: {str(e)}, URL: {download_url}') raise Exception(f'下载图片网络错误: {str(e)}, URL: {download_url}')
except Exception as e: except Exception as e:
raise Exception(f'获取图片失败: {str(e)}') from e raise Exception(f'获取图片失败: {str(e)}') from e
@@ -104,24 +107,24 @@ 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}')
# 读取图片数据 # 读取图片数据
image_data = await response.read() image_data = await response.read()
# 获取图片格式 # 获取图片格式
content_type = response.headers.get('Content-Type', '') content_type = response.headers.get('Content-Type', '')
image_format = content_type.split('/')[-1] # 例如 'image/jpeg' -> 'jpeg' image_format = content_type.split('/')[-1] # 例如 'image/jpeg' -> 'jpeg'
# 转换为 base64 # 转换为 base64
import base64 import base64
image_base64 = base64.b64encode(image_data).decode('utf-8') image_base64 = base64.b64encode(image_data).decode('utf-8')
return image_base64, image_format return image_base64, image_format
async def get_qq_official_image_base64(pic_url: str, content_type: str) -> tuple[str, str]: async def get_qq_official_image_base64(pic_url: str, content_type: str) -> tuple[str, str]:
@@ -152,21 +155,19 @@ 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) resp.raise_for_status()
) as resp: file_bytes = await resp.read()
resp.raise_for_status() content_type = resp.headers.get('Content-Type')
file_bytes = await resp.read() if not content_type:
content_type = resp.headers.get('Content-Type') image_format = 'jpeg'
if not content_type: elif not content_type.startswith('image/'):
image_format = 'jpeg' pil_img = PIL.Image.open(io.BytesIO(file_bytes))
elif not content_type.startswith('image/'): image_format = pil_img.format.lower()
pil_img = PIL.Image.open(io.BytesIO(file_bytes)) else:
image_format = pil_img.format.lower() image_format = content_type.split('/')[-1]
else: return file_bytes, image_format
image_format = content_type.split('/')[-1]
return file_bytes, image_format
async def qq_image_url_to_base64(image_url: str) -> typing.Tuple[str, str]: async def qq_image_url_to_base64(image_url: str) -> typing.Tuple[str, str]:
@@ -204,11 +205,11 @@ 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()
base64_str = base64.b64encode(file_bytes).decode('utf-8') base64_str = base64.b64encode(file_bytes).decode('utf-8')
return f'data:{mime_type};base64,{base64_str}' return f'data:{mime_type};base64,{base64_str}'
except Exception as e: except Exception as e:
raise (e) raise (e)

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.4" 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,34 +146,39 @@ 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 emitValues = useCallback(() => {
const formValues = form.getValues(); const formValues = form.getValues();
const initialFinalValues = itemConfigList.reduce( const finalValues = itemConfigList.reduce(
(acc, item) => { (acc, item) => {
acc[item.name] = formValues[item.name] ?? item.default; acc[item.name] = formValues[item.name] ?? item.default;
return acc; return acc;
}, },
{} as Record<string, object>, {} as Record<string, object>,
); );
onSubmitRef.current?.(initialFinalValues); const serialized = JSON.stringify(finalValues);
if (serialized !== lastEmittedRef.current) {
lastEmittedRef.current = serialized;
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(() => { const subscription = form.watch(() => {
const formValues = form.getValues(); emitValues();
const finalValues = itemConfigList.reduce(
(acc, item) => {
acc[item.name] = formValues[item.name] ?? item.default;
return acc;
},
{} as Record<string, object>,
);
onSubmitRef.current?.(finalValues);
}); });
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,
[ [

View File

@@ -297,15 +297,6 @@ function MarketPageContent({
const handleInstallPlugin = useCallback( const handleInstallPlugin = useCallback(
async (author: string, pluginName: string) => { async (author: string, pluginName: string) => {
try { try {
// Find the full plugin object from the list
const pluginVO = plugins.find(
(p) => p.author === author && p.pluginName === pluginName,
);
if (!pluginVO) {
console.error('Plugin not found:', author, pluginName);
return;
}
// Fetch full plugin details to get PluginV4 object // Fetch full plugin details to get PluginV4 object
const response = await getCloudServiceClientSync().getPluginDetail( const response = await getCloudServiceClientSync().getPluginDetail(
author, author,