From 6421a6f5cb9d3fabc81114fe791efd5296da6fad Mon Sep 17 00:00:00 2001 From: "Junyan Qin (Chin)" Date: Sat, 6 Dec 2025 21:11:01 +0800 Subject: [PATCH] Feat/complete adapter features (#1849) * feat: add voice and file supports for wecom * feat: add and in query variables * feat: supports for lark recv file message * feat: kook recv voice msg * feat: supports for Voice and File in discord * chore: remove debug msg * perf: remove unnecessary bot logs * feat: implement bot log filtering and per label color (#1839) * feat: add sender_name and group_name in query variables --- src/langbot/libs/wecom_ai_bot_api/api.py | 1 - src/langbot/libs/wecom_api/api.py | 179 +++++++++++++++++- src/langbot/pkg/pipeline/preproc/preproc.py | 15 ++ .../pkg/pipeline/process/handlers/chat.py | 12 +- src/langbot/pkg/platform/sources/discord.py | 77 ++++++-- src/langbot/pkg/platform/sources/kook.py | 25 +-- src/langbot/pkg/platform/sources/lark.py | 31 +++ src/langbot/pkg/platform/sources/line.py | 13 -- .../pkg/platform/sources/officialaccount.py | 14 -- .../pkg/platform/sources/qqofficial.py | 14 -- src/langbot/pkg/platform/sources/slack.py | 15 -- .../pkg/platform/sources/websocket_adapter.py | 6 +- src/langbot/pkg/platform/sources/wecom.py | 35 ++-- src/langbot/pkg/platform/sources/wecombot.py | 21 +- src/langbot/pkg/platform/sources/wecomcs.py | 15 +- .../pkg/provider/runners/langflowapi.py | 1 - .../components/bot-log/view/BotLogCard.tsx | 25 ++- .../bot-log/view/BotLogListComponent.tsx | 94 ++++++++- web/src/i18n/locales/en-US.ts | 4 + web/src/i18n/locales/ja-JP.ts | 4 + web/src/i18n/locales/zh-Hans.ts | 4 + web/src/i18n/locales/zh-Hant.ts | 4 + 22 files changed, 464 insertions(+), 145 deletions(-) diff --git a/src/langbot/libs/wecom_ai_bot_api/api.py b/src/langbot/libs/wecom_ai_bot_api/api.py index a26170e3..c5f5d84d 100644 --- a/src/langbot/libs/wecom_ai_bot_api/api.py +++ b/src/langbot/libs/wecom_ai_bot_api/api.py @@ -394,7 +394,6 @@ class WecomBotClient: """ try: self.wxcpt = WXBizMsgCrypt(self.Token, self.EnCodingAESKey, '') - await self.logger.info(f'{req.method} {req.url} {str(req.args)}') if req.method == 'GET': return await self._handle_get_callback(req) diff --git a/src/langbot/libs/wecom_api/api.py b/src/langbot/libs/wecom_api/api.py index 7a6e1c69..1b8591fd 100644 --- a/src/langbot/libs/wecom_api/api.py +++ b/src/langbot/libs/wecom_api/api.py @@ -139,6 +139,58 @@ class WecomClient: await self.logger.error(f'发送图片失败:{data}') raise Exception('Failed to send image: ' + str(data)) + async def send_voice(self, user_id: str, agent_id: int, media_id: str): + if not await self.check_access_token(): + self.access_token = await self.get_access_token(self.secret) + url = self.base_url + '/message/send?access_token=' + self.access_token + async with httpx.AsyncClient() as client: + params = { + 'touser': user_id, + 'msgtype': 'voice', + 'agentid': agent_id, + 'voice': { + 'media_id': media_id, + }, + 'safe': 0, + 'enable_id_trans': 0, + 'enable_duplicate_check': 0, + 'duplicate_check_interval': 1800, + } + response = await client.post(url, json=params) + data = response.json() + if data['errcode'] == 40014 or data['errcode'] == 42001: + self.access_token = await self.get_access_token(self.secret) + return await self.send_voice(user_id, agent_id, media_id) + if data['errcode'] != 0: + await self.logger.error(f'发送语音失败:{data}') + raise Exception('Failed to send voice: ' + str(data)) + + async def send_file(self, user_id: str, agent_id: int, media_id: str): + if not await self.check_access_token(): + self.access_token = await self.get_access_token(self.secret) + url = self.base_url + '/message/send?access_token=' + self.access_token + async with httpx.AsyncClient() as client: + params = { + 'touser': user_id, + 'msgtype': 'file', + 'agentid': agent_id, + 'file': { + 'media_id': media_id, + }, + 'safe': 0, + 'enable_id_trans': 0, + 'enable_duplicate_check': 0, + 'duplicate_check_interval': 1800, + } + response = await client.post(url, json=params) + data = response.json() + if data['errcode'] == 40014 or data['errcode'] == 42001: + self.access_token = await self.get_access_token(self.secret) + return await self.send_file(user_id, agent_id, media_id) + if data['errcode'] != 0: + await self.logger.error(f'发送文件失败:{data}') + raise Exception('Failed to send file: ' + str(data)) + async def send_private_msg(self, user_id: str, agent_id: int, content: str): if not await self.check_access_token(): self.access_token = await self.get_access_token(self.secret) @@ -287,7 +339,7 @@ class WecomClient: return ext return 'jpg' # 默认返回jpg - async def upload_to_work(self, image: platform_message.Image): + async def upload_image_to_work(self, image: platform_message.Image): """ 获取 media_id """ @@ -304,7 +356,7 @@ class WecomClient: file_bytes = await f.read() file_name = image.path.split('/')[-1] elif image.url: - file_bytes = await self.download_image_to_bytes(image.url) + file_bytes = await self.download_media_to_bytes(image.url) file_name = image.url.split('/')[-1] elif image.base64: try: @@ -339,7 +391,7 @@ class WecomClient: data = response.json() if data['errcode'] == 40014 or data['errcode'] == 42001: self.access_token = await self.get_access_token(self.secret) - media_id = await self.upload_to_work(image) + media_id = await self.upload_image_to_work(image) if data.get('errcode', 0) != 0: await self.logger.error(f'上传图片失败:{data}') raise Exception('failed to upload file') @@ -347,13 +399,128 @@ class WecomClient: media_id = data.get('media_id') return media_id - async def download_image_to_bytes(self, url: str) -> bytes: + async def upload_voice_to_work(self, voice: platform_message.Voice): + """ + 上传语音文件到企业微信 + """ + if not await self.check_access_token(): + self.access_token = await self.get_access_token(self.secret) + url = self.base_url + '/media/upload?access_token=' + self.access_token + '&type=file' + file_bytes = None + file_name = 'voice.mp3' + + if voice.path: + async with aiofiles.open(voice.path, 'rb') as f: + file_bytes = await f.read() + file_name = voice.path.split('/')[-1] + elif voice.url: + file_bytes = await self.download_media_to_bytes(voice.url) + file_name = voice.url.split('/')[-1] + elif voice.base64: + try: + base64_data = voice.base64 + if ',' in base64_data: + base64_data = base64_data.split(',', 1)[1] + padding = 4 - (len(base64_data) % 4) if len(base64_data) % 4 else 0 + padded_base64 = base64_data + '=' * padding + file_bytes = base64.b64decode(padded_base64) + except binascii.Error as e: + raise ValueError(f'Invalid base64 string: {str(e)}') + else: + await self.logger.error('Voice对象出错') + raise ValueError('voice对象出错') + + boundary = '-------------------------acebdf13572468' + headers = {'Content-Type': f'multipart/form-data; boundary={boundary}'} + body = ( + ( + f'--{boundary}\r\n' + f'Content-Disposition: form-data; name="media"; filename="{file_name}"; filelength={len(file_bytes)}\r\n' + f'Content-Type: application/octet-stream\r\n\r\n' + ).encode('utf-8') + + file_bytes + + f'\r\n--{boundary}--\r\n'.encode('utf-8') + ) + + # print(body) + async with httpx.AsyncClient() as client: + response = await client.post(url, headers=headers, content=body) + data = response.json() + if data['errcode'] == 40014 or data['errcode'] == 42001: + self.access_token = await self.get_access_token(self.secret) + media_id = await self.upload_voice_to_work(voice) + if data.get('errcode', 0) != 0: + await self.logger.error(f'上传语音文件失败:{data}') + raise Exception('failed to upload file') + media_id = data.get('media_id') + return media_id + + async def upload_file_to_work(self, file: platform_message.File): + """ + 上传文件到企业微信 + """ + if not await self.check_access_token(): + self.access_token = await self.get_access_token(self.secret) + url = self.base_url + '/media/upload?access_token=' + self.access_token + '&type=file' + file_bytes = None + file_name = 'file.txt' + if file.path: + async with aiofiles.open(file.path, 'rb') as f: + file_bytes = await f.read() + file_name = file.path.split('/')[-1] + elif file.url: + file_bytes = await self.download_media_to_bytes(file.url) + file_name = file.url.split('/')[-1] + elif file.base64: + try: + base64_data = file.base64 + if ',' in base64_data: + base64_data = base64_data.split(',', 1)[1] + padding = 4 - (len(base64_data) % 4) if len(base64_data) % 4 else 0 + padded_base64 = base64_data + '=' * padding + file_bytes = base64.b64decode(padded_base64) + except binascii.Error as e: + raise ValueError(f'Invalid base64 string: {str(e)}') + else: + await self.logger.error('File对象出错') + raise ValueError('file对象出错') + boundary = '-------------------------acebdf13572468' + headers = {'Content-Type': f'multipart/form-data; boundary={boundary}'} + body = ( + ( + f'--{boundary}\r\n' + f'Content-Disposition: form-data; name="media"; filename="{file_name}"; filelength={len(file_bytes)}\r\n' + f'Content-Type: application/octet-stream\r\n\r\n' + ).encode('utf-8') + + file_bytes + + f'\r\n--{boundary}--\r\n'.encode('utf-8') + ) + async with httpx.AsyncClient() as client: + response = await client.post(url, headers=headers, content=body) + data = response.json() + if data['errcode'] == 40014 or data['errcode'] == 42001: + self.access_token = await self.get_access_token(self.secret) + media_id = await self.upload_file_to_work(file) + if data.get('errcode', 0) != 0: + await self.logger.error(f'上传文件失败:{data}') + raise Exception('failed to upload file') + media_id = data.get('media_id') + return media_id + + async def download_media_to_bytes(self, url: str) -> bytes: async with httpx.AsyncClient() as client: response = await client.get(url) response.raise_for_status() return response.content # 进行media_id的获取 - async def get_media_id(self, image: platform_message.Image): - media_id = await self.upload_to_work(image=image) + async def get_media_id(self, media: platform_message.Image | platform_message.Voice | platform_message.File): + if isinstance(media, platform_message.Image): + media_id = await self.upload_image_to_work(image=media) + elif isinstance(media, platform_message.Voice): + media_id = await self.upload_voice_to_work(voice=media) + elif isinstance(media, platform_message.File): + media_id = await self.upload_file_to_work(file=media) + else: + raise ValueError('Unsupported media type') return media_id diff --git a/src/langbot/pkg/pipeline/preproc/preproc.py b/src/langbot/pkg/pipeline/preproc/preproc.py index 3f6db6a2..cd039d79 100644 --- a/src/langbot/pkg/pipeline/preproc/preproc.py +++ b/src/langbot/pkg/pipeline/preproc/preproc.py @@ -7,6 +7,7 @@ from langbot_plugin.api.entities.builtin.provider import message as provider_mes import langbot_plugin.api.entities.events as events import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query +import langbot_plugin.api.entities.builtin.platform.events as platform_events @stage.stage_class('PreProcessor') @@ -74,12 +75,26 @@ class PreProcessor(stage.PipelineStage): self.ap.logger.debug(f'Bound MCP servers: {bound_mcp_servers}') self.ap.logger.debug(f'Use funcs: {query.use_funcs}') + sender_name = '' + + if isinstance(query.message_event, platform_events.GroupMessage): + sender_name = query.message_event.sender.member_name + elif isinstance(query.message_event, platform_events.FriendMessage): + sender_name = query.message_event.sender.nickname + variables = { + 'launcher_type': query.session.launcher_type.value, + 'launcher_id': query.session.launcher_id, + 'sender_id': query.sender_id, 'session_id': f'{query.session.launcher_type.value}_{query.session.launcher_id}', 'conversation_id': conversation.uuid, 'msg_create_time': ( int(query.message_event.time) if query.message_event.time else int(datetime.datetime.now().timestamp()) ), + 'group_name': query.message_event.group.name + if isinstance(query.message_event, platform_events.GroupMessage) + else '', + 'sender_name': sender_name, } query.variables.update(variables) diff --git a/src/langbot/pkg/pipeline/process/handlers/chat.py b/src/langbot/pkg/pipeline/process/handlers/chat.py index 817e0b77..e4337742 100644 --- a/src/langbot/pkg/pipeline/process/handlers/chat.py +++ b/src/langbot/pkg/pipeline/process/handlers/chat.py @@ -76,7 +76,7 @@ class ChatMessageHandler(handler.MessageHandler): runner = r(self.ap, query.pipeline_config) break else: - raise ValueError(f'未找到请求运行器: {query.pipeline_config["ai"]["runner"]["runner"]}') + raise ValueError(f'Request Runner not found: {query.pipeline_config["ai"]["runner"]["runner"]}') if is_stream: resp_message_id = uuid.uuid4() @@ -91,7 +91,9 @@ class ChatMessageHandler(handler.MessageHandler): await query.adapter.create_message_card(str(resp_message_id), query.message_event) is_create_card = True query.resp_messages.append(result) - self.ap.logger.info(f'对话({query.query_id})流式响应: {self.cut_str(result.readable_str())}') + self.ap.logger.info( + f'Conversation({query.query_id}) Streaming Response: {self.cut_str(result.readable_str())}' + ) if result.content is not None: text_length += len(result.content) @@ -102,7 +104,9 @@ class ChatMessageHandler(handler.MessageHandler): async for result in runner.run(query): query.resp_messages.append(result) - self.ap.logger.info(f'对话({query.query_id})响应: {self.cut_str(result.readable_str())}') + self.ap.logger.info( + f'Conversation({query.query_id}) Response: {self.cut_str(result.readable_str())}' + ) if result.content is not None: text_length += len(result.content) @@ -113,7 +117,7 @@ class ChatMessageHandler(handler.MessageHandler): query.session.using_conversation.messages.extend(query.resp_messages) except Exception as e: - self.ap.logger.error(f'对话({query.query_id})请求失败: {type(e).__name__} {str(e)}') + self.ap.logger.error(f'Conversation({query.query_id}) Request Failed: {type(e).__name__} {str(e)}') traceback.print_exc() hide_exception_info = query.pipeline_config['output']['misc']['hide-exception'] diff --git a/src/langbot/pkg/platform/sources/discord.py b/src/langbot/pkg/platform/sources/discord.py index 7653e03f..cb80ce48 100644 --- a/src/langbot/pkg/platform/sources/discord.py +++ b/src/langbot/pkg/platform/sources/discord.py @@ -8,6 +8,9 @@ import base64 import uuid import os import datetime + +# 使用BytesIO创建文件对象,避免路径问题 +import io import asyncio from enum import Enum @@ -594,7 +597,7 @@ class DiscordMessageConverter(abstract_platform_adapter.AbstractMessageConverter break text_string = '' - image_files = [] + files = [] for ele in message_chain: if isinstance(ele, platform_message.Image): @@ -668,22 +671,67 @@ class DiscordMessageConverter(abstract_platform_adapter.AbstractMessageConverter continue # 跳过读取失败的文件 if image_bytes: - # 使用BytesIO创建文件对象,避免路径问题 - import io - - image_files.append(discord.File(fp=io.BytesIO(image_bytes), filename=filename)) + files.append(discord.File(fp=io.BytesIO(image_bytes), filename=filename)) elif isinstance(ele, platform_message.Plain): text_string += ele.text + elif isinstance(ele, platform_message.Voice): + file_bytes = None + filename = f'{uuid.uuid4()}.mp3' + if ele.base64: + if ele.base64.startswith('data:'): + data_header = ele.base64.split(',')[0] + if 'wav' in data_header: + filename = f'{uuid.uuid4()}.wav' + elif 'mp3' in data_header: + filename = f'{uuid.uuid4()}.mp3' + elif 'ogg' in data_header: + filename = f'{uuid.uuid4()}.ogg' + elif 'm4a' in data_header: + filename = f'{uuid.uuid4()}.m4a' + elif 'aac' in data_header: + filename = f'{uuid.uuid4()}.aac' + elif 'flac' in data_header: + filename = f'{uuid.uuid4()}.flac' + elif 'alac' in data_header: + filename = f'{uuid.uuid4()}.alac' + elif 'opus' in data_header: + filename = f'{uuid.uuid4()}.opus' + elif 'webm' in data_header: + filename = f'{uuid.uuid4()}.webm' + + file_base64 = ele.base64.split(',')[-1] + file_bytes = base64.b64decode(file_base64) + elif ele.url: + async with aiohttp.ClientSession() as session: + async with session.get(ele.url) as response: + file_bytes = await response.read() + if file_bytes: + files.append(discord.File(fp=io.BytesIO(file_bytes), filename=filename)) + elif isinstance(ele, platform_message.File): + file_bytes = None + filename = f'{uuid.uuid4()}.{ele.name.split(".")[-1]}' + if ele.base64: + if ele.base64.startswith('data:'): + file_base64 = ele.base64.split(',')[1] + file_bytes = base64.b64decode(file_base64) + else: + file_bytes = base64.b64decode(ele.base64) + elif ele.url: + async with aiohttp.ClientSession() as session: + async with session.get(ele.url) as response: + file_bytes = await response.read() + if file_bytes: + files.append(discord.File(fp=io.BytesIO(file_bytes), filename=filename)) elif isinstance(ele, platform_message.Forward): for node in ele.node_list: ( node_text, - node_images, + node_files, ) = await DiscordMessageConverter.yiri2target(node.message_chain) text_string += node_text - image_files.extend(node_images) + files.extend(node_files) - return text_string, image_files + return text_string, files @staticmethod async def target2yiri(message: discord.Message) -> platform_message.MessageChain: @@ -990,7 +1038,7 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): await self.voice_manager.cleanup_inactive_connections() async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): - msg_to_send, image_files = await self.message_converter.yiri2target(message) + msg_to_send, files = await self.message_converter.yiri2target(message) try: # 获取频道对象 @@ -1003,8 +1051,8 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): 'content': msg_to_send, } - if len(image_files) > 0: - args['files'] = image_files + if len(files) > 0: + args['files'] = files await channel.send(**args) @@ -1018,15 +1066,16 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): message: platform_message.MessageChain, quote_origin: bool = False, ): - msg_to_send, image_files = await self.message_converter.yiri2target(message) + msg_to_send, files = await self.message_converter.yiri2target(message) + assert isinstance(message_source.source_platform_object, discord.Message) args = { 'content': msg_to_send, } - if len(image_files) > 0: - args['files'] = image_files + if len(files) > 0: + args['files'] = files if quote_origin: args['reference'] = message_source.source_platform_object diff --git a/src/langbot/pkg/platform/sources/kook.py b/src/langbot/pkg/platform/sources/kook.py index d5070be4..17777a95 100644 --- a/src/langbot/pkg/platform/sources/kook.py +++ b/src/langbot/pkg/platform/sources/kook.py @@ -137,7 +137,11 @@ class KookMessageConverter(abstract_platform_adapter.AbstractMessageConverter): # For file messages, content is typically the file URL attachments = extra.get('attachments', {}) file_name = attachments.get('name', 'file') - components.append(platform_message.Plain(text=f'[File: {file_name}]')) + components.append(platform_message.File(url=content, name=file_name)) + elif msg_type == 8: # Audio message + # For audio messages, content is typically the audio URL + attachments = extra.get('attachments', {}) + components.append(platform_message.Voice(url=content)) elif msg_type == 9: # KMarkdown message # Note: content is already stripped of mention patterns above if content: @@ -317,9 +321,6 @@ class KookAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): data = await response.json() if data.get('code') == 0: user_info = data['data'] - await self.logger.info( - f'Retrieved bot user info: {user_info.get("username")} (ID: {user_info.get("id")})' - ) return user_info else: raise Exception(f'Failed to get bot user info: {data.get("message")}') @@ -343,11 +344,10 @@ class KookAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): # Ignore messages from bot itself to prevent infinite loops if self.bot_account_id and str(author_id) == self.bot_account_id: - await self.logger.debug(f'Ignoring message from bot itself (author_id: {author_id})') return - # Only process text messages (type 1, 2, 4, 9, 10) in GROUP or PERSON channels - if event_type in [1, 2, 4, 9, 10] and channel_type in ['GROUP', 'PERSON']: + # Only process text messages (type 1, 2, 4, 8, 9, 10) in GROUP or PERSON channels + if event_type in [1, 2, 4, 8, 9, 10] and channel_type in ['GROUP', 'PERSON']: try: # Convert to LangBot event lb_event = await self.event_converter.target2yiri(data, self.bot_account_id) @@ -377,7 +377,6 @@ class KookAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): 'sn': self.current_sn, } await self.ws.send(json.dumps(ping_msg)) - await self.logger.debug(f'Sent PING with sn={self.current_sn}') except Exception: # Connection closed or send failed, exit loop break @@ -398,10 +397,9 @@ class KookAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): self.gateway_url = await self._get_gateway_url() # Connect to WebSocket - await self.logger.info(f'Connecting to KOOK WebSocket: {self.gateway_url}') async with websockets.connect(self.gateway_url) as ws: + await self.logger.info(f'Connected to KOOK WebSocket: {self.gateway_url}') self.ws = ws - await self.logger.info('KOOK WebSocket connected') # Start heartbeat self.heartbeat_task = asyncio.create_task(self._heartbeat_loop()) @@ -452,10 +450,11 @@ class KookAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): elif signal == 3: # PONG await self._handle_pong(msg_data.get('d', {})) elif signal == 5: # RECONNECT - await self.logger.info('Received RECONNECT signal') + # await self.logger.info('Received RECONNECT signal') break # Break to reconnect elif signal == 6: # RESUME ACK - await self.logger.info('Resume successful') + # await self.logger.info('Resume successful') + pass except json.JSONDecodeError: await self.logger.error(f'Failed to parse message: {message}') except Exception as e: @@ -568,6 +567,8 @@ class KookAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): if quote_origin and msg_id: payload['quote'] = msg_id + payload['reply_msg_id'] = msg_id + headers = { 'Authorization': f'Bot {self.config["token"]}', 'Content-Type': 'application/json', diff --git a/src/langbot/pkg/platform/sources/lark.py b/src/langbot/pkg/platform/sources/lark.py index 77fae8b0..0e2af06a 100644 --- a/src/langbot/pkg/platform/sources/lark.py +++ b/src/langbot/pkg/platform/sources/lark.py @@ -297,6 +297,10 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter): message_content['content'] = new_list elif message.message_type == 'image': message_content['content'] = [{'tag': 'img', 'image_key': message_content['image_key'], 'style': []}] + elif message.message_type == 'file': + message_content['content'] = [ + {'tag': 'file', 'file_key': message_content['file_key'], 'file_name': message_content['file_name']} + ] for ele in message_content['content']: if ele['tag'] == 'text': @@ -327,6 +331,33 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter): image_format = response.raw.headers['content-type'] lb_msg_list.append(platform_message.Image(base64=f'data:{image_format};base64,{image_base64}')) + elif ele['tag'] == 'file': + file_key = ele['file_key'] + file_name = ele['file_name'] + + request: GetMessageResourceRequest = ( + GetMessageResourceRequest.builder() + .message_id(message.message_id) + .file_key(file_key) + .type('file') + .build() + ) + + response: GetMessageResourceResponse = await api_client.im.v1.message_resource.aget(request) + + if not response.success(): + raise Exception( + f'client.im.v1.message_resource.get failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}' + ) + + file_bytes = response.file.read() + file_base64 = base64.b64encode(file_bytes).decode() + + file_format = response.raw.headers['content-type'] + + lb_msg_list.append( + platform_message.File(base64=f'data:{file_format};base64,{file_base64}', name=file_name) + ) return platform_message.MessageChain(lb_msg_list) diff --git a/src/langbot/pkg/platform/sources/line.py b/src/langbot/pkg/platform/sources/line.py index 97e3802c..3d0f75c7 100644 --- a/src/langbot/pkg/platform/sources/line.py +++ b/src/langbot/pkg/platform/sources/line.py @@ -259,19 +259,6 @@ class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): # 保持运行但不启动独立端口 # 打印 webhook 回调地址 - if self.bot_uuid and hasattr(self.logger, 'ap'): - try: - api_port = self.logger.ap.instance_config.data['api']['port'] - webhook_url = f'http://127.0.0.1:{api_port}/bots/{self.bot_uuid}' - webhook_url_public = f'http://:{api_port}/bots/{self.bot_uuid}' - - await self.logger.info('LINE Webhook 回调地址:') - await self.logger.info(f' 本地地址: {webhook_url}') - await self.logger.info(f' 公网地址: {webhook_url_public}') - await self.logger.info('请在 LINE 后台配置此回调地址') - except Exception as e: - await self.logger.warning(f'无法生成 webhook URL: {e}') - async def keep_alive(): while True: await asyncio.sleep(1) diff --git a/src/langbot/pkg/platform/sources/officialaccount.py b/src/langbot/pkg/platform/sources/officialaccount.py index 5f6f0e41..e0c48a23 100644 --- a/src/langbot/pkg/platform/sources/officialaccount.py +++ b/src/langbot/pkg/platform/sources/officialaccount.py @@ -155,20 +155,6 @@ class OfficialAccountAdapter(abstract_platform_adapter.AbstractMessagePlatformAd # 统一 webhook 模式下,不启动独立的 Quart 应用 # 保持运行但不启动独立端口 - # 打印 webhook 回调地址 - if self.bot_uuid and hasattr(self.logger, 'ap'): - try: - api_port = self.logger.ap.instance_config.data['api']['port'] - webhook_url = f'http://127.0.0.1:{api_port}/bots/{self.bot_uuid}' - webhook_url_public = f'http://:{api_port}/bots/{self.bot_uuid}' - - await self.logger.info('微信公众号 Webhook 回调地址:') - await self.logger.info(f' 本地地址: {webhook_url}') - await self.logger.info(f' 公网地址: {webhook_url_public}') - await self.logger.info('请在微信公众号后台配置此回调地址') - except Exception as e: - await self.logger.warning(f'无法生成 webhook URL: {e}') - async def keep_alive(): while True: await asyncio.sleep(1) diff --git a/src/langbot/pkg/platform/sources/qqofficial.py b/src/langbot/pkg/platform/sources/qqofficial.py index a5444119..354afc41 100644 --- a/src/langbot/pkg/platform/sources/qqofficial.py +++ b/src/langbot/pkg/platform/sources/qqofficial.py @@ -241,20 +241,6 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter # 统一 webhook 模式下,不启动独立的 Quart 应用 # 保持运行但不启动独立端口 - # 打印 webhook 回调地址 - if self.bot_uuid and hasattr(self.logger, 'ap'): - try: - api_port = self.logger.ap.instance_config.data['api']['port'] - webhook_url = f'http://127.0.0.1:{api_port}/bots/{self.bot_uuid}' - webhook_url_public = f'http://:{api_port}/bots/{self.bot_uuid}' - - await self.logger.info('QQ 官方机器人 Webhook 回调地址:') - await self.logger.info(f' 本地地址: {webhook_url}') - await self.logger.info(f' 公网地址: {webhook_url_public}') - await self.logger.info('请在 QQ 官方机器人后台配置此回调地址') - except Exception as e: - await self.logger.warning(f'无法生成 webhook URL: {e}') - async def keep_alive(): while True: await asyncio.sleep(1) diff --git a/src/langbot/pkg/platform/sources/slack.py b/src/langbot/pkg/platform/sources/slack.py index e325657f..e577e3bd 100644 --- a/src/langbot/pkg/platform/sources/slack.py +++ b/src/langbot/pkg/platform/sources/slack.py @@ -188,21 +188,6 @@ class SlackAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): async def run_async(self): # 统一 webhook 模式下,不启动独立的 Quart 应用 # 保持运行但不启动独立端口 - - # 打印 webhook 回调地址 - if self.bot_uuid and hasattr(self.logger, 'ap'): - try: - api_port = self.logger.ap.instance_config.data['api']['port'] - webhook_url = f'http://127.0.0.1:{api_port}/bots/{self.bot_uuid}' - webhook_url_public = f'http://:{api_port}/bots/{self.bot_uuid}' - - await self.logger.info('Slack 机器人 Webhook 回调地址:') - await self.logger.info(f' 本地地址: {webhook_url}') - await self.logger.info(f' 公网地址: {webhook_url_public}') - await self.logger.info('请在 Slack 后台配置此回调地址') - except Exception as e: - await self.logger.warning(f'无法生成 webhook URL: {e}') - async def keep_alive(): while True: await asyncio.sleep(1) diff --git a/src/langbot/pkg/platform/sources/websocket_adapter.py b/src/langbot/pkg/platform/sources/websocket_adapter.py index a9f32800..53ff681e 100644 --- a/src/langbot/pkg/platform/sources/websocket_adapter.py +++ b/src/langbot/pkg/platform/sources/websocket_adapter.py @@ -97,8 +97,6 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter) # 推送到所有相关连接 await self.outbound_message_queue.put(message_data) - await self.logger.info(f'Send message to {target_id}: {message}') - return message_data async def reply_message( @@ -242,7 +240,6 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter) async def run_async(self): """运行适配器""" - await self.logger.info('WebSocket适配器已启动') try: while True: @@ -258,12 +255,11 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter) await asyncio.sleep(0.1) except asyncio.CancelledError: - await self.logger.info('WebSocket适配器已停止') raise async def kill(self): """停止适配器""" - await self.logger.info('WebSocket适配器正在停止') + pass async def _process_image_components(self, message_chain_obj: list): """ diff --git a/src/langbot/pkg/platform/sources/wecom.py b/src/langbot/pkg/platform/sources/wecom.py index 21daad67..c42d0c9c 100644 --- a/src/langbot/pkg/platform/sources/wecom.py +++ b/src/langbot/pkg/platform/sources/wecom.py @@ -35,6 +35,20 @@ class WecomMessageConverter(abstract_platform_adapter.AbstractMessageConverter): 'media_id': await bot.get_media_id(msg), } ) + elif type(msg) is platform_message.Voice: + content_list.append( + { + 'type': 'voice', + 'media_id': await bot.get_media_id(msg), + } + ) + elif type(msg) is platform_message.File: + content_list.append( + { + 'type': 'file', + 'media_id': await bot.get_media_id(msg), + } + ) elif type(msg) is platform_message.Forward: for node in msg.node_list: content_list.extend((await WecomMessageConverter.yiri2target(node.message_chain, bot))) @@ -185,6 +199,10 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): await self.bot.send_private_msg(fixed_user_id, Wecom_event.agent_id, content['content']) elif content['type'] == 'image': await self.bot.send_image(fixed_user_id, Wecom_event.agent_id, content['media_id']) + elif content['type'] == 'voice': + await self.bot.send_voice(fixed_user_id, Wecom_event.agent_id, content['media_id']) + elif content['type'] == 'file': + await self.bot.send_file(fixed_user_id, Wecom_event.agent_id, content['media_id']) async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): content_list = await WecomMessageConverter.yiri2target(message, self.bot) @@ -197,6 +215,10 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): await self.bot.send_private_msg(user_id, agent_id, content['content']) if content['type'] == 'image': await self.bot.send_image(user_id, agent_id, content['media']) + if content['type'] == 'voice': + await self.bot.send_voice(user_id, agent_id, content['media']) + if content['type'] == 'file': + await self.bot.send_file(user_id, agent_id, content['media']) def register_listener( self, @@ -232,19 +254,6 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): return await self.bot.handle_unified_webhook(request) async def run_async(self): - if self.bot_uuid and hasattr(self.logger, 'ap'): - try: - api_port = self.logger.ap.instance_config.data['api']['port'] - webhook_url = f'http://127.0.0.1:{api_port}/bots/{self.bot_uuid}' - webhook_url_public = f'http://:{api_port}/bots/{self.bot_uuid}' - - await self.logger.info('企业微信 Webhook 回调地址:') - await self.logger.info(f' 本地地址: {webhook_url}') - await self.logger.info(f' 公网地址: {webhook_url_public}') - await self.logger.info('请在企业微信后台配置此回调地址') - except Exception as e: - await self.logger.warning(f'无法生成 webhook URL: {e}') - async def keep_alive(): while True: await asyncio.sleep(1) diff --git a/src/langbot/pkg/platform/sources/wecombot.py b/src/langbot/pkg/platform/sources/wecombot.py index c35c2b18..724a32b2 100644 --- a/src/langbot/pkg/platform/sources/wecombot.py +++ b/src/langbot/pkg/platform/sources/wecombot.py @@ -72,7 +72,7 @@ class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverte voice_payload = voice_info.get('base64') or voice_info.get('url') if voice_payload: if voice_info.get('base64') and not voice_payload.startswith('data:'): - voice_payload = f"data:audio/mpeg;base64,{voice_info.get('base64')}" + voice_payload = f'data:audio/mpeg;base64,{voice_info.get("base64")}' try: yiri_msg_list.append(platform_message.Voice(base64=voice_payload)) except Exception: @@ -113,7 +113,10 @@ class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverte if event.msgtype == 'link' and event.link: link = event.link summary = '\n'.join( - filter(None, [link.get('title', ''), link.get('description') or link.get('digest', ''), link.get('url', '')]) + filter( + None, + [link.get('title', ''), link.get('description') or link.get('digest', ''), link.get('url', '')], + ) ) if summary: yiri_msg_list.append(platform_message.Plain(text=summary)) @@ -301,20 +304,6 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): # 统一 webhook 模式下,不启动独立的 Quart 应用 # 保持运行但不启动独立端口 - # 打印 webhook 回调地址 - if self.bot_uuid and hasattr(self.logger, 'ap'): - try: - api_port = self.logger.ap.instance_config.data['api']['port'] - webhook_url = f'http://127.0.0.1:{api_port}/bots/{self.bot_uuid}' - webhook_url_public = f'http://:{api_port}/bots/{self.bot_uuid}' - - await self.logger.info('企业微信机器人 Webhook 回调地址:') - await self.logger.info(f' 本地地址: {webhook_url}') - await self.logger.info(f' 公网地址: {webhook_url_public}') - await self.logger.info('请在企业微信后台配置此回调地址') - except Exception as e: - await self.logger.warning(f'无法生成 webhook URL: {e}') - async def keep_alive(): while True: await asyncio.sleep(1) diff --git a/src/langbot/pkg/platform/sources/wecomcs.py b/src/langbot/pkg/platform/sources/wecomcs.py index 7d3a6ff7..bfe6811b 100644 --- a/src/langbot/pkg/platform/sources/wecomcs.py +++ b/src/langbot/pkg/platform/sources/wecomcs.py @@ -213,23 +213,10 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): # 统一 webhook 模式下,不启动独立的 Quart 应用 # 保持运行但不启动独立端口 - # 打印 webhook 回调地址 - if self.bot_uuid and hasattr(self.logger, 'ap'): - try: - api_port = self.logger.ap.instance_config.data['api']['port'] - webhook_url = f"http://127.0.0.1:{api_port}/bots/{self.bot_uuid}" - webhook_url_public = f"http://:{api_port}/bots/{self.bot_uuid}" - - await self.logger.info(f"企业微信客服 Webhook 回调地址:") - await self.logger.info(f" 本地地址: {webhook_url}") - await self.logger.info(f" 公网地址: {webhook_url_public}") - await self.logger.info(f"请在企业微信后台配置此回调地址") - except Exception as e: - await self.logger.warning(f"无法生成 webhook URL: {e}") - async def keep_alive(): while True: await asyncio.sleep(1) + await keep_alive() async def kill(self) -> bool: diff --git a/src/langbot/pkg/provider/runners/langflowapi.py b/src/langbot/pkg/provider/runners/langflowapi.py index d4cc345f..8995476d 100644 --- a/src/langbot/pkg/provider/runners/langflowapi.py +++ b/src/langbot/pkg/provider/runners/langflowapi.py @@ -94,7 +94,6 @@ class LangflowAPIRunner(runner.RequestRunner): if is_stream: # 流式请求 async with client.stream('POST', url, json=payload, headers=headers, timeout=120.0) as response: - print(response) response.raise_for_status() accumulated_content = '' diff --git a/web/src/app/home/bots/components/bot-log/view/BotLogCard.tsx b/web/src/app/home/bots/components/bot-log/view/BotLogCard.tsx index 25be395a..f916af2f 100644 --- a/web/src/app/home/bots/components/bot-log/view/BotLogCard.tsx +++ b/web/src/app/home/bots/components/bot-log/view/BotLogCard.tsx @@ -44,12 +44,35 @@ export function BotLogCard({ botLog }: { botLog: BotLog }) { const strArr = str.split(''); return strArr; } + + // 根据日志级别返回对应的样式类 + function getLevelStyles(level: string) { + switch (level.toLowerCase()) { + case 'error': + return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'; + case 'warning': + return 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400'; + case 'info': + return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400'; + case 'debug': + return 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400'; + default: + return 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400'; + } + } + return (
{/* 头部标签,时间 */}
-
{botLog.level}
+
+ {botLog.level} +
{botLog.message_session_id && (
([]); const [autoFlush, setAutoFlush] = useState(true); + const [selectedLevels, setSelectedLevels] = useState([ + 'info', + 'warning', + 'error', + ]); const listContainerRef = useRef(null); const botLogListRef = useRef(botLogList); + const logLevels = [ + { value: 'error', label: 'ERROR' }, + { value: 'warning', label: 'WARNING' }, + { value: 'info', label: 'INFO' }, + { value: 'debug', label: 'DEBUG' }, + ]; + useEffect(() => { initComponent(); return () => { @@ -28,6 +48,42 @@ export function BotLogListComponent({ botId }: { botId: string }) { botLogListRef.current = botLogList; }, [botLogList]); + // 根据级别过滤日志 + const filteredLogs = useMemo(() => { + if (selectedLevels.length === 0) { + return botLogList; + } + return botLogList.filter((log) => selectedLevels.includes(log.level)); + }, [botLogList, selectedLevels]); + + const handleLevelToggle = (levelValue: string) => { + setSelectedLevels((prev) => { + if (prev.includes(levelValue)) { + return prev.filter((l) => l !== levelValue); + } else { + return [...prev, levelValue]; + } + }); + }; + + const getDisplayText = () => { + if (selectedLevels.length === 0) { + return t('bots.selectLevel'); + } + if (selectedLevels.length === logLevels.length) { + return t('bots.allLevels'); + } + // 如果选中3个或以上,显示数量 + if (selectedLevels.length >= 3) { + return `${selectedLevels.length} ${t('bots.levelsSelected')}`; + } + // 显示选中级别的标签(大写形式) + return logLevels + .filter((level) => selectedLevels.includes(level.value)) + .map((level) => level.label) + .join(', '); + }; + // 观测自动刷新状态 useEffect(() => { if (autoFlush) { @@ -116,9 +172,43 @@ export function BotLogListComponent({ botId }: { botId: string }) {
{t('bots.enableAutoRefresh')}
setAutoFlush(e)} /> +
{t('bots.logLevel')}
+ + + + + +
+ {logLevels.map((level) => ( +
+ handleLevelToggle(level.value)} + /> + +
+ ))} +
+
+
- {botLogList.map((botLog) => { + {filteredLogs.map((botLog) => { return ; })}
diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index aa84f3bc..8bacc613 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -192,6 +192,10 @@ const enUS = { webhookUrlCopied: 'Webhook URL copied', webhookUrlHint: 'Click the input to select all, then press Ctrl+C (Mac: Cmd+C) to copy, or click the button', + logLevel: 'Log Level', + allLevels: 'All Levels', + selectLevel: 'Select Level', + levelsSelected: 'levels selected', }, plugins: { title: 'Extensions', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 32d58160..3a06d3a3 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -194,6 +194,10 @@ const jaJP = { webhookUrlCopied: 'Webhook URL をコピーしました', webhookUrlHint: '入力ボックスをクリックして全選択し、Ctrl+C (Mac: Cmd+C) でコピーするか、右側のボタンをクリックしてください', + logLevel: 'ログレベル', + allLevels: 'すべてのレベル', + selectLevel: 'レベルを選択', + levelsSelected: 'レベル選択済み', }, plugins: { title: '拡張機能', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 6c1ea48c..fdcecf54 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -187,6 +187,10 @@ const zhHans = { webhookUrlCopied: 'Webhook 地址已复制', webhookUrlHint: '点击输入框自动全选,然后按 Ctrl+C (Mac: Cmd+C) 复制,或点击右侧按钮', + logLevel: '日志级别', + allLevels: '全部级别', + selectLevel: '选择级别', + levelsSelected: '个级别已选', }, plugins: { title: '插件扩展', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index c0c5e3d2..7c76fb5d 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -187,6 +187,10 @@ const zhHant = { webhookUrlCopied: 'Webhook 位址已複製', webhookUrlHint: '點擊輸入框自動全選,然後按 Ctrl+C (Mac: Cmd+C) 複製,或點擊右側按鈕', + logLevel: '日誌級別', + allLevels: '全部級別', + selectLevel: '選擇級別', + levelsSelected: '個級別已選', }, plugins: { title: '外掛擴展',