From 399ebd36d705a0877cf3f2462cc5732a444adee0 Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Thu, 2 Oct 2025 10:23:59 +0800 Subject: [PATCH 01/26] chore: bump langbot-plugin to 0.1.3 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8e76b3ae..c85288f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,7 +62,7 @@ dependencies = [ "langchain>=0.2.0", "chromadb>=0.4.24", "qdrant-client (>=1.15.1,<2.0.0)", - "langbot-plugin==0.1.3b1", + "langbot-plugin==0.1.3", "asyncpg>=0.30.0", "line-bot-sdk>=3.19.0", "tboxsdk>=0.0.10", From 35a4b0f55f661f72d3ac948333fe56787c9db60d Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Thu, 2 Oct 2025 10:26:48 +0800 Subject: [PATCH 02/26] chore: bump version v4.3.4 --- pkg/utils/constants.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/utils/constants.py b/pkg/utils/constants.py index 05b94ef4..e52f94a5 100644 --- a/pkg/utils/constants.py +++ b/pkg/utils/constants.py @@ -1,4 +1,4 @@ -semantic_version = 'v4.3.3' +semantic_version = 'v4.3.4' required_database_version = 8 """Tag the version of the database schema, used to check if the database needs to be migrated""" diff --git a/pyproject.toml b/pyproject.toml index c85288f3..5a32ecb3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "langbot" -version = "4.3.3" +version = "4.3.4" description = "Easy-to-use global IM bot platform designed for LLM era" readme = "README.md" requires-python = ">=3.10.1,<4.0" From d1274366a0b438358b4a1aa746b649e84e87f6a4 Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Thu, 2 Oct 2025 10:30:19 +0800 Subject: [PATCH 03/26] chore: release v4.3.5 --- pkg/utils/constants.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/utils/constants.py b/pkg/utils/constants.py index e52f94a5..968bacdc 100644 --- a/pkg/utils/constants.py +++ b/pkg/utils/constants.py @@ -1,4 +1,4 @@ -semantic_version = 'v4.3.4' +semantic_version = 'v4.3.5' required_database_version = 8 """Tag the version of the database schema, used to check if the database needs to be migrated""" diff --git a/pyproject.toml b/pyproject.toml index 5a32ecb3..4cedc515 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "langbot" -version = "4.3.4" +version = "4.3.5" description = "Easy-to-use global IM bot platform designed for LLM era" readme = "README.md" requires-python = ">=3.10.1,<4.0" From 3b181cff93cd915f856e0ab648c4fe79c092a39c Mon Sep 17 00:00:00 2001 From: Thetail001 <56257172+Thetail001@users.noreply.github.com> Date: Sat, 4 Oct 2025 00:20:27 +0800 Subject: [PATCH 04/26] Fix: Correct data type mismatch in AtBotRule (#1705) Fix can't '@' in QQ group. --- pkg/pipeline/resprule/rules/atbot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/pipeline/resprule/rules/atbot.py b/pkg/pipeline/resprule/rules/atbot.py index 51431519..68c3ace9 100644 --- a/pkg/pipeline/resprule/rules/atbot.py +++ b/pkg/pipeline/resprule/rules/atbot.py @@ -21,7 +21,7 @@ class AtBotRule(rule_model.GroupRespondRule): def remove_at(message_chain: platform_message.MessageChain): nonlocal found for component in message_chain.root: - if isinstance(component, platform_message.At) and component.target == query.adapter.bot_account_id: + if isinstance(component, platform_message.At) and str(component.target) == str(query.adapter.bot_account_id): message_chain.remove(component) found = True break From 6535ba4f726427e9b9adea01af27fecf935341fa Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Sat, 4 Oct 2025 00:22:03 +0800 Subject: [PATCH 05/26] chore: bump version 4.3.6 --- pkg/utils/constants.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/utils/constants.py b/pkg/utils/constants.py index 968bacdc..5cf281d3 100644 --- a/pkg/utils/constants.py +++ b/pkg/utils/constants.py @@ -1,4 +1,4 @@ -semantic_version = 'v4.3.5' +semantic_version = 'v4.3.6' required_database_version = 8 """Tag the version of the database schema, used to check if the database needs to be migrated""" diff --git a/pyproject.toml b/pyproject.toml index 4cedc515..86385e4d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "langbot" -version = "4.3.5" +version = "4.3.6" description = "Easy-to-use global IM bot platform designed for LLM era" readme = "README.md" requires-python = ">=3.10.1,<4.0" From d664039e545b842c5300a22d8e260b6febf33403 Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Mon, 6 Oct 2025 23:22:38 +0800 Subject: [PATCH 06/26] feat: update for new events fields --- pkg/pipeline/process/handlers/chat.py | 10 +++++----- pkg/pipeline/process/handlers/command.py | 18 ++++++++---------- pkg/pipeline/wrapper/wrapper.py | 10 ++++------ 3 files changed, 17 insertions(+), 21 deletions(-) diff --git a/pkg/pipeline/process/handlers/chat.py b/pkg/pipeline/process/handlers/chat.py index b6da5fa6..6e133cc9 100644 --- a/pkg/pipeline/process/handlers/chat.py +++ b/pkg/pipeline/process/handlers/chat.py @@ -9,7 +9,6 @@ from .. import handler from ... import entities from ....provider import runner as runner_module -import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.events as events from ....utils import importutil from ....provider import runners @@ -47,18 +46,19 @@ class ChatMessageHandler(handler.MessageHandler): event_ctx = await self.ap.plugin_connector.emit_event(event) is_create_card = False # 判断下是否需要创建流式卡片 + if event_ctx.is_prevented_default(): - if event_ctx.event.reply is not None: - mc = platform_message.MessageChain(event_ctx.event.reply) + if event_ctx.event.reply_message_chain is not None: + mc = event_ctx.event.reply_message_chain query.resp_messages.append(mc) yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) else: yield entities.StageProcessResult(result_type=entities.ResultType.INTERRUPT, new_query=query) else: - if event_ctx.event.alter is not None: + if event_ctx.event.user_message_alter is not None: # if isinstance(event_ctx.event, str): # 现在暂时不考虑多模态alter - query.user_message.content = event_ctx.event.alter + query.user_message.content = event_ctx.event.user_message_alter text_length = 0 try: diff --git a/pkg/pipeline/process/handlers/command.py b/pkg/pipeline/process/handlers/command.py index 382838f8..52bcdb6f 100644 --- a/pkg/pipeline/process/handlers/command.py +++ b/pkg/pipeline/process/handlers/command.py @@ -5,7 +5,6 @@ import typing from .. import handler from ... import entities import langbot_plugin.api.entities.builtin.provider.message as provider_message -import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.provider.session as provider_session import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query import langbot_plugin.api.entities.events as events @@ -49,8 +48,8 @@ class CommandHandler(handler.MessageHandler): event_ctx = await self.ap.plugin_connector.emit_event(event) if event_ctx.is_prevented_default(): - if event_ctx.event.reply is not None: - mc = platform_message.MessageChain(event_ctx.event.reply) + if event_ctx.event.reply_message_chain is not None: + mc = event_ctx.event.reply_message_chain query.resp_messages.append(mc) @@ -59,11 +58,6 @@ class CommandHandler(handler.MessageHandler): yield entities.StageProcessResult(result_type=entities.ResultType.INTERRUPT, new_query=query) else: - if event_ctx.event.alter is not None: - query.message_chain = platform_message.MessageChain( - [platform_message.Plain(text=event_ctx.event.alter)] - ) - session = await self.ap.sess_mgr.get_session(query) async for ret in self.ap.cmd_mgr.execute( @@ -80,8 +74,12 @@ class CommandHandler(handler.MessageHandler): self.ap.logger.info(f'Command({query.query_id}) error: {self.cut_str(str(ret.error))}') yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) - elif (ret.text is not None or ret.image_url is not None or ret.image_base64 is not None - or ret.file_url is not None): + elif ( + ret.text is not None + or ret.image_url is not None + or ret.image_base64 is not None + or ret.file_url is not None + ): content: list[provider_message.ContentElement] = [] if ret.text is not None: diff --git a/pkg/pipeline/wrapper/wrapper.py b/pkg/pipeline/wrapper/wrapper.py index 439595e9..6267c864 100644 --- a/pkg/pipeline/wrapper/wrapper.py +++ b/pkg/pipeline/wrapper/wrapper.py @@ -80,8 +80,8 @@ class ResponseWrapper(stage.PipelineStage): new_query=query, ) else: - if event_ctx.event.reply is not None: - query.resp_message_chain.append(platform_message.MessageChain(event_ctx.event.reply)) + if event_ctx.event.reply_message_chain is not None: + query.resp_message_chain.append(event_ctx.event.reply_message_chain) else: query.resp_message_chain.append(result.get_content_platform_message_chain()) @@ -123,10 +123,8 @@ class ResponseWrapper(stage.PipelineStage): new_query=query, ) else: - if event_ctx.event.reply is not None: - query.resp_message_chain.append( - platform_message.MessageChain(text=event_ctx.event.reply) - ) + if event_ctx.event.reply_message_chain is not None: + query.resp_message_chain.append(event_ctx.event.reply_message_chain) else: query.resp_message_chain.append( From 0ab366fcac9a10ce32a359dbd4573bdf76c2f3a4 Mon Sep 17 00:00:00 2001 From: Guanchao Wang Date: Tue, 7 Oct 2025 00:06:07 +0800 Subject: [PATCH 07/26] Fix/qqo (#1709) * fix: qq official * fix: appid --- pkg/platform/sources/qqofficial.py | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/pkg/platform/sources/qqofficial.py b/pkg/platform/sources/qqofficial.py index 28a09d8c..240b46d0 100644 --- a/pkg/platform/sources/qqofficial.py +++ b/pkg/platform/sources/qqofficial.py @@ -139,19 +139,15 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter event_converter: QQOfficialEventConverter = QQOfficialEventConverter() def __init__(self, config: dict, logger: EventLogger): - self.config = config - self.logger = logger + bot = QQOfficialClient( + app_id=config['appid'], secret=config['secret'], token=config['token'], logger=logger + ) - required_keys = [ - 'appid', - 'secret', - ] - missing_keys = [key for key in required_keys if key not in config] - if missing_keys: - raise command_errors.ParamNotEnoughError('QQ官方机器人缺少相关配置项,请查看文档或联系管理员') - - self.bot = QQOfficialClient( - app_id=config['appid'], secret=config['secret'], token=config['token'], logger=self.logger + super().__init__( + config=config, + logger=logger, + bot=bot, + bot_account_id=config['appid'], ) async def reply_message( From 8bedaa468a06cf0bd3440e6f6ad1522f657919eb Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Tue, 7 Oct 2025 00:15:56 +0800 Subject: [PATCH 08/26] chore: add `codecov.yml` --- codecov.yml | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 codecov.yml diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 00000000..5dd21786 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,4 @@ +coverage: + status: + project: off + patch: off \ No newline at end of file From 18ec4adac9a3bb95ba719dbd2f74b3301dbc1e9c Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Tue, 7 Oct 2025 15:25:49 +0800 Subject: [PATCH 09/26] chore: bump langbot-plugin to 0.1.4b2 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 86385e4d..57f6ca19 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,7 +62,7 @@ dependencies = [ "langchain>=0.2.0", "chromadb>=0.4.24", "qdrant-client (>=1.15.1,<2.0.0)", - "langbot-plugin==0.1.3", + "langbot-plugin==0.1.4b2", "asyncpg>=0.30.0", "line-bot-sdk>=3.19.0", "tboxsdk>=0.0.10", From 09dba91a3736a41609305efcab49ff15b42f4492 Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Tue, 7 Oct 2025 15:30:33 +0800 Subject: [PATCH 10/26] chore: bump version 4.3.7b1 --- pkg/utils/constants.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/utils/constants.py b/pkg/utils/constants.py index 5cf281d3..d8fd6297 100644 --- a/pkg/utils/constants.py +++ b/pkg/utils/constants.py @@ -1,4 +1,4 @@ -semantic_version = 'v4.3.6' +semantic_version = 'v4.3.7b1' required_database_version = 8 """Tag the version of the database schema, used to check if the database needs to be migrated""" diff --git a/pyproject.toml b/pyproject.toml index 57f6ca19..00927300 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "langbot" -version = "4.3.6" +version = "4.3.7b1" description = "Easy-to-use global IM bot platform designed for LLM era" readme = "README.md" requires-python = ">=3.10.1,<4.0" From 99e5478ced73fab49b8c48151b6ac4076b7cd482 Mon Sep 17 00:00:00 2001 From: "Junyan Qin (Chin)" Date: Tue, 7 Oct 2025 16:24:38 +0800 Subject: [PATCH 11/26] fix: return empty data when plugin system disabled (#1710) --- pkg/plugin/connector.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/pkg/plugin/connector.py b/pkg/plugin/connector.py index 0100a9b5..d8dac894 100644 --- a/pkg/plugin/connector.py +++ b/pkg/plugin/connector.py @@ -18,7 +18,7 @@ from langbot_plugin.api.entities import events from langbot_plugin.api.entities import context import langbot_plugin.runtime.io.connection as base_connection from langbot_plugin.api.definition.components.manifest import ComponentManifest -from langbot_plugin.api.entities.builtin.command import context as command_context +from langbot_plugin.api.entities.builtin.command import context as command_context, errors as command_errors from langbot_plugin.runtime.plugin.mgr import PluginInstallSource from ..core import taskmgr @@ -191,6 +191,9 @@ class PluginRuntimeConnector: task_context.trace(trace) async def list_plugins(self) -> list[dict[str, Any]]: + if not self.is_enable_plugin: + return [] + return await self.handler.list_plugins() async def get_plugin_info(self, author: str, plugin_name: str) -> dict[str, Any]: @@ -211,6 +214,7 @@ class PluginRuntimeConnector: if not self.is_enable_plugin: return event_ctx + event_ctx_result = await self.handler.emit_event(event_ctx.model_dump(serialize_as_any=True)) event_ctx = context.EventContext.model_validate(event_ctx_result['event_context']) @@ -218,14 +222,23 @@ class PluginRuntimeConnector: return event_ctx async def list_tools(self) -> list[ComponentManifest]: + if not self.is_enable_plugin: + return [] + list_tools_data = await self.handler.list_tools() return [ComponentManifest.model_validate(tool) for tool in list_tools_data] async def call_tool(self, tool_name: str, parameters: dict[str, Any]) -> dict[str, Any]: + if not self.is_enable_plugin: + return {'error': 'Tool not found: plugin system is disabled'} + return await self.handler.call_tool(tool_name, parameters) async def list_commands(self) -> list[ComponentManifest]: + if not self.is_enable_plugin: + return [] + list_commands_data = await self.handler.list_commands() return [ComponentManifest.model_validate(command) for command in list_commands_data] @@ -233,6 +246,9 @@ class PluginRuntimeConnector: async def execute_command( self, command_ctx: command_context.ExecuteContext ) -> typing.AsyncGenerator[command_context.CommandReturn, None]: + if not self.is_enable_plugin: + yield command_context.CommandReturn(error=command_errors.CommandNotFoundError(command_ctx.command)) + gen = self.handler.execute_command(command_ctx.model_dump(serialize_as_any=True)) async for ret in gen: From b560432b0bf0d581fbf2618ba4964d69aa482b94 Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Wed, 8 Oct 2025 14:36:48 +0800 Subject: [PATCH 12/26] chore: bump version 4.3.7 --- pkg/utils/constants.py | 2 +- pyproject.toml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/utils/constants.py b/pkg/utils/constants.py index d8fd6297..1bf67aa5 100644 --- a/pkg/utils/constants.py +++ b/pkg/utils/constants.py @@ -1,4 +1,4 @@ -semantic_version = 'v4.3.7b1' +semantic_version = 'v4.3.7' required_database_version = 8 """Tag the version of the database schema, used to check if the database needs to be migrated""" diff --git a/pyproject.toml b/pyproject.toml index 00927300..0bfd56c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "langbot" -version = "4.3.7b1" +version = "4.3.7" description = "Easy-to-use global IM bot platform designed for LLM era" readme = "README.md" requires-python = ">=3.10.1,<4.0" @@ -62,7 +62,7 @@ dependencies = [ "langchain>=0.2.0", "chromadb>=0.4.24", "qdrant-client (>=1.15.1,<2.0.0)", - "langbot-plugin==0.1.4b2", + "langbot-plugin==0.1.4", "asyncpg>=0.30.0", "line-bot-sdk>=3.19.0", "tboxsdk>=0.0.10", From f452742cd2b6cb805b0866e8acc12ec8ed60e715 Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Fri, 10 Oct 2025 14:48:21 +0800 Subject: [PATCH 13/26] fix: bad Plain component init in wechatpad (#1712) --- pkg/platform/sources/wechatpad.py | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/pkg/platform/sources/wechatpad.py b/pkg/platform/sources/wechatpad.py index e35bad63..26d735ae 100644 --- a/pkg/platform/sources/wechatpad.py +++ b/pkg/platform/sources/wechatpad.py @@ -139,7 +139,7 @@ class WeChatPadMessageConverter(abstract_platform_adapter.AbstractMessageConvert pattern = r'@\S{1,20}' content_no_preifx = re.sub(pattern, '', content_no_preifx) - return platform_message.MessageChain([platform_message.Plain(content_no_preifx)]) + return platform_message.MessageChain([platform_message.Plain(text=content_no_preifx)]) async def _handler_image(self, message: Optional[dict], content_no_preifx: str) -> platform_message.MessageChain: """处理图像消息 (msg_type=3)""" @@ -265,7 +265,7 @@ class WeChatPadMessageConverter(abstract_platform_adapter.AbstractMessageConvert # 文本消息 try: if '' not in quote_data: - quote_data_message_list.append(platform_message.Plain(quote_data)) + quote_data_message_list.append(platform_message.Plain(text=quote_data)) else: # 引用消息展开 quote_data_xml = ET.fromstring(quote_data) @@ -280,7 +280,7 @@ class WeChatPadMessageConverter(abstract_platform_adapter.AbstractMessageConvert quote_data_message_list.extend(await self._handler_compound(None, quote_data)) except Exception as e: self.logger.error(f'处理引用消息异常 expcetion:{e}') - quote_data_message_list.append(platform_message.Plain(quote_data)) + quote_data_message_list.append(platform_message.Plain(text=quote_data)) message_list.append( platform_message.Quote( sender_id=sender_id, @@ -290,7 +290,7 @@ class WeChatPadMessageConverter(abstract_platform_adapter.AbstractMessageConvert if len(user_data) > 0: pattern = r'@\S{1,20}' user_data = re.sub(pattern, '', user_data) - message_list.append(platform_message.Plain(user_data)) + message_list.append(platform_message.Plain(text=user_data)) return platform_message.MessageChain(message_list) @@ -543,7 +543,6 @@ class WeChatPadAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter) ] = {} def __init__(self, config: dict, logger: EventLogger): - quart_app = quart.Quart(__name__) message_converter = WeChatPadMessageConverter(config, logger) @@ -551,15 +550,14 @@ class WeChatPadAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter) bot = WeChatPadClient(config['wechatpad_url'], config['token']) super().__init__( config=config, - logger = logger, - quart_app = quart_app, - message_converter =message_converter, - event_converter = event_converter, + logger=logger, + quart_app=quart_app, + message_converter=message_converter, + event_converter=event_converter, listeners={}, - bot_account_id ='', - name="WeChatPad", + bot_account_id='', + name='WeChatPad', bot=bot, - ) async def ws_message(self, data): From 3e29ec789283099acaedb425702effa334ebb5a5 Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Fri, 10 Oct 2025 16:34:01 +0800 Subject: [PATCH 14/26] perf: allow not set llm model (#1703) --- pkg/pipeline/preproc/preproc.py | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/pkg/pipeline/preproc/preproc.py b/pkg/pipeline/preproc/preproc.py index ff6ffd6f..8e8e1755 100644 --- a/pkg/pipeline/preproc/preproc.py +++ b/pkg/pipeline/preproc/preproc.py @@ -35,11 +35,17 @@ class PreProcessor(stage.PipelineStage): session = await self.ap.sess_mgr.get_session(query) # When not local-agent, llm_model is None - llm_model = ( - await self.ap.model_mgr.get_model_by_uuid(query.pipeline_config['ai']['local-agent']['model']) - if selected_runner == 'local-agent' - else None - ) + try: + llm_model = ( + await self.ap.model_mgr.get_model_by_uuid(query.pipeline_config['ai']['local-agent']['model']) + if selected_runner == 'local-agent' + else None + ) + except ValueError: + self.ap.logger.warning( + f'LLM model {query.pipeline_config["ai"]["local-agent"]["model"] + " "}not found or not configured' + ) + llm_model = None conversation = await self.ap.sess_mgr.get_conversation( query, @@ -54,7 +60,7 @@ class PreProcessor(stage.PipelineStage): query.prompt = conversation.prompt.copy() query.messages = conversation.messages.copy() - if selected_runner == 'local-agent': + if selected_runner == 'local-agent' and llm_model: query.use_funcs = [] query.use_llm_model_uuid = llm_model.model_entity.uuid @@ -72,7 +78,11 @@ class PreProcessor(stage.PipelineStage): # Check if this model supports vision, if not, remove all images # TODO this checking should be performed in runner, and in this stage, the image should be reserved - if selected_runner == 'local-agent' and not llm_model.model_entity.abilities.__contains__('vision'): + if ( + selected_runner == 'local-agent' + and llm_model + and not llm_model.model_entity.abilities.__contains__('vision') + ): for msg in query.messages: if isinstance(msg.content, list): for me in msg.content: @@ -89,7 +99,9 @@ class PreProcessor(stage.PipelineStage): content_list.append(provider_message.ContentElement.from_text(me.text)) plain_text += me.text elif isinstance(me, platform_message.Image): - if selected_runner != 'local-agent' or llm_model.model_entity.abilities.__contains__('vision'): + if selected_runner != 'local-agent' or ( + llm_model and llm_model.model_entity.abilities.__contains__('vision') + ): if me.base64 is not None: content_list.append(provider_message.ContentElement.from_image_base64(me.base64)) elif isinstance(me, platform_message.File): @@ -100,7 +112,9 @@ class PreProcessor(stage.PipelineStage): if isinstance(msg, platform_message.Plain): content_list.append(provider_message.ContentElement.from_text(msg.text)) elif isinstance(msg, platform_message.Image): - if selected_runner != 'local-agent' or llm_model.model_entity.abilities.__contains__('vision'): + if selected_runner != 'local-agent' or ( + llm_model and llm_model.model_entity.abilities.__contains__('vision') + ): if msg.base64 is not None: content_list.append(provider_message.ContentElement.from_image_base64(msg.base64)) From 9e3cf418baad1bd2ed652e3f9e9ebb679996ac29 Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Fri, 10 Oct 2025 17:55:49 +0800 Subject: [PATCH 15/26] perf: output pipeline error in en --- pkg/pipeline/pipelinemgr.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/pipeline/pipelinemgr.py b/pkg/pipeline/pipelinemgr.py index c19d5b6f..ab663293 100644 --- a/pkg/pipeline/pipelinemgr.py +++ b/pkg/pipeline/pipelinemgr.py @@ -213,7 +213,7 @@ class RuntimePipeline: await self._execute_from_stage(0, query) except Exception as e: inst_name = query.current_stage_name if query.current_stage_name else 'unknown' - self.ap.logger.error(f'处理请求时出错 query_id={query.query_id} stage={inst_name} : {e}') + self.ap.logger.error(f'Error processing query {query.query_id} stage={inst_name} : {e}') self.ap.logger.error(f'Traceback: {traceback.format_exc()}') finally: self.ap.logger.debug(f'Query {query.query_id} processed') From 4e6130215600dfa9a0fe2e82ac9e10be7bf4f0cb Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Fri, 10 Oct 2025 22:37:39 +0800 Subject: [PATCH 16/26] fix: datetime serialization error in emit_event (#1713) --- pkg/plugin/connector.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/plugin/connector.py b/pkg/plugin/connector.py index d8dac894..5a979474 100644 --- a/pkg/plugin/connector.py +++ b/pkg/plugin/connector.py @@ -215,7 +215,7 @@ class PluginRuntimeConnector: if not self.is_enable_plugin: return event_ctx - event_ctx_result = await self.handler.emit_event(event_ctx.model_dump(serialize_as_any=True)) + event_ctx_result = await self.handler.emit_event(event_ctx.model_dump(serialize_as_any=False)) event_ctx = context.EventContext.model_validate(event_ctx_result['event_context']) From f1ddddfe002bdd7d1d299fccbd88cfadc4e1a9c1 Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Fri, 10 Oct 2025 22:50:57 +0800 Subject: [PATCH 17/26] chore: bump version 4.3.8 --- pkg/utils/constants.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/utils/constants.py b/pkg/utils/constants.py index 1bf67aa5..f395cbcb 100644 --- a/pkg/utils/constants.py +++ b/pkg/utils/constants.py @@ -1,4 +1,4 @@ -semantic_version = 'v4.3.7' +semantic_version = 'v4.3.8' required_database_version = 8 """Tag the version of the database schema, used to check if the database needs to be migrated""" diff --git a/pyproject.toml b/pyproject.toml index 0bfd56c9..26e69706 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "langbot" -version = "4.3.7" +version = "4.3.8" description = "Easy-to-use global IM bot platform designed for LLM era" readme = "README.md" requires-python = ">=3.10.1,<4.0" From 547e3d098e235785693a91220564d562e0d3485d Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Sun, 12 Oct 2025 19:57:42 +0800 Subject: [PATCH 18/26] perf: add component list in plugin detail dialog --- .../plugin-installed/PluginCardVO.ts | 0 .../plugin-installed/PluginComponentList.tsx | 75 +++++++++++++++++++ .../PluginInstalledComponent.tsx | 6 +- .../plugin-card/PluginCardComponent.tsx | 67 +++-------------- .../plugin-form/PluginForm.tsx | 12 +++ .../plugin-market/PluginMarketComponent.tsx | 0 .../PluginDetailDialog.tsx | 0 .../PluginMarketCardComponent.tsx | 0 .../plugin-market-card/PluginMarketCardVO.ts | 0 .../plugin-sort/PluginSortDialog.tsx | 0 web/src/app/home/plugins/page.tsx | 4 +- web/src/i18n/locales/en-US.ts | 8 +- web/src/i18n/locales/ja-JP.ts | 8 +- web/src/i18n/locales/zh-Hans.ts | 8 +- web/src/i18n/locales/zh-Hant.ts | 8 +- 15 files changed, 122 insertions(+), 74 deletions(-) rename web/src/app/home/plugins/{ => components}/plugin-installed/PluginCardVO.ts (100%) create mode 100644 web/src/app/home/plugins/components/plugin-installed/PluginComponentList.tsx rename web/src/app/home/plugins/{ => components}/plugin-installed/PluginInstalledComponent.tsx (97%) rename web/src/app/home/plugins/{ => components}/plugin-installed/plugin-card/PluginCardComponent.tsx (82%) rename web/src/app/home/plugins/{ => components}/plugin-installed/plugin-form/PluginForm.tsx (90%) rename web/src/app/home/plugins/{ => components}/plugin-market/PluginMarketComponent.tsx (100%) rename web/src/app/home/plugins/{ => components}/plugin-market/plugin-detail-dialog/PluginDetailDialog.tsx (100%) rename web/src/app/home/plugins/{ => components}/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx (100%) rename web/src/app/home/plugins/{ => components}/plugin-market/plugin-market-card/PluginMarketCardVO.ts (100%) rename web/src/app/home/plugins/{ => components}/plugin-sort/PluginSortDialog.tsx (100%) diff --git a/web/src/app/home/plugins/plugin-installed/PluginCardVO.ts b/web/src/app/home/plugins/components/plugin-installed/PluginCardVO.ts similarity index 100% rename from web/src/app/home/plugins/plugin-installed/PluginCardVO.ts rename to web/src/app/home/plugins/components/plugin-installed/PluginCardVO.ts diff --git a/web/src/app/home/plugins/components/plugin-installed/PluginComponentList.tsx b/web/src/app/home/plugins/components/plugin-installed/PluginComponentList.tsx new file mode 100644 index 00000000..603599f8 --- /dev/null +++ b/web/src/app/home/plugins/components/plugin-installed/PluginComponentList.tsx @@ -0,0 +1,75 @@ +import { PluginComponent } from '@/app/infra/entities/plugin'; +import { TFunction } from 'i18next'; +import { Wrench, AudioWaveform, Hash } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; + +export default function PluginComponentList({ + components, + showComponentName, + showTitle, + useBadge, + t, +}: { + components: PluginComponent[]; + showComponentName: boolean; + showTitle: boolean; + useBadge: boolean; + t: TFunction; +}) { + const componentKindCount: Record = {}; + + for (const component of components) { + const kind = component.manifest.manifest.kind; + if (componentKindCount[kind]) { + componentKindCount[kind]++; + } else { + componentKindCount[kind] = 1; + } + } + + const kindIconMap: Record = { + Tool: , + EventListener: , + Command: , + }; + + const componentKindList = Object.keys(componentKindCount); + + return ( + <> + {showTitle &&
{t('plugins.componentsList')}
} + {componentKindList.length > 0 && ( + <> + {componentKindList.map((kind) => { + return ( + <> + {useBadge && ( + + {kindIconMap[kind]} + {showComponentName && + t('plugins.componentName.' + kind) + ' '} + {componentKindCount[kind]} + + )} + + {!useBadge && ( +
+ {kindIconMap[kind]} + {showComponentName && + t('plugins.componentName.' + kind) + ' '} + {componentKindCount[kind]} +
+ )} + + ); + })} + + )} + + {componentKindList.length === 0 &&
{t('plugins.noComponents')}
} + + ); +} diff --git a/web/src/app/home/plugins/plugin-installed/PluginInstalledComponent.tsx b/web/src/app/home/plugins/components/plugin-installed/PluginInstalledComponent.tsx similarity index 97% rename from web/src/app/home/plugins/plugin-installed/PluginInstalledComponent.tsx rename to web/src/app/home/plugins/components/plugin-installed/PluginInstalledComponent.tsx index 5581fc7a..77368a1a 100644 --- a/web/src/app/home/plugins/plugin-installed/PluginInstalledComponent.tsx +++ b/web/src/app/home/plugins/components/plugin-installed/PluginInstalledComponent.tsx @@ -1,9 +1,9 @@ 'use client'; import { useState, useEffect, forwardRef, useImperativeHandle } from 'react'; -import { PluginCardVO } from '@/app/home/plugins/plugin-installed/PluginCardVO'; -import PluginCardComponent from '@/app/home/plugins/plugin-installed/plugin-card/PluginCardComponent'; -import PluginForm from '@/app/home/plugins/plugin-installed/plugin-form/PluginForm'; +import { PluginCardVO } from '@/app/home/plugins/components/plugin-installed/PluginCardVO'; +import PluginCardComponent from '@/app/home/plugins/components/plugin-installed/plugin-card/PluginCardComponent'; +import PluginForm from '@/app/home/plugins/components/plugin-installed/plugin-form/PluginForm'; import styles from '@/app/home/plugins/plugins.module.css'; import { httpClient } from '@/app/infra/http/HttpClient'; import { diff --git a/web/src/app/home/plugins/plugin-installed/plugin-card/PluginCardComponent.tsx b/web/src/app/home/plugins/components/plugin-installed/plugin-card/PluginCardComponent.tsx similarity index 82% rename from web/src/app/home/plugins/plugin-installed/plugin-card/PluginCardComponent.tsx rename to web/src/app/home/plugins/components/plugin-installed/plugin-card/PluginCardComponent.tsx index a3e7596d..457ebdc3 100644 --- a/web/src/app/home/plugins/plugin-installed/plugin-card/PluginCardComponent.tsx +++ b/web/src/app/home/plugins/components/plugin-installed/plugin-card/PluginCardComponent.tsx @@ -1,21 +1,10 @@ -import { PluginCardVO } from '@/app/home/plugins/plugin-installed/PluginCardVO'; +import { PluginCardVO } from '@/app/home/plugins/components/plugin-installed/PluginCardVO'; import { useState } from 'react'; import { Badge } from '@/components/ui/badge'; import { useTranslation } from 'react-i18next'; -import { TFunction } from 'i18next'; -import { - AudioWaveform, - Wrench, - Hash, - BugIcon, - ExternalLink, - Ellipsis, - Trash, - ArrowUp, -} from 'lucide-react'; +import { BugIcon, ExternalLink, Ellipsis, Trash, ArrowUp } from 'lucide-react'; import { getCloudServiceClientSync } from '@/app/infra/http'; import { httpClient } from '@/app/infra/http/HttpClient'; -import { PluginComponent } from '@/app/infra/entities/plugin'; import { Button } from '@/components/ui/button'; import { DropdownMenu, @@ -23,49 +12,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; - -function getComponentList(components: PluginComponent[], t: TFunction) { - const componentKindCount: Record = {}; - - for (const component of components) { - const kind = component.manifest.manifest.kind; - if (componentKindCount[kind]) { - componentKindCount[kind]++; - } else { - componentKindCount[kind] = 1; - } - } - - const kindIconMap: Record = { - Tool: , - EventListener: , - Command: , - }; - - const componentKindList = Object.keys(componentKindCount); - - return ( - <> -
{t('plugins.componentsList')}
- {componentKindList.length > 0 && ( - <> - {componentKindList.map((kind) => { - return ( -
- {kindIconMap[kind]} {componentKindCount[kind]} -
- ); - })} - - )} - - {componentKindList.length === 0 &&
{t('plugins.noComponents')}
} - - ); -} +import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList'; export default function PluginCardComponent({ cardVO, @@ -180,7 +127,13 @@ export default function PluginCardComponent({
- {getComponentList(cardVO.components, t)} +
diff --git a/web/src/app/home/plugins/plugin-installed/plugin-form/PluginForm.tsx b/web/src/app/home/plugins/components/plugin-installed/plugin-form/PluginForm.tsx similarity index 90% rename from web/src/app/home/plugins/plugin-installed/plugin-form/PluginForm.tsx rename to web/src/app/home/plugins/components/plugin-installed/plugin-form/PluginForm.tsx index 09a79d2f..2a658f5f 100644 --- a/web/src/app/home/plugins/plugin-installed/plugin-form/PluginForm.tsx +++ b/web/src/app/home/plugins/components/plugin-installed/plugin-form/PluginForm.tsx @@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button'; import { toast } from 'sonner'; import { extractI18nObject } from '@/i18n/I18nProvider'; import { useTranslation } from 'react-i18next'; +import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList'; export default function PluginForm({ pluginAuthor, @@ -78,6 +79,17 @@ export default function PluginForm({ }, )} + +
+ +
+ {pluginInfo.manifest.manifest.spec.config.length > 0 && ( Date: Sun, 12 Oct 2025 21:11:30 +0800 Subject: [PATCH 19/26] perf: store pipeline sort method --- web/src/app/home/pipelines/page.tsx | 23 ++++++++++++++++++++++- web/src/i18n/locales/en-US.ts | 1 + web/src/i18n/locales/ja-JP.ts | 1 + web/src/i18n/locales/zh-Hans.ts | 1 + web/src/i18n/locales/zh-Hant.ts | 1 + 5 files changed, 26 insertions(+), 1 deletion(-) diff --git a/web/src/app/home/pipelines/page.tsx b/web/src/app/home/pipelines/page.tsx index c7801a33..c0b3930a 100644 --- a/web/src/app/home/pipelines/page.tsx +++ b/web/src/app/home/pipelines/page.tsx @@ -29,7 +29,17 @@ export default function PluginConfigPage() { const [sortOrderValue, setSortOrderValue] = useState('DESC'); useEffect(() => { - getPipelines(); + // Load sort preference from localStorage + const savedSortBy = localStorage.getItem('pipeline_sort_by'); + const savedSortOrder = localStorage.getItem('pipeline_sort_order'); + + if (savedSortBy && savedSortOrder) { + setSortByValue(savedSortBy); + setSortOrderValue(savedSortOrder); + getPipelines(savedSortBy, savedSortOrder); + } else { + getPipelines(); + } }, []); function getPipelines( @@ -91,6 +101,11 @@ export default function PluginConfigPage() { const [newSortBy, newSortOrder] = value.split(',').map((s) => s.trim()); setSortByValue(newSortBy); setSortOrderValue(newSortOrder); + + // Save sort preference to localStorage + localStorage.setItem('pipeline_sort_by', newSortBy); + localStorage.setItem('pipeline_sort_order', newSortOrder); + getPipelines(newSortBy, newSortOrder); } @@ -135,6 +150,12 @@ export default function PluginConfigPage() { > {t('pipelines.newestCreated')} + + {t('pipelines.earliestCreated')} + Date: Fri, 17 Oct 2025 18:13:03 +0800 Subject: [PATCH 20/26] Feat/coze runner (#1714) * feat:add coze api client and coze runner and coze config * del print * fix:Change the default setting of the plugin system to true * fix:del multimodal-support config, default multimodal-support,and in cozeapi.py Obtain timeout and auto-save-history config * chore: add comment for coze.com --------- Co-authored-by: Junyan Qin --- libs/coze_server_api/__init__.py | 0 libs/coze_server_api/client.py | 192 +++++++++++++++++ pkg/provider/runners/cozeapi.py | 312 ++++++++++++++++++++++++++++ templates/config.yaml | 2 +- templates/metadata/pipeline/ai.yaml | 59 +++++- 5 files changed, 563 insertions(+), 2 deletions(-) create mode 100644 libs/coze_server_api/__init__.py create mode 100644 libs/coze_server_api/client.py create mode 100644 pkg/provider/runners/cozeapi.py diff --git a/libs/coze_server_api/__init__.py b/libs/coze_server_api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/libs/coze_server_api/client.py b/libs/coze_server_api/client.py new file mode 100644 index 00000000..67f53736 --- /dev/null +++ b/libs/coze_server_api/client.py @@ -0,0 +1,192 @@ +import json +import asyncio +import aiohttp +import io +from typing import Dict, List, Any, AsyncGenerator +import os +from pathlib import Path + + + + +class AsyncCozeAPIClient: + def __init__(self, api_key: str, api_base: str = "https://api.coze.cn"): + self.api_key = api_key + self.api_base = api_base + self.session = None + + async def __aenter__(self): + """支持异步上下文管理器""" + await self.coze_session() + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + """退出时自动关闭会话""" + await self.close() + + + + async def coze_session(self): + """确保HTTP session存在""" + if self.session is None: + connector = aiohttp.TCPConnector( + ssl=False if self.api_base.startswith("http://") else True, + limit=100, + limit_per_host=30, + keepalive_timeout=30, + enable_cleanup_closed=True, + ) + timeout = aiohttp.ClientTimeout( + total=120, # 默认超时时间 + connect=30, + sock_read=120, + ) + headers = { + "Authorization": f"Bearer {self.api_key}", + "Accept": "text/event-stream", + } + self.session = aiohttp.ClientSession( + headers=headers, timeout=timeout, connector=connector + ) + return self.session + + async def close(self): + """显式关闭会话""" + if self.session and not self.session.closed: + await self.session.close() + self.session = None + + async def upload( + self, + file, + ) -> str: + # 处理 Path 对象 + if isinstance(file, Path): + if not file.exists(): + raise ValueError(f"File not found: {file}") + with open(file, "rb") as f: + file = f.read() + + # 处理文件路径字符串 + elif isinstance(file, str): + if not os.path.isfile(file): + raise ValueError(f"File not found: {file}") + with open(file, "rb") as f: + file = f.read() + + # 处理文件对象 + elif hasattr(file, 'read'): + file = file.read() + + session = await self.coze_session() + url = f"{self.api_base}/v1/files/upload" + + try: + file_io = io.BytesIO(file) + async with session.post( + url, + data={ + "file": file_io, + }, + timeout=aiohttp.ClientTimeout(total=60), + ) as response: + if response.status == 401: + raise Exception("Coze API 认证失败,请检查 API Key 是否正确") + + response_text = await response.text() + + + if response.status != 200: + raise Exception( + f"文件上传失败,状态码: {response.status}, 响应: {response_text}" + ) + try: + result = await response.json() + except json.JSONDecodeError: + raise Exception(f"文件上传响应解析失败: {response_text}") + + if result.get("code") != 0: + raise Exception(f"文件上传失败: {result.get('msg', '未知错误')}") + + file_id = result["data"]["id"] + return file_id + + except asyncio.TimeoutError: + raise Exception("文件上传超时") + except Exception as e: + raise Exception(f"文件上传失败: {str(e)}") + + + async def chat_messages( + self, + bot_id: str, + user_id: str, + additional_messages: List[Dict] | None = None, + conversation_id: str | None = None, + auto_save_history: bool = True, + stream: bool = True, + timeout: float = 120, + ) -> AsyncGenerator[Dict[str, Any], None]: + """发送聊天消息并返回流式响应 + + Args: + bot_id: Bot ID + user_id: 用户ID + additional_messages: 额外消息列表 + conversation_id: 会话ID + auto_save_history: 是否自动保存历史 + stream: 是否流式响应 + timeout: 超时时间 + """ + session = await self.coze_session() + url = f"{self.api_base}/v3/chat" + + payload = { + "bot_id": bot_id, + "user_id": user_id, + "stream": stream, + "auto_save_history": auto_save_history, + } + + if additional_messages: + payload["additional_messages"] = additional_messages + + params = {} + if conversation_id: + params["conversation_id"] = conversation_id + + + try: + async with session.post( + url, + json=payload, + params=params, + timeout=aiohttp.ClientTimeout(total=timeout), + ) as response: + if response.status == 401: + raise Exception("Coze API 认证失败,请检查 API Key 是否正确") + + if response.status != 200: + raise Exception(f"Coze API 流式请求失败,状态码: {response.status}") + + + async for chunk in response.content: + chunk = chunk.decode("utf-8") + if chunk != '\n': + if chunk.startswith("event:"): + chunk_type = chunk.replace("event:", "", 1).strip() + elif chunk.startswith("data:"): + chunk_data = chunk.replace("data:", "", 1).strip() + else: + yield {"event": chunk_type, "data": json.loads(chunk_data)} + + except asyncio.TimeoutError: + raise Exception(f"Coze API 流式请求超时 ({timeout}秒)") + except Exception as e: + raise Exception(f"Coze API 流式请求失败: {str(e)}") + + + + + + diff --git a/pkg/provider/runners/cozeapi.py b/pkg/provider/runners/cozeapi.py new file mode 100644 index 00000000..6d4f02a1 --- /dev/null +++ b/pkg/provider/runners/cozeapi.py @@ -0,0 +1,312 @@ +from __future__ import annotations + +import typing +import json +import uuid +import base64 + +from .. import runner +from ...core import app +import langbot_plugin.api.entities.builtin.provider.message as provider_message +from ...utils import image +import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query +from libs.coze_server_api.client import AsyncCozeAPIClient + +@runner.runner_class('coze-api') +class CozeAPIRunner(runner.RequestRunner): + """Coze API 对话请求器""" + + def __init__(self, ap: app.Application, pipeline_config: dict): + self.pipeline_config = pipeline_config + self.ap = ap + self.agent_token = pipeline_config["ai"]['coze-api']['api-key'] + self.bot_id = pipeline_config["ai"]['coze-api'].get('bot-id') + self.chat_timeout = pipeline_config["ai"]['coze-api'].get('timeout') + self.auto_save_history = pipeline_config["ai"]['coze-api'].get('auto_save_history') + self.api_base = pipeline_config["ai"]['coze-api'].get('api-base') + + self.coze = AsyncCozeAPIClient( + self.agent_token, + self.api_base + ) + + def _process_thinking_content( + self, + content: str, + ) -> tuple[str, str]: + """处理思维链内容 + + Args: + content: 原始内容 + Returns: + (处理后的内容, 提取的思维链内容) + """ + remove_think = self.pipeline_config.get('output', {}).get('misc', {}).get('remove-think', False) + thinking_content = '' + # 从 content 中提取 标签内容 + if content and '' in content and '' in content: + import re + + think_pattern = r'(.*?)' + think_matches = re.findall(think_pattern, content, re.DOTALL) + if think_matches: + thinking_content = '\n'.join(think_matches) + # 移除 content 中的 标签 + content = re.sub(think_pattern, '', content, flags=re.DOTALL).strip() + + # 根据 remove_think 参数决定是否保留思维链 + if remove_think: + return content, '' + else: + # 如果有思维链内容,将其以 格式添加到 content 开头 + if thinking_content: + content = f'\n{thinking_content}\n\n{content}'.strip() + return content, thinking_content + + async def _preprocess_user_message(self, query: pipeline_query.Query) -> list[dict]: + """预处理用户消息,转换为Coze消息格式 + + Returns: + list[dict]: Coze消息列表 + """ + messages = [] + + if isinstance(query.user_message.content, list): + # 多模态消息处理 + content_parts = [] + + for ce in query.user_message.content: + if ce.type == 'text': + content_parts.append({"type": "text", "text": ce.text}) + elif ce.type == 'image_base64': + image_b64, image_format = await image.extract_b64_and_format(ce.image_base64) + file_bytes = base64.b64decode(image_b64) + file_id = await self._get_file_id(file_bytes) + content_parts.append({"type": "image", "file_id": file_id}) + elif ce.type == 'file': + # 处理文件,上传到Coze + file_id = await self._get_file_id(ce.file) + content_parts.append({"type": "file", "file_id": file_id}) + + # 创建多模态消息 + if content_parts: + messages.append({ + "role": "user", + "content": json.dumps(content_parts), + "content_type": "object_string", + "meta_data": None + }) + + elif isinstance(query.user_message.content, str): + # 纯文本消息 + messages.append({ + "role": "user", + "content": query.user_message.content, + "content_type": "text", + "meta_data": None + }) + + return messages + + async def _get_file_id(self, file) -> str: + """上传文件到Coze服务 + Args: + file: 文件 + Returns: + str: 文件ID + """ + file_id = await self.coze.upload(file=file) + return file_id + + async def _chat_messages( + self, query: pipeline_query.Query + ) -> typing.AsyncGenerator[provider_message.Message, None]: + """调用聊天助手(非流式) + + 注意:由于cozepy没有提供非流式API,这里使用流式API并在结束后一次性返回完整内容 + """ + user_id = f'{query.launcher_id}_{query.sender_id}' + + # 预处理用户消息 + additional_messages = await self._preprocess_user_message(query) + + # 获取会话ID + conversation_id = None + + # 收集完整内容 + full_content = '' + full_reasoning = '' + + try: + # 调用Coze API流式接口 + async for chunk in self.coze.chat_messages( + bot_id=self.bot_id, + user_id=user_id, + additional_messages=additional_messages, + conversation_id=conversation_id, + timeout=self.chat_timeout, + auto_save_history=self.auto_save_history, + stream=True + ): + self.ap.logger.debug(f'coze-chat-stream: {chunk}') + + event_type = chunk.get('event') + data = chunk.get('data', {}) + + if event_type == 'conversation.message.delta': + # 收集内容 + if 'content' in data: + full_content += data.get('content', '') + + # 收集推理内容(如果有) + if 'reasoning_content' in data: + full_reasoning += data.get('reasoning_content', '') + + elif event_type == 'done': + # 保存会话ID + if 'conversation_id' in data: + conversation_id = data.get('conversation_id') + + elif event_type == 'error': + # 处理错误 + error_msg = f"Coze API错误: {data.get('message', '未知错误')}" + yield provider_message.Message( + role='assistant', + content=error_msg, + ) + return + + # 处理思维链内容 + content, thinking_content = self._process_thinking_content(full_content) + if full_reasoning: + remove_think = self.pipeline_config.get('output', {}).get('misc', {}).get('remove-think', False) + if not remove_think: + content = f'\n{full_reasoning}\n\n{content}'.strip() + + # 一次性返回完整内容 + yield provider_message.Message( + role='assistant', + content=content, + ) + + # 保存会话ID + if conversation_id and query.session.using_conversation: + query.session.using_conversation.uuid = conversation_id + + except Exception as e: + self.ap.logger.error(f'Coze API错误: {str(e)}') + yield provider_message.Message( + role='assistant', + content=f'Coze API调用失败: {str(e)}', + ) + + + async def _chat_messages_chunk( + self, query: pipeline_query.Query + ) -> typing.AsyncGenerator[provider_message.MessageChunk, None]: + """调用聊天助手(流式)""" + user_id = f'{query.launcher_id}_{query.sender_id}' + + # 预处理用户消息 + additional_messages = await self._preprocess_user_message(query) + + # 获取会话ID + conversation_id = None + + start_reasoning = False + stop_reasoning = False + message_idx = 1 + is_final = False + full_content = '' + remove_think = self.pipeline_config.get('output', {}).get('misc', {}).get('remove-think', False) + + + + try: + # 调用Coze API流式接口 + async for chunk in self.coze.chat_messages( + bot_id=self.bot_id, + user_id=user_id, + additional_messages=additional_messages, + conversation_id=conversation_id, + timeout=self.chat_timeout, + auto_save_history=self.auto_save_history, + stream=True + ): + self.ap.logger.debug(f'coze-chat-stream-chunk: {chunk}') + + event_type = chunk.get('event') + data = chunk.get('data', {}) + content = "" + + if event_type == 'conversation.message.delta': + message_idx += 1 + # 处理内容增量 + if "reasoning_content" in data and not remove_think: + + reasoning_content = data.get('reasoning_content', '') + if reasoning_content and not start_reasoning: + content = f"\n" + start_reasoning = True + content += reasoning_content + + if 'content' in data: + if data.get('content', ''): + content += data.get('content', '') + if not stop_reasoning and start_reasoning: + content = f"\n{content}" + stop_reasoning = True + + + elif event_type == 'done': + # 保存会话ID + if 'conversation_id' in data: + conversation_id = data.get('conversation_id') + if query.session.using_conversation: + query.session.using_conversation.uuid = conversation_id + is_final = True + + + elif event_type == 'error': + # 处理错误 + error_msg = f"Coze API错误: {data.get('message', '未知错误')}" + yield provider_message.MessageChunk( + role='assistant', + content=error_msg, + finish_reason='error' + ) + return + full_content += content + if message_idx % 8 == 0 or is_final: + if full_content: + yield provider_message.MessageChunk( + role='assistant', + content=full_content, + is_final=is_final + ) + + except Exception as e: + self.ap.logger.error(f'Coze API流式调用错误: {str(e)}') + yield provider_message.MessageChunk( + role='assistant', + content=f'Coze API流式调用失败: {str(e)}', + finish_reason='error' + ) + + + async def run(self, query: pipeline_query.Query) -> typing.AsyncGenerator[provider_message.Message, None]: + """运行""" + msg_seq = 0 + if await query.adapter.is_stream_output_supported(): + async for msg in self._chat_messages_chunk(query): + if isinstance(msg, provider_message.MessageChunk): + msg_seq += 1 + msg.msg_sequence = msg_seq + yield msg + else: + async for msg in self._chat_messages(query): + yield msg + + + + diff --git a/templates/config.yaml b/templates/config.yaml index 13916d9f..b81b04dc 100644 --- a/templates/config.yaml +++ b/templates/config.yaml @@ -41,4 +41,4 @@ plugin: enable: true runtime_ws_url: 'ws://langbot_plugin_runtime:5400/control/ws' enable_marketplace: true - cloud_service_url: 'https://space.langbot.app' \ No newline at end of file + cloud_service_url: 'https://space.langbot.app' diff --git a/templates/metadata/pipeline/ai.yaml b/templates/metadata/pipeline/ai.yaml index 2b69806c..e4d16a95 100644 --- a/templates/metadata/pipeline/ai.yaml +++ b/templates/metadata/pipeline/ai.yaml @@ -43,6 +43,10 @@ stages: label: en_US: Langflow API zh_Hans: Langflow API + - name: coze-api + label: + en_US: Coze API + zh_Hans: 扣子 API - name: local-agent label: en_US: Local Agent @@ -380,4 +384,57 @@ stages: zh_Hans: 可选的流程调整参数 type: json required: false - default: '{}' \ No newline at end of file + default: '{}' + - name: coze-api + label: + en_US: coze API + zh_Hans: 扣子 API + description: + en_US: Configure the Coze API of the pipeline + zh_Hans: 配置Coze API + config: + - name: api-key + label: + en_US: API Key + zh_Hans: API 密钥 + description: + en_US: The API key for the Coze server + zh_Hans: Coze服务器的 API 密钥 + type: string + required: true + - name: bot-id + label: + en_US: Bot ID + zh_Hans: 机器人 ID + description: + en_US: The ID of the bot to run + zh_Hans: 要运行的机器人 ID + type: string + required: true + - name: api-base + label: + en_US: API Base URL + zh_Hans: API 基础 URL + description: + en_US: The base URL for the Coze API, please use https://api.coze.com for global Coze edition(coze.com). + zh_Hans: Coze API 的基础 URL,请使用 https://api.coze.com 用于全球 Coze 版(coze.com) + type: string + default: "https://api.coze.cn" + - name: auto-save-history + label: + en_US: Auto Save History + zh_Hans: 自动保存历史 + description: + en_US: Whether to automatically save conversation history + zh_Hans: 是否自动保存对话历史 + type: boolean + default: true + - name: timeout + label: + en_US: Request Timeout + zh_Hans: 请求超时 + description: + en_US: Timeout in seconds for API requests + zh_Hans: API 请求超时时间(秒) + type: number + default: 120 \ No newline at end of file From 127a38b15c6c4dde28892f6e92ed6f0c116b28ec Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Wed, 22 Oct 2025 18:52:45 +0800 Subject: [PATCH 21/26] chore: bump version 4.3.9 --- pkg/utils/constants.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/utils/constants.py b/pkg/utils/constants.py index f395cbcb..aa557005 100644 --- a/pkg/utils/constants.py +++ b/pkg/utils/constants.py @@ -1,4 +1,4 @@ -semantic_version = 'v4.3.8' +semantic_version = 'v4.3.9' required_database_version = 8 """Tag the version of the database schema, used to check if the database needs to be migrated""" diff --git a/pyproject.toml b/pyproject.toml index 26e69706..c0200bd0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "langbot" -version = "4.3.8" +version = "4.3.9" description = "Easy-to-use global IM bot platform designed for LLM era" readme = "README.md" requires-python = ">=3.10.1,<4.0" From 53ecd0933ead339d36de18116dfd9f8dcf226179 Mon Sep 17 00:00:00 2001 From: Alfons Date: Tue, 28 Oct 2025 18:12:35 +0800 Subject: [PATCH 22/26] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E4=BC=81?= =?UTF-8?q?=E4=B8=9A=E5=BE=AE=E4=BF=A1=E6=99=BA=E8=83=BD=E6=9C=BA=E5=99=A8?= =?UTF-8?q?=E4=BA=BA=E6=B5=81=E5=BC=8F=E5=93=8D=E5=BA=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重构 WecomBotClient,支持流式会话管理和队列机制 - 新增 StreamSession 和 StreamSessionManager 类管理流式上下文 - 实现 reply_message_chunk 接口支持流式输出 - 优化消息处理流程,支持异步流式响应 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- libs/wecom_ai_bot_api/api.py | 585 +++++++++++++++++++++++-------- pkg/platform/sources/wecombot.py | 44 +++ 2 files changed, 477 insertions(+), 152 deletions(-) diff --git a/libs/wecom_ai_bot_api/api.py b/libs/wecom_ai_bot_api/api.py index 6b5dc573..41d379a6 100644 --- a/libs/wecom_ai_bot_api/api.py +++ b/libs/wecom_ai_bot_api/api.py @@ -1,189 +1,445 @@ +import asyncio +import base64 import json import time +import traceback import uuid import xml.etree.ElementTree as ET +from dataclasses import dataclass, field +from typing import Any, Callable, Optional from urllib.parse import unquote -import hashlib -import traceback import httpx -from libs.wecom_ai_bot_api.WXBizMsgCrypt3 import WXBizMsgCrypt -from quart import Quart, request, Response, jsonify -import langbot_plugin.api.entities.builtin.platform.message as platform_message -import asyncio -from libs.wecom_ai_bot_api import wecombotevent -from typing import Callable -import base64 from Crypto.Cipher import AES +from quart import Quart, request, Response, jsonify + +from libs.wecom_ai_bot_api import wecombotevent +from libs.wecom_ai_bot_api.WXBizMsgCrypt3 import WXBizMsgCrypt from pkg.platform.logger import EventLogger +@dataclass +class StreamChunk: + """描述单次推送给企业微信的流式片段。""" + + # 需要返回给企业微信的文本内容 + content: str + + # 标记是否为最终片段,对应企业微信协议里的 finish 字段 + is_final: bool = False + + # 预留额外元信息,未来支持多模态扩展时可使用 + meta: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class StreamSession: + """维护一次企业微信流式会话的上下文。""" + + # 企业微信要求的 stream_id,用于标识后续刷新请求 + stream_id: str + + # 原始消息的 msgid,便于与流水线消息对应 + msg_id: str + + # 群聊会话标识(单聊时为空) + chat_id: Optional[str] + + # 触发消息的发送者 + user_id: Optional[str] + + # 会话创建时间 + created_at: float = field(default_factory=time.time) + + # 最近一次被访问的时间,cleanup 依据该值判断过期 + last_access: float = field(default_factory=time.time) + + # 将流水线增量结果缓存到队列,刷新请求逐条消费 + queue: asyncio.Queue = field(default_factory=asyncio.Queue) + + # 是否已经完成(收到最终片段) + finished: bool = False + + # 缓存最近一次片段,处理重试或超时兜底 + last_chunk: Optional[StreamChunk] = None + + +class StreamSessionManager: + """管理 stream 会话的生命周期,并负责队列的生产消费。""" + + def __init__(self, logger: EventLogger, ttl: int = 60) -> None: + self.logger = logger + + self.ttl = ttl # 超时时间(秒),超过该时间未被访问的会话会被清理由 cleanup + self._sessions: dict[str, StreamSession] = {} # stream_id -> StreamSession 映射 + self._msg_index: dict[str, str] = {} # msgid -> stream_id 映射,便于流水线根据消息 ID 找到会话 + + def get_stream_id_by_msg(self, msg_id: str) -> Optional[str]: + if not msg_id: + return None + return self._msg_index.get(msg_id) + + def get_session(self, stream_id: str) -> Optional[StreamSession]: + return self._sessions.get(stream_id) + + def create_or_get(self, msg_json: dict[str, Any]) -> tuple[StreamSession, bool]: + """根据企业微信回调创建或获取会话。 + + Args: + msg_json: 企业微信解密后的回调 JSON。 + + Returns: + Tuple[StreamSession, bool]: `StreamSession` 为会话实例,`bool` 指示是否为新建会话。 + + Example: + 在首次回调中调用,得到 `is_new=True` 后再触发流水线。 + """ + msg_id = msg_json.get('msgid', '') + if msg_id and msg_id in self._msg_index: + stream_id = self._msg_index[msg_id] + session = self._sessions.get(stream_id) + if session: + session.last_access = time.time() + return session, False + + stream_id = str(uuid.uuid4()) + session = StreamSession( + stream_id=stream_id, + msg_id=msg_id, + chat_id=msg_json.get('chatid'), + user_id=msg_json.get('from', {}).get('userid'), + ) + + if msg_id: + self._msg_index[msg_id] = stream_id + self._sessions[stream_id] = session + return session, True + + async def publish(self, stream_id: str, chunk: StreamChunk) -> bool: + """向 stream 队列写入新的增量片段。 + + Args: + stream_id: 企业微信分配的流式会话 ID。 + chunk: 待发送的增量片段。 + + Returns: + bool: 当流式队列存在并成功入队时返回 True。 + + Example: + 在收到模型增量后调用 `await manager.publish('sid', StreamChunk('hello'))`。 + """ + session = self._sessions.get(stream_id) + if not session: + return False + + session.last_access = time.time() + session.last_chunk = chunk + + try: + session.queue.put_nowait(chunk) + except asyncio.QueueFull: + # 默认无界队列,此处兜底防御 + await session.queue.put(chunk) + + if chunk.is_final: + session.finished = True + + return True + + async def consume(self, stream_id: str, timeout: float = 0.5) -> Optional[StreamChunk]: + """从队列中取出一个片段,若超时返回 None。 + + Args: + stream_id: 企业微信流式会话 ID。 + timeout: 取片段的最长等待时间(秒)。 + + Returns: + Optional[StreamChunk]: 成功时返回片段,超时或会话不存在时返回 None。 + + Example: + 企业微信刷新到达时调用,若队列有数据则立即返回 `StreamChunk`。 + """ + session = self._sessions.get(stream_id) + if not session: + return None + + session.last_access = time.time() + + try: + chunk = await asyncio.wait_for(session.queue.get(), timeout) + session.last_access = time.time() + if chunk.is_final: + session.finished = True + return chunk + except asyncio.TimeoutError: + if session.finished and session.last_chunk: + return session.last_chunk + return None + + def mark_finished(self, stream_id: str) -> None: + session = self._sessions.get(stream_id) + if session: + session.finished = True + session.last_access = time.time() + + def cleanup(self) -> None: + """定期清理过期会话,防止队列与映射无上限累积。""" + now = time.time() + expired: list[str] = [] + for stream_id, session in self._sessions.items(): + if now - session.last_access > self.ttl: + expired.append(stream_id) + + for stream_id in expired: + session = self._sessions.pop(stream_id, None) + if not session: + continue + msg_id = session.msg_id + if msg_id and self._msg_index.get(msg_id) == stream_id: + self._msg_index.pop(msg_id, None) + class WecomBotClient: - def __init__(self,Token:str,EnCodingAESKey:str,Corpid:str,logger:EventLogger): - self.Token=Token - self.EnCodingAESKey=EnCodingAESKey - self.Corpid=Corpid + def __init__(self, Token: str, EnCodingAESKey: str, Corpid: str, logger: EventLogger): + """企业微信智能机器人客户端。 + + Args: + Token: 企业微信回调验证使用的 token。 + EnCodingAESKey: 企业微信消息加解密密钥。 + Corpid: 企业 ID。 + logger: 日志记录器。 + + Example: + >>> client = WecomBotClient(Token='token', EnCodingAESKey='aeskey', Corpid='corp', logger=logger) + """ + + self.Token = Token + self.EnCodingAESKey = EnCodingAESKey + self.Corpid = Corpid self.ReceiveId = '' self.app = Quart(__name__) self.app.add_url_rule( '/callback/command', 'handle_callback', self.handle_callback_request, - methods=['POST','GET'] + methods=['POST', 'GET'] ) self._message_handlers = { 'example': [], } - self.user_stream_map = {} self.logger = logger - self.generated_content = {} - self.msg_id_map = {} + self.generated_content: dict[str, str] = {} + self.msg_id_map: dict[str, int] = {} + self.stream_sessions = StreamSessionManager(logger=logger) + self.stream_poll_timeout = 0.5 - async def sha1_signature(token: str, timestamp: str, nonce: str, encrypt: str) -> str: - raw = "".join(sorted([token, timestamp, nonce, encrypt])) - return hashlib.sha1(raw.encode("utf-8")).hexdigest() - - async def handle_callback_request(self): + @staticmethod + def _build_stream_payload(stream_id: str, content: str, finish: bool) -> dict[str, Any]: + """按照企业微信协议拼装返回报文。 + + Args: + stream_id: 企业微信会话 ID。 + content: 推送的文本内容。 + finish: 是否为最终片段。 + + Returns: + dict[str, Any]: 可直接加密返回的 payload。 + + Example: + 组装 `{'msgtype': 'stream', 'stream': {'id': 'sid', ...}}` 结构。 + """ + return { + 'msgtype': 'stream', + 'stream': { + 'id': stream_id, + 'finish': finish, + 'content': content, + }, + } + + async def _encrypt_and_reply(self, payload: dict[str, Any], nonce: str) -> tuple[Response, int]: + """对响应进行加密封装并返回给企业微信。 + + Args: + payload: 待加密的响应内容。 + nonce: 企业微信回调参数中的 nonce。 + + Returns: + Tuple[Response, int]: Quart Response 对象及状态码。 + + Example: + 在首包或刷新场景中调用以生成加密响应。 + """ + reply_plain_str = json.dumps(payload, ensure_ascii=False) + reply_timestamp = str(int(time.time())) + ret, encrypt_text = self.wxcpt.EncryptMsg(reply_plain_str, nonce, reply_timestamp) + if ret != 0: + await self.logger.error(f'加密失败: {ret}') + return jsonify({'error': 'encrypt_failed'}), 500 + + root = ET.fromstring(encrypt_text) + encrypt = root.find('Encrypt').text + resp = { + 'encrypt': encrypt, + } + return jsonify(resp), 200 + + async def _dispatch_event(self, event: wecombotevent.WecomBotEvent) -> None: + """异步触发流水线处理,避免阻塞首包响应。 + + Args: + event: 由企业微信消息转换的内部事件对象。 + """ try: - self.wxcpt=WXBizMsgCrypt(self.Token,self.EnCodingAESKey,'') + await self._handle_message(event) + except Exception: + await self.logger.error(traceback.format_exc()) - if request.method == "GET": + async def _handle_initial_message(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]: + """处理企业微信首次推送的消息,返回 stream_id 并开启流水线。 - msg_signature = unquote(request.args.get("msg_signature", "")) - timestamp = unquote(request.args.get("timestamp", "")) - nonce = unquote(request.args.get("nonce", "")) - echostr = unquote(request.args.get("echostr", "")) + Args: + msg_json: 解密后的企业微信消息 JSON。 + nonce: 企业微信回调参数 nonce。 + + Returns: + Tuple[Response, int]: Quart Response 及状态码。 + + Example: + 首次回调时调用,立即返回带 `stream_id` 的响应。 + """ + session, is_new = self.stream_sessions.create_or_get(msg_json) + + message_data = await self.get_message(msg_json) + if message_data: + message_data['stream_id'] = session.stream_id + try: + event = wecombotevent.WecomBotEvent(message_data) + except Exception: + await self.logger.error(traceback.format_exc()) + else: + if is_new: + asyncio.create_task(self._dispatch_event(event)) + + payload = self._build_stream_payload(session.stream_id, '', False) + return await self._encrypt_and_reply(payload, nonce) + + async def _handle_stream_refresh(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]: + """处理企业微信的流式刷新请求,按需返回增量片段。 + + Args: + msg_json: 解密后的企业微信刷新请求。 + nonce: 企业微信回调参数 nonce。 + + Returns: + Tuple[Response, int]: Quart Response 及状态码。 + + Example: + 在刷新请求中调用,按需返回增量片段。 + """ + stream_info = msg_json.get('stream', {}) + stream_id = stream_info.get('id', '') + if not stream_id: + await self.logger.error('刷新请求缺少 stream.id') + return await self._encrypt_and_reply(self._build_stream_payload('', '', True), nonce) + + session = self.stream_sessions.get_session(stream_id) + chunk = await self.stream_sessions.consume(stream_id, timeout=self.stream_poll_timeout) + + if not chunk: + cached_content = None + if session and session.msg_id: + cached_content = self.generated_content.pop(session.msg_id, None) + if cached_content is not None: + chunk = StreamChunk(content=cached_content, is_final=True) + else: + payload = self._build_stream_payload(stream_id, '', False) + return await self._encrypt_and_reply(payload, nonce) + + payload = self._build_stream_payload(stream_id, chunk.content, chunk.is_final) + if chunk.is_final: + self.stream_sessions.mark_finished(stream_id) + return await self._encrypt_and_reply(payload, nonce) + + async def handle_callback_request(self): + """企业微信回调入口。 + + Returns: + Quart Response: 根据请求类型返回验证、首包或刷新结果。 + + Example: + 作为 Quart 路由处理函数直接注册并使用。 + """ + try: + self.wxcpt = WXBizMsgCrypt(self.Token, self.EnCodingAESKey, '') + await self.logger.info(f'{request.method} {request.url} {str(request.args)}') + + if request.method == 'GET': + # GET 用于验证回调 URL,有效期内直接返回微信给的 echostr + msg_signature = unquote(request.args.get('msg_signature', '')) + timestamp = unquote(request.args.get('timestamp', '')) + nonce = unquote(request.args.get('nonce', '')) + echostr = unquote(request.args.get('echostr', '')) if not all([msg_signature, timestamp, nonce, echostr]): - await self.logger.error("请求参数缺失") - return Response("缺少参数", status=400) + await self.logger.error('请求参数缺失') + return Response('缺少参数', status=400) ret, decrypted_str = self.wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr) if ret != 0: - - await self.logger.error("验证URL失败") - return Response("验证失败", status=403) + await self.logger.error('验证URL失败') + return Response('验证失败', status=403) - return Response(decrypted_str, mimetype="text/plain") + return Response(decrypted_str, mimetype='text/plain') - elif request.method == "POST": - msg_signature = unquote(request.args.get("msg_signature", "")) - timestamp = unquote(request.args.get("timestamp", "")) - nonce = unquote(request.args.get("nonce", "")) + if request.method != 'POST': + return Response('', status=405) - try: - timeout = 3 - interval = 0.1 - start_time = time.monotonic() - encrypted_json = await request.get_json() - encrypted_msg = encrypted_json.get("encrypt", "") - if not encrypted_msg: - await self.logger.error("请求体中缺少 'encrypt' 字段") + self.stream_sessions.cleanup() - xml_post_data = f"" - ret, decrypted_xml = self.wxcpt.DecryptMsg(xml_post_data, msg_signature, timestamp, nonce) - if ret != 0: - await self.logger.error("解密失败") + msg_signature = unquote(request.args.get('msg_signature', '')) + timestamp = unquote(request.args.get('timestamp', '')) + nonce = unquote(request.args.get('nonce', '')) + encrypted_json = await request.get_json() + encrypted_msg = (encrypted_json or {}).get('encrypt', '') + if not encrypted_msg: + await self.logger.error("请求体中缺少 'encrypt' 字段") + return Response('Bad Request', status=400) - msg_json = json.loads(decrypted_xml) - - from_user_id = msg_json.get("from", {}).get("userid") - chatid = msg_json.get("chatid", "") - - message_data = await self.get_message(msg_json) - - + xml_post_data = f"" + ret, decrypted_xml = self.wxcpt.DecryptMsg(xml_post_data, msg_signature, timestamp, nonce) + if ret != 0: + await self.logger.error('解密失败') + return Response('解密失败', status=400) - if message_data: - try: - event = wecombotevent.WecomBotEvent(message_data) - if event: - await self._handle_message(event) - except Exception as e: - await self.logger.error(traceback.format_exc()) - print(traceback.format_exc()) + msg_json = json.loads(decrypted_xml) - start_time = time.time() - try: - if msg_json.get('chattype','') == 'single': - if from_user_id in self.user_stream_map: - stream_id = self.user_stream_map[from_user_id] - else: - stream_id =str(uuid.uuid4()) - self.user_stream_map[from_user_id] = stream_id - + if msg_json.get('msgtype') == 'stream': + # 企业微信刷新请求:尝试从队列中取出增量回复 + return await self._handle_stream_refresh(msg_json, nonce) - else: - - if chatid in self.user_stream_map: - stream_id = self.user_stream_map[chatid] - else: - stream_id = str(uuid.uuid4()) - self.user_stream_map[chatid] = stream_id - except Exception as e: - await self.logger.error(traceback.format_exc()) - print(traceback.format_exc()) - while True: - content = self.generated_content.pop(msg_json['msgid'],None) - if content: - reply_plain = { - "msgtype": "stream", - "stream": { - "id": stream_id, - "finish": True, - "content": content - } - } - reply_plain_str = json.dumps(reply_plain, ensure_ascii=False) + # 首次请求:快速返回 stream_id 并异步处理流水线 + return await self._handle_initial_message(msg_json, nonce) - reply_timestamp = str(int(time.time())) - ret, encrypt_text = self.wxcpt.EncryptMsg(reply_plain_str, nonce, reply_timestamp) - if ret != 0: - - await self.logger.error("加密失败"+str(ret)) - - - root = ET.fromstring(encrypt_text) - encrypt = root.find("Encrypt").text - resp = { - "encrypt": encrypt, - } - return jsonify(resp), 200 - - if time.time() - start_time > timeout: - break - - await asyncio.sleep(interval) - - if self.msg_id_map.get(message_data['msgid'], 1) == 3: - await self.logger.error('请求失效:暂不支持智能机器人超过7秒的请求,如有需求,请联系 LangBot 团队。') - return '' - - except Exception as e: - await self.logger.error(traceback.format_exc()) - print(traceback.format_exc()) - - except Exception as e: + except Exception: await self.logger.error(traceback.format_exc()) - print(traceback.format_exc()) + return Response('Internal Server Error', status=500) - - async def get_message(self,msg_json): + async def get_message(self, msg_json): message_data = {} - if msg_json.get('chattype','') == 'single': + if msg_json.get('chattype', '') == 'single': message_data['type'] = 'single' - elif msg_json.get('chattype','') == 'group': + elif msg_json.get('chattype', '') == 'group': message_data['type'] = 'group' if msg_json.get('msgtype') == 'text': - message_data['content'] = msg_json.get('text',{}).get('content') + message_data['content'] = msg_json.get('text', {}).get('content') elif msg_json.get('msgtype') == 'image': - picurl = msg_json.get('image', {}).get('url','') - base64 = await self.download_url_to_base64(picurl,self.EnCodingAESKey) - message_data['picurl'] = base64 + picurl = msg_json.get('image', {}).get('url', '') + base64 = await self.download_url_to_base64(picurl, self.EnCodingAESKey) + message_data['picurl'] = base64 elif msg_json.get('msgtype') == 'mixed': items = msg_json.get('mixed', {}).get('msg_item', []) texts = [] @@ -197,8 +453,8 @@ class WecomBotClient: if texts: message_data['content'] = "".join(texts) # 拼接所有 text if picurl: - base64 = await self.download_url_to_base64(picurl,self.EnCodingAESKey) - message_data['picurl'] = base64 # 只保留第一个 image + base64 = await self.download_url_to_base64(picurl, self.EnCodingAESKey) + message_data['picurl'] = base64 # 只保留第一个 image message_data['userid'] = msg_json.get('from', {}).get('userid', '') message_data['msgid'] = msg_json.get('msgid', '') @@ -207,7 +463,7 @@ class WecomBotClient: message_data['aibotid'] = msg_json.get('aibotid', '') return message_data - + async def _handle_message(self, event: wecombotevent.WecomBotEvent): """ 处理消息事件。 @@ -223,10 +479,46 @@ class WecomBotClient: for handler in self._message_handlers[msg_type]: await handler(event) except Exception: - print(traceback.format_exc()) + print(traceback.format_exc()) + + async def push_stream_chunk(self, msg_id: str, content: str, is_final: bool = False) -> bool: + """将流水线片段推送到 stream 会话。 + + Args: + msg_id: 原始企业微信消息 ID。 + content: 模型产生的片段内容。 + is_final: 是否为最终片段。 + + Returns: + bool: 当成功写入流式队列时返回 True。 + + Example: + 在流水线 `reply_message_chunk` 中调用,将增量推送至企业微信。 + """ + # 根据 msg_id 找到对应 stream 会话,如果不存在说明当前消息非流式 + stream_id = self.stream_sessions.get_stream_id_by_msg(msg_id) + if not stream_id: + return False + + chunk = StreamChunk(content=content, is_final=is_final) + await self.stream_sessions.publish(stream_id, chunk) + if is_final: + self.stream_sessions.mark_finished(stream_id) + return True async def set_message(self, msg_id: str, content: str): - self.generated_content[msg_id] = content + """兼容旧逻辑:若无法流式返回则缓存最终结果。 + + Args: + msg_id: 企业微信消息 ID。 + content: 最终回复的文本内容。 + + Example: + 在非流式场景下缓存最终结果以备刷新时返回。 + """ + handled = await self.push_stream_chunk(msg_id, content, is_final=True) + if not handled: + self.generated_content[msg_id] = content def on_message(self, msg_type: str): def decorator(func: Callable[[wecombotevent.WecomBotEvent], None]): @@ -237,7 +529,6 @@ class WecomBotClient: return decorator - async def download_url_to_base64(self, download_url, encoding_aes_key): async with httpx.AsyncClient() as client: response = await client.get(download_url) @@ -247,26 +538,22 @@ class WecomBotClient: encrypted_bytes = response.content - aes_key = base64.b64decode(encoding_aes_key + "=") # base64 补齐 iv = aes_key[:16] - cipher = AES.new(aes_key, AES.MODE_CBC, iv) decrypted = cipher.decrypt(encrypted_bytes) - pad_len = decrypted[-1] decrypted = decrypted[:-pad_len] - - if decrypted.startswith(b"\xff\xd8"): # JPEG + if decrypted.startswith(b"\xff\xd8"): # JPEG mime_type = "image/jpeg" elif decrypted.startswith(b"\x89PNG"): # PNG mime_type = "image/png" elif decrypted.startswith((b"GIF87a", b"GIF89a")): # GIF mime_type = "image/gif" - elif decrypted.startswith(b"BM"): # BMP + elif decrypted.startswith(b"BM"): # BMP mime_type = "image/bmp" elif decrypted.startswith(b"II*\x00") or decrypted.startswith(b"MM\x00*"): # TIFF mime_type = "image/tiff" @@ -276,15 +563,9 @@ class WecomBotClient: # 转 base64 base64_str = base64.b64encode(decrypted).decode("utf-8") return f"data:{mime_type};base64,{base64_str}" - async def run_task(self, host: str, port: int, *args, **kwargs): """ 启动 Quart 应用。 """ await self.app.run_task(host=host, port=port, *args, **kwargs) - - - - - diff --git a/pkg/platform/sources/wecombot.py b/pkg/platform/sources/wecombot.py index 9487b637..e5f2d1b5 100644 --- a/pkg/platform/sources/wecombot.py +++ b/pkg/platform/sources/wecombot.py @@ -117,6 +117,50 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): content = await self.message_converter.yiri2target(message) await self.bot.set_message(message_source.source_platform_object.message_id, content) + async def reply_message_chunk( + self, + message_source: platform_events.MessageEvent, + bot_message, + message: platform_message.MessageChain, + quote_origin: bool = False, + is_final: bool = False, + ): + """将流水线增量输出写入企业微信 stream 会话。 + + Args: + message_source: 流水线提供的原始消息事件。 + bot_message: 当前片段对应的模型元信息(未使用)。 + message: 需要回复的消息链。 + quote_origin: 是否引用原消息(企业微信暂不支持)。 + is_final: 标记当前片段是否为最终回复。 + + Returns: + dict: 包含 `stream` 键,标识写入是否成功。 + + Example: + 在流水线 `reply_message_chunk` 调用中自动触发,无需手动调用。 + """ + # 转换为纯文本(智能机器人当前协议仅支持文本流) + content = await self.message_converter.yiri2target(message) + msg_id = message_source.source_platform_object.message_id + + # 将片段推送到 WecomBotClient 中的队列,返回值用于判断是否走降级逻辑 + success = await self.bot.push_stream_chunk(msg_id, content, is_final=is_final) + if not success and is_final: + # 未命中流式队列时使用旧有 set_message 兜底 + await self.bot.set_message(msg_id, content) + return {'stream': success} + + async def is_stream_output_supported(self) -> bool: + """智能机器人侧默认开启流式能力。 + + Returns: + bool: 恒定返回 True。 + + Example: + 流水线执行阶段会调用此方法以确认是否启用流式。""" + return True + async def send_message(self, target_type, target_id, message): pass From 69767ebdb4fda644e57229e7d3811acc624825ef Mon Sep 17 00:00:00 2001 From: Alfonsxh Date: Tue, 28 Oct 2025 18:30:55 +0800 Subject: [PATCH 23/26] refactor: split WeCom callback handlers --- libs/wecom_ai_bot_api/api.py | 99 +++++++++++++++++++----------------- 1 file changed, 53 insertions(+), 46 deletions(-) diff --git a/libs/wecom_ai_bot_api/api.py b/libs/wecom_ai_bot_api/api.py index 41d379a6..9568eab4 100644 --- a/libs/wecom_ai_bot_api/api.py +++ b/libs/wecom_ai_bot_api/api.py @@ -295,7 +295,7 @@ class WecomBotClient: except Exception: await self.logger.error(traceback.format_exc()) - async def _handle_initial_message(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]: + async def _handle_post_initial_response(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]: """处理企业微信首次推送的消息,返回 stream_id 并开启流水线。 Args: @@ -324,7 +324,7 @@ class WecomBotClient: payload = self._build_stream_payload(session.stream_id, '', False) return await self._encrypt_and_reply(payload, nonce) - async def _handle_stream_refresh(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]: + async def _handle_post_followup_response(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]: """处理企业微信的流式刷新请求,按需返回增量片段。 Args: @@ -375,57 +375,64 @@ class WecomBotClient: await self.logger.info(f'{request.method} {request.url} {str(request.args)}') if request.method == 'GET': - # GET 用于验证回调 URL,有效期内直接返回微信给的 echostr - msg_signature = unquote(request.args.get('msg_signature', '')) - timestamp = unquote(request.args.get('timestamp', '')) - nonce = unquote(request.args.get('nonce', '')) - echostr = unquote(request.args.get('echostr', '')) + return await self._handle_get_callback() - if not all([msg_signature, timestamp, nonce, echostr]): - await self.logger.error('请求参数缺失') - return Response('缺少参数', status=400) + if request.method == 'POST': + return await self._handle_post_callback() - ret, decrypted_str = self.wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr) - if ret != 0: - await self.logger.error('验证URL失败') - return Response('验证失败', status=403) - - return Response(decrypted_str, mimetype='text/plain') - - if request.method != 'POST': - return Response('', status=405) - - self.stream_sessions.cleanup() - - msg_signature = unquote(request.args.get('msg_signature', '')) - timestamp = unquote(request.args.get('timestamp', '')) - nonce = unquote(request.args.get('nonce', '')) - - encrypted_json = await request.get_json() - encrypted_msg = (encrypted_json or {}).get('encrypt', '') - if not encrypted_msg: - await self.logger.error("请求体中缺少 'encrypt' 字段") - return Response('Bad Request', status=400) - - xml_post_data = f"" - ret, decrypted_xml = self.wxcpt.DecryptMsg(xml_post_data, msg_signature, timestamp, nonce) - if ret != 0: - await self.logger.error('解密失败') - return Response('解密失败', status=400) - - msg_json = json.loads(decrypted_xml) - - if msg_json.get('msgtype') == 'stream': - # 企业微信刷新请求:尝试从队列中取出增量回复 - return await self._handle_stream_refresh(msg_json, nonce) - - # 首次请求:快速返回 stream_id 并异步处理流水线 - return await self._handle_initial_message(msg_json, nonce) + return Response('', status=405) except Exception: await self.logger.error(traceback.format_exc()) return Response('Internal Server Error', status=500) + async def _handle_get_callback(self) -> tuple[Response, int] | Response: + """处理企业微信的 GET 验证请求。""" + + msg_signature = unquote(request.args.get('msg_signature', '')) + timestamp = unquote(request.args.get('timestamp', '')) + nonce = unquote(request.args.get('nonce', '')) + echostr = unquote(request.args.get('echostr', '')) + + if not all([msg_signature, timestamp, nonce, echostr]): + await self.logger.error('请求参数缺失') + return Response('缺少参数', status=400) + + ret, decrypted_str = self.wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr) + if ret != 0: + await self.logger.error('验证URL失败') + return Response('验证失败', status=403) + + return Response(decrypted_str, mimetype='text/plain') + + async def _handle_post_callback(self) -> tuple[Response, int] | Response: + """处理企业微信的 POST 回调请求。""" + + self.stream_sessions.cleanup() + + msg_signature = unquote(request.args.get('msg_signature', '')) + timestamp = unquote(request.args.get('timestamp', '')) + nonce = unquote(request.args.get('nonce', '')) + + encrypted_json = await request.get_json() + encrypted_msg = (encrypted_json or {}).get('encrypt', '') + if not encrypted_msg: + await self.logger.error("请求体中缺少 'encrypt' 字段") + return Response('Bad Request', status=400) + + xml_post_data = f"" + ret, decrypted_xml = self.wxcpt.DecryptMsg(xml_post_data, msg_signature, timestamp, nonce) + if ret != 0: + await self.logger.error('解密失败') + return Response('解密失败', status=400) + + msg_json = json.loads(decrypted_xml) + + if msg_json.get('msgtype') == 'stream': + return await self._handle_post_followup_response(msg_json, nonce) + + return await self._handle_post_initial_response(msg_json, nonce) + async def get_message(self, msg_json): message_data = {} From 254feb6a3a7238791643f5e67eefed2cdb923528 Mon Sep 17 00:00:00 2001 From: WangCham <651122857@qq.com> Date: Thu, 30 Oct 2025 12:37:09 +0800 Subject: [PATCH 24/26] fix: langchain error --- pkg/rag/knowledge/services/chunker.py | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/pkg/rag/knowledge/services/chunker.py b/pkg/rag/knowledge/services/chunker.py index f169d5f1..19b1f296 100644 --- a/pkg/rag/knowledge/services/chunker.py +++ b/pkg/rag/knowledge/services/chunker.py @@ -4,6 +4,7 @@ import json from typing import List from pkg.rag.knowledge.services import base_service from pkg.core import app +from langchain_text_splitters import RecursiveCharacterTextSplitter class Chunker(base_service.BaseService): @@ -27,21 +28,6 @@ class Chunker(base_service.BaseService): """ if not text: return [] - # words = text.split() - # chunks = [] - # current_chunk = [] - - # for word in words: - # current_chunk.append(word) - # if len(current_chunk) > self.chunk_size: - # chunks.append(" ".join(current_chunk[:self.chunk_size])) - # current_chunk = current_chunk[self.chunk_size - self.chunk_overlap:] - - # if current_chunk: - # chunks.append(" ".join(current_chunk)) - - # A more robust chunking strategy (e.g., using recursive character text splitter) - from langchain.text_splitter import RecursiveCharacterTextSplitter text_splitter = RecursiveCharacterTextSplitter( chunk_size=self.chunk_size, From f576f990de3ed4636118b4c5c6e58a76de7ef7df Mon Sep 17 00:00:00 2001 From: WangCham <651122857@qq.com> Date: Thu, 30 Oct 2025 12:52:11 +0800 Subject: [PATCH 25/26] fix: add langchain test splitter module --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index c0200bd0..1384b22c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,6 +60,7 @@ dependencies = [ "ebooklib>=0.18", "html2text>=2024.2.26", "langchain>=0.2.0", + "langchain-text-splitters>=0.0.1", "chromadb>=0.4.24", "qdrant-client (>=1.15.1,<2.0.0)", "langbot-plugin==0.1.4", From da9afcd0adbf86441b5f6579312867eb39bb8176 Mon Sep 17 00:00:00 2001 From: "Junyan Qin (Chin)" Date: Tue, 4 Nov 2025 15:33:44 +0800 Subject: [PATCH 26/26] perf: config reset logic (#1742) * fix: inherit settings from existing settings * feat: add optional data cleanup checkbox to plugin uninstall dialog (#1743) * Initial plan * Add checkbox for plugin config/storage deletion - Add delete_data parameter to backend API endpoint - Update delete_plugin flow to clean up settings and binary storage - Add checkbox in uninstall dialog using shadcn/ui - Add translations for checkbox label in all languages Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com> * perf: param list --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com> Co-authored-by: Junyan Qin * chore: fix linter errors --------- Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> --- pkg/api/http/controller/groups/plugins.py | 3 +- pkg/plugin/connector.py | 12 ++++- pkg/plugin/handler.py | 25 +++++++++- .../PluginInstalledComponent.tsx | 48 ++++++++++++++----- web/src/app/infra/http/BackendClient.ts | 5 +- web/src/i18n/locales/en-US.ts | 4 +- web/src/i18n/locales/ja-JP.ts | 3 +- web/src/i18n/locales/zh-Hans.ts | 4 +- web/src/i18n/locales/zh-Hant.ts | 1 + 9 files changed, 86 insertions(+), 19 deletions(-) diff --git a/pkg/api/http/controller/groups/plugins.py b/pkg/api/http/controller/groups/plugins.py index 4966553b..4a3f723e 100644 --- a/pkg/api/http/controller/groups/plugins.py +++ b/pkg/api/http/controller/groups/plugins.py @@ -45,9 +45,10 @@ class PluginsRouterGroup(group.RouterGroup): return self.http_status(404, -1, 'plugin not found') return self.success(data={'plugin': plugin}) elif quart.request.method == 'DELETE': + delete_data = quart.request.args.get('delete_data', 'false').lower() == 'true' ctx = taskmgr.TaskContext.new() wrapper = self.ap.task_mgr.create_user_task( - self.ap.plugin_connector.delete_plugin(author, plugin_name, task_context=ctx), + self.ap.plugin_connector.delete_plugin(author, plugin_name, delete_data=delete_data, task_context=ctx), kind='plugin-operation', name=f'plugin-remove-{plugin_name}', label=f'Removing plugin {plugin_name}', diff --git a/pkg/plugin/connector.py b/pkg/plugin/connector.py index 5a979474..5fa745ec 100644 --- a/pkg/plugin/connector.py +++ b/pkg/plugin/connector.py @@ -177,7 +177,11 @@ class PluginRuntimeConnector: task_context.trace(trace) async def delete_plugin( - self, plugin_author: str, plugin_name: str, task_context: taskmgr.TaskContext | None = None + self, + plugin_author: str, + plugin_name: str, + delete_data: bool = False, + task_context: taskmgr.TaskContext | None = None, ) -> dict[str, Any]: async for ret in self.handler.delete_plugin(plugin_author, plugin_name): current_action = ret.get('current_action', None) @@ -190,6 +194,12 @@ class PluginRuntimeConnector: if task_context is not None: task_context.trace(trace) + # Clean up plugin settings and binary storage if requested + if delete_data: + if task_context is not None: + task_context.trace('Cleaning up plugin configuration and storage...') + await self.handler.cleanup_plugin_data(plugin_author, plugin_name) + async def list_plugins(self) -> list[dict[str, Any]]: if not self.is_enable_plugin: return [] diff --git a/pkg/plugin/handler.py b/pkg/plugin/handler.py index b138fd42..da710f41 100644 --- a/pkg/plugin/handler.py +++ b/pkg/plugin/handler.py @@ -56,7 +56,9 @@ class RuntimeConnectionHandler(handler.Handler): .where(persistence_plugin.PluginSetting.plugin_name == plugin_name) ) - if result.first() is not None: + setting = result.first() + + if setting is not None: # delete plugin setting await self.ap.persistence_mgr.execute_async( sqlalchemy.delete(persistence_plugin.PluginSetting) @@ -71,6 +73,10 @@ class RuntimeConnectionHandler(handler.Handler): plugin_name=plugin_name, install_source=install_source, install_info=install_info, + # inherit from existing setting + enabled=setting.enabled if setting is not None else True, + priority=setting.priority if setting is not None else 0, + config=setting.config if setting is not None else {}, # noqa: F821 ) ) @@ -573,6 +579,23 @@ class RuntimeConnectionHandler(handler.Handler): 'mime_type': mime_type, } + async def cleanup_plugin_data(self, plugin_author: str, plugin_name: str) -> None: + """Cleanup plugin settings and binary storage""" + # Delete plugin settings + await self.ap.persistence_mgr.execute_async( + sqlalchemy.delete(persistence_plugin.PluginSetting) + .where(persistence_plugin.PluginSetting.plugin_author == plugin_author) + .where(persistence_plugin.PluginSetting.plugin_name == plugin_name) + ) + + # Delete all binary storage for this plugin + owner = f'{plugin_author}/{plugin_name}' + await self.ap.persistence_mgr.execute_async( + sqlalchemy.delete(persistence_bstorage.BinaryStorage) + .where(persistence_bstorage.BinaryStorage.owner_type == 'plugin') + .where(persistence_bstorage.BinaryStorage.owner == owner) + ) + async def call_tool(self, tool_name: str, parameters: dict[str, Any]) -> dict[str, Any]: """Call tool""" result = await self.call_action( diff --git a/web/src/app/home/plugins/components/plugin-installed/PluginInstalledComponent.tsx b/web/src/app/home/plugins/components/plugin-installed/PluginInstalledComponent.tsx index 77368a1a..e2af33a8 100644 --- a/web/src/app/home/plugins/components/plugin-installed/PluginInstalledComponent.tsx +++ b/web/src/app/home/plugins/components/plugin-installed/PluginInstalledComponent.tsx @@ -15,6 +15,7 @@ import { DialogFooter, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; import { useTranslation } from 'react-i18next'; import { extractI18nObject } from '@/i18n/I18nProvider'; import { toast } from 'sonner'; @@ -43,6 +44,7 @@ const PluginInstalledComponent = forwardRef( PluginOperationType.DELETE, ); const [targetPlugin, setTargetPlugin] = useState(null); + const [deleteData, setDeleteData] = useState(false); const asyncTask = useAsyncTask({ onSuccess: () => { @@ -109,6 +111,7 @@ const PluginInstalledComponent = forwardRef( setTargetPlugin(plugin); setOperationType(PluginOperationType.DELETE); setShowOperationModal(true); + setDeleteData(false); asyncTask.reset(); } @@ -124,7 +127,11 @@ const PluginInstalledComponent = forwardRef( const apiCall = operationType === PluginOperationType.DELETE - ? httpClient.removePlugin(targetPlugin.author, targetPlugin.name) + ? httpClient.removePlugin( + targetPlugin.author, + targetPlugin.name, + deleteData, + ) : httpClient.upgradePlugin(targetPlugin.author, targetPlugin.name); apiCall @@ -162,16 +169,35 @@ const PluginInstalledComponent = forwardRef( {asyncTask.status === AsyncTaskStatus.WAIT_INPUT && ( -
- {operationType === PluginOperationType.DELETE - ? t('plugins.confirmDeletePlugin', { - author: targetPlugin?.author ?? '', - name: targetPlugin?.name ?? '', - }) - : t('plugins.confirmUpdatePlugin', { - author: targetPlugin?.author ?? '', - name: targetPlugin?.name ?? '', - })} +
+
+ {operationType === PluginOperationType.DELETE + ? t('plugins.confirmDeletePlugin', { + author: targetPlugin?.author ?? '', + name: targetPlugin?.name ?? '', + }) + : t('plugins.confirmUpdatePlugin', { + author: targetPlugin?.author ?? '', + name: targetPlugin?.name ?? '', + })} +
+ {operationType === PluginOperationType.DELETE && ( +
+ + setDeleteData(checked === true) + } + /> + +
+ )}
)} {asyncTask.status === AsyncTaskStatus.RUNNING && ( diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index 2ed83b41..84492990 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -477,8 +477,11 @@ export class BackendClient extends BaseHttpClient { public removePlugin( author: string, name: string, + deleteData: boolean = false, ): Promise { - return this.delete(`/api/v1/plugins/${author}/${name}`); + return this.delete( + `/api/v1/plugins/${author}/${name}?delete_data=${deleteData}`, + ); } public upgradePlugin( diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 85b3698f..ed72e36c 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -199,7 +199,9 @@ const enUS = { saveConfig: 'Save Config', saving: 'Saving...', confirmDeletePlugin: - 'Are you sure you want to delete the plugin ({{author}}/{{name}})? This will also delete the plugin configuration.', + 'Are you sure you want to delete the plugin ({{author}}/{{name}})?', + deleteDataCheckbox: + 'Also delete plugin configuration and persistence storage', confirmDelete: 'Confirm Delete', deleteError: 'Delete failed: ', close: 'Close', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index a4c6c536..af366d9b 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -200,7 +200,8 @@ const jaJP = { saveConfig: '設定を保存', saving: '保存中...', confirmDeletePlugin: - 'プラグイン「{{author}}/{{name}}」を削除してもよろしいですか?この操作により、プラグインの設定も削除されます。', + 'プラグイン「{{author}}/{{name}}」を削除してもよろしいですか?', + deleteDataCheckbox: 'プラグイン設定と永続化ストレージも削除する', confirmDelete: '削除を確認', deleteError: '削除に失敗しました:', close: '閉じる', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index b33ab51d..8c1ac3d3 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -191,8 +191,8 @@ const zhHans = { cancel: '取消', saveConfig: '保存配置', saving: '保存中...', - confirmDeletePlugin: - '你确定要删除插件({{author}}/{{name}})吗?这将同时删除插件的配置。', + confirmDeletePlugin: '你确定要删除插件({{author}}/{{name}})吗?', + deleteDataCheckbox: '同时删除插件配置和持久化存储', confirmDelete: '确认删除', deleteError: '删除失败:', close: '关闭', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 96c1f609..ab3ce9a6 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -192,6 +192,7 @@ const zhHant = { saveConfig: '儲存設定', saving: '儲存中...', confirmDeletePlugin: '您確定要刪除外掛({{author}}/{{name}})嗎?', + deleteDataCheckbox: '同時刪除外掛設定和持久化儲存', confirmDelete: '確認刪除', deleteError: '刪除失敗:', close: '關閉',