mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-08 14:56:03 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b34ebf85a6 | ||
|
|
06d3298cde | ||
|
|
614621ab7b | ||
|
|
8600d0a8e7 | ||
|
|
b83e6a53be | ||
|
|
88132dff8a | ||
|
|
2dc5999583 | ||
|
|
73461814c9 | ||
|
|
210e5e50d3 | ||
|
|
4fd488b97a | ||
|
|
422a34ead4 |
@@ -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",
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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}',
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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'])
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)}')
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
43
src/langbot/pkg/utils/httpclient.py
Normal file
43
src/langbot/pkg/utils/httpclient.py
Normal 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()
|
||||||
@@ -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
6
uv.lock
generated
@@ -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
42
web/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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
18
web/pnpm-lock.yaml
generated
@@ -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:
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 }) => (
|
||||||
|
|||||||
@@ -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,
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -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,
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user