Compare commits

...

10 Commits

Author SHA1 Message Date
Junyan Qin
239223be3f chore: release v4.0.4 2025-05-28 12:55:15 +08:00
Junyan Qin
b112cb320c fix: bad ability name in preproc check 2025-05-28 12:54:30 +08:00
Junyan Qin
5aaf2ba3ef fix: base url 2025-05-27 22:58:31 +08:00
Junyan Qin (Chin)
f1e9f46af1 feat: event log of bots (#1441)
* feat: basic arch of event log

* feat: complete event log framework

* fix: bad struct in bot log api

* feat: add event logging to all platform adapters

Co-Authored-By: wangcham233@gmail.com <651122857@qq.com>

* feat: add event logging to client classes

Co-Authored-By: wangcham233@gmail.com <651122857@qq.com>

* refactor: bot log getting api

* perf: logger for aiocqhttp and gewechat

* fix: add ignored logger in dingtalk

* fix: seq id bug in log getting

* feat: add logger in dingtalk,QQ official,Slack, wxoa

* feat: add logger for wecom

* feat: add logger for wecomcs

* perf(event logger): image processing

* 完成机器人日志的前端部分 (#1479)

* feat: webui  bot log framework done

* feat: bot log complete

* perf(bot-log): style

* chore: fix incompleted i18n

* feat: support message session copy

* fix: filter and badge text

* perf: styles

* feat: add bot toggle switch in bot card

* fix: linter errors

---------

Co-authored-by: Junyan Qin <rockchinq@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: wangcham233@gmail.com <651122857@qq.com>
Co-authored-by: HYana <65863826+KaedeSAMA@users.noreply.github.com>
2025-05-27 22:36:50 +08:00
aberry
8dfef1d118 Bugfix (#1482)
* Update modelscopechatcmpl.py

tool_call 流式输出的最后一个参数是 None,需要判断一下

* Update mcp.py

问题:闭包(closure)对循环变量 tool 的捕获,导致最终注册到 self.functions 里的所有 func,都会引用 同一个(最后一个)tool

解决:在定义 func 时,通过函数参数的 默认值 把当下的 tool “冻结”住

* Update mcp.py
2025-05-27 15:09:09 +08:00
Junyan Qin (Chin)
919a621bf8 fix: lru bug in t2i (#1445) (#1481) 2025-05-27 09:58:22 +08:00
Junyan Qin
3ac96f464d perf: show description in bot form 2025-05-23 10:31:11 +08:00
Junyan Qin
f9f03b81d1 chore: release v4.0.3.3 2025-05-22 10:49:24 +08:00
Junyan Qin
42171a9c07 fix: combine quote message not in default pipeline config 2025-05-22 10:44:33 +08:00
Junyan Qin
f1f00115c9 chore: update issue template 2025-05-22 10:42:59 +08:00
66 changed files with 1222 additions and 160 deletions

View File

@@ -7,7 +7,7 @@ body:
attributes:
label: 运行环境
description: LangBot 版本、操作系统、系统架构、**Python版本**、**主机地理位置**
placeholder: 例如v3.3.0、CentOS x64 Python 3.10.3、Docker 的系统直接写 Docker 就行
placeholder: 例如v3.3.0、CentOS x64 Python 3.10.3、Docker
validations:
required: true
- type: textarea

View File

@@ -7,7 +7,7 @@ body:
attributes:
label: Runtime environment
description: LangBot version, operating system, system architecture, **Python version**, **host location**
placeholder: "For example: v3.3.0, CentOS x64 Python 3.10.3, Docker system directly write Docker"
placeholder: "For example: v3.3.0, CentOS x64 Python 3.10.3, Docker"
validations:
required: true
- type: textarea

View File

@@ -17,6 +17,7 @@ class DingTalkClient:
robot_name: str,
robot_code: str,
markdown_card: bool,
logger: None,
):
"""初始化 WebSocket 连接并自动启动"""
self.credential = dingtalk_stream.Credential(client_id, client_secret)
@@ -34,6 +35,7 @@ class DingTalkClient:
self.robot_code = robot_code
self.access_token_expiry_time = ''
self.markdown_card = markdown_card
self.logger = logger
async def get_access_token(self):
url = 'https://api.dingtalk.com/v1.0/oauth2/accessToken'
@@ -48,7 +50,7 @@ class DingTalkClient:
expires_in = int(response_data.get('expireIn', 7200))
self.access_token_expiry_time = time.time() + expires_in - 60
except Exception as e:
raise Exception(e)
await self.logger.error("failed to get access token in dingtalk")
async def is_token_expired(self):
"""检查token是否过期"""
@@ -73,7 +75,7 @@ class DingTalkClient:
result = response.json()
download_url = result.get('downloadUrl')
else:
raise Exception(f'Error: {response.status_code}, {response.text}')
await self.logger.error(f"failed to get download url: {response.json()}")
if download_url:
return await self.download_url_to_base64(download_url)
@@ -87,7 +89,7 @@ class DingTalkClient:
base64_str = base64.b64encode(file_bytes).decode('utf-8') # 返回字符串格式
return base64_str
else:
raise Exception('获取文件失败')
await self.logger.error(f"failed to get files: {response.json()}")
async def get_audio_url(self, download_code: str):
if not await self.check_access_token():
@@ -103,7 +105,7 @@ class DingTalkClient:
if download_url:
return await self.download_url_to_base64(download_url)
else:
raise Exception('获取音频失败')
await self.logger.error(f"failed to get audio: {response.json()}")
else:
raise Exception(f'Error: {response.status_code}, {response.text}')
@@ -115,7 +117,7 @@ class DingTalkClient:
if event:
await self._handle_message(event)
async def send_message(self, content: str, incoming_message,at:bool):
async def send_message(self, content: str, incoming_message,at:bool):
if self.markdown_card:
if at:
self.EchoTextHandler.reply_markdown(
@@ -190,8 +192,11 @@ class DingTalkClient:
copy_message_data = message_data.copy()
del copy_message_data['IncomingMessage']
# print("message_data:", json.dumps(copy_message_data, indent=4, ensure_ascii=False))
except Exception:
traceback.print_exc()
except Exception as e:
if self.logger:
await self.logger.error(f"Error in get_message: {traceback.format_exc()}")
else:
traceback.print_exc()
return message_data
@@ -214,9 +219,12 @@ class DingTalkClient:
}
try:
async with httpx.AsyncClient() as client:
await client.post(url, headers=headers, json=data)
response = await client.post(url, headers=headers, json=data)
if response.status_code == 200:
return
except Exception:
traceback.print_exc()
await self.logger.error(f"failed to send proactive massage to person: {traceback.format_exc()}")
raise Exception(f"failed to send proactive massage to person: {traceback.format_exc()}")
async def send_proactive_message_to_group(self, target_id: str, content: str):
if not await self.check_access_token():
@@ -237,9 +245,12 @@ class DingTalkClient:
}
try:
async with httpx.AsyncClient() as client:
await client.post(url, headers=headers, json=data)
response = await client.post(url, headers=headers, json=data)
if response.status_code == 200:
return
except Exception:
traceback.print_exc()
await self.logger.error(f"failed to send proactive massage to group: {traceback.format_exc()}")
raise Exception(f"failed to send proactive massage to group: {traceback.format_exc()}")
async def start(self):
"""启动 WebSocket 连接,监听消息"""

View File

@@ -23,7 +23,7 @@ xml_template = """
class OAClient:
def __init__(self, token: str, EncodingAESKey: str, AppID: str, Appsecret: str):
def __init__(self, token: str, EncodingAESKey: str, AppID: str, Appsecret: str, logger: None):
self.token = token
self.aes = EncodingAESKey
self.appid = AppID
@@ -43,6 +43,7 @@ class OAClient:
self.access_token_expiry_time = None
self.msg_id_map = {}
self.generated_content = {}
self.logger = logger
async def handle_callback_request(self):
try:
@@ -54,6 +55,7 @@ class OAClient:
echostr = request.args.get('echostr', '')
msg_signature = request.args.get('msg_signature', '')
if msg_signature is None:
await self.logger.error(f'msg_signature不在请求体中')
raise Exception('msg_signature不在请求体中')
if request.method == 'GET':
@@ -64,6 +66,7 @@ class OAClient:
if check_signature == signature:
return echostr # 验证成功返回echostr
else:
await self.logger.error(f'拒绝请求')
raise Exception('拒绝请求')
elif request.method == 'POST':
encryt_msg = await request.data
@@ -72,8 +75,9 @@ class OAClient:
xml_msg = xml_msg.decode('utf-8')
if ret != 0:
await self.logger.error(f'消息解密失败')
raise Exception('消息解密失败')
message_data = await self.get_message(xml_msg)
if message_data:
event = OAEvent.from_payload(message_data)
@@ -114,6 +118,7 @@ class OAClient:
return ''
except Exception:
await self.logger.error(f'handle_callback_request失败: {traceback.format_exc()}')
traceback.print_exc()
async def get_message(self, xml_msg: str):
@@ -176,6 +181,7 @@ class OAClientForLongerResponse:
AppID: str,
Appsecret: str,
LoadingMessage: str,
logger: None,
):
self.token = token
self.aes = EncodingAESKey
@@ -197,6 +203,7 @@ class OAClientForLongerResponse:
self.loading_message = LoadingMessage
self.msg_queue = {}
self.user_msg_queue = {}
self.logger = logger
async def handle_callback_request(self):
try:
@@ -207,6 +214,7 @@ class OAClientForLongerResponse:
msg_signature = request.args.get('msg_signature', '')
if msg_signature is None:
await self.logger.error(f'msg_signature不在请求体中')
raise Exception('msg_signature不在请求体中')
if request.method == 'GET':
@@ -221,7 +229,9 @@ class OAClientForLongerResponse:
xml_msg = xml_msg.decode('utf-8')
if ret != 0:
await self.logger.error(f'消息解密失败')
raise Exception('消息解密失败')
# 解析 XML
root = ET.fromstring(xml_msg)
@@ -270,6 +280,7 @@ class OAClientForLongerResponse:
return response_xml
except Exception:
await self.logger.error(f'handle_callback_request失败: {traceback.format_exc()}')
traceback.print_exc()
async def get_message(self, xml_msg: str):

View File

@@ -34,7 +34,7 @@ def handle_validation(body: dict, bot_secret: str):
class QQOfficialClient:
def __init__(self, secret: str, token: str, app_id: str):
def __init__(self, secret: str, token: str, app_id: str, logger: None):
self.app = Quart(__name__)
self.app.add_url_rule(
'/callback/command',
@@ -49,6 +49,7 @@ class QQOfficialClient:
self.base_url = 'https://api.sgroup.qq.com'
self.access_token = ''
self.access_token_expiry_time = None
self.logger = logger
async def check_access_token(self):
"""检查access_token是否存在"""
@@ -77,6 +78,7 @@ class QQOfficialClient:
if access_token:
self.access_token = access_token
except Exception as e:
await self.logger.error(f'获取access_token失败: {response_data}')
raise Exception(f'获取access_token失败: {e}')
async def handle_callback_request(self):
@@ -102,7 +104,7 @@ class QQOfficialClient:
return {'code': 0, 'message': 'success'}
except Exception as e:
traceback.print_exc()
await self.logger.error(f"Error in handle_callback_request: {traceback.format_exc()}")
return {'error': str(e)}, 400
async def run_task(self, host: str, port: int, *args, **kwargs):
@@ -166,6 +168,7 @@ class QQOfficialClient:
if not await self.check_access_token():
await self.get_access_token()
url = self.base_url + '/v2/users/' + user_openid + '/messages'
async with httpx.AsyncClient() as client:
headers = {
@@ -178,9 +181,11 @@ class QQOfficialClient:
'msg_id': msg_id,
}
response = await client.post(url, headers=headers, json=data)
response_data = response.json()
if response.status_code == 200:
return
else:
await self.logger.error(f'发送私聊消息失败: {response_data}')
raise ValueError(response)
async def send_group_text_msg(self, group_openid: str, content: str, msg_id: str):
@@ -188,6 +193,7 @@ class QQOfficialClient:
if not await self.check_access_token():
await self.get_access_token()
url = self.base_url + '/v2/groups/' + group_openid + '/messages'
async with httpx.AsyncClient() as client:
headers = {
@@ -203,6 +209,7 @@ class QQOfficialClient:
if response.status_code == 200:
return
else:
await self.logger.error(f"发送群聊消息失败:{response.json()}")
raise Exception(response.read().decode())
async def send_channle_group_text_msg(self, channel_id: str, content: str, msg_id: str):
@@ -210,6 +217,7 @@ class QQOfficialClient:
if not await self.check_access_token():
await self.get_access_token()
url = self.base_url + '/channels/' + channel_id + '/messages'
async with httpx.AsyncClient() as client:
headers = {
@@ -225,12 +233,14 @@ class QQOfficialClient:
if response.status_code == 200:
return True
else:
await self.logger.error(f'发送频道群聊消息失败: {response.json()}')
raise Exception(response)
async def send_channle_private_text_msg(self, guild_id: str, content: str, msg_id: str):
"""发送频道私聊消息"""
if not await self.check_access_token():
await self.get_access_token()
url = self.base_url + '/dms/' + guild_id + '/messages'
async with httpx.AsyncClient() as client:
@@ -247,6 +257,7 @@ class QQOfficialClient:
if response.status_code == 200:
return True
else:
await self.logger.error(f'发送频道私聊消息失败: {response.json()}')
raise Exception(response)
async def is_token_expired(self):

View File

@@ -1,4 +1,5 @@
import json
import traceback
from quart import Quart, jsonify, request
from slack_sdk.web.async_client import AsyncWebClient
from .slackevent import SlackEvent
@@ -7,7 +8,7 @@ from pkg.platform.types import events as platform_events
class SlackClient:
def __init__(self, bot_token: str, signing_secret: str):
def __init__(self, bot_token: str, signing_secret: str, logger: None):
self.bot_token = bot_token
self.signing_secret = signing_secret
self.app = Quart(__name__)
@@ -19,6 +20,7 @@ class SlackClient:
'example': [],
}
self.bot_user_id = None # 避免机器人回复自己的消息
self.logger = logger
async def handle_callback_request(self):
try:
@@ -32,6 +34,7 @@ class SlackClient:
if self.bot_user_id and bot_user_id == self.bot_user_id:
return jsonify({'status': 'ok'})
# 处理私信
if data and data.get('event', {}).get('channel_type') in ['im']:
@@ -49,6 +52,7 @@ class SlackClient:
return jsonify({'status': 'ok'})
except Exception as e:
await self.logger.error(f"Error in handle_callback_request: {traceback.format_exc()}")
raise (e)
async def _handle_message(self, event: SlackEvent):
@@ -78,6 +82,7 @@ class SlackClient:
self.bot_user_id = response['message']['bot_id']
return
except Exception as e:
await self.logger.error(f"Error in send_message: {e}")
raise e
async def send_message_to_one(self, text: str, user_id: str):
@@ -88,6 +93,7 @@ class SlackClient:
return
except Exception as e:
await self.logger.error(f"Error in send_message: {traceback.format_exc()}")
raise e
async def run_task(self, host: str, port: int, *args, **kwargs):

View File

@@ -11,13 +11,14 @@ from libs.wechatpad_api.api.chatroom import ChatRoomApi
class WeChatPadClient:
def __init__(self,base_url, token):
def __init__(self, base_url, token, logger=None):
self._login_api = LoginApi(base_url, token)
self._friend_api = FriendApi(base_url, token)
self._message_api = MessageApi(base_url, token)
self._user_api = UserApi(base_url, token)
self._download_api = DownloadApi(base_url, token)
self._chatroom_api = ChatRoomApi(base_url, token)
self.logger = logger
def get_token(self,admin_key, day: int):
'''获取token'''

View File

@@ -3,6 +3,7 @@ from .WXBizMsgCrypt3 import WXBizMsgCrypt
import base64
import binascii
import httpx
import traceback
from quart import Quart
import xml.etree.ElementTree as ET
from typing import Callable, Dict, Any
@@ -19,6 +20,7 @@ class WecomClient:
token: str,
EncodingAESKey: str,
contacts_secret: str,
logger: None,
):
self.corpid = corpid
self.secret = secret
@@ -28,6 +30,7 @@ class WecomClient:
self.base_url = 'https://qyapi.weixin.qq.com/cgi-bin'
self.access_token = ''
self.secret_for_contacts = contacts_secret
self.logger = logger
self.app = Quart(__name__)
self.app.add_url_rule(
'/callback/command',
@@ -54,6 +57,7 @@ class WecomClient:
if 'access_token' in data:
return data['access_token']
else:
await self.logger.error(f"获取accesstoken失败:{response.json()}")
raise Exception(f'未获取access token: {data}')
async def get_users(self):
@@ -125,6 +129,7 @@ class WecomClient:
response = await client.post(url, json=params)
data = response.json()
except Exception as e:
await self.logger.error(f"发送图片失败:{data}")
raise Exception('Failed to send image: ' + str(e))
# 企业微信错误码40014和42001代表accesstoken问题
@@ -159,6 +164,7 @@ class WecomClient:
self.access_token = await self.get_access_token(self.secret)
return await self.send_private_msg(user_id, agent_id, content)
if data['errcode'] != 0:
await self.logger.error(f"发送消息失败:{data}")
raise Exception('Failed to send message: ' + str(data))
async def handle_callback_request(self):
@@ -175,6 +181,7 @@ class WecomClient:
echostr = request.args.get('echostr')
ret, reply_echo_str = wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr)
if ret != 0:
await self.logger.error("验证失败")
raise Exception(f'验证失败,错误码: {ret}')
return reply_echo_str
@@ -182,7 +189,9 @@ class WecomClient:
encrypt_msg = await request.data
ret, xml_msg = wxcpt.DecryptMsg(encrypt_msg, msg_signature, timestamp, nonce)
if ret != 0:
await self.logger.error("消息解密失败")
raise Exception(f'消息解密失败,错误码: {ret}')
# 解析消息并处理
message_data = await self.get_message(xml_msg)
@@ -193,6 +202,7 @@ class WecomClient:
return 'success'
except Exception as e:
await self.logger.error(f"Error in handle_callback_request: {traceback.format_exc()}")
return f'Error processing request: {str(e)}', 400
async def run_task(self, host: str, port: int, *args, **kwargs):
@@ -291,6 +301,7 @@ class WecomClient:
except binascii.Error as e:
raise ValueError(f'Invalid base64 string: {str(e)}')
else:
await self.logger.error("Image对象出错")
raise ValueError('image对象出错')
# 设置 multipart/form-data 格式的文件
@@ -314,6 +325,7 @@ class WecomClient:
self.access_token = await self.get_access_token(self.secret)
media_id = await self.upload_to_work(image)
if data.get('errcode', 0) != 0:
await self.logger.error(f"上传图片失败:{data}")
raise Exception('failed to upload file')
media_id = data.get('media_id')

View File

@@ -13,7 +13,7 @@ import aiofiles
class WecomCSClient:
def __init__(self, corpid: str, secret: str, token: str, EncodingAESKey: str):
def __init__(self, corpid: str, secret: str, token: str, EncodingAESKey: str, logger: None):
self.corpid = corpid
self.secret = secret
self.access_token_for_contacts = ''
@@ -21,6 +21,7 @@ class WecomCSClient:
self.aes = EncodingAESKey
self.base_url = 'https://qyapi.weixin.qq.com/cgi-bin'
self.access_token = ''
self.logger = logger
self.app = Quart(__name__)
self.app.add_url_rule(
'/callback/command', 'handle_callback', self.handle_callback_request, methods=['GET', 'POST']
@@ -186,6 +187,7 @@ class WecomCSClient:
self.access_token = await self.get_access_token(self.secret)
return await self.send_text_msg(open_kfid, external_userid, msgid, content)
if data['errcode'] != 0:
await self.logger.error(f"发送消息失败:{data}")
raise Exception('Failed to send message')
return data
@@ -224,7 +226,10 @@ class WecomCSClient:
return 'success'
except Exception as e:
traceback.print_exc()
if self.logger:
await self.logger.error(f"Error in handle_callback_request: {traceback.format_exc()}")
else:
traceback.print_exc()
return f'Error processing request: {str(e)}', 400
async def run_task(self, host: str, port: int, *args, **kwargs):

View File

@@ -0,0 +1,22 @@
from __future__ import annotations
import quart
import mimetypes
from .. import group
@group.group_class('files', '/api/v1/files')
class FilesRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('/image/<image_key>', methods=['GET'], auth_type=group.AuthType.NONE)
async def _(image_key: str) -> quart.Response:
if not await self.ap.storage_mgr.storage_provider.exists(image_key):
return quart.Response(status=404)
image_bytes = await self.ap.storage_mgr.storage_provider.load(image_key)
mime_type = mimetypes.guess_type(image_key)[0]
if mime_type is None:
mime_type = 'image/jpeg'
return quart.Response(image_bytes, mimetype=mime_type)

View File

@@ -29,3 +29,16 @@ class BotsRouterGroup(group.RouterGroup):
elif quart.request.method == 'DELETE':
await self.ap.bot_service.delete_bot(bot_uuid)
return self.success()
@self.route('/<bot_uuid>/logs', methods=['POST'])
async def _(bot_uuid: str) -> str:
json_data = await quart.request.json
from_index = json_data.get('from_index', -1)
max_count = json_data.get('max_count', 10)
logs, total_count = await self.ap.bot_service.list_event_logs(bot_uuid, from_index, max_count)
return self.success(
data={
'logs': logs,
'total_count': total_count,
}
)

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import uuid
import sqlalchemy
import typing
from ....core import app
from ....entity.persistence import bot as persistence_bot
@@ -98,3 +99,14 @@ class BotService:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(persistence_bot.Bot).where(persistence_bot.Bot.uuid == bot_uuid)
)
async def list_event_logs(
self, bot_uuid: str, from_index: int, max_count: int
) -> typing.Tuple[list[dict], int, int, int]:
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid)
if runtime_bot is None:
raise Exception('Bot not found')
logs, total_count = await runtime_bot.logger.get_logs(from_index, max_count)
return [log.to_json() for log in logs], total_count

View File

@@ -23,6 +23,7 @@ from ..api.http.service import model as model_service
from ..api.http.service import pipeline as pipeline_service
from ..api.http.service import bot as bot_service
from ..discover import engine as discover_engine
from ..storage import mgr as storagemgr
from ..utils import logcache
from . import taskmgr
from . import entities as core_entities
@@ -96,6 +97,8 @@ class Application:
log_cache: logcache.LogCache = None
storage_mgr: storagemgr.StorageMgr = None
# ========= HTTP Services =========
user_service: user_service.UserService = None

View File

@@ -17,6 +17,7 @@ from ...api.http.service import model as model_service
from ...api.http.service import pipeline as pipeline_service
from ...api.http.service import bot as bot_service
from ...discover import engine as discover_engine
from ...storage import mgr as storagemgr
from ...utils import logcache
from .. import taskmgr
@@ -50,6 +51,10 @@ class BuildAppStage(stage.BootingStage):
log_cache = logcache.LogCache()
ap.log_cache = log_cache
storage_mgr_inst = storagemgr.StorageMgr(ap)
await storage_mgr_inst.initialize()
ap.storage_mgr = storage_mgr_inst
persistence_mgr_inst = persistencemgr.PersistenceManager(ap)
ap.persistence_mgr = persistence_mgr_inst
await persistence_mgr_inst.initialize()

View File

@@ -20,9 +20,9 @@ class Text2ImageStrategy(strategy_model.LongTextStrategy):
pass
@functools.lru_cache(maxsize=16)
def get_font(self, query: core_entities.Query):
def get_font(self, font_path: str):
return ImageFont.truetype(
query.pipeline_config['output']['long-text-processing']['font-path'],
font_path,
32,
encoding='utf-8',
)
@@ -146,7 +146,9 @@ class Text2ImageStrategy(strategy_model.LongTextStrategy):
self.ap.logger.debug('lines: {}, text_width: {}'.format(lines, text_width))
for line in lines:
# 如果长了就分割
line_width = self.get_font(query).getlength(line)
line_width = self.get_font(query.pipeline_config['output']['long-text-processing']['font-path']).getlength(
line
)
self.ap.logger.debug('line_width: {}'.format(line_width))
if line_width < text_width:
final_lines.append(line)
@@ -167,7 +169,9 @@ class Text2ImageStrategy(strategy_model.LongTextStrategy):
final_lines.append(rest_text[:point])
rest_text = rest_text[point:]
line_width = self.text_render_font.getlength(rest_text)
line_width = self.get_font(
query.pipeline_config['output']['long-text-processing']['font-path']
).getlength(rest_text)
if line_width < text_width:
final_lines.append(rest_text)
break
@@ -187,7 +191,7 @@ class Text2ImageStrategy(strategy_model.LongTextStrategy):
(offset_x, offset_y + 35 * line_number),
final_line,
fill=(0, 0, 0),
font=self.text_render_font,
font=self.get_font(query.pipeline_config['output']['long-text-processing']['font-path']),
)
# 遍历此行,检查是否有emoji
idx_in_line = 0

View File

@@ -34,7 +34,6 @@ class PreProcessor(stage.PipelineStage):
session = await self.ap.sess_mgr.get_session(query)
# 非 local-agent 时llm_model 为 None
llm_model = (
await self.ap.model_mgr.get_model_by_uuid(query.pipeline_config['ai']['local-agent']['model'])
@@ -59,7 +58,7 @@ class PreProcessor(stage.PipelineStage):
if selected_runner == 'local-agent':
query.use_funcs = (
conversation.use_funcs if query.use_llm_model.model_entity.abilities.__contains__('tool_call') else None
conversation.use_funcs if query.use_llm_model.model_entity.abilities.__contains__('func_call') else None
)
query.variables = {
@@ -82,7 +81,7 @@ class PreProcessor(stage.PipelineStage):
content_list = []
plain_text = ''
qoute_msg = query.pipeline_config["trigger"].get("misc",'').get("combine-quote-message")
qoute_msg = query.pipeline_config['trigger'].get('misc', '').get('combine-quote-message')
for me in query.message_chain:
if isinstance(me, platform_message.Plain):
@@ -100,13 +99,11 @@ class PreProcessor(stage.PipelineStage):
content_list.append(llm_entities.ContentElement.from_text(msg.text))
elif isinstance(msg, platform_message.Image):
if selected_runner != 'local-agent' or query.use_llm_model.model_entity.abilities.__contains__(
'vision'
'vision'
):
if msg.base64 is not None:
content_list.append(llm_entities.ContentElement.from_image_base64(msg.base64))
query.variables['user_message_text'] = plain_text
query.user_message = llm_entities.Message(role='user', content=content_list)

View File

@@ -7,11 +7,11 @@ from ..core import app, entities as core_entities
from . import entities
preregistered_stages: dict[str, PipelineStage] = {}
preregistered_stages: dict[str, type[PipelineStage]] = {}
def stage_class(name: str):
def decorator(cls):
def stage_class(name: str) -> typing.Callable[[type[PipelineStage]], type[PipelineStage]]:
def decorator(cls: type[PipelineStage]) -> type[PipelineStage]:
preregistered_stages[name] = cls
return cls

View File

@@ -8,6 +8,7 @@ import abc
from ..core import app
from .types import message as platform_message
from .types import events as platform_events
from .logger import EventLogger
class MessagePlatformAdapter(metaclass=abc.ABCMeta):
@@ -22,7 +23,9 @@ class MessagePlatformAdapter(metaclass=abc.ABCMeta):
ap: app.Application
def __init__(self, config: dict, ap: app.Application):
logger: EventLogger
def __init__(self, config: dict, ap: app.Application, logger: EventLogger):
"""初始化适配器
Args:
@@ -31,6 +34,7 @@ class MessagePlatformAdapter(metaclass=abc.ABCMeta):
"""
self.config = config
self.ap = ap
self.logger = logger
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
"""主动发送消息

View File

@@ -10,12 +10,14 @@ import sqlalchemy
from . import adapter as msadapter
from ..core import app, entities as core_entities, taskmgr
from .types import events as platform_events
from .types import events as platform_events, message as platform_message
from ..discover import engine
from ..entity.persistence import bot as persistence_bot
from .logger import EventLogger
# 处理 3.4 移除了 YiriMirai 之后,插件的兼容性问题
from . import types as mirai
@@ -37,23 +39,37 @@ class RuntimeBot:
task_context: taskmgr.TaskContext
logger: EventLogger
def __init__(
self,
ap: app.Application,
bot_entity: persistence_bot.Bot,
adapter: msadapter.MessagePlatformAdapter,
logger: EventLogger,
):
self.ap = ap
self.bot_entity = bot_entity
self.enable = bot_entity.enable
self.adapter = adapter
self.task_context = taskmgr.TaskContext()
self.logger = logger
async def initialize(self):
async def on_friend_message(
event: platform_events.FriendMessage,
adapter: msadapter.MessagePlatformAdapter,
):
image_components = [
component for component in event.message_chain if isinstance(component, platform_message.Image)
]
await self.logger.info(
f'{event.message_chain}',
images=image_components,
message_session_id=f'person_{event.sender.id}',
)
await self.ap.query_pool.add_query(
bot_uuid=self.bot_entity.uuid,
launcher_type=core_entities.LauncherTypes.PERSON,
@@ -68,6 +84,16 @@ class RuntimeBot:
event: platform_events.GroupMessage,
adapter: msadapter.MessagePlatformAdapter,
):
image_components = [
component for component in event.message_chain if isinstance(component, platform_message.Image)
]
await self.logger.info(
f'{event.message_chain}',
images=image_components,
message_session_id=f'group_{event.group.id}',
)
await self.ap.query_pool.add_query(
bot_uuid=self.bot_entity.uuid,
launcher_type=core_entities.LauncherTypes.GROUP,
@@ -92,10 +118,7 @@ class RuntimeBot:
self.task_context.set_current_action('Exited.')
return
self.task_context.set_current_action('Exited with error.')
self.task_context.log(f'平台适配器运行出错: {e}')
self.task_context.log(f'Traceback: {traceback.format_exc()}')
self.ap.logger.error(f'平台适配器运行出错: {e}')
self.ap.logger.debug(f'Traceback: {traceback.format_exc()}')
await self.logger.error(f'平台适配器运行出错:\n{e}\n{traceback.format_exc()}')
self.task_wrapper = self.ap.task_mgr.create_task(
exception_wrapper(),
@@ -166,9 +189,15 @@ class PlatformManager:
elif isinstance(bot_entity, dict):
bot_entity = persistence_bot.Bot(**bot_entity)
adapter_inst = self.adapter_dict[bot_entity.adapter](bot_entity.adapter_config, self.ap)
logger = EventLogger(name=f'platform-adapter-{bot_entity.name}', ap=self.ap)
runtime_bot = RuntimeBot(ap=self.ap, bot_entity=bot_entity, adapter=adapter_inst)
adapter_inst = self.adapter_dict[bot_entity.adapter](
bot_entity.adapter_config,
self.ap,
logger,
)
runtime_bot = RuntimeBot(ap=self.ap, bot_entity=bot_entity, adapter=adapter_inst, logger=logger)
await runtime_bot.initialize()

233
pkg/platform/logger.py Normal file
View File

@@ -0,0 +1,233 @@
from __future__ import annotations
import typing
import mimetypes
import time
import enum
import pydantic
import traceback
import uuid
from ..core import app
from .types import message as platform_message
class EventLogLevel(enum.Enum):
"""日志级别"""
DEBUG = 'debug'
INFO = 'info'
WARNING = 'warning'
ERROR = 'error'
class EventLog(pydantic.BaseModel):
seq_id: int
"""日志序号"""
timestamp: int
"""日志时间戳"""
level: EventLogLevel
"""日志级别"""
text: str
"""日志文本"""
images: typing.Optional[list[str]] = None
"""日志图片 URL 列表,需要通过 /api/v1/image/{uuid} 获取图片"""
message_session_id: typing.Optional[str] = None
"""消息会话ID仅收发消息事件有值"""
def to_json(self) -> dict:
return {
'seq_id': self.seq_id,
'timestamp': self.timestamp,
'level': self.level.value,
'text': self.text,
'images': self.images,
'message_session_id': self.message_session_id,
}
MAX_LOG_COUNT = 200
DELETE_COUNT_PER_TIME = 50
class EventLogger:
"""used for logging bot events"""
ap: app.Application
seq_id_inc: int
logs: list[EventLog]
def __init__(
self,
name: str,
ap: app.Application,
):
self.name = name
self.ap = ap
self.logs = []
self.seq_id_inc = 0
async def get_logs(self, from_seq_id: int, max_count: int) -> typing.Tuple[list[EventLog], int]:
"""
获取日志,从 from_seq_id 开始获取 max_count 条历史日志
Args:
from_seq_id: 起始序号,-1 表示末尾
max_count: 最大数量
Returns:
Tuple[list[EventLog], int]: 日志列表,日志总数
"""
if len(self.logs) == 0:
return [], 0
if from_seq_id <= -1:
from_seq_id = self.logs[-1].seq_id
min_seq_id_in_logs = self.logs[0].seq_id
max_seq_id_in_logs = self.logs[-1].seq_id
if from_seq_id < min_seq_id_in_logs: # 需要的整个范围都已经被删除
return [], len(self.logs)
if (
from_seq_id > max_seq_id_in_logs and from_seq_id - max_count > max_seq_id_in_logs
): # 需要的整个范围都还没生成
return [], len(self.logs)
end_index = 1
for i, log in enumerate(self.logs):
if log.seq_id >= from_seq_id:
end_index = i + 1
break
start_index = max(0, end_index - max_count)
if max_count > 0:
return self.logs[start_index:end_index], len(self.logs)
else:
return [], len(self.logs)
async def _truncate_logs(self):
if len(self.logs) > MAX_LOG_COUNT:
for i in range(DELETE_COUNT_PER_TIME):
for image_key in self.logs[i].images:
await self.ap.storage_mgr.storage_provider.delete(image_key)
self.logs = self.logs[DELETE_COUNT_PER_TIME:]
async def _add_log(
self,
level: EventLogLevel,
text: str,
images: typing.Optional[list[platform_message.Image]] = None,
message_session_id: typing.Optional[str] = None,
no_throw: bool = True,
):
try:
image_keys = []
if images is None:
images = []
if message_session_id is None:
message_session_id = ''
if not isinstance(message_session_id, str):
message_session_id = str(message_session_id)
for img in images:
img_bytes, mime_type = await img.get_bytes()
extension = mimetypes.guess_extension(mime_type)
if extension is None:
extension = '.jpg'
image_key = f'{message_session_id}-{uuid.uuid4()}{extension}'
await self.ap.storage_mgr.storage_provider.save(image_key, img_bytes)
image_keys.append(image_key)
self.logs.append(
EventLog(
seq_id=self.seq_id_inc,
timestamp=int(time.time()),
level=level,
text=text,
images=image_keys,
message_session_id=message_session_id,
)
)
self.seq_id_inc += 1
await self._truncate_logs()
except Exception as e:
if not no_throw:
raise e
else:
traceback.print_exc()
async def info(
self,
text: str,
images: typing.Optional[list[platform_message.Image]] = None,
message_session_id: typing.Optional[str] = None,
no_throw: bool = True,
):
await self._add_log(
level=EventLogLevel.INFO,
text=text,
images=images,
message_session_id=message_session_id,
no_throw=no_throw,
)
async def debug(
self,
text: str,
images: typing.Optional[list[platform_message.Image]] = None,
message_session_id: typing.Optional[str] = None,
no_throw: bool = True,
):
await self._add_log(
level=EventLogLevel.DEBUG,
text=text,
images=images,
message_session_id=message_session_id,
no_throw=no_throw,
)
async def warning(
self,
text: str,
images: typing.Optional[list[platform_message.Image]] = None,
message_session_id: typing.Optional[str] = None,
no_throw: bool = True,
):
await self._add_log(
level=EventLogLevel.WARNING,
text=text,
images=images,
message_session_id=message_session_id,
no_throw=no_throw,
)
async def error(
self,
text: str,
images: typing.Optional[list[platform_message.Image]] = None,
message_session_id: typing.Optional[str] = None,
no_throw: bool = True,
):
await self._add_log(
level=EventLogLevel.ERROR,
text=text,
images=images,
message_session_id=message_session_id,
no_throw=no_throw,
)

View File

@@ -12,6 +12,7 @@ from ..types import message as platform_message
from ..types import events as platform_events
from ..types import entities as platform_entities
from ...utils import image
from ..logger import EventLogger
class AiocqhttpMessageConverter(adapter.MessageConverter):
@@ -209,8 +210,11 @@ class AiocqhttpAdapter(adapter.MessagePlatformAdapter):
ap: app.Application
def __init__(self, config: dict, ap: app.Application):
on_websocket_connection_event_cache: typing.List[typing.Callable[[aiocqhttp.Event], None]] = []
def __init__(self, config: dict, ap: app.Application, logger: EventLogger):
self.config = config
self.logger = logger
async def shutdown_trigger_placeholder():
while True:
@@ -219,6 +223,7 @@ class AiocqhttpAdapter(adapter.MessagePlatformAdapter):
self.config['shutdown_trigger'] = shutdown_trigger_placeholder
self.ap = ap
self.on_websocket_connection_event_cache = []
if 'access-token' in config:
self.bot = aiocqhttp.CQHttp(access_token=config['access-token'])
@@ -260,6 +265,7 @@ class AiocqhttpAdapter(adapter.MessagePlatformAdapter):
try:
return await callback(await self.event_converter.target2yiri(event,self.bot), self)
except Exception:
await self.logger.error(f'Error in on_message: {traceback.format_exc()}')
traceback.print_exc()
if event_type == platform_events.GroupMessage:
@@ -267,6 +273,16 @@ class AiocqhttpAdapter(adapter.MessagePlatformAdapter):
elif event_type == platform_events.FriendMessage:
self.bot.on_message('private')(on_message)
async def on_websocket_connection(event: aiocqhttp.Event):
for event in self.on_websocket_connection_event_cache:
if event.self_id == event.self_id and event.time == event.time:
return
self.on_websocket_connection_event_cache.append(event)
await self.logger.info(f'WebSocket connection established, bot id: {event.self_id}')
self.bot.on_websocket_connection(on_websocket_connection)
def unregister_listener(
self,
event_type: typing.Type[platform_events.Event],

View File

@@ -9,6 +9,7 @@ from ..types import events as platform_events
from ..types import entities as platform_entities
from libs.dingtalk_api.api import DingTalkClient
import datetime
from ..logger import EventLogger
class DingTalkMessageConverter(adapter.MessageConverter):
@@ -99,9 +100,10 @@ class DingTalkAdapter(adapter.MessagePlatformAdapter):
event_converter: DingTalkEventConverter = DingTalkEventConverter()
config: dict
def __init__(self, config: dict, ap: app.Application):
def __init__(self, config: dict, ap: app.Application, logger: EventLogger):
self.config = config
self.ap = ap
self.logger = logger
required_keys = [
'client_id',
'client_secret',
@@ -120,6 +122,7 @@ class DingTalkAdapter(adapter.MessagePlatformAdapter):
robot_name=config['robot_name'],
robot_code=config['robot_code'],
markdown_card=config['markdown_card'],
logger=self.logger,
)
async def reply_message(
@@ -154,8 +157,8 @@ class DingTalkAdapter(adapter.MessagePlatformAdapter):
await self.event_converter.target2yiri(event, self.config['robot_name']),
self,
)
except Exception:
traceback.print_exc()
except Exception as e:
await self.logger.error(f"Error in dingtalk callback: {traceback.format_exc()}")
if event_type == platform_events.FriendMessage:
self.bot.on_message('FriendMessage')(on_message)

View File

@@ -16,6 +16,7 @@ from ...core import app
from ..types import message as platform_message
from ..types import events as platform_events
from ..types import entities as platform_entities
from ..logger import EventLogger
class DiscordMessageConverter(adapter.MessageConverter):
@@ -170,9 +171,10 @@ class DiscordAdapter(adapter.MessagePlatformAdapter):
typing.Callable[[platform_events.Event, adapter.MessagePlatformAdapter], None],
] = {}
def __init__(self, config: dict, ap: app.Application):
def __init__(self, config: dict, ap: app.Application, logger: EventLogger):
self.config = config
self.ap = ap
self.logger = logger
self.bot_account_id = self.config['client_id']

View File

@@ -20,6 +20,7 @@ from ...utils import image
import xml.etree.ElementTree as ET
from typing import Optional, Tuple
from functools import partial
from ..logger import EventLogger
class GewechatMessageConverter(adapter.MessageConverter):
@@ -371,7 +372,7 @@ class GewechatMessageConverter(adapter.MessageConverter):
quote_id = appmsg_data.find('.//refermsg').findtext('.//chatusr') # 引用消息的原发送者
ats_bot = ats_bot or (quote_id == tousername)
except Exception as e:
print(f'_ats_bot got except: {e}')
print(f'Error in gewechat _ats_bot: {e}')
finally:
return ats_bot
@@ -477,9 +478,10 @@ class GeWeChatAdapter(adapter.MessagePlatformAdapter):
typing.Callable[[platform_events.Event, adapter.MessagePlatformAdapter], None],
] = {}
def __init__(self, config: dict, ap: app.Application):
def __init__(self, config: dict, ap: app.Application, logger: EventLogger):
self.config = config
self.ap = ap
self.logger = logger
self.quart_app = quart.Quart(__name__)
self.message_converter = GewechatMessageConverter(config)
@@ -503,7 +505,7 @@ class GeWeChatAdapter(adapter.MessagePlatformAdapter):
try:
event = await self.event_converter.target2yiri(data.copy(), self.bot_account_id)
except Exception:
traceback.print_exc()
await self.logger.error(f'Error in gewechat callback: {traceback.format_exc()}')
if event.__class__ in self.listeners:
await self.listeners[event.__class__](event, self)

View File

@@ -23,6 +23,7 @@ from ...core import app
from ..types import message as platform_message
from ..types import events as platform_events
from ..types import entities as platform_entities
from ..logger import EventLogger
class AESCipher(object):
@@ -338,9 +339,10 @@ class LarkAdapter(adapter.MessagePlatformAdapter):
quart_app: quart.Quart
ap: app.Application
def __init__(self, config: dict, ap: app.Application):
def __init__(self, config: dict, ap: app.Application, logger: EventLogger):
self.config = config
self.ap = ap
self.logger = logger
self.quart_app = quart.Quart(__name__)
self.listeners = {}
@@ -376,15 +378,15 @@ class LarkAdapter(adapter.MessagePlatformAdapter):
if 'im.message.receive_v1' == type:
try:
event = await self.event_converter.target2yiri(p2v1, self.api_client)
except Exception:
traceback.print_exc()
except Exception as e:
await self.logger.error(f"Error in lark callback: {traceback.format_exc()}")
if event.__class__ in self.listeners:
await self.listeners[event.__class__](event, self)
return {'code': 200, 'message': 'ok'}
except Exception:
traceback.print_exc()
except Exception as e:
await self.logger.error(f"Error in lark callback: {traceback.format_exc()}")
return {'code': 500, 'message': 'error'}
async def on_message(event: lark_oapi.im.v1.P2ImMessageReceiveV1):

View File

@@ -14,6 +14,7 @@ from ...pipeline.longtext.strategies import forward
from ...platform.types import message as platform_message
from ...platform.types import entities as platform_entities
from ...platform.types import events as platform_events
from ..logger import EventLogger
class NakuruProjectMessageConverter(adapter_model.MessageConverter):
@@ -71,9 +72,8 @@ class NakuruProjectMessageConverter(adapter_model.MessageConverter):
content=content_list,
)
nakuru_forward_node_list.append(nakuru_forward_node)
except Exception:
except Exception as e:
import traceback
traceback.print_exc()
nakuru_msg_list.append(nakuru_forward_node_list)
@@ -178,12 +178,13 @@ class NakuruAdapter(adapter_model.MessagePlatformAdapter):
cfg: dict
def __init__(self, cfg: dict, ap):
def __init__(self, cfg: dict, ap, logger: EventLogger):
"""初始化nakuru-project的对象"""
cfg['port'] = cfg['ws_port']
del cfg['ws_port']
self.cfg = cfg
self.ap = ap
self.logger = logger
self.listener_list = []
self.bot = nakuru.CQHTTP(**self.cfg)
@@ -275,7 +276,7 @@ class NakuruAdapter(adapter_model.MessagePlatformAdapter):
# 注册监听器
self.bot.receiver(source_cls.__name__)(listener_wrapper)
except Exception as e:
traceback.print_exc()
self.logger.error(f"Error in nakuru register_listener: {traceback.format_exc()}")
raise e
def unregister_listener(

View File

@@ -13,6 +13,7 @@ from .. import adapter
from ...core import app
from ..types import entities as platform_entities
from ...command.errors import ParamNotEnoughError
from ..logger import EventLogger
class OAMessageConverter(adapter.MessageConverter):
@@ -63,10 +64,10 @@ class OfficialAccountAdapter(adapter.MessagePlatformAdapter):
event_converter: OAEventConverter = OAEventConverter()
config: dict
def __init__(self, config: dict, ap: app.Application):
def __init__(self, config: dict, ap: app.Application, logger: EventLogger):
self.config = config
self.ap = ap
self.logger = logger
required_keys = [
'token',
@@ -85,6 +86,7 @@ class OfficialAccountAdapter(adapter.MessagePlatformAdapter):
EncodingAESKey=config['EncodingAESKey'],
Appsecret=config['AppSecret'],
AppID=config['AppID'],
logger=self.logger,
)
elif self.config['Mode'] == 'passive':
self.bot = OAClientForLongerResponse(
@@ -93,6 +95,7 @@ class OfficialAccountAdapter(adapter.MessagePlatformAdapter):
Appsecret=config['AppSecret'],
AppID=config['AppID'],
LoadingMessage=config['LoadingMessage'],
logger=self.logger,
)
else:
raise KeyError('请设置微信公众号通信模式')
@@ -122,8 +125,8 @@ class OfficialAccountAdapter(adapter.MessagePlatformAdapter):
self.bot_account_id = event.receiver_id
try:
return await callback(await self.event_converter.target2yiri(event), self)
except Exception:
traceback.print_exc()
except Exception as e:
await self.logger.error(f"Error in officialaccount callback: {traceback.format_exc()}")
if event_type == platform_events.FriendMessage:
self.bot.on_message('text')(on_message)

View File

@@ -57,6 +57,9 @@ spec:
label:
en_US: Host
zh_Hans: 监听主机
description:
en_US: The host that Official Account listens on for Webhook connections.
zh_Hans: 微信公众号监听的主机,除非你知道自己在做什么,否则请写 0.0.0.0
type: string
required: true
default: 0.0.0.0

View File

@@ -17,6 +17,7 @@ from ...config import manager as cfg_mgr
from ...platform.types import entities as platform_entities
from ...platform.types import events as platform_events
from ...platform.types import message as platform_message
from ..logger import EventLogger
class OfficialGroupMessage(platform_events.GroupMessage):
@@ -357,10 +358,11 @@ class OfficialAdapter(adapter_model.MessagePlatformAdapter):
group_msg_seq = None
c2c_msg_seq = None
def __init__(self, cfg: dict, ap: app.Application):
def __init__(self, cfg: dict, ap: app.Application, logger: EventLogger):
"""初始化适配器"""
self.cfg = cfg
self.ap = ap
self.logger = logger
self.group_msg_seq = 1
self.c2c_msg_seq = 1
@@ -499,7 +501,7 @@ class OfficialAdapter(adapter_model.MessagePlatformAdapter):
for event_handler in event_handler_mapping[event_type]:
setattr(self.bot, event_handler, wrapper)
except Exception as e:
traceback.print_exc()
self.logger.error(f"Error in qqbotpy callback: {traceback.format_exc()}")
raise e
def unregister_listener(

View File

@@ -14,6 +14,7 @@ from ...command.errors import ParamNotEnoughError
from libs.qq_official_api.api import QQOfficialClient
from libs.qq_official_api.qqofficialevent import QQOfficialEvent
from ...utils import image
from ..logger import EventLogger
class QQOfficialMessageConverter(adapter.MessageConverter):
@@ -139,9 +140,10 @@ class QQOfficialAdapter(adapter.MessagePlatformAdapter):
message_converter: QQOfficialMessageConverter = QQOfficialMessageConverter()
event_converter: QQOfficialEventConverter = QQOfficialEventConverter()
def __init__(self, config: dict, ap: app.Application):
def __init__(self, config: dict, ap: app.Application, logger: EventLogger):
self.config = config
self.ap = ap
self.logger = logger
required_keys = [
'appid',
@@ -155,6 +157,7 @@ class QQOfficialAdapter(adapter.MessagePlatformAdapter):
app_id=config['appid'],
secret=config['secret'],
token=config['token'],
logger=self.logger
)
async def reply_message(
@@ -221,8 +224,8 @@ class QQOfficialAdapter(adapter.MessagePlatformAdapter):
self.bot_account_id = 'justbot'
try:
return await callback(await self.event_converter.target2yiri(event), self)
except Exception:
traceback.print_exc()
except Exception as e:
await self.logger.error(f"Error in qqofficial callback: {traceback.format_exc()}")
if event_type == platform_events.FriendMessage:
self.bot.on_message('DIRECT_MESSAGE_CREATE')(on_message)

View File

@@ -14,6 +14,7 @@ from .. import adapter
from ..types import entities as platform_entities
from ...command.errors import ParamNotEnoughError
from ...utils import image
from ..logger import EventLogger
class SlackMessageConverter(adapter.MessageConverter):
@@ -91,9 +92,10 @@ class SlackAdapter(adapter.MessagePlatformAdapter):
event_converter: SlackEventConverter = SlackEventConverter()
config: dict
def __init__(self, config: dict, ap: app.Application):
def __init__(self, config: dict, ap: app.Application, logger: EventLogger):
self.config = config
self.ap = ap
self.logger = logger
required_keys = [
'bot_token',
'signing_secret',
@@ -102,7 +104,7 @@ class SlackAdapter(adapter.MessagePlatformAdapter):
if missing_keys:
raise ParamNotEnoughError('Slack机器人缺少相关配置项请查看文档或联系管理员')
self.bot = SlackClient(bot_token=self.config['bot_token'], signing_secret=self.config['signing_secret'])
self.bot = SlackClient(bot_token=self.config['bot_token'], signing_secret=self.config['signing_secret'], logger=self.logger)
async def reply_message(
self,
@@ -137,8 +139,8 @@ class SlackAdapter(adapter.MessagePlatformAdapter):
self.bot_account_id = 'SlackBot'
try:
return await callback(await self.event_converter.target2yiri(event, self.bot), self)
except:
traceback.print_exc()
except Exception as e:
await self.logger.error(f"Error in slack callback: {traceback.format_exc()}")
if event_type == platform_events.FriendMessage:
self.bot.on_message('im')(on_message)

View File

@@ -17,6 +17,7 @@ from ...core import app
from ..types import message as platform_message
from ..types import events as platform_events
from ..types import entities as platform_entities
from ..logger import EventLogger
class TelegramMessageConverter(adapter.MessageConverter):
@@ -147,9 +148,10 @@ class TelegramAdapter(adapter.MessagePlatformAdapter):
typing.Callable[[platform_events.Event, adapter.MessagePlatformAdapter], None],
] = {}
def __init__(self, config: dict, ap: app.Application):
def __init__(self, config: dict, ap: app.Application, logger: EventLogger):
self.config = config
self.ap = ap
self.logger = logger
async def telegram_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
if update.message.from_user.is_bot:
@@ -158,8 +160,8 @@ class TelegramAdapter(adapter.MessagePlatformAdapter):
try:
lb_event = await self.event_converter.target2yiri(update, self.bot, self.bot_account_id)
await self.listeners[type(lb_event)](lb_event, self)
except Exception:
print(traceback.format_exc())
except Exception as e:
await self.logger.error(f"Error in telegram callback: {traceback.format_exc()}")
self.application = ApplicationBuilder().token(self.config['token']).build()
self.bot = self.application.bot

View File

@@ -30,6 +30,7 @@ from ..types import message as platform_message
from ..types import events as platform_events
from ..types import entities as platform_entities
from ...utils import image
from ..logger import EventLogger
import xml.etree.ElementTree as ET
from typing import Optional, List, Tuple
from functools import partial
@@ -533,9 +534,10 @@ class WeChatPadAdapter(adapter.MessagePlatformAdapter):
typing.Callable[[platform_events.Event, adapter.MessagePlatformAdapter], None],
] = {}
def __init__(self, config: dict, ap: app.Application):
def __init__(self, config: dict, ap: app.Application, logger: EventLogger):
self.config = config
self.ap = ap
self.logger = logger
self.quart_app = quart.Quart(__name__)
self.message_converter = WeChatPadMessageConverter(config)
@@ -550,7 +552,7 @@ class WeChatPadAdapter(adapter.MessagePlatformAdapter):
try:
event = await self.event_converter.target2yiri(data.copy(), self.bot_account_id)
except Exception as e:
traceback.print_exc()
await self.logger.error(f"Error in wechatpad callback: {traceback.format_exc()}")
if event.__class__ in self.listeners:
await self.listeners[event.__class__](event, self)
@@ -694,7 +696,8 @@ class WeChatPadAdapter(adapter.MessagePlatformAdapter):
self.bot = WeChatPadClient(
self.config['wechatpad_url'],
self.config["token"]
self.config["token"],
logger=self.logger
)
self.ap.logger.info(self.config["token"])
thread_1 = threading.Event()

View File

@@ -14,6 +14,7 @@ from ...core import app
from ..types import entities as platform_entities
from ...command.errors import ParamNotEnoughError
from ...utils import image
from ..logger import EventLogger
class WecomMessageConverter(adapter.MessageConverter):
@@ -134,10 +135,10 @@ class WecomAdapter(adapter.MessagePlatformAdapter):
event_converter: WecomEventConverter = WecomEventConverter()
config: dict
def __init__(self, config: dict, ap: app.Application):
def __init__(self, config: dict, ap: app.Application, logger: EventLogger):
self.config = config
self.ap = ap
self.logger = logger
required_keys = [
'corpid',
@@ -156,6 +157,7 @@ class WecomAdapter(adapter.MessagePlatformAdapter):
token=config['token'],
EncodingAESKey=config['EncodingAESKey'],
contacts_secret=config['contacts_secret'],
logger=self.logger
)
async def reply_message(
@@ -199,8 +201,8 @@ class WecomAdapter(adapter.MessagePlatformAdapter):
self.bot_account_id = event.receiver_id
try:
return await callback(await self.event_converter.target2yiri(event), self)
except Exception:
traceback.print_exc()
except Exception as e:
await self.logger.error(f"Error in wecom callback: {traceback.format_exc()}")
if event_type == platform_events.FriendMessage:
self.bot.on_message('text')(on_message)

View File

@@ -13,6 +13,7 @@ from pkg.core import app
from .. import adapter
from ..types import entities as platform_entities
from ...command.errors import ParamNotEnoughError
from ..logger import EventLogger
class WecomMessageConverter(adapter.MessageConverter):
@@ -124,10 +125,10 @@ class WecomCSAdapter(adapter.MessagePlatformAdapter):
event_converter: WecomEventConverter = WecomEventConverter()
config: dict
def __init__(self, config: dict, ap: app.Application):
def __init__(self, config: dict, ap: app.Application, logger: EventLogger):
self.config = config
self.ap = ap
self.logger = logger
required_keys = [
'corpid',
@@ -144,6 +145,7 @@ class WecomCSAdapter(adapter.MessagePlatformAdapter):
secret=config['secret'],
token=config['token'],
EncodingAESKey=config['EncodingAESKey'],
logger=self.logger
)
async def reply_message(
@@ -176,8 +178,8 @@ class WecomCSAdapter(adapter.MessagePlatformAdapter):
self.bot_account_id = event.receiver_id
try:
return await callback(await self.event_converter.target2yiri(event), self)
except:
traceback.print_exc()
except Exception as e:
await self.logger.error(f"Error in wecomcs callback: {traceback.format_exc()}")
if event_type == platform_events.FriendMessage:
self.bot.on_message('text')(on_message)

View File

@@ -3,7 +3,10 @@ import logging
import typing
from datetime import datetime
from pathlib import Path
import base64
import aiofiles
import httpx
import pydantic.v1 as pydantic
from . import entities as platform_entities
@@ -552,52 +555,29 @@ class Image(MessageComponent):
image_id = image_id[1:]
return image_id
async def download(
self,
filename: typing.Union[str, Path, None] = None,
directory: typing.Union[str, Path, None] = None,
determine_type: bool = True,
):
"""下载图片到本地。
async def get_bytes(self) -> typing.Tuple[bytes, str]:
"""获取图片的 bytes 和 mime type"""
if self.url:
async with httpx.AsyncClient() as client:
response = await client.get(self.url)
response.raise_for_status()
return response.content, response.headers.get('Content-Type')
elif self.base64:
mime_type = 'image/jpeg'
Args:
filename: 下载到本地的文件路径。与 `directory` 二选一。
directory: 下载到本地的文件夹路径。与 `filename` 二选一。
determine_type: 是否自动根据图片类型确定拓展名,默认为 True。
"""
if not self.url:
logger.warning(f'图片 `{self.uuid}` 无 url 参数,下载失败。')
return
split_index = self.base64.find(';base64,')
if split_index == -1:
raise ValueError('Invalid base64 string')
import httpx
mime_type = self.base64[5:split_index]
base64_data = self.base64[split_index + 8 :]
async with httpx.AsyncClient() as client:
response = await client.get(self.url)
response.raise_for_status()
content = response.content
if filename:
path = Path(filename)
if determine_type:
import imghdr
path = path.with_suffix('.' + str(imghdr.what(None, content)))
path.parent.mkdir(parents=True, exist_ok=True)
elif directory:
import imghdr
path = Path(directory)
path.mkdir(parents=True, exist_ok=True)
path = path / f'{self.uuid}.{imghdr.what(None, content)}'
else:
raise ValueError('请指定文件路径或文件夹路径!')
import aiofiles
async with aiofiles.open(path, 'wb') as f:
await f.write(content)
return path
return base64.b64decode(base64_data), mime_type
elif self.path:
async with aiofiles.open(self.path, 'rb') as f:
return await f.read(), 'image/jpeg'
else:
raise ValueError('Can not get bytes from image')
@classmethod
async def from_local(

View File

@@ -57,6 +57,8 @@ class ModelScopeChatCompletions(requester.LLMAPIRequester):
if chunk.choices[0].delta.tool_calls is not None:
for tool_call in chunk.choices[0].delta.tool_calls:
if tool_call.function.arguments is None:
continue
for tc in tool_calls:
if tc.index == tool_call.index:
tc.function.arguments += tool_call.function.arguments

View File

@@ -82,8 +82,8 @@ class RuntimeMCPSession:
for tool in tools.tools:
async def func(query: core_entities.Query, **kwargs):
result = await self.session.call_tool(tool.name, kwargs)
async def func(query: core_entities.Query, *, _tool=tool, **kwargs):
result = await self.session.call_tool(_tool.name, kwargs)
if result.isError:
raise Exception(result.content[0].text)
return result.content[0].text

0
pkg/storage/__init__.py Normal file
View File

21
pkg/storage/mgr.py Normal file
View File

@@ -0,0 +1,21 @@
from __future__ import annotations
from ..core import app
from . import provider
from .providers import localstorage
class StorageMgr:
"""存储管理器"""
ap: app.Application
storage_provider: provider.StorageProvider
def __init__(self, ap: app.Application):
self.ap = ap
self.storage_provider = localstorage.LocalStorageProvider(ap)
async def initialize(self):
await self.storage_provider.initialize()

44
pkg/storage/provider.py Normal file
View File

@@ -0,0 +1,44 @@
from __future__ import annotations
import abc
from ..core import app
class StorageProvider(abc.ABC):
ap: app.Application
def __init__(self, ap: app.Application):
self.ap = ap
async def initialize(self):
pass
@abc.abstractmethod
async def save(
self,
key: str,
value: bytes,
):
pass
@abc.abstractmethod
async def load(
self,
key: str,
) -> bytes:
pass
@abc.abstractmethod
async def exists(
self,
key: str,
) -> bool:
pass
@abc.abstractmethod
async def delete(
self,
key: str,
):
pass

View File

View File

@@ -0,0 +1,45 @@
from __future__ import annotations
import os
import aiofiles
from ...core import app
from .. import provider
LOCAL_STORAGE_PATH = os.path.join('data', 'storage')
class LocalStorageProvider(provider.StorageProvider):
def __init__(self, ap: app.Application):
super().__init__(ap)
if not os.path.exists(LOCAL_STORAGE_PATH):
os.makedirs(LOCAL_STORAGE_PATH)
async def save(
self,
key: str,
value: bytes,
):
async with aiofiles.open(os.path.join(LOCAL_STORAGE_PATH, f'{key}'), 'wb') as f:
await f.write(value)
async def load(
self,
key: str,
) -> bytes:
async with aiofiles.open(os.path.join(LOCAL_STORAGE_PATH, f'{key}'), 'rb') as f:
return await f.read()
async def exists(
self,
key: str,
) -> bool:
return os.path.exists(os.path.join(LOCAL_STORAGE_PATH, f'{key}'))
async def delete(
self,
key: str,
):
os.remove(os.path.join(LOCAL_STORAGE_PATH, f'{key}'))

View File

@@ -1,4 +1,4 @@
semantic_version = 'v4.0.3.2'
semantic_version = 'v4.0.4'
required_database_version = 2
"""标记本版本所需要的数据库结构版本,用于判断数据库迁移"""

View File

@@ -1,8 +1,9 @@
import re
import inspect
import typing
def get_func_schema(function: callable) -> dict:
def get_func_schema(function: typing.Callable) -> dict:
"""
Return the data schema of a function.
{

View File

@@ -16,6 +16,9 @@
"ignore-rules": {
"prefix": [],
"regexp": []
},
"misc": {
"combine-quote-message": true
}
},
"safety": {

10
web/package-lock.json generated
View File

@@ -36,6 +36,7 @@
"react-dom": "^19.0.0",
"react-hook-form": "^7.56.3",
"react-i18next": "^15.5.1",
"react-photo-view": "^1.2.7",
"sonner": "^2.0.3",
"tailwind-merge": "^3.2.0",
"tailwindcss": "^4.1.5",
@@ -6126,6 +6127,15 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true
},
"node_modules/react-photo-view": {
"version": "1.2.7",
"resolved": "https://registry.npmmirror.com/react-photo-view/-/react-photo-view-1.2.7.tgz",
"integrity": "sha512-MfOWVPxuibncRLaycZUNxqYU8D9IA+rbGDDaq6GM8RIoGJal592hEJoRAyRSI7ZxyyJNJTLMUWWL3UIXHJJOpw==",
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/react-remove-scroll": {
"version": "2.6.3",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.3.tgz",

View File

@@ -44,6 +44,7 @@
"react-dom": "^19.0.0",
"react-hook-form": "^7.56.3",
"react-i18next": "^15.5.1",
"react-photo-view": "^1.2.7",
"sonner": "^2.0.3",
"tailwind-merge": "^3.2.0",
"tailwindcss": "^4.1.5",

View File

@@ -0,0 +1,63 @@
import { httpClient } from '@/app/infra/http/HttpClient';
import {
BotLog,
GetBotLogsResponse,
} from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
export class BotLogManager {
private botId: string;
private callbacks: ((_: BotLog[]) => void)[] = [];
private intervalIds: number[] = [];
constructor(botId: string) {
this.botId = botId;
}
startListenServerPush() {
const timerNumber = setInterval(() => {
this.getLogList(-1, 50).then((response) => {
this.callbacks.forEach((callback) =>
callback(this.parseResponse(response)),
);
});
}, 3000);
this.intervalIds.push(Number(timerNumber));
}
stopServerPush() {
this.intervalIds.forEach((id) => clearInterval(id));
this.intervalIds = [];
}
subscribeLogPush(callback: (_: BotLog[]) => void) {
if (!this.callbacks.includes(callback)) {
this.callbacks.push(callback);
}
}
dispose() {
this.callbacks = [];
}
/**
* 获取日志页的基本信息
*/
private getLogList(next: number, count: number = 20) {
return httpClient.getBotLogs(this.botId, {
from_index: next,
max_count: count,
});
}
async loadFirstPage() {
return this.parseResponse(await this.getLogList(-1, 10));
}
async loadMore(position: number, total: number) {
return this.parseResponse(await this.getLogList(position, total));
}
private parseResponse(httpResponse: GetBotLogsResponse): BotLog[] {
return httpResponse.logs;
}
}

View File

@@ -0,0 +1,116 @@
'use client';
import { BotLog } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
import styles from './botLog.module.css';
import { httpClient } from '@/app/infra/http/HttpClient';
import { PhotoProvider } from 'react-photo-view';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
export function BotLogCard({ botLog }: { botLog: BotLog }) {
const { t } = useTranslation();
const baseURL = httpClient.getBaseUrl();
function formatTime(timestamp: number) {
const now = new Date();
const date = new Date(timestamp * 1000);
// 获取各个时间部分
const year = date.getFullYear();
const month = date.getMonth() + 1; // 月份从0开始需要+1
const day = date.getDate();
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
// 判断时间范围
const isToday = now.toDateString() === date.toDateString();
const isYesterday =
new Date(now.setDate(now.getDate() - 1)).toDateString() ===
date.toDateString();
const isThisYear = now.getFullYear() === year;
if (isToday) {
return `${hours}:${minutes}`; // 今天的消息:小时:分钟
} else if (isYesterday) {
return `${t('bots.yesterday')} ${hours}:${minutes}`; // 昨天的消息:昨天 小时:分钟
} else if (isThisYear) {
return t('bots.dateFormat', { month, day }); // 本年消息x月x日
} else {
return t('bots.earlier'); // 更早的消息:更久之前
}
}
function getSubChatId(str: string) {
const strArr = str.split('');
return strArr;
}
return (
<div className={`${styles.botLogCardContainer}`}>
{/* 头部标签,时间 */}
<div className={`${styles.cardTitleContainer}`}>
<div className={`flex flex-row gap-4`}>
<div className={`${styles.tag}`}>{botLog.level}</div>
{botLog.message_session_id && (
<div
className={`${styles.tag} ${styles.chatTag}`}
onClick={() => {
navigator.clipboard
.writeText(botLog.message_session_id)
.then(() => {
toast.success(t('common.copySuccess'));
});
}}
>
<svg
className="icon"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
p-id="1664"
width="20"
height="20"
fill="currentColor"
>
<path
d="M96.1 575.7a32.2 32.1 0 1 0 64.4 0 32.2 32.1 0 1 0-64.4 0Z"
p-id="1665"
fill="currentColor"
></path>
<path
d="M742.1 450.7l-269.5-2.1c-14.3-0.1-26 13.8-26 31s11.7 31.3 26 31.4l269.5 2.1c14.3 0.1 26-13.8 26-31s-11.7-31.3-26-31.4zM742.1 577.7l-269.5-2.1c-14.3-0.1-26 13.8-26 31s11.7 31.3 26 31.4l269.5 2.1c14.3 0.2 26-13.8 26-31s-11.7-31.3-26-31.4z"
p-id="1666"
fill="currentColor"
></path>
<path
d="M736.1 63.9H417c-70.4 0-128 57.6-128 128h-64.9c-70.4 0-128 57.6-128 128v128c-0.1 17.7 14.4 32 32.2 32 17.8 0 32.2-14.4 32.2-32.1V320c0-35.2 28.8-64 64-64H289v447.8c0 70.4 57.6 128 128 128h255.1c-0.1 35.2-28.8 63.8-64 63.8H224.5c-35.2 0-64-28.8-64-64V703.5c0-17.7-14.4-32.1-32.2-32.1-17.8 0-32.3 14.4-32.3 32.1v128.3c0 70.4 57.6 128 128 128h384.1c70.4 0 128-57.6 128-128h65c70.4 0 128-57.6 128-128V255.9l-193-192z m0.1 63.4l127.7 128.3H800c-35.2 0-64-28.8-64-64v-64.3h0.2z m64 641H416.1c-35.2 0-64-28.8-64-64v-513c0-35.2 28.8-64 64-64H671V191c0 70.4 57.6 128 128 128h65.2v385.3c0 35.2-28.8 64-64 64z"
p-id="1667"
fill="currentColor"
></path>
</svg>
{/* 会话ID */}
<span className={`${styles.chatId}`}>
{getSubChatId(botLog.message_session_id)}
</span>
</div>
)}
</div>
<div>{formatTime(botLog.timestamp)}</div>
</div>
<div className={`${styles.cardTitleContainer} ${styles.cardText}`}>
{botLog.text}
</div>
<PhotoProvider className={``}>
<div className={`w-50 mt-2`}>
{botLog.images.map((item) => (
<img
key={item}
src={`${baseURL}/api/v1/files/image/${item}`}
alt=""
/>
))}
</div>
</PhotoProvider>
</div>
);
}

View File

@@ -0,0 +1,129 @@
'use client';
import { BotLogManager } from '@/app/home/bots/bot-log/BotLogManager';
import { useCallback, useEffect, useRef, useState } from 'react';
import { BotLog } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
import { BotLogCard } from '@/app/home/bots/bot-log/view/BotLogCard';
import styles from './botLog.module.css';
import { Switch } from '@/components/ui/switch';
import { debounce } from 'lodash';
import { useTranslation } from 'react-i18next';
export function BotLogListComponent({ botId }: { botId: string }) {
const { t } = useTranslation();
const manager = useRef(new BotLogManager(botId)).current;
const [botLogList, setBotLogList] = useState<BotLog[]>([]);
const [autoFlush, setAutoFlush] = useState(true);
const listContainerRef = useRef<HTMLDivElement>(null);
const botLogListRef = useRef<BotLog[]>(botLogList);
useEffect(() => {
initComponent();
return () => {
onDestroy();
};
}, []);
useEffect(() => {
botLogListRef.current = botLogList;
}, [botLogList]);
// 观测自动刷新状态
useEffect(() => {
if (autoFlush) {
manager.startListenServerPush();
} else {
manager.stopServerPush();
}
return () => {
manager.stopServerPush();
};
}, [autoFlush]);
function initComponent() {
// 订阅日志推送
manager.subscribeLogPush(handleBotLogPush);
// 加载第一页日志
manager.loadFirstPage().then((response) => {
setBotLogList(response.reverse());
});
// 监听滚动
listenScroll();
}
function onDestroy() {
manager.dispose();
removeScrollListener();
}
function listenScroll() {
if (!listContainerRef.current) {
return;
}
const list = listContainerRef.current;
list.addEventListener('scroll', handleScroll);
}
function removeScrollListener() {
if (!listContainerRef.current) {
return;
}
const list = listContainerRef.current;
list.removeEventListener('scroll', handleScroll);
}
function loadMore() {
// 加载更多日志
const list = botLogListRef.current;
const lastSeq = list[list.length - 1].seq_id;
if (lastSeq === 0) {
return;
}
manager.loadMore(lastSeq - 1, 10).then((response) => {
setBotLogList([...list, ...response.reverse()]);
});
}
function handleBotLogPush(response: BotLog[]) {
setBotLogList(response.reverse());
}
const handleScroll = useCallback(
debounce(() => {
if (!listContainerRef.current) return;
const { scrollTop, scrollHeight, clientHeight } =
listContainerRef.current;
const isBottom = scrollTop + clientHeight >= scrollHeight - 5;
const isTop = scrollTop === 0;
if (isBottom) {
setAutoFlush(false);
loadMore();
}
if (isTop) {
setAutoFlush(true);
}
if (!isTop && !isBottom) {
setAutoFlush(false);
}
}, 300), // 防抖延迟 300ms
[botLogList], // 依赖项为空
);
return (
<div
className={`${styles.botLogListContainer} px-6`}
ref={listContainerRef}
>
<div className={`${styles.listHeader}`}>
<div className={'mr-2'}>{t('bots.enableAutoRefresh')}</div>
<Switch checked={autoFlush} onCheckedChange={(e) => setAutoFlush(e)} />
</div>
{botLogList.map((botLog) => {
return <BotLogCard botLog={botLog} key={botLog.seq_id} />;
})}
</div>
);
}

View File

@@ -0,0 +1,68 @@
.botLogListContainer {
width: 100%;
min-height: 10rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
overflow-y: scroll;
}
.botLogCardContainer {
width: 100%;
background-color: #fff;
border-radius: 10px;
border: 1px solid #cbd5e1;
padding: 1.2rem;
margin-bottom: 1rem;
cursor: pointer;
}
.listHeader {
width: 100%;
height: 2.5rem;
display: flex;
flex-direction: row;
align-items: center;
}
.tag {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 0.2rem;
height: 1.5rem;
padding: 0.5rem;
border-radius: 0.4rem;
background-color: #a5d8ff;
color: #ffffff;
max-width: 16rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chatTag {
color: #626262;
background-color: #d1d1d1;
}
.chatId {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cardTitleContainer {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.cardText {
margin-top: 0.4rem;
color: #64748b;
}

View File

@@ -1,7 +1,34 @@
import { BotCardVO } from '@/app/home/bots/components/bot-card/BotCardVO';
import styles from './botCard.module.css';
import { httpClient } from '@/app/infra/http/HttpClient';
import { Switch } from '@/components/ui/switch';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
export default function BotCard({
botCardVO,
clickLogIconCallback,
setBotEnableCallback,
}: {
botCardVO: BotCardVO;
clickLogIconCallback: (id: string) => void;
setBotEnableCallback: (id: string, enable: boolean) => void;
}) {
const { t } = useTranslation();
function onClickLogIcon() {
clickLogIconCallback(botCardVO.id);
}
function setBotEnable(enable: boolean) {
return httpClient.updateBot(botCardVO.id, {
name: botCardVO.name,
description: botCardVO.description,
adapter: botCardVO.adapter,
adapter_config: botCardVO.adapterConfig,
enable: enable,
});
}
export default function BotCard({ botCardVO }: { botCardVO: BotCardVO }) {
return (
<div className={`${styles.cardContainer}`}>
<div className={`${styles.iconBasicInfoContainer}`}>
@@ -47,6 +74,44 @@ export default function BotCard({ botCardVO }: { botCardVO: BotCardVO }) {
</span>
</div>
</div>
<div className={`${styles.botOperationContainer}`}>
<Switch
checked={botCardVO.enable}
onCheckedChange={(e) => {
setBotEnable(e)
.then(() => {
setBotEnableCallback(botCardVO.id, e);
})
.catch((err) => {
console.error(err);
toast.error(t('bots.setBotEnableError'));
});
}}
onClick={(e) => {
e.stopPropagation();
}}
/>
<div
className={`${styles.botLogsIcon}`}
onClick={(e) => {
onClickLogIcon();
e.stopPropagation();
}}
>
<svg
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="w-[24px] h-[24px] z-10"
>
<path
d="M21 8V20.9932C21 21.5501 20.5552 22 20.0066 22H3.9934C3.44495 22 3 21.556 3 21.0082V2.9918C3 2.45531 3.4487 2 4.00221 2H14.9968L21 8ZM19 9H14V4H5V20H19V9ZM8 7H11V9H8V7ZM8 11H16V13H8V11ZM8 15H16V17H8V15Z"
fill="#9A9A9A"
/>
</svg>
</div>
</div>
</div>
</div>
);

View File

@@ -3,8 +3,11 @@ export interface IBotCardVO {
iconURL: string;
name: string;
description: string;
adapter: string;
adapterLabel: string;
adapterConfig: object;
usePipelineName: string;
enable: boolean;
}
export class BotCardVO implements IBotCardVO {
@@ -12,15 +15,21 @@ export class BotCardVO implements IBotCardVO {
iconURL: string;
name: string;
description: string;
adapter: string;
adapterLabel: string;
adapterConfig: object;
usePipelineName: string;
enable: boolean;
constructor(props: IBotCardVO) {
this.id = props.id;
this.iconURL = props.iconURL;
this.name = props.name;
this.description = props.description;
this.adapter = props.adapter;
this.adapterConfig = props.adapterConfig;
this.adapterLabel = props.adapterLabel;
this.usePipelineName = props.usePipelineName;
this.enable = props.enable;
}
}

View File

@@ -19,7 +19,6 @@
flex-direction: row;
gap: 0.8rem;
user-select: none;
/* background-color: aqua; */
}
.iconImage {
@@ -30,10 +29,10 @@
}
.basicInfoContainer {
position: relative;
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
width: 100%;
}
@@ -104,4 +103,14 @@
font-size: 1.4rem;
font-weight: bold;
max-width: 100%;
}
.botOperationContainer {
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: flex-end;
height: 100%;
width: 3rem;
gap: 0.4rem;
}

View File

@@ -202,6 +202,7 @@ export default function BotForm({
default: item.default,
id: UUID.generate(),
label: item.label,
description: item.description,
name: item.name,
required: item.required,
type: parseDynamicFormItemType(item.type),

View File

@@ -17,13 +17,18 @@ import {
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { i18nObj } from '@/i18n/I18nProvider';
import { BotLogListComponent } from '@/app/home/bots/bot-log/view/BotLogListComponent';
export default function BotConfigPage() {
const { t } = useTranslation();
// 编辑机器人的modal
const [modalOpen, setModalOpen] = useState<boolean>(false);
// 机器人日志的modal
const [logModalOpen, setLogModalOpen] = useState<boolean>(false);
const [botList, setBotList] = useState<BotCardVO[]>([]);
const [isEditForm, setIsEditForm] = useState(false);
const [nowSelectedBotUUID, setNowSelectedBotUUID] = useState<string>();
const [nowSelectedBotLog, setNowSelectedBotLog] = useState<string>();
useEffect(() => {
getBotList();
@@ -47,10 +52,13 @@ export default function BotConfigPage() {
iconURL: httpClient.getAdapterIconURL(bot.adapter),
name: bot.name,
description: bot.description,
adapter: bot.adapter,
adapterConfig: bot.adapter_config,
adapterLabel:
adapterList.find((item) => item.value === bot.adapter)?.label ||
bot.adapter.substring(0, 10),
usePipelineName: bot.use_pipeline_name || '',
enable: bot.enable || false,
});
});
setBotList(botList);
@@ -76,6 +84,11 @@ export default function BotConfigPage() {
setModalOpen(true);
}
function onClickLogIcon(botId: string) {
setNowSelectedBotLog(botId);
setLogModalOpen(true);
}
return (
<div className={styles.configPageContainer}>
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
@@ -107,6 +120,15 @@ export default function BotConfigPage() {
</DialogContent>
</Dialog>
<Dialog open={logModalOpen} onOpenChange={setLogModalOpen}>
<DialogContent className="w-[700px] max-h-[80vh] p-0 flex flex-col">
<DialogHeader className="px-6 pt-6 pb-0">
<DialogTitle>{t('bots.botLogTitle')}</DialogTitle>
</DialogHeader>
<BotLogListComponent botId={nowSelectedBotLog || ''} />
</DialogContent>
</Dialog>
{/* 注意其余的返回内容需要保持在Spin组件外部 */}
<div className={`${styles.botListContainer}`}>
<CreateCardComponent
@@ -123,7 +145,22 @@ export default function BotConfigPage() {
selectBot(cardVO.id);
}}
>
<BotCard botCardVO={cardVO} />
<BotCard
botCardVO={cardVO}
clickLogIconCallback={(id) => {
onClickLogIcon(id);
}}
setBotEnableCallback={(id, enable) => {
setBotList(
botList.map((bot) => {
if (bot.id === id) {
return { ...bot, enable: enable };
}
return bot;
}),
);
}}
/>
</div>
);
})}

View File

@@ -169,7 +169,6 @@ export default function LLMForm({
} else {
form.reset();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
});
}, []);

View File

@@ -84,18 +84,10 @@ export interface Adapter {
description: I18nLabel;
icon?: string;
spec: {
config: AdapterSpecConfig[];
config: IDynamicFormItemSchema[];
};
}
export interface AdapterSpecConfig {
default: string | number | boolean | Array<unknown>;
label: I18nLabel;
name: string;
required: boolean;
type: string;
}
export interface ApiRespPlatformBots {
bots: Bot[];
}

View File

@@ -30,6 +30,8 @@ import {
GetPipelineMetadataResponseData,
AsyncTask,
} from '@/app/infra/entities/api';
import { GetBotLogsRequest } from '@/app/infra/http/requestParam/bots/GetBotLogsRequest';
import { GetBotLogsResponse } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
type JSONValue = string | number | boolean | JSONObject | JSONArray | null;
interface JSONObject {
@@ -54,12 +56,14 @@ export let systemInfo: ApiRespSystemInfo | null = null;
class HttpClient {
private instance: AxiosInstance;
private disableToken: boolean = false;
private baseURL: string;
// 暂不需要SSR
// private ssrInstance: AxiosInstance | null = null
constructor(baseURL?: string, disableToken?: boolean) {
constructor(baseURL: string, disableToken?: boolean) {
this.baseURL = baseURL;
this.instance = axios.create({
baseURL: baseURL || this.getBaseUrl(),
baseURL: baseURL,
timeout: 15000,
headers: {
'Content-Type': 'application/json',
@@ -75,15 +79,9 @@ class HttpClient {
}
}
// 兜底URL如果使用未配置会走到这里
private getBaseUrl(): string {
// NOT IMPLEMENT
if (typeof window === 'undefined') {
// 服务端环境
return '';
}
// 客户端环境
return '';
// 外部获取baseURL的方法
getBaseUrl(): string {
return this.baseURL;
}
// 获取Session
@@ -345,6 +343,13 @@ class HttpClient {
return this.delete(`/api/v1/platform/bots/${uuid}`);
}
public getBotLogs(
botId: string,
request: GetBotLogsRequest,
): Promise<GetBotLogsResponse> {
return this.post(`/api/v1/platform/bots/${botId}/logs`, request);
}
// ============ Plugins API ============
public getPlugins(): Promise<ApiRespPlugins> {
return this.get('/api/v1/plugins');
@@ -450,7 +455,7 @@ class HttpClient {
}
}
// export const httpClient = new HttpClient("https://version-4.langbot.dev");
// export const httpClient = new HttpClient('https://event-log.langbot.dev');
// export const httpClient = new HttpClient('http://localhost:5300');
export const httpClient = new HttpClient('/');

View File

@@ -0,0 +1,4 @@
export interface GetBotLogsRequest {
from_index: number; // 从某索引开始往前找,-1代表结尾也就是拉取最新的
max_count: number; // 最大拉取数量
}

View File

@@ -0,0 +1,13 @@
export interface GetBotLogsResponse {
logs: BotLog[];
total_count: number;
}
export interface BotLog {
images: [];
level: string;
message_session_id: string;
seq_id: number;
text: string;
timestamp: number;
}

View File

@@ -1,4 +1,5 @@
import './global.css';
import 'react-photo-view/dist/react-photo-view.css';
import type { Metadata } from 'next';
import { Toaster } from '@/components/ui/sonner';
import I18nProvider from '@/i18n/I18nProvider';

View File

@@ -60,6 +60,7 @@ function DialogContent({
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
className,
)}
onInteractOutside={() => {}}
{...props}
>
{children}

View File

@@ -37,6 +37,7 @@ const enUS = {
deleteSuccess: 'Deleted successfully',
deleteError: 'Delete failed: ',
addRound: 'Add Round',
copySuccess: 'Copy Successfully',
test: 'Test',
},
notFound: {
@@ -120,6 +121,13 @@ const enUS = {
adapterConfig: 'Adapter Configuration',
bindPipeline: 'Bind Pipeline',
selectPipeline: 'Select Pipeline',
botLogTitle: 'Bot Log',
enableAutoRefresh: 'Enable Auto Refresh',
session: 'Session',
yesterday: 'Yesterday',
earlier: 'Earlier',
dateFormat: '{{month}}/{{day}}',
setBotEnableError: 'Failed to set bot enable status',
},
plugins: {
title: 'Plugins',

View File

@@ -37,6 +37,7 @@ const zhHans = {
deleteSuccess: '删除成功',
deleteError: '删除失败:',
addRound: '添加回合',
copySuccess: '复制成功',
test: '测试',
},
notFound: {
@@ -118,6 +119,13 @@ const zhHans = {
adapterConfig: '适配器配置',
bindPipeline: '绑定流水线',
selectPipeline: '选择流水线',
botLogTitle: '机器人日志',
enableAutoRefresh: '开启自动刷新',
session: '会话',
yesterday: '昨天',
earlier: '更久之前',
dateFormat: '{{month}}月{{day}}日',
setBotEnableError: '设置机器人启用状态失败',
},
plugins: {
title: '插件管理',