From 824145f79cea1d62290612d4699389d0c864e225 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=92=8B=E9=87=91=E9=BE=99=EF=BC=88=E5=A4=9C=E9=9B=A8?= =?UTF-8?q?=EF=BC=89?= Date: Tue, 10 Mar 2026 10:16:41 +0800 Subject: [PATCH 01/36] fix(wecombot): improve dify streaming reply handling --- src/langbot/libs/wecom_ai_bot_api/api.py | 15 +- src/langbot/pkg/pipeline/respback/respback.py | 29 ++-- src/langbot/pkg/platform/sources/wecombot.py | 27 +++- src/langbot/pkg/provider/runners/difysvapi.py | 57 ++------ .../pipeline/test_wecombot_dify_minfix.py | 135 ++++++++++++++++++ 5 files changed, 202 insertions(+), 61 deletions(-) create mode 100644 tests/unit_tests/pipeline/test_wecombot_dify_minfix.py diff --git a/src/langbot/libs/wecom_ai_bot_api/api.py b/src/langbot/libs/wecom_ai_bot_api/api.py index c5f5d84df..de6fe8fdd 100644 --- a/src/langbot/libs/wecom_ai_bot_api/api.py +++ b/src/langbot/libs/wecom_ai_bot_api/api.py @@ -135,6 +135,13 @@ async def publish(self, stream_id: str, chunk: StreamChunk) -> bool: session.last_access = time.time() session.last_chunk = chunk + # 企业微信消费的是当前完整快照,保留最新片段即可,避免旧片段堆积导致显示延迟。 + while not session.queue.empty(): + try: + session.queue.get_nowait() + except asyncio.QueueEmpty: + break + try: session.queue.put_nowait(chunk) except asyncio.QueueFull: @@ -234,7 +241,7 @@ def __init__(self, Token: str, EnCodingAESKey: str, Corpid: str, logger: EventLo 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 + self.stream_poll_timeout = 0.15 @staticmethod def _build_stream_payload(stream_id: str, content: str, finish: bool) -> dict[str, Any]: @@ -431,6 +438,12 @@ async def _handle_post_callback(self, req) -> tuple[Response, int] | Response: self.stream_sessions.cleanup() + # 清理过期的消息ID记录,保留最近1000条,防止内存无限增长 + if len(self.msg_id_map) > 1000: + # 只保留最近的500条记录 + recent_items = list(self.msg_id_map.items())[-500:] + self.msg_id_map = dict(recent_items) + msg_signature = unquote(req.args.get('msg_signature', '')) timestamp = unquote(req.args.get('timestamp', '')) nonce = unquote(req.args.get('nonce', '')) diff --git a/src/langbot/pkg/pipeline/respback/respback.py b/src/langbot/pkg/pipeline/respback/respback.py index 574404bcf..ba382b882 100644 --- a/src/langbot/pkg/pipeline/respback/respback.py +++ b/src/langbot/pkg/pipeline/respback/respback.py @@ -19,16 +19,21 @@ class SendResponseBackStage(stage.PipelineStage): async def process(self, query: pipeline_query.Query, stage_inst_name: str) -> entities.StageProcessResult: """处理""" - random_range = ( - query.pipeline_config['output']['force-delay']['min'], - query.pipeline_config['output']['force-delay']['max'], - ) + has_chunks = any(isinstance(msg, provider_message.MessageChunk) for msg in query.resp_messages) + is_stream_mode = await query.adapter.is_stream_output_supported() and has_chunks + + # 流式模式下跳过强制延迟,确保首字快速响应 + if not is_stream_mode: + random_range = ( + query.pipeline_config['output']['force-delay']['min'], + query.pipeline_config['output']['force-delay']['max'], + ) - random_delay = random.uniform(*random_range) + random_delay = random.uniform(*random_range) - self.ap.logger.debug('根据规则强制延迟回复: %s s', random_delay) + self.ap.logger.debug('根据规则强制延迟回复: %s s', random_delay) - await asyncio.sleep(random_delay) + await asyncio.sleep(random_delay) if query.pipeline_config['output']['misc']['at-sender'] and isinstance( query.message_event, platform_events.GroupMessage @@ -37,16 +42,18 @@ async def process(self, query: pipeline_query.Query, stage_inst_name: str) -> en quote_origin = query.pipeline_config['output']['misc']['quote-origin'] - has_chunks = any(isinstance(msg, provider_message.MessageChunk) for msg in query.resp_messages) # TODO 命令与流式的兼容性问题 - if await query.adapter.is_stream_output_supported() and has_chunks: - is_final = [msg.is_final for msg in query.resp_messages][0] + if is_stream_mode: + latest_chunk = next( + (msg for msg in reversed(query.resp_messages) if isinstance(msg, provider_message.MessageChunk)), + None, + ) await query.adapter.reply_message_chunk( message_source=query.message_event, bot_message=query.resp_messages[-1], message=query.resp_message_chain[-1], quote_origin=quote_origin, - is_final=is_final, + is_final=latest_chunk.is_final if latest_chunk else False, ) else: await query.adapter.reply_message( diff --git a/src/langbot/pkg/platform/sources/wecombot.py b/src/langbot/pkg/platform/sources/wecombot.py index 724a32b28..3898ed6b1 100644 --- a/src/langbot/pkg/platform/sources/wecombot.py +++ b/src/langbot/pkg/platform/sources/wecombot.py @@ -16,11 +16,26 @@ class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverter): @staticmethod async def yiri2target(message_chain: platform_message.MessageChain): - content = '' + content_parts: list[str] = [] for msg in message_chain: - if type(msg) is platform_message.Plain: - content += msg.text - return content + if isinstance(msg, platform_message.Plain): + content_parts.append(msg.text) + continue + + if isinstance(msg, platform_message.Quote): + quote_text = await WecomBotMessageConverter.yiri2target(msg.origin) + if quote_text: + content_parts.append(quote_text) + continue + + if isinstance(msg, (platform_message.Source, platform_message.At, platform_message.AtAll)): + continue + + rendered_text = str(msg).strip() + if rendered_text: + content_parts.append(rendered_text) + + return ''.join(content_parts) @staticmethod async def target2yiri(event: WecomBotEvent): @@ -258,6 +273,10 @@ async def is_stream_output_supported(self) -> bool: 流水线执行阶段会调用此方法以确认是否启用流式。""" return True + async def create_message_card(self, message_id: str, event) -> bool: + """企微智能机器人不需要创建卡片,流式消息直接通过 stream 协议推送。""" + return False + async def send_message(self, target_type, target_id, message): pass diff --git a/src/langbot/pkg/provider/runners/difysvapi.py b/src/langbot/pkg/provider/runners/difysvapi.py index 98da78c18..2b5668592 100644 --- a/src/langbot/pkg/provider/runners/difysvapi.py +++ b/src/langbot/pkg/provider/runners/difysvapi.py @@ -72,28 +72,6 @@ def _process_thinking_content( content = f'\n{thinking_content}\n\n{content}'.strip() return content, thinking_content - def _extract_dify_text_output(self, value: typing.Any) -> str: - """Extract text content from Dify output payload.""" - if value is None: - return '' - if isinstance(value, dict): - content = value.get('content') - if isinstance(content, str): - return content - return json.dumps(value, ensure_ascii=False) - if isinstance(value, str): - text = value.strip() - if not text: - return '' - try: - parsed = json.loads(text) - except json.JSONDecodeError: - return value - if isinstance(parsed, dict) and isinstance(parsed.get('content'), str): - return parsed['content'] - return value - return str(value) - async def _preprocess_user_message(self, query: pipeline_query.Query) -> tuple[str, list[dict]]: """预处理用户消息,提取纯文本,并将图片/文件上传到 Dify 服务 @@ -214,8 +192,7 @@ async def _chat_messages( if mode == 'workflow': if chunk['event'] == 'node_finished': if chunk['data']['node_type'] == 'answer': - answer = self._extract_dify_text_output(chunk['data']['outputs'].get('answer')) - content, _ = self._process_thinking_content(answer) + content, _ = self._process_thinking_content(chunk['data']['outputs']['answer']) yield provider_message.Message( role='assistant', @@ -428,7 +405,6 @@ async def _chat_messages_chunk( for f in upload_files ] - mode = 'basic' basic_mode_pending_chunk = '' inputs = {} @@ -454,12 +430,11 @@ async def _chat_messages_chunk( ): self.ap.logger.debug('dify-chat-chunk: ' + str(chunk)) - if chunk['event'] == 'workflow_started': - mode = 'workflow' - elif chunk['event'] in ('node_started', 'node_finished', 'workflow_finished'): - # Some Dify deployments may omit workflow_started in streamed chunks. - mode = 'workflow' - + # if chunk['event'] == 'workflow_started': + # mode = 'workflow' + # if mode == 'workflow': + # elif mode == 'basic': + # 因为都只是返回的 message也没有工具调用什么的,暂时不分类 if chunk['event'] == 'message': message_idx += 1 if remove_think: @@ -482,19 +457,9 @@ async def _chat_messages_chunk( if chunk['event'] == 'message_end': is_final = True - elif chunk['event'] == 'workflow_finished': - is_final = True - if chunk['data'].get('error'): - raise errors.DifyAPIError(chunk['data']['error']) - - if mode == 'workflow' and chunk['event'] == 'node_finished': - if chunk['data'].get('node_type') == 'answer': - answer = self._extract_dify_text_output(chunk['data'].get('outputs', {}).get('answer')) - if answer: - basic_mode_pending_chunk = answer - if (is_final or message_idx % 8 == 0) and (basic_mode_pending_chunk != '' or is_final): - # content, _ = self._process_thinking_content(basic_mode_pending_chunk) + # 确保 is_final=True 时一定会 yield,即使 basic_mode_pending_chunk 为空也要发送最终标记 + if is_final or (basic_mode_pending_chunk and (message_idx == 1 or message_idx % 8 == 0)): yield provider_message.MessageChunk( role='assistant', content=basic_mode_pending_chunk, @@ -617,7 +582,8 @@ async def _agent_chat_messages_chunk( if chunk['event'] == 'error': raise errors.DifyAPIError('dify 服务错误: ' + chunk['message']) - if message_idx % 8 == 0 or is_final: + # 确保 is_final=True 时一定会 yield,即使内容为空也要发送最终标记 + if is_final or (pending_agent_message and (message_idx == 1 or message_idx % 8 == 0)): yield provider_message.MessageChunk( role='assistant', content=pending_agent_message, @@ -722,7 +688,8 @@ async def _workflow_messages_chunk( yield msg - if messsage_idx % 8 == 0 or is_final: + # 确保 is_final=True 时一定会 yield,即使内容为空也要发送最终标记 + if is_final or (workflow_contents and (messsage_idx == 1 or messsage_idx % 8 == 0)): yield provider_message.MessageChunk( role='assistant', content=workflow_contents, diff --git a/tests/unit_tests/pipeline/test_wecombot_dify_minfix.py b/tests/unit_tests/pipeline/test_wecombot_dify_minfix.py new file mode 100644 index 000000000..9fdf953a7 --- /dev/null +++ b/tests/unit_tests/pipeline/test_wecombot_dify_minfix.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import sys +import types +from importlib import import_module +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +import langbot_plugin.api.entities.builtin.platform.message as platform_message +import langbot_plugin.api.entities.builtin.provider.message as provider_message +import langbot_plugin.api.entities.builtin.provider.session as provider_session + +# Avoid importing the full application graph during isolated unit tests. +logger_stub = types.ModuleType('langbot.pkg.platform.logger') +logger_stub.EventLogger = object +sys.modules.setdefault('langbot.pkg.platform.logger', logger_stub) + + +def get_respback_stage(): + import_module('langbot.pkg.pipeline.pipelinemgr') + return import_module('langbot.pkg.pipeline.respback.respback').SendResponseBackStage + + +def get_wecom_converter(): + return import_module('langbot.pkg.platform.sources.wecombot').WecomBotMessageConverter + + +def get_dify_runner(): + return import_module('langbot.pkg.provider.runners.difysvapi').DifyServiceAPIRunner + + +def get_stream_types(): + api_module = import_module('langbot.libs.wecom_ai_bot_api.api') + return api_module.StreamChunk, api_module.StreamSessionManager + + +@pytest.mark.asyncio +async def test_respback_uses_latest_chunk_final_flag(mock_app, sample_query): + SendResponseBackStage = get_respback_stage() + stage = SendResponseBackStage(mock_app) + sample_query.pipeline_config['output']['force-delay'] = {'min': 0, 'max': 0} + sample_query.adapter.is_stream_output_supported = AsyncMock(return_value=True) + sample_query.resp_messages = [ + provider_message.MessageChunk(role='assistant', content='he', is_final=False), + provider_message.MessageChunk(role='assistant', content='hello', is_final=True), + ] + sample_query.resp_message_chain = [platform_message.MessageChain([platform_message.Plain(text='hello')])] + + result = await stage.process(sample_query, 'SendResponseBackStage') + + assert result.new_query is sample_query + sample_query.adapter.reply_message_chunk.assert_awaited_once() + assert sample_query.adapter.reply_message_chunk.await_args.kwargs['is_final'] is True + + +@pytest.mark.asyncio +async def test_wecombot_converter_keeps_quote_and_non_plain_components(): + WecomBotMessageConverter = get_wecom_converter() + message_chain = platform_message.MessageChain( + [ + platform_message.Quote( + id='origin-1', + origin=platform_message.MessageChain([platform_message.Plain(text='引用内容')]), + ), + platform_message.Image(url='https://example.com/a.png'), + platform_message.Plain(text='直接回复'), + ] + ) + + content = await WecomBotMessageConverter.yiri2target(message_chain) + + assert '引用内容' in content + assert '[Image]' in content + assert content.endswith('直接回复') + + +@pytest.mark.asyncio +async def test_dify_stream_emits_first_chunk_immediately(): + DifyServiceAPIRunner = get_dify_runner() + app = Mock() + app.logger = Mock() + runner = DifyServiceAPIRunner( + app, + { + 'ai': { + 'dify-service-api': { + 'app-type': 'chat', + 'api-key': 'test-key', + 'base-url': 'https://example.com/v1', + 'base-prompt': '', + } + }, + 'output': {'misc': {'remove-think': False}}, + }, + ) + + async def fake_chat_messages(**kwargs): + yield {'event': 'message', 'answer': '你', 'conversation_id': 'conv-2'} + yield {'event': 'message_end', 'conversation_id': 'conv-2'} + + runner.dify_client = SimpleNamespace(chat_messages=fake_chat_messages, base_url='https://example.com/v1') + + query = SimpleNamespace( + session=SimpleNamespace( + using_conversation=SimpleNamespace(uuid='conv-1'), + launcher_type=provider_session.LauncherTypes.PERSON, + launcher_id='user-1', + ), + variables={}, + user_message=provider_message.Message(role='user', content='hello'), + ) + + chunks = [chunk async for chunk in runner._chat_messages_chunk(query)] + + assert chunks[0].content == '你' + assert chunks[-1].is_final is True + assert query.session.using_conversation.uuid == 'conv-2' + + +@pytest.mark.asyncio +async def test_stream_session_manager_keeps_latest_snapshot_only(): + StreamChunk, StreamSessionManager = get_stream_types() + manager = StreamSessionManager(logger=Mock()) + session, _ = manager.create_or_get({'msgid': 'msg-1', 'from': {'userid': 'user-1'}}) + + await manager.publish(session.stream_id, StreamChunk(content='a', is_final=False)) + await manager.publish(session.stream_id, StreamChunk(content='abc', is_final=True)) + + chunk = await manager.consume(session.stream_id, timeout=0.01) + + assert chunk is not None + assert chunk.content == 'abc' + assert chunk.is_final is True From 17704e463ed94125db6be769a6bd94f458ee62ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=92=8B=E9=87=91=E9=BE=99=EF=BC=88=E5=A4=9C=E9=9B=A8?= =?UTF-8?q?=EF=BC=89?= <58286951@qq.com> Date: Tue, 10 Mar 2026 22:31:14 +0800 Subject: [PATCH 02/36] perf(wecombot): optimize streaming message performance - Reduce stream_poll_timeout from 0.15s to 0.05s for faster response - Change yield condition from % 8 to % 2 for higher push frequency - Skip plugin events for intermediate streaming chunks to reduce latency - Fix yield condition to ensure is_final is always sent - Add msg_id_map cleanup to prevent memory bloat - Update version to 4.9.0-wecom.1 Co-Authored-By: Claude Opus 4.6 --- pyproject.toml | 2 +- src/langbot/__init__.py | 2 +- src/langbot/libs/wecom_ai_bot_api/api.py | 2 +- src/langbot/pkg/pipeline/wrapper/wrapper.py | 72 +++++++++++-------- src/langbot/pkg/provider/runners/difysvapi.py | 9 ++- 5 files changed, 52 insertions(+), 35 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0bd8a5bc6..c2a02ed05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "langbot" -version = "4.9.0" +version = "4.9.0.post1" description = "Production-grade platform for building agentic IM bots" readme = "README.md" license-files = ["LICENSE"] diff --git a/src/langbot/__init__.py b/src/langbot/__init__.py index 970cb5491..ff05b6d06 100644 --- a/src/langbot/__init__.py +++ b/src/langbot/__init__.py @@ -1,3 +1,3 @@ """LangBot - Production-grade platform for building agentic IM bots""" -__version__ = '4.9.0' +__version__ = '4.9.0.post1' diff --git a/src/langbot/libs/wecom_ai_bot_api/api.py b/src/langbot/libs/wecom_ai_bot_api/api.py index de6fe8fdd..e20b7240f 100644 --- a/src/langbot/libs/wecom_ai_bot_api/api.py +++ b/src/langbot/libs/wecom_ai_bot_api/api.py @@ -241,7 +241,7 @@ def __init__(self, Token: str, EnCodingAESKey: str, Corpid: str, logger: EventLo self.generated_content: dict[str, str] = {} self.msg_id_map: dict[str, int] = {} self.stream_sessions = StreamSessionManager(logger=logger) - self.stream_poll_timeout = 0.15 + self.stream_poll_timeout = 0.05 # 缩短轮询超时,提升流式响应速度 @staticmethod def _build_stream_payload(stream_id: str, content: str, finish: bool) -> dict[str, Any]: diff --git a/src/langbot/pkg/pipeline/wrapper/wrapper.py b/src/langbot/pkg/pipeline/wrapper/wrapper.py index a1ebc97a2..981da3f8b 100644 --- a/src/langbot/pkg/pipeline/wrapper/wrapper.py +++ b/src/langbot/pkg/pipeline/wrapper/wrapper.py @@ -8,6 +8,7 @@ import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query import langbot_plugin.api.entities.events as events +import langbot_plugin.api.entities.builtin.provider.message as provider_message @stage.stage_class('ResponseWrapper') @@ -50,7 +51,9 @@ async def process( else: if query.resp_messages[-1].role == 'assistant': result = query.resp_messages[-1] - session = await self.ap.sess_mgr.get_session(query) + + # 判断是否为流式中间 chunk(非最终 chunk),跳过插件事件以提升性能 + is_streaming_chunk = isinstance(result, provider_message.MessageChunk) and not result.is_final reply_text = '' @@ -58,40 +61,49 @@ async def process( reply_text = str(result.get_content_platform_message_chain()) # ============= 触发插件事件 =============== - event = events.NormalMessageResponded( - launcher_type=query.launcher_type.value, - launcher_id=query.launcher_id, - sender_id=query.sender_id, - session=session, - prefix='', - response_text=reply_text, - finish_reason='stop', - funcs_called=[fc.function.name for fc in result.tool_calls] - if result.tool_calls is not None - else [], - query=query, - ) - - # Get bound plugins for filtering - bound_plugins = query.variables.get('_pipeline_bound_plugins', None) - event_ctx = await self.ap.plugin_connector.emit_event(event, bound_plugins) - - if event_ctx.is_prevented_default(): + # 流式中间 chunk 跳过插件事件,只在最终 chunk 或非流式消息时触发 + if is_streaming_chunk: + query.resp_message_chain.append(result.get_content_platform_message_chain()) yield entities.StageProcessResult( - result_type=entities.ResultType.INTERRUPT, + result_type=entities.ResultType.CONTINUE, new_query=query, ) else: - if event_ctx.event.reply_message_chain is not None: - query.resp_message_chain.append(event_ctx.event.reply_message_chain) + session = await self.ap.sess_mgr.get_session(query) + event = events.NormalMessageResponded( + launcher_type=query.launcher_type.value, + launcher_id=query.launcher_id, + sender_id=query.sender_id, + session=session, + prefix='', + response_text=reply_text, + finish_reason='stop', + funcs_called=[fc.function.name for fc in result.tool_calls] + if result.tool_calls is not None + else [], + query=query, + ) + # Get bound plugins for filtering + bound_plugins = query.variables.get('_pipeline_bound_plugins', None) + event_ctx = await self.ap.plugin_connector.emit_event(event, bound_plugins) + + if event_ctx.is_prevented_default(): + yield entities.StageProcessResult( + result_type=entities.ResultType.INTERRUPT, + new_query=query, + ) else: - query.resp_message_chain.append(result.get_content_platform_message_chain()) + if event_ctx.event.reply_message_chain is not None: + query.resp_message_chain.append(event_ctx.event.reply_message_chain) - yield entities.StageProcessResult( - result_type=entities.ResultType.CONTINUE, - new_query=query, - ) + else: + query.resp_message_chain.append(result.get_content_platform_message_chain()) + + yield entities.StageProcessResult( + result_type=entities.ResultType.CONTINUE, + new_query=query, + ) if result.tool_calls is not None and len(result.tool_calls) > 0: # 有函数调用 function_names = [tc.function.name for tc in result.tool_calls] @@ -102,7 +114,9 @@ async def process( platform_message.MessageChain([platform_message.Plain(text=reply_text)]) ) - if query.pipeline_config['output']['misc']['track-function-calls']: + # 流式中间 chunk 跳过函数调用追踪事件 + if not is_streaming_chunk and query.pipeline_config['output']['misc']['track-function-calls']: + session = await self.ap.sess_mgr.get_session(query) event = events.NormalMessageResponded( launcher_type=query.launcher_type.value, launcher_id=query.launcher_id, diff --git a/src/langbot/pkg/provider/runners/difysvapi.py b/src/langbot/pkg/provider/runners/difysvapi.py index 2b5668592..f8ff5c478 100644 --- a/src/langbot/pkg/provider/runners/difysvapi.py +++ b/src/langbot/pkg/provider/runners/difysvapi.py @@ -459,7 +459,8 @@ async def _chat_messages_chunk( is_final = True # 确保 is_final=True 时一定会 yield,即使 basic_mode_pending_chunk 为空也要发送最终标记 - if is_final or (basic_mode_pending_chunk and (message_idx == 1 or message_idx % 8 == 0)): + # 优化:降低 yield 频率条件,从每 8 个 chunk 改为每 2 个,提升流式响应流畅度 + if is_final or (basic_mode_pending_chunk and (message_idx == 1 or message_idx % 2 == 0)): yield provider_message.MessageChunk( role='assistant', content=basic_mode_pending_chunk, @@ -583,7 +584,8 @@ async def _agent_chat_messages_chunk( if chunk['event'] == 'error': raise errors.DifyAPIError('dify 服务错误: ' + chunk['message']) # 确保 is_final=True 时一定会 yield,即使内容为空也要发送最终标记 - if is_final or (pending_agent_message and (message_idx == 1 or message_idx % 8 == 0)): + # 优化:降低 yield 频率条件,从每 8 个 chunk 改为每 2 个,提升流式响应流畅度 + if is_final or (pending_agent_message and (message_idx == 1 or message_idx % 2 == 0)): yield provider_message.MessageChunk( role='assistant', content=pending_agent_message, @@ -689,7 +691,8 @@ async def _workflow_messages_chunk( yield msg # 确保 is_final=True 时一定会 yield,即使内容为空也要发送最终标记 - if is_final or (workflow_contents and (messsage_idx == 1 or messsage_idx % 8 == 0)): + # 优化:降低 yield 频率条件,从每 8 个 chunk 改为每 2 个,提升流式响应流畅度 + if is_final or (workflow_contents and (messsage_idx == 1 or messsage_idx % 2 == 0)): yield provider_message.MessageChunk( role='assistant', content=workflow_contents, From 7d3190fb94d1e362a04fa921e5c9c34316c9738f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=92=8B=E9=87=91=E9=BE=99=EF=BC=88=E5=A4=9C=E9=9B=A8?= =?UTF-8?q?=EF=BC=89?= <58286951@qq.com> Date: Wed, 11 Mar 2026 12:40:58 +0800 Subject: [PATCH 03/36] fix(wecombot): harden pull stream lifecycle --- .gitignore | 1 + src/langbot/libs/wecom_ai_bot_api/api.py | 164 +++++++++++++++++- src/langbot/pkg/platform/sources/wecombot.py | 16 ++ .../pkg/platform/sources/wecombot.yaml | 42 ++++- src/langbot/pkg/provider/runners/difysvapi.py | 26 ++- .../pipeline/test_wecombot_dify_minfix.py | 146 ++++++++++++++++ uv.lock | 2 +- 7 files changed, 380 insertions(+), 17 deletions(-) diff --git a/.gitignore b/.gitignore index 7d870c336..e517c1457 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,4 @@ src/langbot/web/ /dist /build *.egg-info +.serena/ diff --git a/src/langbot/libs/wecom_ai_bot_api/api.py b/src/langbot/libs/wecom_ai_bot_api/api.py index e20b7240f..79fe88d12 100644 --- a/src/langbot/libs/wecom_ai_bot_api/api.py +++ b/src/langbot/libs/wecom_ai_bot_api/api.py @@ -207,7 +207,17 @@ def cleanup(self) -> None: class WecomBotClient: - def __init__(self, Token: str, EnCodingAESKey: str, Corpid: str, logger: EventLogger, unified_mode: bool = False): + def __init__( + self, + Token: str, + EnCodingAESKey: str, + Corpid: str, + logger: EventLogger, + unified_mode: bool = False, + stream_poll_timeout: float = 0.15, + stream_max_lifetime: float = 120, + pending_placeholder: str = 'AI 正在思考中,请稍候...', + ): """企业微信智能机器人客户端。 Args: @@ -241,7 +251,104 @@ def __init__(self, Token: str, EnCodingAESKey: str, Corpid: str, logger: EventLo self.generated_content: dict[str, str] = {} self.msg_id_map: dict[str, int] = {} self.stream_sessions = StreamSessionManager(logger=logger) - self.stream_poll_timeout = 0.05 # 缩短轮询超时,提升流式响应速度 + self.stream_poll_timeout = max(0.05, stream_poll_timeout) + self.stream_max_lifetime = max(1.0, stream_max_lifetime) + self.pending_placeholder = pending_placeholder + self.stream_timeout_final_text = '抱歉,处理超时,请稍后重试。' + self.stream_error_final_text = '抱歉,处理失败,请稍后重试。' + + async def _log_stream_debug( + self, + action: str, + stream_id: str, + session: Optional[StreamSession] = None, + source: str = '', + chunk: Optional[StreamChunk] = None, + ) -> None: + """记录流式会话关键路径日志,便于在消息记录中排查轮询与收口问题。""" + age_ms = -1 + msg_id = '' + if session: + msg_id = session.msg_id + age_ms = int((time.time() - session.created_at) * 1000) + + content_bytes = 0 + finish = False + if chunk: + finish = chunk.is_final + content_bytes = len((chunk.content or '').encode('utf-8')) + + await self.logger.debug( + '[wecom-stream] ' + f'action={action} ' + f'stream_id={stream_id or "-"} ' + f'msg_id={msg_id or "-"} ' + f'source={source or "-"} ' + f'finish={str(finish).lower()} ' + f'age_ms={age_ms} ' + f'content_bytes={content_bytes}' + ) + + def _is_stream_lifetime_exceeded(self, session: StreamSession) -> bool: + """判断当前 stream 是否已经超过最大生命周期。""" + if session.finished: + return False + return time.time() - session.created_at >= self.stream_max_lifetime + + async def _force_finish_stream( + self, + stream_id: str, + content: str, + reason: str, + ) -> Optional[StreamChunk]: + """强制结束未正常收口的 stream,避免企微持续轮询后展示官方兜底文案。""" + if not stream_id: + return None + + chunk = StreamChunk(content=content, is_final=True, meta={'reason': reason}) + published = await self.stream_sessions.publish(stream_id, chunk) + self.stream_sessions.mark_finished(stream_id) + + session = self.stream_sessions.get_session(stream_id) + if session: + session.last_chunk = chunk + session.finished = True + session.last_access = time.time() + + await self._log_stream_debug( + action='force_finish', + stream_id=stream_id, + session=session, + source=reason if published else f'{reason}_no_queue', + chunk=chunk, + ) + return chunk + + def _resolve_followup_chunk( + self, + session: Optional[StreamSession], + cached_content: Optional[str], + ) -> Optional[StreamChunk]: + """为 follow-up 请求返回兜底片段,避免企微收到空内容后展示官方兜底文案。""" + if cached_content is not None: + return StreamChunk(content=cached_content, is_final=True, meta={'reason': 'cached_final'}) + + if session and session.last_chunk: + if 'reason' not in session.last_chunk.meta: + session.last_chunk.meta['reason'] = 'last_snapshot' + return session.last_chunk + + if session: + placeholder_chunk = StreamChunk( + content=self.pending_placeholder, + is_final=False, + meta={'reason': 'pending_placeholder'}, + ) + session.last_chunk = placeholder_chunk + session.last_access = time.time() + return placeholder_chunk + + return None @staticmethod def _build_stream_payload(stream_id: str, content: str, finish: bool) -> dict[str, Any]: @@ -304,6 +411,11 @@ async def _dispatch_event(self, event: wecombotevent.WecomBotEvent) -> None: await self._handle_message(event) except Exception: await self.logger.error(traceback.format_exc()) + await self._force_finish_stream( + str(event.get('stream_id', '')), + self.stream_error_final_text, + 'dispatch_exception', + ) async def _handle_post_initial_response(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]: """处理企业微信首次推送的消息,返回 stream_id 并开启流水线。 @@ -327,10 +439,22 @@ async def _handle_post_initial_response(self, msg_json: dict[str, Any], nonce: s event = wecombotevent.WecomBotEvent(message_data) except Exception: await self.logger.error(traceback.format_exc()) + await self._force_finish_stream( + session.stream_id, + self.stream_error_final_text, + 'event_build_failed', + ) else: if is_new: asyncio.create_task(self._dispatch_event(event)) + await self._log_stream_debug( + action='initial_response', + stream_id=session.stream_id, + session=session, + source='new' if is_new else 'reuse', + ) + payload = self._build_stream_payload(session.stream_id, '', False) return await self._encrypt_and_reply(payload, nonce) @@ -354,18 +478,46 @@ async def _handle_post_followup_response(self, msg_json: dict[str, Any], nonce: return await self._encrypt_and_reply(self._build_stream_payload('', '', True), nonce) session = self.stream_sessions.get_session(stream_id) + if not session: + chunk = StreamChunk(content=self.stream_error_final_text, is_final=True, meta={'reason': 'missing_session'}) + await self._log_stream_debug( + action='followup_response', + stream_id=stream_id, + source='missing_session', + chunk=chunk, + ) + payload = self._build_stream_payload(stream_id, chunk.content, chunk.is_final) + return await self._encrypt_and_reply(payload, nonce) + + if self._is_stream_lifetime_exceeded(session): + timeout_content = session.last_chunk.content if session.last_chunk else self.stream_timeout_final_text + chunk = await self._force_finish_stream(stream_id, timeout_content, 'max_lifetime_exceeded') + payload = self._build_stream_payload(stream_id, chunk.content if chunk else '', True) + return await self._encrypt_and_reply(payload, nonce) + chunk = await self.stream_sessions.consume(stream_id, timeout=self.stream_poll_timeout) if not chunk: + if self._is_stream_lifetime_exceeded(session): + timeout_content = session.last_chunk.content if session.last_chunk else self.stream_timeout_final_text + chunk = await self._force_finish_stream(stream_id, timeout_content, 'max_lifetime_exceeded') + cached_content = None - if session and session.msg_id: + if not chunk 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: + if not chunk: + chunk = self._resolve_followup_chunk(session, cached_content) + if not chunk: payload = self._build_stream_payload(stream_id, '', False) return await self._encrypt_and_reply(payload, nonce) + await self._log_stream_debug( + action='followup_response', + stream_id=stream_id, + session=session, + source=chunk.meta.get('reason', 'queue'), + chunk=chunk, + ) payload = self._build_stream_payload(stream_id, chunk.content, chunk.is_final) if chunk.is_final: self.stream_sessions.mark_finished(stream_id) diff --git a/src/langbot/pkg/platform/sources/wecombot.py b/src/langbot/pkg/platform/sources/wecombot.py index 3898ed6b1..0c660019c 100644 --- a/src/langbot/pkg/platform/sources/wecombot.py +++ b/src/langbot/pkg/platform/sources/wecombot.py @@ -198,18 +198,34 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): config: dict bot_uuid: str = None + @staticmethod + def _get_int_config(config: dict, key: str, default: int, min_value: int, max_value: int) -> int: + value = config.get(key, default) + try: + value = int(value) + except (TypeError, ValueError): + value = default + return max(min_value, min(max_value, value)) + def __init__(self, config: dict, logger: EventLogger): required_keys = ['Token', 'EncodingAESKey', 'Corpid', 'BotId'] missing_keys = [key for key in required_keys if key not in config] if missing_keys: raise Exception(f'WecomBot 缺少配置项: {missing_keys}') + pull_poll_timeout_ms = self._get_int_config(config, 'PullPollTimeoutMs', 150, 50, 2000) + pull_stream_max_lifetime_ms = self._get_int_config(config, 'PullStreamMaxLifetimeMs', 120000, 1000, 600000) + pending_placeholder = config.get('PullPendingPlaceholder', 'AI 正在思考中,请稍候...') + bot = WecomBotClient( Token=config['Token'], EnCodingAESKey=config['EncodingAESKey'], Corpid=config['Corpid'], logger=logger, unified_mode=True, + stream_poll_timeout=pull_poll_timeout_ms / 1000, + stream_max_lifetime=pull_stream_max_lifetime_ms / 1000, + pending_placeholder=pending_placeholder, ) bot_account_id = config['BotId'] diff --git a/src/langbot/pkg/platform/sources/wecombot.yaml b/src/langbot/pkg/platform/sources/wecombot.yaml index 31a13bc6d..4c988a2bc 100644 --- a/src/langbot/pkg/platform/sources/wecombot.yaml +++ b/src/langbot/pkg/platform/sources/wecombot.yaml @@ -39,7 +39,47 @@ spec: type: string required: false default: "" + - name: PullChunkBatchSize + label: + en_US: Pull Chunk Batch Size + zh_Hans: Pull 模式 Chunk 批大小 + description: + en_US: In pull mode, merge every N chunks before returning a refreshed snapshot. Larger values reduce update frequency and lower the risk of rate limiting. + zh_Hans: Pull 模式下,每累计 N 个 chunk 再返回一次新的流式快照。值越大更新越少,更不容易触发企微限流。 + type: integer + required: true + default: 2 + - name: PullPollTimeoutMs + label: + en_US: Pull Poll Timeout (ms) + zh_Hans: Pull 模式轮询等待时间(毫秒) + description: + en_US: How long LangBot waits for a new chunk before replying to WeCom follow-up polling. Higher values reduce empty polling and repeated snapshots, but may slightly increase latency. + zh_Hans: LangBot 在响应企微 follow-up 轮询前,等待新 chunk 的时间。值越大越不容易出现空轮询和重复快照,但延迟会略增。 + type: integer + required: true + default: 150 + - name: PullStreamMaxLifetimeMs + label: + en_US: Pull Stream Max Lifetime (ms) + zh_Hans: Pull 模式 Stream 最大生命周期(毫秒) + description: + en_US: Maximum lifetime of a single WeCom pull stream. Once exceeded, LangBot will force finish the stream with the latest snapshot to avoid endless polling. + zh_Hans: 单个企微 Pull 流式会话的最大生命周期。超过后 LangBot 会使用最后快照强制 finish,避免企微无休止轮询后展示官方兜底文案。 + type: integer + required: true + default: 120000 + - name: PullPendingPlaceholder + label: + en_US: Pull Pending Placeholder + zh_Hans: Pull 模式等待占位文案 + description: + en_US: Temporary placeholder returned before the first chunk arrives, preventing WeCom from showing its fallback error message. + zh_Hans: 首个 chunk 到达前返回的临时占位文案,用于避免企微展示官方兜底错误提示。 + type: string + required: true + default: "AI 正在思考中,请稍候..." execution: python: path: ./wecombot.py - attr: WecomBotAdapter \ No newline at end of file + attr: WecomBotAdapter diff --git a/src/langbot/pkg/provider/runners/difysvapi.py b/src/langbot/pkg/provider/runners/difysvapi.py index f8ff5c478..edd761f54 100644 --- a/src/langbot/pkg/provider/runners/difysvapi.py +++ b/src/langbot/pkg/provider/runners/difysvapi.py @@ -72,6 +72,17 @@ def _process_thinking_content( content = f'\n{thinking_content}\n\n{content}'.strip() return content, thinking_content + @staticmethod + def _get_stream_chunk_batch_size(query: pipeline_query.Query) -> int: + adapter = getattr(query, 'adapter', None) + adapter_config = getattr(adapter, 'config', {}) or {} + value = adapter_config.get('PullChunkBatchSize', 2) + try: + value = int(value) + except (TypeError, ValueError): + value = 2 + return max(1, min(20, value)) + async def _preprocess_user_message(self, query: pipeline_query.Query) -> tuple[str, list[dict]]: """预处理用户消息,提取纯文本,并将图片/文件上传到 Dify 服务 @@ -419,6 +430,7 @@ async def _chat_messages_chunk( think_end = False remove_think = self.pipeline_config['output'].get('misc', '').get('remove-think') + chunk_batch_size = self._get_stream_chunk_batch_size(query) async for chunk in self.dify_client.chat_messages( inputs=inputs, @@ -458,9 +470,7 @@ async def _chat_messages_chunk( if chunk['event'] == 'message_end': is_final = True - # 确保 is_final=True 时一定会 yield,即使 basic_mode_pending_chunk 为空也要发送最终标记 - # 优化:降低 yield 频率条件,从每 8 个 chunk 改为每 2 个,提升流式响应流畅度 - if is_final or (basic_mode_pending_chunk and (message_idx == 1 or message_idx % 2 == 0)): + if is_final or (basic_mode_pending_chunk and (message_idx == 1 or message_idx % chunk_batch_size == 0)): yield provider_message.MessageChunk( role='assistant', content=basic_mode_pending_chunk, @@ -505,6 +515,7 @@ async def _agent_chat_messages_chunk( think_end = False remove_think = self.pipeline_config['output'].get('misc', '').get('remove-think') + chunk_batch_size = self._get_stream_chunk_batch_size(query) async for chunk in self.dify_client.chat_messages( inputs=inputs, @@ -583,9 +594,7 @@ async def _agent_chat_messages_chunk( if chunk['event'] == 'error': raise errors.DifyAPIError('dify 服务错误: ' + chunk['message']) - # 确保 is_final=True 时一定会 yield,即使内容为空也要发送最终标记 - # 优化:降低 yield 频率条件,从每 8 个 chunk 改为每 2 个,提升流式响应流畅度 - if is_final or (pending_agent_message and (message_idx == 1 or message_idx % 2 == 0)): + if is_final or (pending_agent_message and (message_idx == 1 or message_idx % chunk_batch_size == 0)): yield provider_message.MessageChunk( role='assistant', content=pending_agent_message, @@ -635,6 +644,7 @@ async def _workflow_messages_chunk( workflow_contents = '' remove_think = self.pipeline_config['output'].get('misc', '').get('remove-think') + chunk_batch_size = self._get_stream_chunk_batch_size(query) async for chunk in self.dify_client.workflow_run( inputs=inputs, user=f'{query.session.launcher_type.value}_{query.session.launcher_id}', @@ -690,9 +700,7 @@ async def _workflow_messages_chunk( yield msg - # 确保 is_final=True 时一定会 yield,即使内容为空也要发送最终标记 - # 优化:降低 yield 频率条件,从每 8 个 chunk 改为每 2 个,提升流式响应流畅度 - if is_final or (workflow_contents and (messsage_idx == 1 or messsage_idx % 2 == 0)): + if is_final or (workflow_contents and (messsage_idx == 1 or messsage_idx % chunk_batch_size == 0)): yield provider_message.MessageChunk( role='assistant', content=workflow_contents, diff --git a/tests/unit_tests/pipeline/test_wecombot_dify_minfix.py b/tests/unit_tests/pipeline/test_wecombot_dify_minfix.py index 9fdf953a7..dc1f8f05b 100644 --- a/tests/unit_tests/pipeline/test_wecombot_dify_minfix.py +++ b/tests/unit_tests/pipeline/test_wecombot_dify_minfix.py @@ -36,6 +36,19 @@ def get_stream_types(): return api_module.StreamChunk, api_module.StreamSessionManager +def get_wecom_client(): + return import_module('langbot.libs.wecom_ai_bot_api.api').WecomBotClient + + +def make_async_logger(): + return SimpleNamespace( + debug=AsyncMock(), + info=AsyncMock(), + warning=AsyncMock(), + error=AsyncMock(), + ) + + @pytest.mark.asyncio async def test_respback_uses_latest_chunk_final_flag(mock_app, sample_query): SendResponseBackStage = get_respback_stage() @@ -119,6 +132,51 @@ async def fake_chat_messages(**kwargs): assert query.session.using_conversation.uuid == 'conv-2' +@pytest.mark.asyncio +async def test_dify_stream_respects_configured_pull_chunk_batch_size(): + DifyServiceAPIRunner = get_dify_runner() + app = Mock() + app.logger = Mock() + runner = DifyServiceAPIRunner( + app, + { + 'ai': { + 'dify-service-api': { + 'app-type': 'chat', + 'api-key': 'test-key', + 'base-url': 'https://example.com/v1', + 'base-prompt': '', + } + }, + 'output': {'misc': {'remove-think': False}}, + }, + ) + + async def fake_chat_messages(**kwargs): + yield {'event': 'message', 'answer': '你', 'conversation_id': 'conv-2'} + yield {'event': 'message', 'answer': '好', 'conversation_id': 'conv-2'} + yield {'event': 'message', 'answer': '呀', 'conversation_id': 'conv-2'} + yield {'event': 'message_end', 'conversation_id': 'conv-2'} + + runner.dify_client = SimpleNamespace(chat_messages=fake_chat_messages, base_url='https://example.com/v1') + + query = SimpleNamespace( + adapter=SimpleNamespace(config={'PullChunkBatchSize': 3}), + session=SimpleNamespace( + using_conversation=SimpleNamespace(uuid='conv-1'), + launcher_type=provider_session.LauncherTypes.PERSON, + launcher_id='user-1', + ), + variables={}, + user_message=provider_message.Message(role='user', content='hello'), + ) + + chunks = [chunk async for chunk in runner._chat_messages_chunk(query)] + + assert [chunk.content for chunk in chunks] == ['你', '你好呀', '你好呀'] + assert chunks[-1].is_final is True + + @pytest.mark.asyncio async def test_stream_session_manager_keeps_latest_snapshot_only(): StreamChunk, StreamSessionManager = get_stream_types() @@ -133,3 +191,91 @@ async def test_stream_session_manager_keeps_latest_snapshot_only(): assert chunk is not None assert chunk.content == 'abc' assert chunk.is_final is True + + +def test_wecom_followup_uses_placeholder_before_first_chunk(): + WecomBotClient = get_wecom_client() + client = WecomBotClient( + Token='token', + EnCodingAESKey='aes', + Corpid='corp', + logger=Mock(), + pending_placeholder='思考中...', + ) + + _, StreamSessionManager = get_stream_types() + session, _ = client.stream_sessions.create_or_get({'msgid': 'msg-2', 'from': {'userid': 'user-2'}}) + fallback_chunk = client._resolve_followup_chunk(session, None) + + assert fallback_chunk is not None + assert fallback_chunk.content == '思考中...' + assert fallback_chunk.is_final is False + assert session.last_chunk is fallback_chunk + + +def test_wecom_followup_prefers_latest_snapshot_over_empty_response(): + WecomBotClient = get_wecom_client() + StreamChunk, _ = get_stream_types() + client = WecomBotClient( + Token='token', + EnCodingAESKey='aes', + Corpid='corp', + logger=Mock(), + ) + + session, _ = client.stream_sessions.create_or_get({'msgid': 'msg-3', 'from': {'userid': 'user-3'}}) + session.last_chunk = StreamChunk(content='最新快照', is_final=False) + + fallback_chunk = client._resolve_followup_chunk(session, None) + + assert fallback_chunk is session.last_chunk + assert fallback_chunk.content == '最新快照' + + +@pytest.mark.asyncio +async def test_wecom_followup_forces_finish_after_stream_timeout(): + WecomBotClient = get_wecom_client() + StreamChunk, _ = get_stream_types() + client = WecomBotClient( + Token='token', + EnCodingAESKey='aes', + Corpid='corp', + logger=make_async_logger(), + stream_max_lifetime=1, + ) + + session, _ = client.stream_sessions.create_or_get({'msgid': 'msg-4', 'from': {'userid': 'user-4'}}) + session.created_at -= 5 + session.last_chunk = StreamChunk(content='进行中的回答', is_final=False) + + client.stream_sessions.consume = AsyncMock(return_value=None) + client._encrypt_and_reply = AsyncMock(side_effect=lambda payload, nonce: (payload, 200)) + + await client._handle_post_followup_response({'stream': {'id': session.stream_id}}, 'nonce') + + payload = client._encrypt_and_reply.await_args.args[0] + assert payload['stream']['content'] == '进行中的回答' + assert payload['stream']['finish'] is True + assert client.stream_sessions.get_session(session.stream_id).finished is True + + +@pytest.mark.asyncio +async def test_wecom_dispatch_exception_forces_finish(): + WecomBotClient = get_wecom_client() + StreamChunk, _ = get_stream_types() + client = WecomBotClient( + Token='token', + EnCodingAESKey='aes', + Corpid='corp', + logger=make_async_logger(), + ) + + session, _ = client.stream_sessions.create_or_get({'msgid': 'msg-5', 'from': {'userid': 'user-5'}}) + client._handle_message = AsyncMock(side_effect=RuntimeError('boom')) + + await client._dispatch_event({'msgid': 'msg-5', 'type': 'text', 'stream_id': session.stream_id}) + + chunk = await client.stream_sessions.consume(session.stream_id, timeout=0.01) + assert isinstance(chunk, StreamChunk) + assert chunk.is_final is True + assert chunk.content == client.stream_error_final_text diff --git a/uv.lock b/uv.lock index 3c8c1b7c1..71ba485a3 100644 --- a/uv.lock +++ b/uv.lock @@ -1832,7 +1832,7 @@ wheels = [ [[package]] name = "langbot" -version = "4.9.0" +version = "4.9.0.post1" source = { editable = "." } dependencies = [ { name = "aiocqhttp" }, From 6f32b3b0202aad6ac1e820d9c0c05e90c23156e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=92=8B=E9=87=91=E9=BE=99=EF=BC=88=E5=A4=9C=E9=9B=A8?= =?UTF-8?q?=EF=BC=89?= <58286951@qq.com> Date: Wed, 11 Mar 2026 12:59:52 +0800 Subject: [PATCH 04/36] perf(docker): optimize build cache for faster rebuilds Move dependency installation before source code copy to leverage Docker layer caching. Code changes no longer trigger dependency reinstallation, reducing build time from 2-3 minutes to 10-20 seconds. Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index b30d21b0e..3a9fcaaab 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,14 +10,19 @@ FROM python:3.12.7-slim WORKDIR /app -COPY . . - -COPY --from=node /app/web/out ./web/out +# 先复制依赖文件,利用 Docker 层缓存 +COPY pyproject.toml uv.lock ./ +# 安装系统依赖和 Python 依赖 RUN apt update \ && apt install gcc -y \ && python -m pip install --no-cache-dir uv \ && uv sync \ && touch /.dockerenv -CMD [ "uv", "run", "--no-sync", "main.py" ] \ No newline at end of file +# 再复制源代码,代码变化不会触发依赖重装 +COPY . . + +COPY --from=node /app/web/out ./web/out + +CMD [ "uv", "run", "--no-sync", "main.py" ] From 25b6318f9420107eb9db88aeb4fca4d13aa8eee3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=92=8B=E9=87=91=E9=BE=99=EF=BC=88=E5=A4=9C=E9=9B=A8?= =?UTF-8?q?=EF=BC=89?= <58286951@qq.com> Date: Wed, 11 Mar 2026 13:22:39 +0800 Subject: [PATCH 05/36] perf(docker): reduce image size by 400MB - Use `uv sync --no-dev` to exclude dev dependencies - Remove gcc after build with apt-get purge - Clean apt cache with rm -rf /var/lib/apt/lists/* - Expand .dockerignore to exclude docs, tests, and other non-runtime files Image size reduced from 2.07GB to 1.67GB (19% reduction). Co-Authored-By: Claude Opus 4.6 --- .dockerignore | 28 ++++++++++++++++++++++++++++ Dockerfile | 7 +++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/.dockerignore b/.dockerignore index d18057c9b..f6a588dc7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,8 +1,36 @@ +# Version control .github +.git + +# Development .venv .vscode +.idea + +# Data and temp .data .temp +*.log + +# Frontend build artifacts web/.next web/node_modules web/.env + +# Documentation (not needed in runtime) +docs +*.md +!README.md + +# Tests (not needed in runtime) +tests +pytest.ini + +# CI/CD +.gitlab-ci.yml +.travis.yml + +# Misc +*.pyc +__pycache__ +.DS_Store diff --git a/Dockerfile b/Dockerfile index 3a9fcaaab..cb2a2db5c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,11 +13,14 @@ WORKDIR /app # 先复制依赖文件,利用 Docker 层缓存 COPY pyproject.toml uv.lock ./ -# 安装系统依赖和 Python 依赖 +# 安装系统依赖和 Python 依赖(排除开发依赖) RUN apt update \ && apt install gcc -y \ && python -m pip install --no-cache-dir uv \ - && uv sync \ + && uv sync --no-dev \ + && apt-get purge -y gcc \ + && apt-get autoremove -y \ + && rm -rf /var/lib/apt/lists/* \ && touch /.dockerenv # 再复制源代码,代码变化不会触发依赖重装 From 039a6d4fce7932ab7a02aecb14ccfbd156f90c7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=92=8B=E9=87=91=E9=BE=99=EF=BC=88=E5=A4=9C=E9=9B=A8?= =?UTF-8?q?=EF=BC=89?= <58286951@qq.com> Date: Wed, 11 Mar 2026 14:35:57 +0800 Subject: [PATCH 06/36] fix(docker): set python path for src layout --- Dockerfile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index cb2a2db5c..340f5a0c2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,8 @@ FROM python:3.12.7-slim WORKDIR /app +ENV PYTHONPATH=/app/src + # 先复制依赖文件,利用 Docker 层缓存 COPY pyproject.toml uv.lock ./ @@ -28,4 +30,4 @@ COPY . . COPY --from=node /app/web/out ./web/out -CMD [ "uv", "run", "--no-sync", "main.py" ] +CMD [ "uv", "run", "--no-sync", "python", "-m", "langbot" ] From 2d5ef8d9bd5e2eb3385bac1ff0dd85d3c7e7d595 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=92=8B=E9=87=91=E9=BE=99=EF=BC=88=E5=A4=9C=E9=9B=A8?= =?UTF-8?q?=EF=BC=89?= <58286951@qq.com> Date: Wed, 11 Mar 2026 15:05:16 +0800 Subject: [PATCH 07/36] fix(release): align version and compose image source --- docker/docker-compose.yaml | 12 ++++++++---- pyproject.toml | 2 +- src/langbot/__init__.py | 2 +- uv.lock | 2 +- 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 6971477dc..eb8795f33 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -1,11 +1,12 @@ # Docker Compose configuration for LangBot # For Kubernetes deployment, see kubernetes.yaml and README_K8S.md -version: "3" - services: langbot_plugin_runtime: - image: rockchin/langbot:latest + build: + context: .. + dockerfile: Dockerfile + image: bjhx2003/langbot:feat-wecom container_name: langbot_plugin_runtime volumes: - ./data/plugins:/app/data/plugins @@ -19,7 +20,10 @@ services: - langbot_network langbot: - image: rockchin/langbot:latest + build: + context: .. + dockerfile: Dockerfile + image: bjhx2003/langbot:feat-wecom container_name: langbot volumes: - ./data:/app/data diff --git a/pyproject.toml b/pyproject.toml index c2a02ed05..501c1d7ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "langbot" -version = "4.9.0.post1" +version = "4.9.0.post2" description = "Production-grade platform for building agentic IM bots" readme = "README.md" license-files = ["LICENSE"] diff --git a/src/langbot/__init__.py b/src/langbot/__init__.py index ff05b6d06..fe44d0b67 100644 --- a/src/langbot/__init__.py +++ b/src/langbot/__init__.py @@ -1,3 +1,3 @@ """LangBot - Production-grade platform for building agentic IM bots""" -__version__ = '4.9.0.post1' +__version__ = '4.9.0.post2' diff --git a/uv.lock b/uv.lock index 71ba485a3..b23cfc6ba 100644 --- a/uv.lock +++ b/uv.lock @@ -1832,7 +1832,7 @@ wheels = [ [[package]] name = "langbot" -version = "4.9.0.post1" +version = "4.9.0.post2" source = { editable = "." } dependencies = [ { name = "aiocqhttp" }, From 92b157012023a8b3963d01326dffec3545014c27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=92=8B=E9=87=91=E9=BE=99=EF=BC=88=E5=A4=9C=E9=9B=A8?= =?UTF-8?q?=EF=BC=89?= <58286951@qq.com> Date: Wed, 11 Mar 2026 15:54:56 +0800 Subject: [PATCH 08/36] =?UTF-8?q?fix(wecombot):=20=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E4=BC=81=E5=BE=AE=E6=8B=89=E6=B5=81=E9=85=8D=E7=BD=AE=E5=88=86?= =?UTF-8?q?=E5=B1=82=E4=B8=8E=E6=94=B6=E5=8F=A3=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 对齐版本号到 4.9.0.post2,并同步 pyproject、包版本与 uv.lock\n- 将企微通道层配置保留在机器人适配器中,新增占位文案延迟配置\n- 将 chunk 批大小与时间窗口下沉到流水线输出配置,补齐默认值与 UI 元数据\n- 将 Dify 流式输出改为 chunk 阈值或时间窗口双触发,并保证 final 立即收口\n- 优化 pull 模式占位文案逻辑,仅在首字超时后返回占位文案\n- 补充企微与 Dify 定向单元测试,覆盖双阈值 flush、占位延迟与最终收口场景 --- pyproject.toml | 2 +- src/langbot/__init__.py | 2 +- src/langbot/libs/wecom_ai_bot_api/api.py | 16 ++- src/langbot/pkg/platform/sources/wecombot.py | 4 +- .../pkg/platform/sources/wecombot.yaml | 22 ++-- src/langbot/pkg/provider/runners/difysvapi.py | 107 ++++++++++++++++-- .../templates/default-pipeline-config.json | 4 + .../templates/metadata/pipeline/output.yaml | 28 +++++ .../pipeline/test_wecombot_dify_minfix.py | 78 ++++++++++++- uv.lock | 2 +- 10 files changed, 239 insertions(+), 26 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 501c1d7ba..80121d6b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "langbot" -version = "4.9.0.post2" +version = "4.9.0.post3" description = "Production-grade platform for building agentic IM bots" readme = "README.md" license-files = ["LICENSE"] diff --git a/src/langbot/__init__.py b/src/langbot/__init__.py index fe44d0b67..c9642abab 100644 --- a/src/langbot/__init__.py +++ b/src/langbot/__init__.py @@ -1,3 +1,3 @@ """LangBot - Production-grade platform for building agentic IM bots""" -__version__ = '4.9.0.post2' +__version__ = '4.9.0.post3' diff --git a/src/langbot/libs/wecom_ai_bot_api/api.py b/src/langbot/libs/wecom_ai_bot_api/api.py index 79fe88d12..f776e0687 100644 --- a/src/langbot/libs/wecom_ai_bot_api/api.py +++ b/src/langbot/libs/wecom_ai_bot_api/api.py @@ -216,7 +216,8 @@ def __init__( unified_mode: bool = False, stream_poll_timeout: float = 0.15, stream_max_lifetime: float = 120, - pending_placeholder: str = 'AI 正在思考中,请稍候...', + pending_placeholder: str = 'AI 正在思考中,请稍候', + pending_placeholder_delay: float = 1.2, ): """企业微信智能机器人客户端。 @@ -254,6 +255,7 @@ def __init__( self.stream_poll_timeout = max(0.05, stream_poll_timeout) self.stream_max_lifetime = max(1.0, stream_max_lifetime) self.pending_placeholder = pending_placeholder + self.pending_placeholder_delay = max(0.0, pending_placeholder_delay) self.stream_timeout_final_text = '抱歉,处理超时,请稍后重试。' self.stream_error_final_text = '抱歉,处理失败,请稍后重试。' @@ -339,6 +341,10 @@ def _resolve_followup_chunk( return session.last_chunk if session: + elapsed = time.time() - session.created_at + if elapsed < self.pending_placeholder_delay: + return None + placeholder_chunk = StreamChunk( content=self.pending_placeholder, is_final=False, @@ -495,7 +501,13 @@ async def _handle_post_followup_response(self, msg_json: dict[str, Any], nonce: payload = self._build_stream_payload(stream_id, chunk.content if chunk else '', True) return await self._encrypt_and_reply(payload, nonce) - chunk = await self.stream_sessions.consume(stream_id, timeout=self.stream_poll_timeout) + consume_timeout = self.stream_poll_timeout + if not session.last_chunk and not session.finished and self.pending_placeholder_delay > 0: + remaining_delay = self.pending_placeholder_delay - (time.time() - session.created_at) + if remaining_delay > 0: + consume_timeout = max(consume_timeout, remaining_delay) + + chunk = await self.stream_sessions.consume(stream_id, timeout=consume_timeout) if not chunk: if self._is_stream_lifetime_exceeded(session): diff --git a/src/langbot/pkg/platform/sources/wecombot.py b/src/langbot/pkg/platform/sources/wecombot.py index 0c660019c..497b7ed7e 100644 --- a/src/langbot/pkg/platform/sources/wecombot.py +++ b/src/langbot/pkg/platform/sources/wecombot.py @@ -215,7 +215,8 @@ def __init__(self, config: dict, logger: EventLogger): pull_poll_timeout_ms = self._get_int_config(config, 'PullPollTimeoutMs', 150, 50, 2000) pull_stream_max_lifetime_ms = self._get_int_config(config, 'PullStreamMaxLifetimeMs', 120000, 1000, 600000) - pending_placeholder = config.get('PullPendingPlaceholder', 'AI 正在思考中,请稍候...') + pending_placeholder_delay_ms = self._get_int_config(config, 'PullPendingPlaceholderDelayMs', 1200, 0, 10000) + pending_placeholder = config.get('PullPendingPlaceholder', 'AI 正在思考中,请稍候') bot = WecomBotClient( Token=config['Token'], @@ -226,6 +227,7 @@ def __init__(self, config: dict, logger: EventLogger): stream_poll_timeout=pull_poll_timeout_ms / 1000, stream_max_lifetime=pull_stream_max_lifetime_ms / 1000, pending_placeholder=pending_placeholder, + pending_placeholder_delay=pending_placeholder_delay_ms / 1000, ) bot_account_id = config['BotId'] diff --git a/src/langbot/pkg/platform/sources/wecombot.yaml b/src/langbot/pkg/platform/sources/wecombot.yaml index 4c988a2bc..bb3765694 100644 --- a/src/langbot/pkg/platform/sources/wecombot.yaml +++ b/src/langbot/pkg/platform/sources/wecombot.yaml @@ -39,16 +39,6 @@ spec: type: string required: false default: "" - - name: PullChunkBatchSize - label: - en_US: Pull Chunk Batch Size - zh_Hans: Pull 模式 Chunk 批大小 - description: - en_US: In pull mode, merge every N chunks before returning a refreshed snapshot. Larger values reduce update frequency and lower the risk of rate limiting. - zh_Hans: Pull 模式下,每累计 N 个 chunk 再返回一次新的流式快照。值越大更新越少,更不容易触发企微限流。 - type: integer - required: true - default: 2 - name: PullPollTimeoutMs label: en_US: Pull Poll Timeout (ms) @@ -69,6 +59,16 @@ spec: type: integer required: true default: 120000 + - name: PullPendingPlaceholderDelayMs + label: + en_US: Pull Pending Placeholder Delay (ms) + zh_Hans: Pull 模式占位文案延迟(毫秒) + description: + en_US: Only return the pending placeholder if no first token arrives within this delay window. + zh_Hans: 只有在这段延迟时间内仍未收到首字时,才会返回等待占位文案。 + type: integer + required: true + default: 1200 - name: PullPendingPlaceholder label: en_US: Pull Pending Placeholder @@ -78,7 +78,7 @@ spec: zh_Hans: 首个 chunk 到达前返回的临时占位文案,用于避免企微展示官方兜底错误提示。 type: string required: true - default: "AI 正在思考中,请稍候..." + default: "AI 正在思考中,请稍候" execution: python: path: ./wecombot.py diff --git a/src/langbot/pkg/provider/runners/difysvapi.py b/src/langbot/pkg/provider/runners/difysvapi.py index edd761f54..dea383397 100644 --- a/src/langbot/pkg/provider/runners/difysvapi.py +++ b/src/langbot/pkg/provider/runners/difysvapi.py @@ -5,7 +5,7 @@ import uuid import base64 import mimetypes - +import time from langbot.pkg.provider import runner from langbot.pkg.core import app @@ -74,15 +74,60 @@ def _process_thinking_content( @staticmethod def _get_stream_chunk_batch_size(query: pipeline_query.Query) -> int: - adapter = getattr(query, 'adapter', None) - adapter_config = getattr(adapter, 'config', {}) or {} - value = adapter_config.get('PullChunkBatchSize', 2) + pipeline_config = getattr(query, 'pipeline_config', {}) or {} + output_config = pipeline_config.get('output', {}) or {} + wecom_stream_config = output_config.get('wecom-stream', {}) or {} + value = wecom_stream_config.get('chunk-batch-size') + + if value is None: + adapter = getattr(query, 'adapter', None) + adapter_config = getattr(adapter, 'config', {}) or {} + value = adapter_config.get('PullChunkBatchSize', 2) + try: value = int(value) except (TypeError, ValueError): value = 2 return max(1, min(20, value)) + @staticmethod + def _get_stream_flush_window_ms(query: pipeline_query.Query) -> int: + pipeline_config = getattr(query, 'pipeline_config', {}) or {} + output_config = pipeline_config.get('output', {}) or {} + wecom_stream_config = output_config.get('wecom-stream', {}) or {} + value = wecom_stream_config.get('flush-window-ms', 2000) + + try: + value = int(value) + except (TypeError, ValueError): + value = 2000 + return max(200, min(10000, value)) + + @staticmethod + def _should_emit_stream_snapshot( + content: str, + is_final: bool, + pending_chunk_count: int, + chunk_batch_size: int, + flush_window_ms: int, + last_emitted_content: str, + last_emit_at: float, + ) -> bool: + if is_final: + return True + + if not content or content == last_emitted_content: + return False + + if last_emitted_content == '': + return True + + if pending_chunk_count >= chunk_batch_size: + return True + + elapsed_ms = (time.monotonic() - last_emit_at) * 1000 + return elapsed_ms >= flush_window_ms + async def _preprocess_user_message(self, query: pipeline_query.Query) -> tuple[str, list[dict]]: """预处理用户消息,提取纯文本,并将图片/文件上传到 Dify 服务 @@ -428,9 +473,13 @@ async def _chat_messages_chunk( is_final = False think_start = False think_end = False + last_emitted_content = '' + pending_chunk_count = 0 + last_emit_at = time.monotonic() remove_think = self.pipeline_config['output'].get('misc', '').get('remove-think') chunk_batch_size = self._get_stream_chunk_batch_size(query) + flush_window_ms = self._get_stream_flush_window_ms(query) async for chunk in self.dify_client.chat_messages( inputs=inputs, @@ -449,6 +498,7 @@ async def _chat_messages_chunk( # 因为都只是返回的 message也没有工具调用什么的,暂时不分类 if chunk['event'] == 'message': message_idx += 1 + pending_chunk_count += 1 if remove_think: if '' in chunk['answer'] and not think_start: think_start = True @@ -470,12 +520,23 @@ async def _chat_messages_chunk( if chunk['event'] == 'message_end': is_final = True - if is_final or (basic_mode_pending_chunk and (message_idx == 1 or message_idx % chunk_batch_size == 0)): + if self._should_emit_stream_snapshot( + content=basic_mode_pending_chunk, + is_final=is_final, + pending_chunk_count=pending_chunk_count, + chunk_batch_size=chunk_batch_size, + flush_window_ms=flush_window_ms, + last_emitted_content=last_emitted_content, + last_emit_at=last_emit_at, + ): yield provider_message.MessageChunk( role='assistant', content=basic_mode_pending_chunk, is_final=is_final, ) + last_emitted_content = basic_mode_pending_chunk + pending_chunk_count = 0 + last_emit_at = time.monotonic() if chunk is None: raise errors.DifyAPIError('Dify API 没有返回任何响应,请检查网络连接和API配置') @@ -513,9 +574,13 @@ async def _agent_chat_messages_chunk( is_final = False think_start = False think_end = False + last_emitted_content = '' + pending_chunk_count = 0 + last_emit_at = time.monotonic() remove_think = self.pipeline_config['output'].get('misc', '').get('remove-think') chunk_batch_size = self._get_stream_chunk_batch_size(query) + flush_window_ms = self._get_stream_flush_window_ms(query) async for chunk in self.dify_client.chat_messages( inputs=inputs, @@ -533,6 +598,7 @@ async def _agent_chat_messages_chunk( if chunk['event'] == 'agent_message': message_idx += 1 + pending_chunk_count += 1 if remove_think: if '' in chunk['answer'] and not think_start: think_start = True @@ -594,12 +660,23 @@ async def _agent_chat_messages_chunk( if chunk['event'] == 'error': raise errors.DifyAPIError('dify 服务错误: ' + chunk['message']) - if is_final or (pending_agent_message and (message_idx == 1 or message_idx % chunk_batch_size == 0)): + if self._should_emit_stream_snapshot( + content=pending_agent_message, + is_final=is_final, + pending_chunk_count=pending_chunk_count, + chunk_batch_size=chunk_batch_size, + flush_window_ms=flush_window_ms, + last_emitted_content=last_emitted_content, + last_emit_at=last_emit_at, + ): yield provider_message.MessageChunk( role='assistant', content=pending_agent_message, is_final=is_final, ) + last_emitted_content = pending_agent_message + pending_chunk_count = 0 + last_emit_at = time.monotonic() if chunk is None: raise errors.DifyAPIError('Dify API 没有返回任何响应,请检查网络连接和API配置') @@ -642,9 +719,13 @@ async def _workflow_messages_chunk( think_start = False think_end = False workflow_contents = '' + last_emitted_content = '' + pending_chunk_count = 0 + last_emit_at = time.monotonic() remove_think = self.pipeline_config['output'].get('misc', '').get('remove-think') chunk_batch_size = self._get_stream_chunk_batch_size(query) + flush_window_ms = self._get_stream_flush_window_ms(query) async for chunk in self.dify_client.workflow_run( inputs=inputs, user=f'{query.session.launcher_type.value}_{query.session.launcher_id}', @@ -661,6 +742,7 @@ async def _workflow_messages_chunk( if chunk['event'] == 'text_chunk': messsage_idx += 1 + pending_chunk_count += 1 if remove_think: if '' in chunk['data']['text'] and not think_start: think_start = True @@ -700,12 +782,23 @@ async def _workflow_messages_chunk( yield msg - if is_final or (workflow_contents and (messsage_idx == 1 or messsage_idx % chunk_batch_size == 0)): + if self._should_emit_stream_snapshot( + content=workflow_contents, + is_final=is_final, + pending_chunk_count=pending_chunk_count, + chunk_batch_size=chunk_batch_size, + flush_window_ms=flush_window_ms, + last_emitted_content=last_emitted_content, + last_emit_at=last_emit_at, + ): yield provider_message.MessageChunk( role='assistant', content=workflow_contents, is_final=is_final, ) + last_emitted_content = workflow_contents + pending_chunk_count = 0 + last_emit_at = time.monotonic() async def run(self, query: pipeline_query.Query) -> typing.AsyncGenerator[provider_message.Message, None]: """运行请求""" diff --git a/src/langbot/templates/default-pipeline-config.json b/src/langbot/templates/default-pipeline-config.json index ee47ef9f0..470726e1d 100644 --- a/src/langbot/templates/default-pipeline-config.json +++ b/src/langbot/templates/default-pipeline-config.json @@ -94,6 +94,10 @@ "min": 0, "max": 0 }, + "wecom-stream": { + "chunk-batch-size": 2, + "flush-window-ms": 2000 + }, "misc": { "hide-exception": true, "at-sender": true, diff --git a/src/langbot/templates/metadata/pipeline/output.yaml b/src/langbot/templates/metadata/pipeline/output.yaml index 1978dea47..5e1fc7b33 100644 --- a/src/langbot/templates/metadata/pipeline/output.yaml +++ b/src/langbot/templates/metadata/pipeline/output.yaml @@ -73,6 +73,34 @@ stages: type: integer required: true default: 0 + - name: wecom-stream + label: + en_US: WeCom Stream + zh_Hans: 企微流式 + description: + en_US: Stream tuning options for WeCom pull mode. Larger chunk batch size reduces refresh frequency and lowers the risk of rate limiting. + zh_Hans: 企业微信 Pull 流式的调优选项。Chunk 批大小越大,刷新越少,越不容易触发企微限流。 + config: + - name: chunk-batch-size + label: + en_US: Chunk Batch Size + zh_Hans: Chunk 批大小 + description: + en_US: Merge every N model chunks into one refreshed snapshot. + zh_Hans: 每累计 N 个模型 chunk 再输出一次新的流式快照。 + type: integer + required: true + default: 2 + - name: flush-window-ms + label: + en_US: Flush Window (ms) + zh_Hans: 时间窗口(毫秒) + description: + en_US: Emit a refreshed snapshot when content changed and the window elapsed, even if chunk batch size is not reached. + zh_Hans: 当内容有变化且时间窗口到达时,即使未达到 chunk 批大小,也会主动输出新的流式快照。 + type: integer + required: true + default: 2000 - name: misc label: en_US: Misc diff --git a/tests/unit_tests/pipeline/test_wecombot_dify_minfix.py b/tests/unit_tests/pipeline/test_wecombot_dify_minfix.py index dc1f8f05b..6fbcfe860 100644 --- a/tests/unit_tests/pipeline/test_wecombot_dify_minfix.py +++ b/tests/unit_tests/pipeline/test_wecombot_dify_minfix.py @@ -161,7 +161,7 @@ async def fake_chat_messages(**kwargs): runner.dify_client = SimpleNamespace(chat_messages=fake_chat_messages, base_url='https://example.com/v1') query = SimpleNamespace( - adapter=SimpleNamespace(config={'PullChunkBatchSize': 3}), + pipeline_config={'output': {'wecom-stream': {'chunk-batch-size': 3}}}, session=SimpleNamespace( using_conversation=SimpleNamespace(uuid='conv-1'), launcher_type=provider_session.LauncherTypes.PERSON, @@ -173,10 +173,66 @@ async def fake_chat_messages(**kwargs): chunks = [chunk async for chunk in runner._chat_messages_chunk(query)] - assert [chunk.content for chunk in chunks] == ['你', '你好呀', '你好呀'] + assert [chunk.content for chunk in chunks] == ['你', '你好呀'] assert chunks[-1].is_final is True +@pytest.mark.asyncio +async def test_dify_stream_flushes_on_time_window_before_batch_threshold(monkeypatch): + dify_module = import_module('langbot.pkg.provider.runners.difysvapi') + DifyServiceAPIRunner = dify_module.DifyServiceAPIRunner + app = Mock() + app.logger = Mock() + runner = DifyServiceAPIRunner( + app, + { + 'ai': { + 'dify-service-api': { + 'app-type': 'chat', + 'api-key': 'test-key', + 'base-url': 'https://example.com/v1', + 'base-prompt': '', + } + }, + 'output': {'misc': {'remove-think': False}}, + }, + ) + + async def fake_chat_messages(**kwargs): + yield {'event': 'message', 'answer': '你', 'conversation_id': 'conv-3'} + yield {'event': 'message', 'answer': '好', 'conversation_id': 'conv-3'} + yield {'event': 'message', 'answer': '呀', 'conversation_id': 'conv-3'} + yield {'event': 'message_end', 'conversation_id': 'conv-3'} + + monotonic_values = iter([0.0, 0.1, 0.2, 2.6, 2.7, 2.8]) + + def fake_monotonic(): + try: + return next(monotonic_values) + except StopIteration: + return 3.0 + + monkeypatch.setattr(dify_module.time, 'monotonic', fake_monotonic) + + runner.dify_client = SimpleNamespace(chat_messages=fake_chat_messages, base_url='https://example.com/v1') + + query = SimpleNamespace( + pipeline_config={'output': {'wecom-stream': {'chunk-batch-size': 8, 'flush-window-ms': 2000}}}, + session=SimpleNamespace( + using_conversation=SimpleNamespace(uuid='conv-1'), + launcher_type=provider_session.LauncherTypes.PERSON, + launcher_id='user-1', + ), + variables={}, + user_message=provider_message.Message(role='user', content='hello'), + ) + + chunks = [chunk async for chunk in runner._chat_messages_chunk(query)] + + assert [chunk.content for chunk in chunks] == ['你', '你好呀', '你好呀'] + assert [chunk.is_final for chunk in chunks] == [False, False, True] + + @pytest.mark.asyncio async def test_stream_session_manager_keeps_latest_snapshot_only(): StreamChunk, StreamSessionManager = get_stream_types() @@ -201,6 +257,7 @@ def test_wecom_followup_uses_placeholder_before_first_chunk(): Corpid='corp', logger=Mock(), pending_placeholder='思考中...', + pending_placeholder_delay=0, ) _, StreamSessionManager = get_stream_types() @@ -213,6 +270,23 @@ def test_wecom_followup_uses_placeholder_before_first_chunk(): assert session.last_chunk is fallback_chunk +def test_wecom_followup_delays_placeholder_before_window_expires(): + WecomBotClient = get_wecom_client() + client = WecomBotClient( + Token='token', + EnCodingAESKey='aes', + Corpid='corp', + logger=Mock(), + pending_placeholder='思考中...', + pending_placeholder_delay=2, + ) + + session, _ = client.stream_sessions.create_or_get({'msgid': 'msg-delay', 'from': {'userid': 'user-delay'}}) + fallback_chunk = client._resolve_followup_chunk(session, None) + + assert fallback_chunk is None + + def test_wecom_followup_prefers_latest_snapshot_over_empty_response(): WecomBotClient = get_wecom_client() StreamChunk, _ = get_stream_types() diff --git a/uv.lock b/uv.lock index b23cfc6ba..e300f9450 100644 --- a/uv.lock +++ b/uv.lock @@ -1832,7 +1832,7 @@ wheels = [ [[package]] name = "langbot" -version = "4.9.0.post2" +version = "4.9.0.post3" source = { editable = "." } dependencies = [ { name = "aiocqhttp" }, From 2ddddc24fdaff8fce5393cb88e870416a7781194 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=92=8B=E9=87=91=E9=BE=99=EF=BC=88=E5=A4=9C=E9=9B=A8?= =?UTF-8?q?=EF=BC=89?= <58286951@qq.com> Date: Wed, 11 Mar 2026 18:34:04 +0800 Subject: [PATCH 09/36] fix(difysvapi): handle workflow_finished event to properly end stream - Add workflow_finished event handling to set is_final=True - Add yielded_final flag to prevent duplicate final chunk yield - Fix WeCom continuous polling issue when Dify workflow ends Co-Authored-By: Claude Opus 4.6 --- src/langbot/pkg/provider/runners/difysvapi.py | 192 +++++++++++------- 1 file changed, 118 insertions(+), 74 deletions(-) diff --git a/src/langbot/pkg/provider/runners/difysvapi.py b/src/langbot/pkg/provider/runners/difysvapi.py index dea383397..0deefbac1 100644 --- a/src/langbot/pkg/provider/runners/difysvapi.py +++ b/src/langbot/pkg/provider/runners/difysvapi.py @@ -74,28 +74,21 @@ def _process_thinking_content( @staticmethod def _get_stream_chunk_batch_size(query: pipeline_query.Query) -> int: - pipeline_config = getattr(query, 'pipeline_config', {}) or {} - output_config = pipeline_config.get('output', {}) or {} - wecom_stream_config = output_config.get('wecom-stream', {}) or {} - value = wecom_stream_config.get('chunk-batch-size') - - if value is None: - adapter = getattr(query, 'adapter', None) - adapter_config = getattr(adapter, 'config', {}) or {} - value = adapter_config.get('PullChunkBatchSize', 2) + adapter = getattr(query, 'adapter', None) + adapter_config = getattr(adapter, 'config', {}) or {} + value = adapter_config.get('PullChunkBatchSize', 4) try: value = int(value) except (TypeError, ValueError): - value = 2 + value = 4 return max(1, min(20, value)) @staticmethod def _get_stream_flush_window_ms(query: pipeline_query.Query) -> int: - pipeline_config = getattr(query, 'pipeline_config', {}) or {} - output_config = pipeline_config.get('output', {}) or {} - wecom_stream_config = output_config.get('wecom-stream', {}) or {} - value = wecom_stream_config.get('flush-window-ms', 2000) + adapter = getattr(query, 'adapter', None) + adapter_config = getattr(adapter, 'config', {}) or {} + value = adapter_config.get('PullFlushWindowMs', 2000) try: value = int(value) @@ -471,6 +464,8 @@ async def _chat_messages_chunk( chunk = None # 初始化chunk变量,防止在没有响应时引用错误 is_final = False + stream_completed = False + final_snapshot_emitted = False think_start = False think_end = False last_emitted_content = '' @@ -497,28 +492,31 @@ async def _chat_messages_chunk( # elif mode == 'basic': # 因为都只是返回的 message也没有工具调用什么的,暂时不分类 if chunk['event'] == 'message': - message_idx += 1 - pending_chunk_count += 1 - if remove_think: - if '' in chunk['answer'] and not think_start: - think_start = True - continue - if '' in chunk['answer'] and not think_end: - import re - - content = re.sub(r'^\n', '', chunk['answer']) - basic_mode_pending_chunk += content - think_end = True - elif think_end: - basic_mode_pending_chunk += chunk['answer'] - if think_start: - continue - - else: - basic_mode_pending_chunk += chunk['answer'] - - if chunk['event'] == 'message_end': + answer = chunk.get('answer', '') + if answer != '': + message_idx += 1 + pending_chunk_count += 1 + if remove_think: + if '' in answer and not think_start: + think_start = True + continue + if '' in answer and not think_end: + import re + + content = re.sub(r'^\n', '', answer) + basic_mode_pending_chunk += content + think_end = True + elif think_end: + basic_mode_pending_chunk += answer + if think_start: + continue + + else: + basic_mode_pending_chunk += answer + + if chunk['event'] in ('message_end', 'workflow_finished'): is_final = True + stream_completed = True if self._should_emit_stream_snapshot( content=basic_mode_pending_chunk, @@ -534,6 +532,8 @@ async def _chat_messages_chunk( content=basic_mode_pending_chunk, is_final=is_final, ) + if is_final: + final_snapshot_emitted = True last_emitted_content = basic_mode_pending_chunk pending_chunk_count = 0 last_emit_at = time.monotonic() @@ -541,6 +541,16 @@ async def _chat_messages_chunk( if chunk is None: raise errors.DifyAPIError('Dify API 没有返回任何响应,请检查网络连接和API配置') + if stream_completed and not final_snapshot_emitted: + final_content = basic_mode_pending_chunk or last_emitted_content + if final_content: + self.ap.logger.debug('dify-chat-stream: emit final snapshot fallback') + yield provider_message.MessageChunk( + role='assistant', + content=final_content, + is_final=True, + ) + query.session.using_conversation.uuid = chunk['conversation_id'] async def _agent_chat_messages_chunk( @@ -572,6 +582,8 @@ async def _agent_chat_messages_chunk( chunk = None # 初始化chunk变量,防止在没有响应时引用错误 message_idx = 0 is_final = False + stream_completed = False + final_snapshot_emitted = False think_start = False think_end = False last_emitted_content = '' @@ -597,27 +609,30 @@ async def _agent_chat_messages_chunk( continue if chunk['event'] == 'agent_message': - message_idx += 1 - pending_chunk_count += 1 - if remove_think: - if '' in chunk['answer'] and not think_start: - think_start = True - continue - if '' in chunk['answer'] and not think_end: - import re - - content = re.sub(r'^\n', '', chunk['answer']) - pending_agent_message += content - think_end = True - elif think_end or not think_start: - pending_agent_message += chunk['answer'] - if think_start and not think_end: - continue - - else: - pending_agent_message += chunk['answer'] - elif chunk['event'] == 'message_end': + answer = chunk.get('answer', '') + if answer != '': + message_idx += 1 + pending_chunk_count += 1 + if remove_think: + if '' in answer and not think_start: + think_start = True + continue + if '' in answer and not think_end: + import re + + content = re.sub(r'^\n', '', answer) + pending_agent_message += content + think_end = True + elif think_end or not think_start: + pending_agent_message += answer + if think_start and not think_end: + continue + + else: + pending_agent_message += answer + elif chunk['event'] in ('message_end', 'workflow_finished'): is_final = True + stream_completed = True else: if chunk['event'] == 'agent_thought': if chunk['tool'] != '' and chunk['observation'] != '': # 工具调用结果,跳过 @@ -674,6 +689,8 @@ async def _agent_chat_messages_chunk( content=pending_agent_message, is_final=is_final, ) + if is_final: + final_snapshot_emitted = True last_emitted_content = pending_agent_message pending_chunk_count = 0 last_emit_at = time.monotonic() @@ -681,6 +698,16 @@ async def _agent_chat_messages_chunk( if chunk is None: raise errors.DifyAPIError('Dify API 没有返回任何响应,请检查网络连接和API配置') + if stream_completed and not final_snapshot_emitted: + final_content = pending_agent_message or last_emitted_content + if final_content: + self.ap.logger.debug('dify-agent-stream: emit final snapshot fallback') + yield provider_message.MessageChunk( + role='assistant', + content=final_content, + is_final=True, + ) + query.session.using_conversation.uuid = chunk['conversation_id'] async def _workflow_messages_chunk( @@ -716,6 +743,8 @@ async def _workflow_messages_chunk( inputs.update(query.variables) messsage_idx = 0 is_final = False + stream_completed = False + final_snapshot_emitted = False think_start = False think_end = False workflow_contents = '' @@ -737,29 +766,32 @@ async def _workflow_messages_chunk( continue if chunk['event'] == 'workflow_finished': is_final = True + stream_completed = True if chunk['data']['error']: raise errors.DifyAPIError(chunk['data']['error']) if chunk['event'] == 'text_chunk': - messsage_idx += 1 - pending_chunk_count += 1 - if remove_think: - if '' in chunk['data']['text'] and not think_start: - think_start = True - continue - if '' in chunk['data']['text'] and not think_end: - import re - - content = re.sub(r'^\n', '', chunk['data']['text']) - workflow_contents += content - think_end = True - elif think_end: - workflow_contents += chunk['data']['text'] - if think_start: - continue - - else: - workflow_contents += chunk['data']['text'] + text = chunk['data'].get('text', '') + if text != '': + messsage_idx += 1 + pending_chunk_count += 1 + if remove_think: + if '' in text and not think_start: + think_start = True + continue + if '' in text and not think_end: + import re + + content = re.sub(r'^\n', '', text) + workflow_contents += content + think_end = True + elif think_end: + workflow_contents += text + if think_start: + continue + + else: + workflow_contents += text if chunk['event'] == 'node_started': if chunk['data']['node_type'] == 'start' or chunk['data']['node_type'] == 'end': @@ -796,10 +828,22 @@ async def _workflow_messages_chunk( content=workflow_contents, is_final=is_final, ) + if is_final: + final_snapshot_emitted = True last_emitted_content = workflow_contents pending_chunk_count = 0 last_emit_at = time.monotonic() + if stream_completed and not final_snapshot_emitted: + final_content = workflow_contents or last_emitted_content + if final_content: + self.ap.logger.debug('dify-workflow-stream: emit final snapshot fallback') + yield provider_message.MessageChunk( + role='assistant', + content=final_content, + is_final=True, + ) + async def run(self, query: pipeline_query.Query) -> typing.AsyncGenerator[provider_message.Message, None]: """运行请求""" if await query.adapter.is_stream_output_supported(): From 632c817442ff152f353cbb49d186b6d028ce66da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=92=8B=E9=87=91=E9=BE=99=EF=BC=88=E5=A4=9C=E9=9B=A8?= =?UTF-8?q?=EF=BC=89?= <58286951@qq.com> Date: Wed, 11 Mar 2026 18:34:22 +0800 Subject: [PATCH 10/36] feat(wecombot): add toggle switch for pull mode pending placeholder - Add PullPendingPlaceholderEnabled config option (default: true) - Show delay and content fields only when enabled via visibleOn - Pass effective values to WecomBotClient based on switch state Co-Authored-By: Claude Opus 4.6 --- src/langbot/pkg/platform/sources/wecombot.py | 65 +++++++++++++++++-- .../pkg/platform/sources/wecombot.yaml | 42 ++++++++++-- 2 files changed, 96 insertions(+), 11 deletions(-) diff --git a/src/langbot/pkg/platform/sources/wecombot.py b/src/langbot/pkg/platform/sources/wecombot.py index 497b7ed7e..d72fe3ca6 100644 --- a/src/langbot/pkg/platform/sources/wecombot.py +++ b/src/langbot/pkg/platform/sources/wecombot.py @@ -2,6 +2,7 @@ import typing import asyncio import traceback +import hashlib import datetime import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter @@ -13,6 +14,17 @@ from langbot.libs.wecom_ai_bot_api.api import WecomBotClient +def _summarize_stream_text(content: str, tail_length: int = 32) -> dict[str, str | int]: + text = content or '' + encoded = text.encode('utf-8') + return { + 'chars': len(text), + 'bytes': len(encoded), + 'tail_repr': repr(text[-tail_length:]), + 'md5': hashlib.md5(encoded).hexdigest()[:12] if encoded else '0' * 12, + } + + class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverter): @staticmethod async def yiri2target(message_chain: platform_message.MessageChain): @@ -213,11 +225,27 @@ def __init__(self, config: dict, logger: EventLogger): if missing_keys: raise Exception(f'WecomBot 缺少配置项: {missing_keys}') - pull_poll_timeout_ms = self._get_int_config(config, 'PullPollTimeoutMs', 150, 50, 2000) - pull_stream_max_lifetime_ms = self._get_int_config(config, 'PullStreamMaxLifetimeMs', 120000, 1000, 600000) - pending_placeholder_delay_ms = self._get_int_config(config, 'PullPendingPlaceholderDelayMs', 1200, 0, 10000) + pull_poll_timeout_ms = self._get_int_config(config, 'PullPollTimeoutMs', 200, 50, 2000) + pull_stream_max_lifetime_ms = self._get_int_config(config, 'PullStreamMaxLifetimeMs', 300000, 1000, 600000) + pending_placeholder_enabled = config.get('PullPendingPlaceholderEnabled', True) + pending_placeholder_delay_ms = self._get_int_config(config, 'PullPendingPlaceholderDelayMs', 3000, 0, 10000) + pull_chunk_batch_size = self._get_int_config(config, 'PullChunkBatchSize', 4, 1, 20) + pull_flush_window_ms = self._get_int_config(config, 'PullFlushWindowMs', 2000, 200, 10000) pending_placeholder = config.get('PullPendingPlaceholder', 'AI 正在思考中,请稍候') + normalized_config = dict(config) + normalized_config['PullPollTimeoutMs'] = pull_poll_timeout_ms + normalized_config['PullStreamMaxLifetimeMs'] = pull_stream_max_lifetime_ms + normalized_config['PullPendingPlaceholderEnabled'] = pending_placeholder_enabled + normalized_config['PullPendingPlaceholderDelayMs'] = pending_placeholder_delay_ms + normalized_config['PullChunkBatchSize'] = pull_chunk_batch_size + normalized_config['PullFlushWindowMs'] = pull_flush_window_ms + normalized_config['PullPendingPlaceholder'] = pending_placeholder + + # 如果未开启首字等待占位,则将延迟设为0且占位文案设为空 + effective_placeholder_delay = pending_placeholder_delay_ms / 1000 if pending_placeholder_enabled else 0 + effective_placeholder = pending_placeholder if pending_placeholder_enabled else '' + bot = WecomBotClient( Token=config['Token'], EnCodingAESKey=config['EncodingAESKey'], @@ -226,13 +254,13 @@ def __init__(self, config: dict, logger: EventLogger): unified_mode=True, stream_poll_timeout=pull_poll_timeout_ms / 1000, stream_max_lifetime=pull_stream_max_lifetime_ms / 1000, - pending_placeholder=pending_placeholder, - pending_placeholder_delay=pending_placeholder_delay_ms / 1000, + pending_placeholder=effective_placeholder, + pending_placeholder_delay=effective_placeholder_delay, ) bot_account_id = config['BotId'] super().__init__( - config=config, + config=normalized_config, logger=logger, bot=bot, bot_account_id=bot_account_id, @@ -273,10 +301,35 @@ async def reply_message_chunk( # 转换为纯文本(智能机器人当前协议仅支持文本流) content = await self.message_converter.yiri2target(message) msg_id = message_source.source_platform_object.message_id + resp_message_id = getattr(bot_message, 'resp_message_id', '') if bot_message is not None else '' + summary = _summarize_stream_text(content) + + await self.logger.debug( + '[wecom-stream] ' + f'action=adapter_reply_chunk ' + f'msg_id={msg_id or "-"} ' + f'resp_message_id={resp_message_id or "-"} ' + f'finish={str(is_final).lower()} ' + f'content_chars={summary["chars"]} ' + f'content_bytes={summary["bytes"]} ' + f'content_tail={summary["tail_repr"]} ' + f'content_md5={summary["md5"]}' + ) # 将片段推送到 WecomBotClient 中的队列,返回值用于判断是否走降级逻辑 success = await self.bot.push_stream_chunk(msg_id, content, is_final=is_final) if not success and is_final: + await self.logger.debug( + '[wecom-stream] ' + f'action=adapter_reply_chunk_fallback ' + f'msg_id={msg_id or "-"} ' + f'resp_message_id={resp_message_id or "-"} ' + f'finish=true ' + f'content_chars={summary["chars"]} ' + f'content_bytes={summary["bytes"]} ' + f'content_tail={summary["tail_repr"]} ' + f'content_md5={summary["md5"]}' + ) # 未命中流式队列时使用旧有 set_message 兜底 await self.bot.set_message(msg_id, content) return {'stream': success} diff --git a/src/langbot/pkg/platform/sources/wecombot.yaml b/src/langbot/pkg/platform/sources/wecombot.yaml index bb3765694..9b9494583 100644 --- a/src/langbot/pkg/platform/sources/wecombot.yaml +++ b/src/langbot/pkg/platform/sources/wecombot.yaml @@ -48,7 +48,7 @@ spec: zh_Hans: LangBot 在响应企微 follow-up 轮询前,等待新 chunk 的时间。值越大越不容易出现空轮询和重复快照,但延迟会略增。 type: integer required: true - default: 150 + default: 200 - name: PullStreamMaxLifetimeMs label: en_US: Pull Stream Max Lifetime (ms) @@ -58,7 +58,17 @@ spec: zh_Hans: 单个企微 Pull 流式会话的最大生命周期。超过后 LangBot 会使用最后快照强制 finish,避免企微无休止轮询后展示官方兜底文案。 type: integer required: true - default: 120000 + default: 300000 + - name: PullPendingPlaceholderEnabled + label: + en_US: Pull Pending Placeholder Enabled + zh_Hans: Pull 模式首字等待占位开关 + description: + en_US: When enabled, return a pending placeholder if no first chunk arrives within the delay window. + zh_Hans: 开启后,若在延迟时间内未收到首字,则返回等待占位文案;关闭则不返回任何占位。 + type: boolean + required: false + default: true - name: PullPendingPlaceholderDelayMs label: en_US: Pull Pending Placeholder Delay (ms) @@ -67,8 +77,9 @@ spec: en_US: Only return the pending placeholder if no first token arrives within this delay window. zh_Hans: 只有在这段延迟时间内仍未收到首字时,才会返回等待占位文案。 type: integer - required: true - default: 1200 + required: false + default: 3000 + visibleOn: ${PullPendingPlaceholderEnabled} == true - name: PullPendingPlaceholder label: en_US: Pull Pending Placeholder @@ -77,8 +88,29 @@ spec: en_US: Temporary placeholder returned before the first chunk arrives, preventing WeCom from showing its fallback error message. zh_Hans: 首个 chunk 到达前返回的临时占位文案,用于避免企微展示官方兜底错误提示。 type: string - required: true + required: false default: "AI 正在思考中,请稍候" + visibleOn: ${PullPendingPlaceholderEnabled} == true + - name: PullChunkBatchSize + label: + en_US: Pull Chunk Batch Size + zh_Hans: Pull 模式 Chunk 批大小 + description: + en_US: Merge every N model chunks into one refreshed snapshot for WeCom pull streaming. + zh_Hans: 企业微信 Pull 流式下,每累计 N 个模型 chunk 再输出一次新的快照。 + type: integer + required: true + default: 4 + - name: PullFlushWindowMs + label: + en_US: Pull Flush Window (ms) + zh_Hans: Pull 模式时间窗口(毫秒) + description: + en_US: Emit a refreshed snapshot when content changed and the window elapsed, even if chunk batch size is not reached. + zh_Hans: 当内容有变化且时间窗口到达时,即使未达到 chunk 批大小,也会主动输出新的流式快照。 + type: integer + required: true + default: 2000 execution: python: path: ./wecombot.py From f930b89e92df6c69de5ae8e5399203175713f2e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=92=8B=E9=87=91=E9=BE=99=EF=BC=88=E5=A4=9C=E9=9B=A8?= =?UTF-8?q?=EF=BC=89?= <58286951@qq.com> Date: Wed, 11 Mar 2026 18:37:59 +0800 Subject: [PATCH 11/36] feat(wecom): add detailed streaming logs for debugging - Add _summarize_stream_text helper for content logging - Add publish/consume action logs with seq, content stats - Track cleared queue items and publish sequence - Help troubleshoot WeCom pull mode polling issues Co-Authored-By: Claude Opus 4.6 --- src/langbot/libs/wecom_ai_bot_api/api.py | 106 +++++++++++++++++- src/langbot/pkg/pipeline/respback/respback.py | 25 +++++ 2 files changed, 128 insertions(+), 3 deletions(-) diff --git a/src/langbot/libs/wecom_ai_bot_api/api.py b/src/langbot/libs/wecom_ai_bot_api/api.py index f776e0687..437d7de59 100644 --- a/src/langbot/libs/wecom_ai_bot_api/api.py +++ b/src/langbot/libs/wecom_ai_bot_api/api.py @@ -2,6 +2,7 @@ import base64 import json import time +import hashlib import traceback import uuid import xml.etree.ElementTree as ET @@ -18,6 +19,17 @@ from langbot.pkg.platform.logger import EventLogger +def _summarize_stream_text(content: str, tail_length: int = 32) -> dict[str, str | int]: + text = content or "" + encoded = text.encode('utf-8') + return { + "chars": len(text), + "bytes": len(encoded), + "tail_repr": repr(text[-tail_length:]), + "md5": hashlib.md5(encoded).hexdigest()[:12] if encoded else "0" * 12, + } + + @dataclass class StreamChunk: """描述单次推送给企业微信的流式片段。""" @@ -63,6 +75,9 @@ class StreamSession: # 缓存最近一次片段,处理重试或超时兜底 last_chunk: Optional[StreamChunk] = None + # 发布到企业微信 stream 的快照序号,便于串联日志 + publish_seq: int = 0 + class StreamSessionManager: """管理 stream 会话的生命周期,并负责队列的生产消费。""" @@ -133,12 +148,16 @@ async def publish(self, stream_id: str, chunk: StreamChunk) -> bool: return False session.last_access = time.time() + session.publish_seq += 1 + chunk.meta.setdefault('seq', session.publish_seq) session.last_chunk = chunk # 企业微信消费的是当前完整快照,保留最新片段即可,避免旧片段堆积导致显示延迟。 + cleared_count = 0 while not session.queue.empty(): try: session.queue.get_nowait() + cleared_count += 1 except asyncio.QueueEmpty: break @@ -151,6 +170,22 @@ async def publish(self, stream_id: str, chunk: StreamChunk) -> bool: if chunk.is_final: session.finished = True + summary = _summarize_stream_text(chunk.content) + await self.logger.debug( + '[wecom-stream] ' + f'action=publish ' + f'stream_id={stream_id or "-"} ' + f'msg_id={session.msg_id or "-"} ' + f'seq={chunk.meta.get("seq", -1)} ' + f'finish={str(chunk.is_final).lower()} ' + f'cleared={cleared_count} ' + f'queue_size={session.queue.qsize()} ' + f'content_chars={summary["chars"]} ' + f'content_bytes={summary["bytes"]} ' + f'content_tail={summary["tail_repr"]} ' + f'content_md5={summary["md5"]}' + ) + return True async def consume(self, stream_id: str, timeout: float = 0.5) -> Optional[StreamChunk]: @@ -177,10 +212,47 @@ async def consume(self, stream_id: str, timeout: float = 0.5) -> Optional[Stream session.last_access = time.time() if chunk.is_final: session.finished = True + + summary = _summarize_stream_text(chunk.content) + await self.logger.debug( + '[wecom-stream] ' + f'action=consume ' + f'stream_id={stream_id or "-"} ' + f'msg_id={session.msg_id or "-"} ' + f'seq={chunk.meta.get("seq", -1)} ' + f'finish={str(chunk.is_final).lower()} ' + f'queue_size={session.queue.qsize()} ' + f'content_chars={summary["chars"]} ' + f'content_bytes={summary["bytes"]} ' + f'content_tail={summary["tail_repr"]} ' + f'content_md5={summary["md5"]}' + ) return chunk except asyncio.TimeoutError: if session.finished and session.last_chunk: + summary = _summarize_stream_text(session.last_chunk.content) + await self.logger.debug( + '[wecom-stream] ' + f'action=consume_timeout_last_chunk ' + f'stream_id={stream_id or "-"} ' + f'msg_id={session.msg_id or "-"} ' + f'seq={session.last_chunk.meta.get("seq", -1)} ' + f'finish={str(session.last_chunk.is_final).lower()} ' + f'queue_size={session.queue.qsize()} ' + f'content_chars={summary["chars"]} ' + f'content_bytes={summary["bytes"]} ' + f'content_tail={summary["tail_repr"]} ' + f'content_md5={summary["md5"]}' + ) return session.last_chunk + + await self.logger.debug( + '[wecom-stream] ' + f'action=consume_timeout_empty ' + f'stream_id={stream_id or "-"} ' + f'msg_id={session.msg_id or "-"} ' + f'queue_size={session.queue.qsize()}' + ) return None def mark_finished(self, stream_id: str) -> None: @@ -274,11 +346,15 @@ async def _log_stream_debug( msg_id = session.msg_id age_ms = int((time.time() - session.created_at) * 1000) - content_bytes = 0 finish = False + seq = -1 + summary = _summarize_stream_text('') if chunk: finish = chunk.is_final - content_bytes = len((chunk.content or '').encode('utf-8')) + seq = int(chunk.meta.get('seq', -1)) + summary = _summarize_stream_text(chunk.content) + + queue_size = session.queue.qsize() if session else -1 await self.logger.debug( '[wecom-stream] ' @@ -286,9 +362,14 @@ async def _log_stream_debug( f'stream_id={stream_id or "-"} ' f'msg_id={msg_id or "-"} ' f'source={source or "-"} ' + f'seq={seq} ' f'finish={str(finish).lower()} ' f'age_ms={age_ms} ' - f'content_bytes={content_bytes}' + f'queue_size={queue_size} ' + f'content_chars={summary["chars"]} ' + f'content_bytes={summary["bytes"]} ' + f'content_tail={summary["tail_repr"]} ' + f'content_md5={summary["md5"]}' ) def _is_stream_lifetime_exceeded(self, session: StreamSession) -> bool: @@ -520,6 +601,12 @@ async def _handle_post_followup_response(self, msg_json: dict[str, Any], nonce: if not chunk: chunk = self._resolve_followup_chunk(session, cached_content) if not chunk: + await self._log_stream_debug( + action='followup_response', + stream_id=stream_id, + session=session, + source='empty_response', + ) payload = self._build_stream_payload(stream_id, '', False) return await self._encrypt_and_reply(payload, nonce) @@ -857,6 +944,19 @@ async def push_stream_chunk(self, msg_id: str, content: str, is_final: bool = Fa # 根据 msg_id 找到对应 stream 会话,如果不存在说明当前消息非流式 stream_id = self.stream_sessions.get_stream_id_by_msg(msg_id) if not stream_id: + summary = _summarize_stream_text(content) + await self.logger.debug( + '[wecom-stream] ' + f'action=push_stream_chunk_missing_session ' + f'stream_id=- ' + f'msg_id={msg_id or "-"} ' + f'seq=-1 ' + f'finish={str(is_final).lower()} ' + f'content_chars={summary["chars"]} ' + f'content_bytes={summary["bytes"]} ' + f'content_tail={summary["tail_repr"]} ' + f'content_md5={summary["md5"]}' + ) return False chunk = StreamChunk(content=content, is_final=is_final) diff --git a/src/langbot/pkg/pipeline/respback/respback.py b/src/langbot/pkg/pipeline/respback/respback.py index ba382b882..a29ff1769 100644 --- a/src/langbot/pkg/pipeline/respback/respback.py +++ b/src/langbot/pkg/pipeline/respback/respback.py @@ -2,6 +2,7 @@ import random import asyncio +import hashlib import langbot_plugin.api.entities.builtin.platform.events as platform_events @@ -12,6 +13,17 @@ import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query +def _summarize_stream_text(content: str, tail_length: int = 32) -> dict[str, str | int]: + text = content or '' + encoded = text.encode('utf-8') + return { + 'chars': len(text), + 'bytes': len(encoded), + 'tail_repr': repr(text[-tail_length:]), + 'md5': hashlib.md5(encoded).hexdigest()[:12] if encoded else '0' * 12, + } + + @stage.stage_class('SendResponseBackStage') class SendResponseBackStage(stage.PipelineStage): """发送响应消息""" @@ -48,6 +60,19 @@ async def process(self, query: pipeline_query.Query, stage_inst_name: str) -> en (msg for msg in reversed(query.resp_messages) if isinstance(msg, provider_message.MessageChunk)), None, ) + stream_text = str(query.resp_message_chain[-1]) + summary = _summarize_stream_text(stream_text) + self.ap.logger.debug( + '[wecom-stream] ' + f'action=respback_reply_chunk ' + f'query_id={query.query_id or "-"} ' + f'resp_message_id={getattr(latest_chunk, "resp_message_id", "") or "-"} ' + f'finish={str(latest_chunk.is_final if latest_chunk else False).lower()} ' + f'content_chars={summary["chars"]} ' + f'content_bytes={summary["bytes"]} ' + f'content_tail={summary["tail_repr"]} ' + f'content_md5={summary["md5"]}' + ) await query.adapter.reply_message_chunk( message_source=query.message_event, bot_message=query.resp_messages[-1], From 33dcf9a55fedd8340accf81bf2b5864df4444248 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=92=8B=E9=87=91=E9=BE=99=EF=BC=88=E5=A4=9C=E9=9B=A8?= =?UTF-8?q?=EF=BC=89?= <58286951@qq.com> Date: Wed, 11 Mar 2026 18:38:18 +0800 Subject: [PATCH 12/36] feat(chroma): add full-text and hybrid search support - Add FULL_TEXT and HYBRID search types - Implement RRF (Reciprocal Rank Fusion) for hybrid search - Change add to upsert for idempotent document insertion - Upgrade chromadb dependency to >=1.0.0,<2.0.0 Co-Authored-By: Claude Opus 4.6 --- pyproject.toml | 2 +- src/langbot/pkg/vector/vdbs/chroma.py | 160 +++++++- uv.lock | 520 +++++++++++++------------- 3 files changed, 417 insertions(+), 265 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 80121d6b0..0d318b7be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,7 +61,7 @@ dependencies = [ "html2text>=2024.2.26", "langchain>=0.2.0", "langchain-text-splitters>=0.0.1", - "chromadb>=0.4.24", + "chromadb>=1.0.0,<2.0.0", "qdrant-client (>=1.15.1,<2.0.0)", "pyseekdb==1.1.0.post3", "langbot-plugin==0.3.0", diff --git a/src/langbot/pkg/vector/vdbs/chroma.py b/src/langbot/pkg/vector/vdbs/chroma.py index 6cefce135..93e792a42 100644 --- a/src/langbot/pkg/vector/vdbs/chroma.py +++ b/src/langbot/pkg/vector/vdbs/chroma.py @@ -2,11 +2,14 @@ import asyncio from typing import Any from chromadb import PersistentClient -from langbot.pkg.vector.vdb import VectorDatabase +from langbot.pkg.vector.vdb import VectorDatabase, SearchType from langbot.pkg.core import app import chromadb import chromadb.errors +# RRF smoothing constant (standard value from the literature) +_RRF_K = 60 + class ChromaVectorDatabase(VectorDatabase): def __init__(self, ap: app.Application, base_path: str = './data/chroma'): @@ -14,6 +17,10 @@ def __init__(self, ap: app.Application, base_path: str = './data/chroma'): self.client = PersistentClient(path=base_path) self._collections = {} + @classmethod + def supported_search_types(cls) -> list[SearchType]: + return [SearchType.VECTOR, SearchType.FULL_TEXT, SearchType.HYBRID] + async def get_or_create_collection(self, collection: str) -> chromadb.Collection: if collection not in self._collections: self._collections[collection] = await asyncio.to_thread( @@ -34,8 +41,8 @@ async def add_embeddings( kwargs: dict[str, Any] = dict(embeddings=embeddings_list, ids=ids, metadatas=metadatas) if documents is not None: kwargs['documents'] = documents - await asyncio.to_thread(col.add, **kwargs) - self.ap.logger.info(f"Added {len(ids)} embeddings to Chroma collection '{collection}'.") + await asyncio.to_thread(col.upsert, **kwargs) + self.ap.logger.info(f"Upserted {len(ids)} embeddings to Chroma collection '{collection}'.") async def search( self, @@ -47,6 +54,23 @@ async def search( filter: dict[str, Any] | None = None, ) -> dict[str, Any]: col = await self.get_or_create_collection(collection) + + if search_type == SearchType.FULL_TEXT: + return await self._full_text_search(col, collection, k, query_text, filter) + elif search_type == SearchType.HYBRID: + return await self._hybrid_search(col, collection, query_embedding, k, query_text, filter) + + # Default: vector search + return await self._vector_search(col, collection, query_embedding, k, filter) + + async def _vector_search( + self, + col: chromadb.Collection, + collection: str, + query_embedding: list[float], + k: int, + filter: dict[str, Any] | None, + ) -> dict[str, Any]: query_kwargs: dict[str, Any] = dict( query_embeddings=query_embedding, n_results=k, @@ -55,9 +79,137 @@ async def search( if filter: query_kwargs['where'] = filter results = await asyncio.to_thread(col.query, **query_kwargs) - self.ap.logger.info(f"Chroma search in '{collection}' returned {len(results.get('ids', [[]])[0])} results.") + self.ap.logger.info( + f"Chroma vector search in '{collection}' returned {len(results.get('ids', [[]])[0])} results." + ) return results + async def _full_text_search( + self, + col: chromadb.Collection, + collection: str, + k: int, + query_text: str, + filter: dict[str, Any] | None, + ) -> dict[str, Any]: + if not query_text: + return {'ids': [[]], 'metadatas': [[]], 'distances': [[]], 'documents': [[]]} + + get_kwargs: dict[str, Any] = dict( + where_document={'$contains': query_text}, + include=['metadatas', 'documents'], + limit=k, + ) + if filter: + get_kwargs['where'] = filter + results = await asyncio.to_thread(col.get, **get_kwargs) + + # col.get returns flat lists; wrap into column-major format. + # Distances are all 0.0 because Chroma's local $contains is a boolean + # filter with no relevance scoring. Chroma's BM25 sparse embedding + # function (ChromaBm25EmbeddingFunction) can generate scored sparse + # vectors, but sparse vector *indexing* is only available on Chroma + # Cloud, not locally. For ranked results, use hybrid mode or apply a + # reranker in a downstream stage. + ids = results.get('ids', []) + metadatas = results.get('metadatas', []) or [None] * len(ids) + documents = results.get('documents', []) or [None] * len(ids) + distances = [0.0] * len(ids) + + self.ap.logger.info(f"Chroma full-text search in '{collection}' returned {len(ids)} results.") + return {'ids': [ids], 'metadatas': [metadatas], 'distances': [distances], 'documents': [documents]} + + async def _hybrid_search( + self, + col: chromadb.Collection, + collection: str, + query_embedding: list[float], + k: int, + query_text: str, + filter: dict[str, Any] | None, + ) -> dict[str, Any]: + # Fall back to pure vector search when no text is provided + if not query_text: + return await self._vector_search(col, collection, query_embedding, k, filter) + + # Run vector search and full-text search in parallel + vector_task = self._vector_search(col, collection, query_embedding, k, filter) + text_task = self._full_text_search(col, collection, k, query_text, filter) + vector_results, text_results = await asyncio.gather(vector_task, text_task) + + vector_ids = vector_results.get('ids', [[]])[0] + text_ids = text_results.get('ids', [[]])[0] + + if not vector_ids and not text_ids: + return {'ids': [[]], 'metadatas': [[]], 'distances': [[]], 'documents': [[]]} + + # RRF fusion + fused = self._rrf_fuse([vector_ids, text_ids], k) + if not fused: + return {'ids': [[]], 'metadatas': [[]], 'distances': [[]], 'documents': [[]]} + + fused_ids = [doc_id for doc_id, _ in fused] + + # Fetch full metadata and documents for fused results + fetched = await asyncio.to_thread(col.get, ids=fused_ids, include=['metadatas', 'documents']) + + # col.get returns results in arbitrary order; re-order to match fused ranking + fetched_map: dict[str, tuple] = {} + for i, fid in enumerate(fetched.get('ids', [])): + meta = (fetched.get('metadatas') or [None] * len(fetched['ids']))[i] + doc = (fetched.get('documents') or [None] * len(fetched['ids']))[i] + fetched_map[fid] = (meta, doc) + + ordered_ids = [] + ordered_metas = [] + ordered_docs = [] + ordered_dists = [] + + # Normalize RRF scores to 0~1 distances via min-max scaling. + # Raw RRF scores are tiny (e.g. 0.016~0.033 with k=60) so a naive + # ``1 - score`` would compress all distances into a narrow 0.96~0.98 + # band with almost no discriminative power. Min-max normalization + # spreads them across the full 0~1 range (0.0 = best match). + max_score = fused[0][1] + min_score = fused[-1][1] + score_range = max_score - min_score + + for doc_id, score in fused: + if doc_id in fetched_map: + meta, doc = fetched_map[doc_id] + ordered_ids.append(doc_id) + ordered_metas.append(meta) + ordered_docs.append(doc) + if score_range > 0: + ordered_dists.append(1.0 - (score - min_score) / score_range) + else: + ordered_dists.append(0.0) + + self.ap.logger.info( + f"Chroma hybrid search in '{collection}' returned {len(ordered_ids)} results " + f'(vector={len(vector_ids)}, text={len(text_ids)}).' + ) + return { + 'ids': [ordered_ids], + 'metadatas': [ordered_metas], + 'distances': [ordered_dists], + 'documents': [ordered_docs], + } + + @staticmethod + def _rrf_fuse(result_lists: list[list[str]], k: int) -> list[tuple[str, float]]: + """Reciprocal Rank Fusion over multiple ranked ID lists. + + Returns a list of (doc_id, rrf_score) sorted by descending score, + truncated to *k* entries. + """ + scores: dict[str, float] = {} + for ranked_ids in result_lists: + for rank, doc_id in enumerate(ranked_ids): + scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (_RRF_K + rank + 1) + sorted_results = sorted(scores.items(), key=lambda x: x[1], reverse=True) + return sorted_results[:k] + async def delete_by_file_id(self, collection: str, file_id: str) -> None: col = await self.get_or_create_collection(collection) await asyncio.to_thread(col.delete, where={'file_id': file_id}) diff --git a/uv.lock b/uv.lock index e300f9450..f32b47d24 100644 --- a/uv.lock +++ b/uv.lock @@ -19,7 +19,7 @@ resolution-markers = [ [[package]] name = "aenum" version = "3.1.16" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/09/7a/61ed58e8be9e30c3fe518899cc78c284896d246d51381bab59b5db11e1f3/aenum-3.1.16.tar.gz", hash = "sha256:bfaf9589bdb418ee3a986d85750c7318d9d2839c1b1a1d6fe8fc53ec201cf140", size = 137693, upload-time = "2026-01-12T22:34:38.819Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e3/52/6ad8f63ec8da1bf40f96996d25d5b650fdd38f5975f8c813732c47388f18/aenum-3.1.16-py3-none-any.whl", hash = "sha256:9035092855a98e41b66e3d0998bd7b96280e85ceb3a04cc035636138a1943eaf", size = 165627, upload-time = "2025-04-25T03:17:58.89Z" }, @@ -28,7 +28,7 @@ wheels = [ [[package]] name = "aiocqhttp" version = "1.4.4" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "httpx" }, { name = "quart" }, @@ -38,7 +38,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/c2/91/e4bf8584695b065e2 [[package]] name = "aiofiles" version = "25.1.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" }, @@ -47,7 +47,7 @@ wheels = [ [[package]] name = "aiohappyeyeballs" version = "2.6.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, @@ -56,7 +56,7 @@ wheels = [ [[package]] name = "aiohttp" version = "3.13.3" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "aiohappyeyeballs" }, { name = "aiosignal" }, @@ -158,7 +158,7 @@ wheels = [ [[package]] name = "aioshutil" version = "1.6" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/d3/bd/dcea5abb1792269e70cc75d5f9ae9adbdfba0f0d08a207eb788ec3b469b6/aioshutil-1.6.tar.gz", hash = "sha256:9eae342b9a4cacc2c2c5877877a2d2f7a2b66c62aa1ab57d7e95c8cfd4ede507", size = 7843, upload-time = "2025-10-21T08:42:23.742Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/68/92/7020e67ad83095ecc2ce751c24a63df332fb9a34ebfe14bc12a6b21b8f58/aioshutil-1.6-py3-none-any.whl", hash = "sha256:e0711de25ade421b70094b2a27c69bef6356127013744fec05f019f36732c1bd", size = 4705, upload-time = "2025-10-21T08:42:22.892Z" }, @@ -167,7 +167,7 @@ wheels = [ [[package]] name = "aiosignal" version = "1.4.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "frozenlist" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, @@ -180,7 +180,7 @@ wheels = [ [[package]] name = "aiosqlite" version = "0.22.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, @@ -189,7 +189,7 @@ wheels = [ [[package]] name = "annotated-types" version = "0.7.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, @@ -198,7 +198,7 @@ wheels = [ [[package]] name = "anthropic" version = "0.77.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "anyio" }, { name = "distro" }, @@ -217,7 +217,7 @@ wheels = [ [[package]] name = "anyio" version = "4.12.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "idna" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, @@ -230,7 +230,7 @@ wheels = [ [[package]] name = "apscheduler" version = "3.11.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "tzlocal" }, ] @@ -242,7 +242,7 @@ wheels = [ [[package]] name = "argon2-cffi" version = "25.1.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "argon2-cffi-bindings" }, ] @@ -254,7 +254,7 @@ wheels = [ [[package]] name = "argon2-cffi-bindings" version = "25.1.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "cffi" }, ] @@ -285,7 +285,7 @@ wheels = [ [[package]] name = "async-lru" version = "2.1.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/ef/c3/bbf34f15ea88dfb649ab2c40f9d75081784a50573a9ea431563cab64adb8/async_lru-2.1.0.tar.gz", hash = "sha256:9eeb2fecd3fe42cc8a787fc32ead53a3a7158cc43d039c3c55ab3e4e5b2a80ed", size = 12041, upload-time = "2026-01-17T22:52:18.931Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/2e/e9/eb6a5db5ac505d5d45715388e92bced7a5bb556facc4d0865d192823f2d2/async_lru-2.1.0-py3-none-any.whl", hash = "sha256:fa12dcf99a42ac1280bc16c634bbaf06883809790f6304d85cdab3f666f33a7e", size = 6933, upload-time = "2026-01-17T22:52:17.389Z" }, @@ -294,7 +294,7 @@ wheels = [ [[package]] name = "asyncpg" version = "0.31.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/08/17/cc02bc49bc350623d050fa139e34ea512cd6e020562f2a7312a7bcae4bc9/asyncpg-0.31.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eee690960e8ab85063ba93af2ce128c0f52fd655fdff9fdb1a28df01329f031d", size = 643159, upload-time = "2025-11-24T23:25:36.443Z" }, @@ -342,7 +342,7 @@ wheels = [ [[package]] name = "attrs" version = "25.4.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, @@ -351,7 +351,7 @@ wheels = [ [[package]] name = "audioop-lts" version = "0.2.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/38/53/946db57842a50b2da2e0c1e34bd37f36f5aadba1a929a3971c5d7841dbca/audioop_lts-0.2.2.tar.gz", hash = "sha256:64d0c62d88e67b98a1a5e71987b7aa7b5bcffc7dcee65b635823dbdd0a8dbbd0", size = 30686, upload-time = "2025-08-05T16:43:17.409Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/de/d4/94d277ca941de5a507b07f0b592f199c22454eeaec8f008a286b3fbbacd6/audioop_lts-0.2.2-cp313-abi3-macosx_10_13_universal2.whl", hash = "sha256:fd3d4602dc64914d462924a08c1a9816435a2155d74f325853c1f1ac3b2d9800", size = 46523, upload-time = "2025-08-05T16:42:20.836Z" }, @@ -407,7 +407,7 @@ wheels = [ [[package]] name = "backoff" version = "2.2.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, @@ -416,7 +416,7 @@ wheels = [ [[package]] name = "bcrypt" version = "5.0.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806, upload-time = "2025-09-25T19:49:05.102Z" }, @@ -486,7 +486,7 @@ wheels = [ [[package]] name = "beautifulsoup4" version = "4.14.3" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "soupsieve" }, { name = "typing-extensions" }, @@ -499,7 +499,7 @@ wheels = [ [[package]] name = "blinker" version = "1.9.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, @@ -508,7 +508,7 @@ wheels = [ [[package]] name = "boto3" version = "1.42.39" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "botocore" }, { name = "jmespath" }, @@ -522,7 +522,7 @@ wheels = [ [[package]] name = "botocore" version = "1.42.39" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, @@ -536,7 +536,7 @@ wheels = [ [[package]] name = "build" version = "1.4.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "colorama", marker = "os_name == 'nt'" }, { name = "packaging" }, @@ -550,7 +550,7 @@ wheels = [ [[package]] name = "cachetools" version = "6.2.6" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/39/91/d9ae9a66b01102a18cd16db0cf4cd54187ffe10f0865cc80071a4104fbb3/cachetools-6.2.6.tar.gz", hash = "sha256:16c33e1f276b9a9c0b49ab5782d901e3ad3de0dd6da9bf9bcd29ac5672f2f9e6", size = 32363, upload-time = "2026-01-27T20:32:59.956Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/90/45/f458fa2c388e79dd9d8b9b0c99f1d31b568f27388f2fdba7bb66bbc0c6ed/cachetools-6.2.6-py3-none-any.whl", hash = "sha256:8c9717235b3c651603fff0076db52d6acbfd1b338b8ed50256092f7ce9c85bda", size = 11668, upload-time = "2026-01-27T20:32:58.527Z" }, @@ -559,7 +559,7 @@ wheels = [ [[package]] name = "certifi" version = "2026.1.4" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, @@ -568,7 +568,7 @@ wheels = [ [[package]] name = "cffi" version = "2.0.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "pycparser", marker = "implementation_name != 'PyPy'" }, ] @@ -638,7 +638,7 @@ wheels = [ [[package]] name = "cfgv" version = "3.5.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, @@ -647,7 +647,7 @@ wheels = [ [[package]] name = "chardet" version = "5.2.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/f7b6ab21ec75897ed80c17d79b15951a719226b9fababf1e40ea74d69079/chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7", size = 2069618, upload-time = "2023-08-01T19:23:02.662Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/38/6f/f5fbc992a329ee4e0f288c1fe0e2ad9485ed064cac731ed2fe47dcc38cbf/chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970", size = 199385, upload-time = "2023-08-01T19:23:00.661Z" }, @@ -656,7 +656,7 @@ wheels = [ [[package]] name = "charset-normalizer" version = "3.4.4" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, @@ -729,7 +729,7 @@ wheels = [ [[package]] name = "chromadb" version = "1.4.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "bcrypt" }, { name = "build" }, @@ -771,7 +771,7 @@ wheels = [ [[package]] name = "click" version = "8.3.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] @@ -783,7 +783,7 @@ wheels = [ [[package]] name = "colorama" version = "0.4.6" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, @@ -792,7 +792,7 @@ wheels = [ [[package]] name = "coloredlogs" version = "15.0.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "humanfriendly" }, ] @@ -804,7 +804,7 @@ wheels = [ [[package]] name = "colorlog" version = "6.6.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] @@ -816,7 +816,7 @@ wheels = [ [[package]] name = "coverage" version = "7.13.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/ad/49/349848445b0e53660e258acbcc9b0d014895b6739237920886672240f84b/coverage-7.13.2.tar.gz", hash = "sha256:044c6951ec37146b72a50cc81ef02217d27d4c3640efd2640311393cbbf143d3", size = 826523, upload-time = "2026-01-25T13:00:04.889Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/6c/01/abca50583a8975bb6e1c59eff67ed8e48bb127c07dad5c28d9e96ccc09ec/coverage-7.13.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:060ebf6f2c51aff5ba38e1f43a2095e087389b1c69d559fde6049a4b0001320e", size = 218971, upload-time = "2026-01-25T12:57:36.953Z" }, @@ -908,7 +908,7 @@ toml = [ [[package]] name = "cryptography" version = "46.0.5" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] @@ -967,7 +967,7 @@ wheels = [ [[package]] name = "cuda-bindings" version = "12.9.4" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "cuda-pathfinder", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] @@ -983,7 +983,7 @@ wheels = [ [[package]] name = "cuda-pathfinder" version = "1.4.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } wheels = [ { url = "https://files.pythonhosted.org/packages/07/02/59a5bc738a09def0b49aea0e460bdf97f65206d0d041246147cf6207e69c/cuda_pathfinder-1.4.1-py3-none-any.whl", hash = "sha256:40793006082de88e0950753655e55558a446bed9a7d9d0bcb48b2506d50ed82a", size = 43903, upload-time = "2026-03-06T21:05:24.372Z" }, ] @@ -991,7 +991,7 @@ wheels = [ [[package]] name = "dashscope" version = "1.25.10" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "aiohttp" }, { name = "certifi" }, @@ -1006,7 +1006,7 @@ wheels = [ [[package]] name = "deprecated" version = "1.3.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "wrapt" }, ] @@ -1018,7 +1018,7 @@ wheels = [ [[package]] name = "dingtalk-stream" version = "0.24.3" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "aiohttp" }, { name = "requests" }, @@ -1031,7 +1031,7 @@ wheels = [ [[package]] name = "discord-py" version = "2.6.4" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "aiohttp" }, { name = "audioop-lts", marker = "python_full_version >= '3.13'" }, @@ -1044,7 +1044,7 @@ wheels = [ [[package]] name = "distlib" version = "0.4.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, @@ -1053,7 +1053,7 @@ wheels = [ [[package]] name = "distro" version = "1.9.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, @@ -1062,7 +1062,7 @@ wheels = [ [[package]] name = "docstring-parser" version = "0.17.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, @@ -1071,7 +1071,7 @@ wheels = [ [[package]] name = "dotenv" version = "0.9.9" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "python-dotenv" }, ] @@ -1082,7 +1082,7 @@ wheels = [ [[package]] name = "durationpy" version = "0.10" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/9d/a4/e44218c2b394e31a6dd0d6b095c4e1f32d0be54c2a4b250032d717647bab/durationpy-0.10.tar.gz", hash = "sha256:1fa6893409a6e739c9c72334fc65cca1f355dbdd93405d30f726deb5bde42fba", size = 3335, upload-time = "2025-05-17T13:52:37.26Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b0/0d/9feae160378a3553fa9a339b0e9c1a048e147a4127210e286ef18b730f03/durationpy-0.10-py3-none-any.whl", hash = "sha256:3b41e1b601234296b4fb368338fdcd3e13e0b4fb5b67345948f4f2bf9868b286", size = 3922, upload-time = "2025-05-17T13:52:36.463Z" }, @@ -1091,7 +1091,7 @@ wheels = [ [[package]] name = "ebooklib" version = "0.20" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "lxml" }, { name = "six" }, @@ -1104,7 +1104,7 @@ wheels = [ [[package]] name = "filelock" version = "3.20.3" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485, upload-time = "2026-01-09T17:55:05.421Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" }, @@ -1113,7 +1113,7 @@ wheels = [ [[package]] name = "flask" version = "3.1.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "blinker" }, { name = "click" }, @@ -1130,7 +1130,7 @@ wheels = [ [[package]] name = "flatbuffers" version = "25.12.19" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } wheels = [ { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" }, ] @@ -1138,7 +1138,7 @@ wheels = [ [[package]] name = "frozenlist" version = "1.8.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, @@ -1243,7 +1243,7 @@ wheels = [ [[package]] name = "fsspec" version = "2026.1.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/d5/7d/5df2650c57d47c57232af5ef4b4fdbff182070421e405e0d62c6cdbfaa87/fsspec-2026.1.0.tar.gz", hash = "sha256:e987cb0496a0d81bba3a9d1cee62922fb395e7d4c3b575e57f547953334fe07b", size = 310496, upload-time = "2026-01-09T15:21:35.562Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/01/c9/97cc5aae1648dcb851958a3ddf73ccd7dbe5650d95203ecb4d7720b4cdbf/fsspec-2026.1.0-py3-none-any.whl", hash = "sha256:cb76aa913c2285a3b49bdd5fc55b1d7c708d7208126b60f2eb8194fe1b4cbdcc", size = 201838, upload-time = "2026-01-09T15:21:34.041Z" }, @@ -1252,7 +1252,7 @@ wheels = [ [[package]] name = "future" version = "1.0.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/a7/b2/4140c69c6a66432916b26158687e821ba631a4c9273c474343badf84d3ba/future-1.0.0.tar.gz", hash = "sha256:bd2968309307861edae1458a4f8a4f3598c03be43b97521076aebf5d94c07b05", size = 1228490, upload-time = "2024-02-21T11:52:38.461Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/da/71/ae30dadffc90b9006d77af76b393cb9dfbfc9629f339fc1574a1c52e6806/future-1.0.0-py3-none-any.whl", hash = "sha256:929292d34f5872e70396626ef385ec22355a1fae8ad29e1a734c3e43f9fbc216", size = 491326, upload-time = "2024-02-21T11:52:35.956Z" }, @@ -1261,7 +1261,7 @@ wheels = [ [[package]] name = "gewechat-client" version = "0.2.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "qrcode" }, { name = "requests" }, @@ -1274,7 +1274,7 @@ wheels = [ [[package]] name = "googleapis-common-protos" version = "1.72.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "protobuf" }, ] @@ -1286,7 +1286,7 @@ wheels = [ [[package]] name = "greenlet" version = "3.3.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/8a/99/1cd3411c56a410994669062bd73dd58270c00cc074cac15f385a1fd91f8a/greenlet-3.3.1.tar.gz", hash = "sha256:41848f3230b58c08bb43dee542e74a2a2e34d3c59dc3076cec9151aeeedcae98", size = 184690, upload-time = "2026-01-23T15:31:02.076Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ec/e8/2e1462c8fdbe0f210feb5ac7ad2d9029af8be3bf45bd9fa39765f821642f/greenlet-3.3.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:5fd23b9bc6d37b563211c6abbb1b3cab27db385a4449af5c32e932f93017080c", size = 274974, upload-time = "2026-01-23T15:31:02.891Z" }, @@ -1338,7 +1338,7 @@ wheels = [ [[package]] name = "grpcio" version = "1.76.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "typing-extensions" }, ] @@ -1389,7 +1389,7 @@ wheels = [ [[package]] name = "h11" version = "0.16.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, @@ -1398,7 +1398,7 @@ wheels = [ [[package]] name = "h2" version = "4.3.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "hpack" }, { name = "hyperframe" }, @@ -1411,7 +1411,7 @@ wheels = [ [[package]] name = "hf-xet" version = "1.2.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/5e/6e/0f11bacf08a67f7fb5ee09740f2ca54163863b07b70d579356e9222ce5d8/hf_xet-1.2.0.tar.gz", hash = "sha256:a8c27070ca547293b6890c4bf389f713f80e8c478631432962bb7f4bc0bd7d7f", size = 506020, upload-time = "2025-10-24T19:04:32.129Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/9e/a5/85ef910a0aa034a2abcfadc360ab5ac6f6bc4e9112349bd40ca97551cff0/hf_xet-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:ceeefcd1b7aed4956ae8499e2199607765fbd1c60510752003b6cc0b8413b649", size = 2861870, upload-time = "2025-10-24T19:04:11.422Z" }, @@ -1440,7 +1440,7 @@ wheels = [ [[package]] name = "hpack" version = "4.1.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, @@ -1449,7 +1449,7 @@ wheels = [ [[package]] name = "html2text" version = "2025.4.15" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/f8/27/e158d86ba1e82967cc2f790b0cb02030d4a8bef58e0c79a8590e9678107f/html2text-2025.4.15.tar.gz", hash = "sha256:948a645f8f0bc3abe7fd587019a2197a12436cd73d0d4908af95bfc8da337588", size = 64316, upload-time = "2025-04-15T04:02:30.045Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/1d/84/1a0f9555fd5f2b1c924ff932d99b40a0f8a6b12f6dd625e2a47f415b00ea/html2text-2025.4.15-py3-none-any.whl", hash = "sha256:00569167ffdab3d7767a4cdf589b7f57e777a5ed28d12907d8c58769ec734acc", size = 34656, upload-time = "2025-04-15T04:02:28.44Z" }, @@ -1458,7 +1458,7 @@ wheels = [ [[package]] name = "httpcore" version = "1.0.9" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "certifi" }, { name = "h11" }, @@ -1471,7 +1471,7 @@ wheels = [ [[package]] name = "httptools" version = "0.7.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/9c/08/17e07e8d89ab8f343c134616d72eebfe03798835058e2ab579dcc8353c06/httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657", size = 206521, upload-time = "2025-10-10T03:54:31.002Z" }, @@ -1507,7 +1507,7 @@ wheels = [ [[package]] name = "httpx" version = "0.28.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "anyio" }, { name = "certifi" }, @@ -1527,7 +1527,7 @@ http2 = [ [[package]] name = "httpx-sse" version = "0.4.3" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, @@ -1536,7 +1536,7 @@ wheels = [ [[package]] name = "huggingface-hub" version = "1.3.5" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "filelock" }, { name = "fsspec" }, @@ -1557,7 +1557,7 @@ wheels = [ [[package]] name = "humanfriendly" version = "10.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "pyreadline3", marker = "sys_platform == 'win32'" }, ] @@ -1569,7 +1569,7 @@ wheels = [ [[package]] name = "hypercorn" version = "0.18.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "h11" }, { name = "h2" }, @@ -1584,7 +1584,7 @@ wheels = [ [[package]] name = "hyperframe" version = "6.1.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, @@ -1593,7 +1593,7 @@ wheels = [ [[package]] name = "identify" version = "2.6.16" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/5b/8d/e8b97e6bd3fb6fb271346f7981362f1e04d6a7463abd0de79e1fda17c067/identify-2.6.16.tar.gz", hash = "sha256:846857203b5511bbe94d5a352a48ef2359532bc8f6727b5544077a0dcfb24980", size = 99360, upload-time = "2026-01-12T18:58:58.201Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b8/58/40fbbcefeda82364720eba5cf2270f98496bdfa19ea75b4cccae79c698e6/identify-2.6.16-py2.py3-none-any.whl", hash = "sha256:391ee4d77741d994189522896270b787aed8670389bfd60f326d677d64a6dfb0", size = 99202, upload-time = "2026-01-12T18:58:56.627Z" }, @@ -1602,7 +1602,7 @@ wheels = [ [[package]] name = "idna" version = "3.11" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, @@ -1611,7 +1611,7 @@ wheels = [ [[package]] name = "importlib-metadata" version = "8.7.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "zipp" }, ] @@ -1623,7 +1623,7 @@ wheels = [ [[package]] name = "importlib-resources" version = "6.5.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/cf/8c/f834fbf984f691b4f7ff60f50b514cc3de5cc08abfc3295564dd89c5e2e7/importlib_resources-6.5.2.tar.gz", hash = "sha256:185f87adef5bcc288449d98fb4fba07cea78bc036455dd44c5fc4a2fe78fed2c", size = 44693, upload-time = "2025-01-03T18:51:56.698Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/a4/ed/1f1afb2e9e7f38a545d628f864d562a5ae64fe6f7a10e28ffb9b185b4e89/importlib_resources-6.5.2-py3-none-any.whl", hash = "sha256:789cfdc3ed28c78b67a06acb8126751ced69a3d5f79c095a98298cd8a760ccec", size = 37461, upload-time = "2025-01-03T18:51:54.306Z" }, @@ -1632,7 +1632,7 @@ wheels = [ [[package]] name = "iniconfig" version = "2.3.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, @@ -1641,7 +1641,7 @@ wheels = [ [[package]] name = "itsdangerous" version = "2.2.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, @@ -1650,7 +1650,7 @@ wheels = [ [[package]] name = "jinja2" version = "3.1.6" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "markupsafe" }, ] @@ -1662,7 +1662,7 @@ wheels = [ [[package]] name = "jiter" version = "0.12.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/45/9d/e0660989c1370e25848bb4c52d061c71837239738ad937e83edca174c273/jiter-0.12.0.tar.gz", hash = "sha256:64dfcd7d5c168b38d3f9f8bba7fc639edb3418abcc74f22fdbe6b8938293f30b", size = 168294, upload-time = "2025-11-09T20:49:23.302Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/32/f9/eaca4633486b527ebe7e681c431f529b63fe2709e7c5242fc0f43f77ce63/jiter-0.12.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d8f8a7e317190b2c2d60eb2e8aa835270b008139562d70fe732e1c0020ec53c9", size = 316435, upload-time = "2025-11-09T20:47:02.087Z" }, @@ -1747,7 +1747,7 @@ wheels = [ [[package]] name = "jmespath" version = "1.1.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, @@ -1756,7 +1756,7 @@ wheels = [ [[package]] name = "joblib" version = "1.5.3" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, @@ -1765,7 +1765,7 @@ wheels = [ [[package]] name = "jsonpatch" version = "1.33" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "jsonpointer" }, ] @@ -1777,7 +1777,7 @@ wheels = [ [[package]] name = "jsonpointer" version = "3.0.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/6a/0a/eebeb1fa92507ea94016a2a790b93c2ae41a7e18778f85471dc54475ed25/jsonpointer-3.0.0.tar.gz", hash = "sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef", size = 9114, upload-time = "2024-06-10T19:24:42.462Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/71/92/5e77f98553e9e75130c78900d000368476aed74276eb8ae8796f65f00918/jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942", size = 7595, upload-time = "2024-06-10T19:24:40.698Z" }, @@ -1786,7 +1786,7 @@ wheels = [ [[package]] name = "jsonschema" version = "4.26.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "attrs" }, { name = "jsonschema-specifications" }, @@ -1801,7 +1801,7 @@ wheels = [ [[package]] name = "jsonschema-specifications" version = "2025.9.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "referencing" }, ] @@ -1813,7 +1813,7 @@ wheels = [ [[package]] name = "kubernetes" version = "35.0.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "certifi" }, { name = "durationpy" }, @@ -1928,7 +1928,7 @@ requires-dist = [ { name = "botocore", specifier = ">=1.42.39" }, { name = "certifi", specifier = ">=2025.4.26" }, { name = "chardet", specifier = ">=5.2.0" }, - { name = "chromadb", specifier = ">=0.4.24" }, + { name = "chromadb", specifier = ">=1.0.0,<2.0.0" }, { name = "colorlog", specifier = "~=6.6.0" }, { name = "cryptography", specifier = ">=44.0.3" }, { name = "dashscope", specifier = ">=1.25.10" }, @@ -1994,7 +1994,7 @@ dev = [ [[package]] name = "langbot-plugin" version = "0.3.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "aiofiles" }, { name = "dotenv" }, @@ -2019,7 +2019,7 @@ wheels = [ [[package]] name = "langchain" version = "1.2.7" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "langchain-core" }, { name = "langgraph" }, @@ -2033,7 +2033,7 @@ wheels = [ [[package]] name = "langchain-core" version = "1.2.7" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "jsonpatch" }, { name = "langsmith" }, @@ -2052,7 +2052,7 @@ wheels = [ [[package]] name = "langchain-text-splitters" version = "1.1.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "langchain-core" }, ] @@ -2064,7 +2064,7 @@ wheels = [ [[package]] name = "langgraph" version = "1.0.7" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "langchain-core" }, { name = "langgraph-checkpoint" }, @@ -2081,7 +2081,7 @@ wheels = [ [[package]] name = "langgraph-checkpoint" version = "4.0.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "langchain-core" }, { name = "ormsgpack" }, @@ -2094,7 +2094,7 @@ wheels = [ [[package]] name = "langgraph-prebuilt" version = "1.0.7" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "langchain-core" }, { name = "langgraph-checkpoint" }, @@ -2107,7 +2107,7 @@ wheels = [ [[package]] name = "langgraph-sdk" version = "0.3.3" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "httpx" }, { name = "orjson" }, @@ -2120,7 +2120,7 @@ wheels = [ [[package]] name = "langsmith" version = "0.6.7" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "httpx" }, { name = "orjson", marker = "platform_python_implementation != 'PyPy'" }, @@ -2140,7 +2140,7 @@ wheels = [ [[package]] name = "lark-oapi" version = "1.5.3" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "httpx" }, { name = "pycryptodome" }, @@ -2155,7 +2155,7 @@ wheels = [ [[package]] name = "librt" version = "0.7.8" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/e7/24/5f3646ff414285e0f7708fa4e946b9bf538345a41d1c375c439467721a5e/librt-0.7.8.tar.gz", hash = "sha256:1a4ede613941d9c3470b0368be851df6bb78ab218635512d0370b27a277a0862", size = 148323, upload-time = "2026-01-14T12:56:16.876Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/1b/a3/87ea9c1049f2c781177496ebee29430e4631f439b8553a4969c88747d5d8/librt-0.7.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ff3e9c11aa260c31493d4b3197d1e28dd07768594a4f92bec4506849d736248f", size = 56507, upload-time = "2026-01-14T12:54:54.156Z" }, @@ -2218,7 +2218,7 @@ wheels = [ [[package]] name = "line-bot-sdk" version = "3.22.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "aenum" }, { name = "aiohttp" }, @@ -2237,7 +2237,7 @@ wheels = [ [[package]] name = "linkify-it-py" version = "2.0.3" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "uc-micro-py" }, ] @@ -2249,7 +2249,7 @@ wheels = [ [[package]] name = "logbook" version = "1.9.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "typing-extensions" }, ] @@ -2310,7 +2310,7 @@ wheels = [ [[package]] name = "lxml" version = "6.0.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/77/d5/becbe1e2569b474a23f0c672ead8a29ac50b2dc1d5b9de184831bda8d14c/lxml-6.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:13e35cbc684aadf05d8711a5d1b5857c92e5e580efa9a0d2be197199c8def607", size = 8634365, upload-time = "2025-09-22T04:00:45.672Z" }, @@ -2412,7 +2412,7 @@ wheels = [ [[package]] name = "markdown" version = "3.10.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/b7/b1/af95bcae8549f1f3fd70faacb29075826a0d689a27f232e8cee315efa053/markdown-3.10.1.tar.gz", hash = "sha256:1c19c10bd5c14ac948c53d0d762a04e2fa35a6d58a6b7b1e6bfcbe6fefc0001a", size = 365402, upload-time = "2026-01-21T18:09:28.206Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/59/1b/6ef961f543593969d25b2afe57a3564200280528caa9bd1082eecdd7b3bc/markdown-3.10.1-py3-none-any.whl", hash = "sha256:867d788939fe33e4b736426f5b9f651ad0c0ae0ecf89df0ca5d1176c70812fe3", size = 107684, upload-time = "2026-01-21T18:09:27.203Z" }, @@ -2421,7 +2421,7 @@ wheels = [ [[package]] name = "markdown-it-py" version = "4.0.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "mdurl" }, ] @@ -2438,7 +2438,7 @@ linkify = [ [[package]] name = "markupsafe" version = "3.0.3" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, @@ -2512,7 +2512,7 @@ wheels = [ [[package]] name = "mcp" version = "1.26.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "anyio" }, { name = "httpx" }, @@ -2537,7 +2537,7 @@ wheels = [ [[package]] name = "mdit-py-plugins" version = "0.5.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "markdown-it-py" }, ] @@ -2549,7 +2549,7 @@ wheels = [ [[package]] name = "mdurl" version = "0.1.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, @@ -2558,7 +2558,7 @@ wheels = [ [[package]] name = "mistletoe" version = "1.4.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/11/96/ea46a376a7c4cd56955ecdfff0ea68de43996a4e6d1aee4599729453bd11/mistletoe-1.4.0.tar.gz", hash = "sha256:1630f906e5e4bbe66fdeb4d29d277e2ea515d642bb18a9b49b136361a9818c9d", size = 107203, upload-time = "2024-07-14T10:17:35.212Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/2a/0f/b5e545f0c7962be90366af3418989b12cf441d9da1e5d89d88f2f3e5cf8f/mistletoe-1.4.0-py3-none-any.whl", hash = "sha256:44a477803861de1237ba22e375c6b617690a31d2902b47279d1f8f7ed498a794", size = 51304, upload-time = "2024-07-14T10:17:33.243Z" }, @@ -2567,7 +2567,7 @@ wheels = [ [[package]] name = "mmh3" version = "5.2.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/a7/af/f28c2c2f51f31abb4725f9a64bc7863d5f491f6539bd26aee2a1d21a649e/mmh3-5.2.0.tar.gz", hash = "sha256:1efc8fec8478e9243a78bb993422cf79f8ff85cb4cf6b79647480a31e0d950a8", size = 33582, upload-time = "2025-07-29T07:43:48.49Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f7/87/399567b3796e134352e11a8b973cd470c06b2ecfad5468fe580833be442b/mmh3-5.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7901c893e704ee3c65f92d39b951f8f34ccf8e8566768c58103fb10e55afb8c1", size = 56107, upload-time = "2025-07-29T07:41:57.07Z" }, @@ -2663,7 +2663,7 @@ wheels = [ [[package]] name = "mpmath" version = "1.3.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, @@ -2672,7 +2672,7 @@ wheels = [ [[package]] name = "multidict" version = "6.7.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, @@ -2789,7 +2789,7 @@ wheels = [ [[package]] name = "mypy" version = "1.19.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, { name = "mypy-extensions" }, @@ -2828,7 +2828,7 @@ wheels = [ [[package]] name = "mypy-extensions" version = "1.1.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, @@ -2837,7 +2837,7 @@ wheels = [ [[package]] name = "nakuru-project-idk" version = "0.0.2.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "aiohttp" }, { name = "async-lru" }, @@ -2852,7 +2852,7 @@ wheels = [ [[package]] name = "networkx" version = "3.6.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, @@ -2861,7 +2861,7 @@ wheels = [ [[package]] name = "nodeenv" version = "1.10.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, @@ -2870,7 +2870,7 @@ wheels = [ [[package]] name = "numpy" version = "2.4.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/57/fd/0005efbd0af48e55eb3c7208af93f2862d4b1a56cd78e84309a2d959208d/numpy-2.4.2.tar.gz", hash = "sha256:659a6107e31a83c4e33f763942275fd278b21d095094044eb35569e86a21ddae", size = 20723651, upload-time = "2026-01-31T23:13:10.135Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d3/44/71852273146957899753e69986246d6a176061ea183407e95418c2aa4d9a/numpy-2.4.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e7e88598032542bd49af7c4747541422884219056c268823ef6e5e89851c8825", size = 16955478, upload-time = "2026-01-31T23:10:25.623Z" }, @@ -2949,7 +2949,7 @@ wheels = [ [[package]] name = "nvidia-cublas-cu12" version = "12.8.4.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } wheels = [ { url = "https://files.pythonhosted.org/packages/dc/61/e24b560ab2e2eaeb3c839129175fb330dfcfc29e5203196e5541a4c44682/nvidia_cublas_cu12-12.8.4.1-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:8ac4e771d5a348c551b2a426eda6193c19aa630236b418086020df5ba9667142", size = 594346921, upload-time = "2025-03-07T01:44:31.254Z" }, ] @@ -2957,7 +2957,7 @@ wheels = [ [[package]] name = "nvidia-cuda-cupti-cu12" version = "12.8.90" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } wheels = [ { url = "https://files.pythonhosted.org/packages/f8/02/2adcaa145158bf1a8295d83591d22e4103dbfd821bcaf6f3f53151ca4ffa/nvidia_cuda_cupti_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ea0cb07ebda26bb9b29ba82cda34849e73c166c18162d3913575b0c9db9a6182", size = 10248621, upload-time = "2025-03-07T01:40:21.213Z" }, ] @@ -2965,7 +2965,7 @@ wheels = [ [[package]] name = "nvidia-cuda-nvrtc-cu12" version = "12.8.93" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } wheels = [ { url = "https://files.pythonhosted.org/packages/05/6b/32f747947df2da6994e999492ab306a903659555dddc0fbdeb9d71f75e52/nvidia_cuda_nvrtc_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:a7756528852ef889772a84c6cd89d41dfa74667e24cca16bb31f8f061e3e9994", size = 88040029, upload-time = "2025-03-07T01:42:13.562Z" }, ] @@ -2973,7 +2973,7 @@ wheels = [ [[package]] name = "nvidia-cuda-runtime-cu12" version = "12.8.90" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } wheels = [ { url = "https://files.pythonhosted.org/packages/0d/9b/a997b638fcd068ad6e4d53b8551a7d30fe8b404d6f1804abf1df69838932/nvidia_cuda_runtime_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:adade8dcbd0edf427b7204d480d6066d33902cab2a4707dcfc48a2d0fd44ab90", size = 954765, upload-time = "2025-03-07T01:40:01.615Z" }, ] @@ -2981,7 +2981,7 @@ wheels = [ [[package]] name = "nvidia-cudnn-cu12" version = "9.10.2.21" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "nvidia-cublas-cu12", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] @@ -2992,7 +2992,7 @@ wheels = [ [[package]] name = "nvidia-cufft-cu12" version = "11.3.3.83" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "nvidia-nvjitlink-cu12", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] @@ -3003,7 +3003,7 @@ wheels = [ [[package]] name = "nvidia-cufile-cu12" version = "1.13.1.3" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } wheels = [ { url = "https://files.pythonhosted.org/packages/bb/fe/1bcba1dfbfb8d01be8d93f07bfc502c93fa23afa6fd5ab3fc7c1df71038a/nvidia_cufile_cu12-1.13.1.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1d069003be650e131b21c932ec3d8969c1715379251f8d23a1860554b1cb24fc", size = 1197834, upload-time = "2025-03-07T01:45:50.723Z" }, ] @@ -3011,7 +3011,7 @@ wheels = [ [[package]] name = "nvidia-curand-cu12" version = "10.3.9.90" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } wheels = [ { url = "https://files.pythonhosted.org/packages/fb/aa/6584b56dc84ebe9cf93226a5cde4d99080c8e90ab40f0c27bda7a0f29aa1/nvidia_curand_cu12-10.3.9.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:b32331d4f4df5d6eefa0554c565b626c7216f87a06a4f56fab27c3b68a830ec9", size = 63619976, upload-time = "2025-03-07T01:46:23.323Z" }, ] @@ -3019,7 +3019,7 @@ wheels = [ [[package]] name = "nvidia-cusolver-cu12" version = "11.7.3.90" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "nvidia-cublas-cu12", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, { name = "nvidia-cusparse-cu12", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, @@ -3032,7 +3032,7 @@ wheels = [ [[package]] name = "nvidia-cusparse-cu12" version = "12.5.8.93" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "nvidia-nvjitlink-cu12", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] @@ -3043,7 +3043,7 @@ wheels = [ [[package]] name = "nvidia-cusparselt-cu12" version = "0.7.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } wheels = [ { url = "https://files.pythonhosted.org/packages/56/79/12978b96bd44274fe38b5dde5cfb660b1d114f70a65ef962bcbbed99b549/nvidia_cusparselt_cu12-0.7.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:f1bb701d6b930d5a7cea44c19ceb973311500847f81b634d802b7b539dc55623", size = 287193691, upload-time = "2025-02-26T00:15:44.104Z" }, ] @@ -3051,7 +3051,7 @@ wheels = [ [[package]] name = "nvidia-nccl-cu12" version = "2.27.5" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } wheels = [ { url = "https://files.pythonhosted.org/packages/6e/89/f7a07dc961b60645dbbf42e80f2bc85ade7feb9a491b11a1e973aa00071f/nvidia_nccl_cu12-2.27.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ad730cf15cb5d25fe849c6e6ca9eb5b76db16a80f13f425ac68d8e2e55624457", size = 322348229, upload-time = "2025-06-26T04:11:28.385Z" }, ] @@ -3059,7 +3059,7 @@ wheels = [ [[package]] name = "nvidia-nvjitlink-cu12" version = "12.8.93" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } wheels = [ { url = "https://files.pythonhosted.org/packages/f6/74/86a07f1d0f42998ca31312f998bd3b9a7eff7f52378f4f270c8679c77fb9/nvidia_nvjitlink_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:81ff63371a7ebd6e6451970684f916be2eab07321b73c9d244dc2b4da7f73b88", size = 39254836, upload-time = "2025-03-07T01:49:55.661Z" }, ] @@ -3067,7 +3067,7 @@ wheels = [ [[package]] name = "nvidia-nvshmem-cu12" version = "3.4.5" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } wheels = [ { url = "https://files.pythonhosted.org/packages/b5/09/6ea3ea725f82e1e76684f0708bbedd871fc96da89945adeba65c3835a64c/nvidia_nvshmem_cu12-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:042f2500f24c021db8a06c5eec2539027d57460e1c1a762055a6554f72c369bd", size = 139103095, upload-time = "2025-09-06T00:32:31.266Z" }, ] @@ -3075,7 +3075,7 @@ wheels = [ [[package]] name = "nvidia-nvtx-cu12" version = "12.8.90" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } wheels = [ { url = "https://files.pythonhosted.org/packages/a2/eb/86626c1bbc2edb86323022371c39aa48df6fd8b0a1647bc274577f72e90b/nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5b17e2001cc0d751a5bc2c6ec6d26ad95913324a4adb86788c944f8ce9ba441f", size = 89954, upload-time = "2025-03-07T01:42:44.131Z" }, ] @@ -3083,7 +3083,7 @@ wheels = [ [[package]] name = "oauthlib" version = "3.3.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, @@ -3092,7 +3092,7 @@ wheels = [ [[package]] name = "ollama" version = "0.6.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "httpx" }, { name = "pydantic" }, @@ -3105,7 +3105,7 @@ wheels = [ [[package]] name = "onnxruntime" version = "1.23.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "coloredlogs" }, { name = "flatbuffers" }, @@ -3137,7 +3137,7 @@ wheels = [ [[package]] name = "openai" version = "2.16.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "anyio" }, { name = "distro" }, @@ -3156,7 +3156,7 @@ wheels = [ [[package]] name = "opentelemetry-api" version = "1.39.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "importlib-metadata" }, { name = "typing-extensions" }, @@ -3169,7 +3169,7 @@ wheels = [ [[package]] name = "opentelemetry-exporter-otlp-proto-common" version = "1.39.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "opentelemetry-proto" }, ] @@ -3181,7 +3181,7 @@ wheels = [ [[package]] name = "opentelemetry-exporter-otlp-proto-grpc" version = "1.39.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "googleapis-common-protos" }, { name = "grpcio" }, @@ -3199,7 +3199,7 @@ wheels = [ [[package]] name = "opentelemetry-proto" version = "1.39.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "protobuf" }, ] @@ -3211,7 +3211,7 @@ wheels = [ [[package]] name = "opentelemetry-sdk" version = "1.39.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-semantic-conventions" }, @@ -3225,7 +3225,7 @@ wheels = [ [[package]] name = "opentelemetry-semantic-conventions" version = "0.60b1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "opentelemetry-api" }, { name = "typing-extensions" }, @@ -3238,7 +3238,7 @@ wheels = [ [[package]] name = "orjson" version = "3.11.6" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/70/a3/4e09c61a5f0c521cba0bb433639610ae037437669f1a4cbc93799e731d78/orjson-3.11.6.tar.gz", hash = "sha256:0a54c72259f35299fd033042367df781c2f66d10252955ca1efb7db309b954cb", size = 6175856, upload-time = "2026-01-29T15:13:07.942Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f3/fd/d6b0a36854179b93ed77839f107c4089d91cccc9f9ba1b752b6e3bac5f34/orjson-3.11.6-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e259e85a81d76d9665f03d6129e09e4435531870de5961ddcd0bf6e3a7fde7d7", size = 250029, upload-time = "2026-01-29T15:11:35.942Z" }, @@ -3306,7 +3306,7 @@ wheels = [ [[package]] name = "ormsgpack" version = "1.12.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/12/0c/f1761e21486942ab9bb6feaebc610fa074f7c5e496e6962dea5873348077/ormsgpack-1.12.2.tar.gz", hash = "sha256:944a2233640273bee67521795a73cf1e959538e0dfb7ac635505010455e53b33", size = 39031, upload-time = "2026-01-18T20:55:28.023Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/4b/08/8b68f24b18e69d92238aa8f258218e6dfeacf4381d9d07ab8df303f524a9/ormsgpack-1.12.2-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bd5f4bf04c37888e864f08e740c5a573c4017f6fd6e99fa944c5c935fabf2dd9", size = 378266, upload-time = "2026-01-18T20:55:59.876Z" }, @@ -3354,7 +3354,7 @@ wheels = [ [[package]] name = "overrides" version = "7.7.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/36/86/b585f53236dec60aba864e050778b25045f857e17f6e5ea0ae95fe80edd2/overrides-7.7.0.tar.gz", hash = "sha256:55158fa3d93b98cc75299b1e67078ad9003ca27945c76162c1c0766d6f91820a", size = 22812, upload-time = "2024-01-27T21:01:33.423Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/2c/ab/fc8290c6a4c722e5514d80f62b2dc4c4df1a68a41d1364e625c35990fcf3/overrides-7.7.0-py3-none-any.whl", hash = "sha256:c7ed9d062f78b8e4c1a7b70bd8796b35ead4d9f510227ef9c5dc7626c60d7e49", size = 17832, upload-time = "2024-01-27T21:01:31.393Z" }, @@ -3363,7 +3363,7 @@ wheels = [ [[package]] name = "packaging" version = "25.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, @@ -3372,7 +3372,7 @@ wheels = [ [[package]] name = "pandas" version = "3.0.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "numpy" }, { name = "python-dateutil" }, @@ -3432,7 +3432,7 @@ wheels = [ [[package]] name = "pathspec" version = "1.0.4" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, @@ -3441,7 +3441,7 @@ wheels = [ [[package]] name = "pgvector" version = "0.4.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "numpy" }, ] @@ -3453,7 +3453,7 @@ wheels = [ [[package]] name = "pillow" version = "12.1.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/2b/46/5da1ec4a5171ee7bf1a0efa064aba70ba3d6e0788ce3f5acd1375d23c8c0/pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32", size = 5304084, upload-time = "2026-02-11T04:20:27.501Z" }, @@ -3540,7 +3540,7 @@ wheels = [ [[package]] name = "pip" version = "26.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/44/c2/65686a7783a7c27a329706207147e82f23c41221ee9ae33128fc331670a0/pip-26.0.tar.gz", hash = "sha256:3ce220a0a17915972fbf1ab451baae1521c4539e778b28127efa79b974aff0fa", size = 1812654, upload-time = "2026-01-31T01:40:54.361Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/69/00/5ac7aa77688ec4d34148b423d34dc0c9bc4febe0d872a9a1ad9860b2f6f1/pip-26.0-py3-none-any.whl", hash = "sha256:98436feffb9e31bc9339cf369fd55d3331b1580b6a6f1173bacacddcf9c34754", size = 1787564, upload-time = "2026-01-31T01:40:52.252Z" }, @@ -3549,7 +3549,7 @@ wheels = [ [[package]] name = "platformdirs" version = "4.5.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, @@ -3558,7 +3558,7 @@ wheels = [ [[package]] name = "pluggy" version = "1.6.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, @@ -3567,7 +3567,7 @@ wheels = [ [[package]] name = "portalocker" version = "3.2.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "pywin32", marker = "sys_platform == 'win32'" }, ] @@ -3579,7 +3579,7 @@ wheels = [ [[package]] name = "posthog" version = "5.4.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "backoff" }, { name = "distro" }, @@ -3595,7 +3595,7 @@ wheels = [ [[package]] name = "pre-commit" version = "4.5.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "cfgv" }, { name = "identify" }, @@ -3611,7 +3611,7 @@ wheels = [ [[package]] name = "priority" version = "2.0.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/f5/3c/eb7c35f4dcede96fca1842dac5f4f5d15511aa4b52f3a961219e68ae9204/priority-2.0.0.tar.gz", hash = "sha256:c965d54f1b8d0d0b19479db3924c7c36cf672dbf2aec92d43fbdaf4492ba18c0", size = 24792, upload-time = "2021-06-27T10:15:05.487Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/5e/5f/82c8074f7e84978129347c2c6ec8b6c59f3584ff1a20bc3c940a3e061790/priority-2.0.0-py3-none-any.whl", hash = "sha256:6f8eefce5f3ad59baf2c080a664037bb4725cd0a790d53d59ab4059288faf6aa", size = 8946, upload-time = "2021-06-27T10:15:03.856Z" }, @@ -3620,7 +3620,7 @@ wheels = [ [[package]] name = "propcache" version = "0.4.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/8c/d4/4e2c9aaf7ac2242b9358f98dccd8f90f2605402f5afeff6c578682c2c491/propcache-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf", size = 80208, upload-time = "2025-10-08T19:46:24.597Z" }, @@ -3719,7 +3719,7 @@ wheels = [ [[package]] name = "protobuf" version = "6.33.5" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/ba/25/7c72c307aafc96fa87062aa6291d9f7c94836e43214d43722e86037aac02/protobuf-6.33.5.tar.gz", hash = "sha256:6ddcac2a081f8b7b9642c09406bc6a4290128fce5f471cddd165960bb9119e5c", size = 444465, upload-time = "2026-01-29T21:51:33.494Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b1/79/af92d0a8369732b027e6d6084251dd8e782c685c72da161bd4a2e00fbabb/protobuf-6.33.5-cp310-abi3-win32.whl", hash = "sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b", size = 425769, upload-time = "2026-01-29T21:51:21.751Z" }, @@ -3734,7 +3734,7 @@ wheels = [ [[package]] name = "psutil" version = "7.2.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, @@ -3762,7 +3762,7 @@ wheels = [ [[package]] name = "pybase64" version = "1.4.3" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/aa/b8/4ed5c7ad5ec15b08d35cc79ace6145d5c1ae426e46435f4987379439dfea/pybase64-1.4.3.tar.gz", hash = "sha256:c2ed274c9e0ba9c8f9c4083cfe265e66dd679126cd9c2027965d807352f3f053", size = 137272, upload-time = "2025-12-06T13:27:04.013Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/2b/63/21e981e9d3f1f123e0b0ee2130112b1956cad9752309f574862c7ae77c08/pybase64-1.4.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:70b0d4a4d54e216ce42c2655315378b8903933ecfa32fced453989a92b4317b2", size = 38237, upload-time = "2025-12-06T13:22:52.159Z" }, @@ -3910,7 +3910,7 @@ wheels = [ [[package]] name = "pycparser" version = "3.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, @@ -3919,7 +3919,7 @@ wheels = [ [[package]] name = "pycryptodome" version = "3.23.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" }, @@ -3949,7 +3949,7 @@ wheels = [ [[package]] name = "pydantic" version = "2.12.5" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "annotated-types" }, { name = "pydantic-core" }, @@ -3964,7 +3964,7 @@ wheels = [ [[package]] name = "pydantic-core" version = "2.41.5" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "typing-extensions" }, ] @@ -4061,7 +4061,7 @@ wheels = [ [[package]] name = "pydantic-settings" version = "2.12.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, @@ -4075,7 +4075,7 @@ wheels = [ [[package]] name = "pygments" version = "2.19.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, @@ -4084,7 +4084,7 @@ wheels = [ [[package]] name = "pyjwt" version = "2.11.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" }, @@ -4098,7 +4098,7 @@ crypto = [ [[package]] name = "pylibseekdb" version = "1.1.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } wheels = [ { url = "https://files.pythonhosted.org/packages/1c/b8/c226744a7a1da9295725920a36867ee5665f2617972c7881d5ed4cbd45c8/pylibseekdb-1.1.0-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:0a0ad03d87f1db1a7087ba89e398ce1ee00496e977d38c493104d0d517590968", size = 148743770, upload-time = "2026-01-30T05:26:14.275Z" }, { url = "https://files.pythonhosted.org/packages/51/4d/57151735afc29039f4ed680256012a33dd719ba3fd84d7c33a9bd260fc8a/pylibseekdb-1.1.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:e272bee013aabab152c4795676b3b0ba1107a8058f29a07d2a803168faea090c", size = 147132528, upload-time = "2026-01-30T03:40:10.878Z" }, @@ -4117,7 +4117,7 @@ wheels = [ [[package]] name = "pymilvus" version = "2.6.8" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "cachetools" }, { name = "grpcio" }, @@ -4135,7 +4135,7 @@ wheels = [ [[package]] name = "pymysql" version = "1.1.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/f5/ae/1fe3fcd9f959efa0ebe200b8de88b5a5ce3e767e38c7ac32fb179f16a388/pymysql-1.1.2.tar.gz", hash = "sha256:4961d3e165614ae65014e361811a724e2044ad3ea3739de9903ae7c21f539f03", size = 48258, upload-time = "2025-08-24T12:55:55.146Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/7c/4c/ad33b92b9864cbde84f259d5df035a6447f91891f5be77788e2a3892bce3/pymysql-1.1.2-py3-none-any.whl", hash = "sha256:e6b1d89711dd51f8f74b1631fe08f039e7d76cf67a42a323d3178f0f25762ed9", size = 45300, upload-time = "2025-08-24T12:55:53.394Z" }, @@ -4144,7 +4144,7 @@ wheels = [ [[package]] name = "pynacl" version = "1.6.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] @@ -4179,7 +4179,7 @@ wheels = [ [[package]] name = "pypdf2" version = "3.0.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/9f/bb/18dc3062d37db6c491392007dfd1a7f524bb95886eb956569ac38a23a784/PyPDF2-3.0.1.tar.gz", hash = "sha256:a74408f69ba6271f71b9352ef4ed03dc53a31aa404d29b5d31f53bfecfee1440", size = 227419, upload-time = "2022-12-31T10:36:13.13Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/8e/5e/c86a5643653825d3c913719e788e41386bee415c2b87b4f955432f2de6b2/pypdf2-3.0.1-py3-none-any.whl", hash = "sha256:d16e4205cfee272fbdc0568b68d82be796540b1537508cef59388f839c191928", size = 232572, upload-time = "2022-12-31T10:36:10.327Z" }, @@ -4188,7 +4188,7 @@ wheels = [ [[package]] name = "pypika" version = "0.50.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/fb/fb/b7d5f29108b07c10c69fc3bb72e12f869d55a360a449749fba5a1f903525/pypika-0.50.0.tar.gz", hash = "sha256:2ff66a153adc8d8877879ff2abd5a3b050a5d2adfdf8659d3402076e385e35b3", size = 81033, upload-time = "2026-01-14T12:34:21.895Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/18/5b/419c5bb460cb27b52fcd3bc96830255c3265bc1859f55aafa3ff08fae8bd/pypika-0.50.0-py2.py3-none-any.whl", hash = "sha256:ed11b7e259bc38abbcfde00cfb31f8d00aa42ffa51e437b8f5ac2db12b0fe0f4", size = 60577, upload-time = "2026-01-14T12:34:20.078Z" }, @@ -4197,7 +4197,7 @@ wheels = [ [[package]] name = "pypng" version = "0.20220715.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/93/cd/112f092ec27cca83e0516de0a3368dbd9128c187fb6b52aaaa7cde39c96d/pypng-0.20220715.0.tar.gz", hash = "sha256:739c433ba96f078315de54c0db975aee537cbc3e1d0ae4ed9aab0ca1e427e2c1", size = 128992, upload-time = "2022-07-15T14:11:05.301Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/3e/b9/3766cc361d93edb2ce81e2e1f87dd98f314d7d513877a342d31b30741680/pypng-0.20220715.0-py3-none-any.whl", hash = "sha256:4a43e969b8f5aaafb2a415536c1a8ec7e341cd6a3f957fd5b5f32a4cfeed902c", size = 58057, upload-time = "2022-07-15T14:11:03.713Z" }, @@ -4206,7 +4206,7 @@ wheels = [ [[package]] name = "pyproject-hooks" version = "1.2.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/e7/82/28175b2414effca1cdac8dc99f76d660e7a4fb0ceefa4b4ab8f5f6742925/pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", size = 19228, upload-time = "2024-09-29T09:24:13.293Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" }, @@ -4215,7 +4215,7 @@ wheels = [ [[package]] name = "pyreadline3" version = "3.5.4" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/0f/49/4cea918a08f02817aabae639e3d0ac046fef9f9180518a3ad394e22da148/pyreadline3-3.5.4.tar.gz", hash = "sha256:8d57d53039a1c75adba8e50dd3d992b28143480816187ea5efbd5c78e6c885b7", size = 99839, upload-time = "2024-09-19T02:40:10.062Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178, upload-time = "2024-09-19T02:40:08.598Z" }, @@ -4224,7 +4224,7 @@ wheels = [ [[package]] name = "pyseekdb" version = "1.1.0.post3" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "httpx", marker = "python_full_version < '3.14'" }, { name = "numpy" }, @@ -4243,7 +4243,7 @@ wheels = [ [[package]] name = "pytest" version = "9.0.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "iniconfig" }, @@ -4259,7 +4259,7 @@ wheels = [ [[package]] name = "pytest-asyncio" version = "1.3.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "pytest" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, @@ -4272,7 +4272,7 @@ wheels = [ [[package]] name = "pytest-cov" version = "7.0.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "coverage", extra = ["toml"] }, { name = "pluggy" }, @@ -4286,7 +4286,7 @@ wheels = [ [[package]] name = "python-dateutil" version = "2.9.0.post0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "six" }, ] @@ -4298,7 +4298,7 @@ wheels = [ [[package]] name = "python-docx" version = "1.2.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "lxml" }, { name = "typing-extensions" }, @@ -4311,7 +4311,7 @@ wheels = [ [[package]] name = "python-dotenv" version = "1.2.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, @@ -4320,7 +4320,7 @@ wheels = [ [[package]] name = "python-multipart" version = "0.0.22" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, @@ -4329,7 +4329,7 @@ wheels = [ [[package]] name = "python-socks" version = "2.8.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/6c/07/cfdd6a846ac859e513b4e68bb6c669a90a74d89d8d405516fba7fc9c6f0c/python_socks-2.8.0.tar.gz", hash = "sha256:340f82778b20a290bdd538ee47492978d603dff7826aaf2ce362d21ad9ee6f1b", size = 273130, upload-time = "2025-12-09T12:17:05.433Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/13/10/e2b575faa32d1d32e5e6041fc64794fa9f09526852a06b25353b66f52cae/python_socks-2.8.0-py3-none-any.whl", hash = "sha256:57c24b416569ccea493a101d38b0c82ed54be603aa50b6afbe64c46e4a4e4315", size = 55075, upload-time = "2025-12-09T12:17:03.269Z" }, @@ -4338,7 +4338,7 @@ wheels = [ [[package]] name = "python-telegram-bot" version = "22.6" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "httpcore", marker = "python_full_version >= '3.14'" }, { name = "httpx" }, @@ -4351,7 +4351,7 @@ wheels = [ [[package]] name = "pywin32" version = "311" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } wheels = [ { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" }, { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" }, @@ -4370,7 +4370,7 @@ wheels = [ [[package]] name = "pyyaml" version = "6.0.3" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, @@ -4425,7 +4425,7 @@ wheels = [ [[package]] name = "qdrant-client" version = "1.16.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "grpcio" }, { name = "httpx", extra = ["http2"] }, @@ -4443,7 +4443,7 @@ wheels = [ [[package]] name = "qq-botpy-rc" version = "1.2.1.6" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "aiohttp" }, { name = "apscheduler" }, @@ -4457,7 +4457,7 @@ wheels = [ [[package]] name = "qrcode" version = "7.4.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "pypng" }, @@ -4471,7 +4471,7 @@ wheels = [ [[package]] name = "quart" version = "0.20.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "aiofiles" }, { name = "blinker" }, @@ -4491,7 +4491,7 @@ wheels = [ [[package]] name = "quart-cors" version = "0.8.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "quart" }, ] @@ -4503,7 +4503,7 @@ wheels = [ [[package]] name = "referencing" version = "0.37.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "attrs" }, { name = "rpds-py" }, @@ -4517,7 +4517,7 @@ wheels = [ [[package]] name = "regex" version = "2026.1.15" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/0b/86/07d5056945f9ec4590b518171c4254a5925832eb727b56d3c38a7476f316/regex-2026.1.15.tar.gz", hash = "sha256:164759aa25575cbc0651bef59a0b18353e54300d79ace8084c818ad8ac72b7d5", size = 414811, upload-time = "2026-01-14T23:18:02.775Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d0/c9/0c80c96eab96948363d270143138d671d5731c3a692b417629bf3492a9d6/regex-2026.1.15-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ae6020fb311f68d753b7efa9d4b9a5d47a5d6466ea0d5e3b5a471a960ea6e4a", size = 488168, upload-time = "2026-01-14T23:14:16.129Z" }, @@ -4621,7 +4621,7 @@ wheels = [ [[package]] name = "requests" version = "2.32.5" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "certifi" }, { name = "charset-normalizer" }, @@ -4636,7 +4636,7 @@ wheels = [ [[package]] name = "requests-oauthlib" version = "2.0.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "oauthlib" }, { name = "requests" }, @@ -4649,7 +4649,7 @@ wheels = [ [[package]] name = "requests-toolbelt" version = "1.0.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "requests" }, ] @@ -4661,7 +4661,7 @@ wheels = [ [[package]] name = "rich" version = "14.3.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, @@ -4674,7 +4674,7 @@ wheels = [ [[package]] name = "rpds-py" version = "0.30.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, @@ -4782,7 +4782,7 @@ wheels = [ [[package]] name = "ruff" version = "0.14.14" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/2e/06/f71e3a86b2df0dfa2d2f72195941cd09b44f87711cb7fa5193732cb9a5fc/ruff-0.14.14.tar.gz", hash = "sha256:2d0f819c9a90205f3a867dbbd0be083bee9912e170fd7d9704cc8ae45824896b", size = 4515732, upload-time = "2026-01-22T22:30:17.527Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d2/89/20a12e97bc6b9f9f68343952da08a8099c57237aef953a56b82711d55edd/ruff-0.14.14-py3-none-linux_armv6l.whl", hash = "sha256:7cfe36b56e8489dee8fbc777c61959f60ec0f1f11817e8f2415f429552846aed", size = 10467650, upload-time = "2026-01-22T22:30:08.578Z" }, @@ -4808,7 +4808,7 @@ wheels = [ [[package]] name = "s3transfer" version = "0.16.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "botocore" }, ] @@ -4820,7 +4820,7 @@ wheels = [ [[package]] name = "safetensors" version = "0.7.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/29/9c/6e74567782559a63bd040a236edca26fd71bc7ba88de2ef35d75df3bca5e/safetensors-0.7.0.tar.gz", hash = "sha256:07663963b67e8bd9f0b8ad15bb9163606cd27cc5a1b96235a50d8369803b96b0", size = 200878, upload-time = "2025-11-19T15:18:43.199Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/fa/47/aef6c06649039accf914afef490268e1067ed82be62bcfa5b7e886ad15e8/safetensors-0.7.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c82f4d474cf725255d9e6acf17252991c3c8aac038d6ef363a4bf8be2f6db517", size = 467781, upload-time = "2025-11-19T15:18:35.84Z" }, @@ -4842,7 +4842,7 @@ wheels = [ [[package]] name = "scikit-learn" version = "1.8.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "joblib", marker = "python_full_version >= '3.14'" }, { name = "numpy", marker = "python_full_version >= '3.14'" }, @@ -4892,7 +4892,7 @@ wheels = [ [[package]] name = "scipy" version = "1.17.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "numpy", marker = "python_full_version >= '3.14'" }, ] @@ -4963,7 +4963,7 @@ wheels = [ [[package]] name = "sentence-transformers" version = "5.2.3" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "huggingface-hub", marker = "python_full_version >= '3.14'" }, { name = "numpy", marker = "python_full_version >= '3.14'" }, @@ -4982,7 +4982,7 @@ wheels = [ [[package]] name = "setuptools" version = "80.10.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/76/95/faf61eb8363f26aa7e1d762267a8d602a1b26d4f3a1e758e92cb3cb8b054/setuptools-80.10.2.tar.gz", hash = "sha256:8b0e9d10c784bf7d262c4e5ec5d4ec94127ce206e8738f29a437945fbc219b70", size = 1200343, upload-time = "2026-01-25T22:38:17.252Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/94/b8/f1f62a5e3c0ad2ff1d189590bfa4c46b4f3b6e49cef6f26c6ee4e575394d/setuptools-80.10.2-py3-none-any.whl", hash = "sha256:95b30ddfb717250edb492926c92b5221f7ef3fbcc2b07579bcd4a27da21d0173", size = 1064234, upload-time = "2026-01-25T22:38:15.216Z" }, @@ -4991,7 +4991,7 @@ wheels = [ [[package]] name = "shellingham" version = "1.5.4" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, @@ -5000,7 +5000,7 @@ wheels = [ [[package]] name = "six" version = "1.17.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, @@ -5009,7 +5009,7 @@ wheels = [ [[package]] name = "slack-sdk" version = "3.39.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/b6/dd/645f3eb93fce38eadbb649e85684730b1fc3906c2674ca59bddc2ca2bd2e/slack_sdk-3.39.0.tar.gz", hash = "sha256:6a56be10dc155c436ff658c6b776e1c082e29eae6a771fccf8b0a235822bbcb1", size = 247207, upload-time = "2025-11-20T15:27:57.556Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ef/1f/32bcf088e535c1870b1a1f2e3b916129c66fdfe565a793316317241d41e5/slack_sdk-3.39.0-py2.py3-none-any.whl", hash = "sha256:b1556b2f5b8b12b94e5ea3f56c4f2c7f04462e4e1013d325c5764ff118044fa8", size = 309850, upload-time = "2025-11-20T15:27:55.729Z" }, @@ -5018,7 +5018,7 @@ wheels = [ [[package]] name = "sniffio" version = "1.3.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, @@ -5027,7 +5027,7 @@ wheels = [ [[package]] name = "soupsieve" version = "2.8.3" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, @@ -5036,7 +5036,7 @@ wheels = [ [[package]] name = "sqlalchemy" version = "2.0.46" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, { name = "typing-extensions" }, @@ -5090,7 +5090,7 @@ asyncio = [ [[package]] name = "sqlmodel" version = "0.0.31" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "pydantic" }, { name = "sqlalchemy" }, @@ -5103,7 +5103,7 @@ wheels = [ [[package]] name = "sse-starlette" version = "3.2.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "anyio" }, { name = "starlette" }, @@ -5116,7 +5116,7 @@ wheels = [ [[package]] name = "sseclient-py" version = "1.9.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } wheels = [ { url = "https://files.pythonhosted.org/packages/4d/2e/59920f7d66b7f9932a3d83dd0ec53fab001be1e058bf582606fe414a5198/sseclient_py-1.9.0-py3-none-any.whl", hash = "sha256:340062b1587fc2880892811e2ab5b176d98ef3eee98b3672ff3a3ba1e8ed0f6f", size = 8351, upload-time = "2026-01-02T23:39:30.995Z" }, ] @@ -5124,7 +5124,7 @@ wheels = [ [[package]] name = "starlette" version = "0.52.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, @@ -5137,7 +5137,7 @@ wheels = [ [[package]] name = "sympy" version = "1.14.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "mpmath" }, ] @@ -5149,7 +5149,7 @@ wheels = [ [[package]] name = "tboxsdk" version = "0.0.12" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "httpx" }, { name = "sseclient-py" }, @@ -5162,7 +5162,7 @@ wheels = [ [[package]] name = "telegramify-markdown" version = "0.5.4" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "mistletoe" }, ] @@ -5174,7 +5174,7 @@ wheels = [ [[package]] name = "tenacity" version = "9.1.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/0a/d4/2b0cd0fe285e14b36db076e78c93766ff1d529d70408bd1d2a5a84f1d929/tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb", size = 48036, upload-time = "2025-04-02T08:25:09.966Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248, upload-time = "2025-04-02T08:25:07.678Z" }, @@ -5183,7 +5183,7 @@ wheels = [ [[package]] name = "textual" version = "7.5.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "markdown-it-py", extra = ["linkify"] }, { name = "mdit-py-plugins" }, @@ -5200,7 +5200,7 @@ wheels = [ [[package]] name = "threadpoolctl" version = "3.6.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, @@ -5209,7 +5209,7 @@ wheels = [ [[package]] name = "tiktoken" version = "0.12.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "regex" }, { name = "requests" }, @@ -5263,7 +5263,7 @@ wheels = [ [[package]] name = "tokenizers" version = "0.22.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "huggingface-hub" }, ] @@ -5289,7 +5289,7 @@ wheels = [ [[package]] name = "tomli" version = "2.4.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, @@ -5343,7 +5343,7 @@ wheels = [ [[package]] name = "torch" version = "2.10.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "cuda-bindings", marker = "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'" }, { name = "filelock", marker = "python_full_version >= '3.14'" }, @@ -5403,7 +5403,7 @@ wheels = [ [[package]] name = "tqdm" version = "4.67.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] @@ -5415,7 +5415,7 @@ wheels = [ [[package]] name = "transformers" version = "5.3.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "huggingface-hub", marker = "python_full_version >= '3.14'" }, { name = "numpy", marker = "python_full_version >= '3.14'" }, @@ -5435,7 +5435,7 @@ wheels = [ [[package]] name = "triton" version = "3.6.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } wheels = [ { url = "https://files.pythonhosted.org/packages/e0/12/b05ba554d2c623bffa59922b94b0775673de251f468a9609bc9e45de95e9/triton-3.6.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8e323d608e3a9bfcc2d9efcc90ceefb764a82b99dea12a86d643c72539ad5d3", size = 188214640, upload-time = "2026-01-20T16:00:35.869Z" }, { url = "https://files.pythonhosted.org/packages/ab/a8/cdf8b3e4c98132f965f88c2313a4b493266832ad47fb52f23d14d4f86bb5/triton-3.6.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74caf5e34b66d9f3a429af689c1c7128daba1d8208df60e81106b115c00d6fca", size = 188266850, upload-time = "2026-01-20T16:00:43.041Z" }, @@ -5448,7 +5448,7 @@ wheels = [ [[package]] name = "typer" version = "0.21.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "click" }, { name = "rich" }, @@ -5463,7 +5463,7 @@ wheels = [ [[package]] name = "typer-slim" version = "0.21.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "click" }, { name = "typing-extensions" }, @@ -5476,7 +5476,7 @@ wheels = [ [[package]] name = "types-aiofiles" version = "25.1.0.20251011" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/84/6c/6d23908a8217e36704aa9c79d99a620f2fdd388b66a4b7f72fbc6b6ff6c6/types_aiofiles-25.1.0.20251011.tar.gz", hash = "sha256:1c2b8ab260cb3cd40c15f9d10efdc05a6e1e6b02899304d80dfa0410e028d3ff", size = 14535, upload-time = "2025-10-11T02:44:51.237Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/71/0f/76917bab27e270bb6c32addd5968d69e558e5b6f7fb4ac4cbfa282996a96/types_aiofiles-25.1.0.20251011-py3-none-any.whl", hash = "sha256:8ff8de7f9d42739d8f0dadcceeb781ce27cd8d8c4152d4a7c52f6b20edb8149c", size = 14338, upload-time = "2025-10-11T02:44:50.054Z" }, @@ -5485,7 +5485,7 @@ wheels = [ [[package]] name = "types-pyyaml" version = "6.0.12.20250915" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/7e/69/3c51b36d04da19b92f9e815be12753125bd8bc247ba0470a982e6979e71c/types_pyyaml-6.0.12.20250915.tar.gz", hash = "sha256:0f8b54a528c303f0e6f7165687dd33fafa81c807fcac23f632b63aa624ced1d3", size = 17522, upload-time = "2025-09-15T03:01:00.728Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/bd/e0/1eed384f02555dde685fff1a1ac805c1c7dcb6dd019c916fe659b1c1f9ec/types_pyyaml-6.0.12.20250915-py3-none-any.whl", hash = "sha256:e7d4d9e064e89a3b3cae120b4990cd370874d2bf12fa5f46c97018dd5d3c9ab6", size = 20338, upload-time = "2025-09-15T03:00:59.218Z" }, @@ -5494,7 +5494,7 @@ wheels = [ [[package]] name = "typing-extensions" version = "4.15.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, @@ -5503,7 +5503,7 @@ wheels = [ [[package]] name = "typing-inspection" version = "0.4.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "typing-extensions" }, ] @@ -5515,7 +5515,7 @@ wheels = [ [[package]] name = "tzdata" version = "2025.3" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, @@ -5524,7 +5524,7 @@ wheels = [ [[package]] name = "tzlocal" version = "5.3.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "tzdata", marker = "sys_platform == 'win32'" }, ] @@ -5536,7 +5536,7 @@ wheels = [ [[package]] name = "uc-micro-py" version = "1.0.3" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/91/7a/146a99696aee0609e3712f2b44c6274566bc368dfe8375191278045186b8/uc-micro-py-1.0.3.tar.gz", hash = "sha256:d321b92cff673ec58027c04015fcaa8bb1e005478643ff4a500882eaab88c48a", size = 6043, upload-time = "2024-02-09T16:52:01.654Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/37/87/1f677586e8ac487e29672e4b17455758fce261de06a0d086167bb760361a/uc_micro_py-1.0.3-py3-none-any.whl", hash = "sha256:db1dffff340817673d7b466ec86114a9dc0e9d4d9b5ba229d9d60e5c12600cd5", size = 6229, upload-time = "2024-02-09T16:52:00.371Z" }, @@ -5545,7 +5545,7 @@ wheels = [ [[package]] name = "urllib3" version = "2.6.3" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, @@ -5554,7 +5554,7 @@ wheels = [ [[package]] name = "uuid-utils" version = "0.14.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/57/7c/3a926e847516e67bc6838634f2e54e24381105b4e80f9338dc35cca0086b/uuid_utils-0.14.0.tar.gz", hash = "sha256:fc5bac21e9933ea6c590433c11aa54aaca599f690c08069e364eb13a12f670b4", size = 22072, upload-time = "2026-01-20T20:37:15.729Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/a7/42/42d003f4a99ddc901eef2fd41acb3694163835e037fb6dde79ad68a72342/uuid_utils-0.14.0-cp39-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:f6695c0bed8b18a904321e115afe73b34444bc8451d0ce3244a1ec3b84deb0e5", size = 601786, upload-time = "2026-01-20T20:37:09.843Z" }, @@ -5583,7 +5583,7 @@ wheels = [ [[package]] name = "uv" version = "0.9.28" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/c2/7d/005ab1cab03ca928cef75b424284d14d62c5f18775cf8114a63f210a0c9c/uv-0.9.28.tar.gz", hash = "sha256:253c04b26fb40f74c56ead12ce83db3c018bdefde1fcd1a542bcb88fdca4189c", size = 3834456, upload-time = "2026-01-29T20:15:49.794Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/77/dc/e70698756f1bb74c88bf1eaea63a114a580a38f296ea1567a01db9007490/uv-0.9.28-py3-none-linux_armv6l.whl", hash = "sha256:aede961243bb2c0ca09d0e04ea0bf580d7128dd3b14661b79d133be9a5b69894", size = 22040477, upload-time = "2026-01-29T20:16:11.24Z" }, @@ -5609,7 +5609,7 @@ wheels = [ [[package]] name = "uvicorn" version = "0.40.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "click" }, { name = "h11" }, @@ -5633,7 +5633,7 @@ standard = [ [[package]] name = "uvloop" version = "0.22.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, @@ -5671,7 +5671,7 @@ wheels = [ [[package]] name = "virtualenv" version = "20.36.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "distlib" }, { name = "filelock" }, @@ -5685,7 +5685,7 @@ wheels = [ [[package]] name = "watchdog" version = "6.0.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, @@ -5712,7 +5712,7 @@ wheels = [ [[package]] name = "watchfiles" version = "1.1.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "anyio" }, ] @@ -5799,7 +5799,7 @@ wheels = [ [[package]] name = "websocket-client" version = "1.9.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, @@ -5808,7 +5808,7 @@ wheels = [ [[package]] name = "websockets" version = "16.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, @@ -5867,7 +5867,7 @@ wheels = [ [[package]] name = "werkzeug" version = "3.1.5" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "markupsafe" }, ] @@ -5879,7 +5879,7 @@ wheels = [ [[package]] name = "wrapt" version = "2.1.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/86/31/afb4cf08b9892430ec419a3f0f469fb978cb013f4432e0edb9c2cf06f081/wrapt-2.1.0.tar.gz", hash = "sha256:757ff1de7e1d8db1839846672aaecf4978af433cc57e808255b83980e9651914", size = 80924, upload-time = "2026-01-31T23:25:58.917Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/97/0a/de541b2543e33144043cd58da09bda8d837ba42e13ae90baca32b0553023/wrapt-2.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d877003dbc601e1365bd03f6a980965a20d585f90c056f33e1fc241b63a6f0e7", size = 60558, upload-time = "2026-01-31T23:25:27.784Z" }, @@ -5942,7 +5942,7 @@ wheels = [ [[package]] name = "wsproto" version = "1.3.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "h11" }, ] @@ -5954,7 +5954,7 @@ wheels = [ [[package]] name = "xxhash" version = "3.6.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/02/84/30869e01909fb37a6cc7e18688ee8bf1e42d57e7e0777636bd47524c43c7/xxhash-3.6.0.tar.gz", hash = "sha256:f0162a78b13a0d7617b2845b90c763339d1f1d82bb04a4b07f4ab535cc5e05d6", size = 85160, upload-time = "2025-10-02T14:37:08.097Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/17/d4/cc2f0400e9154df4b9964249da78ebd72f318e35ccc425e9f403c392f22a/xxhash-3.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b47bbd8cf2d72797f3c2772eaaac0ded3d3af26481a26d7d7d41dc2d3c46b04a", size = 32844, upload-time = "2025-10-02T14:34:14.037Z" }, @@ -6057,7 +6057,7 @@ wheels = [ [[package]] name = "yarl" version = "1.22.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "idna" }, { name = "multidict" }, @@ -6167,7 +6167,7 @@ wheels = [ [[package]] name = "zipp" version = "3.23.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, @@ -6176,7 +6176,7 @@ wheels = [ [[package]] name = "zstandard" version = "0.25.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/2a/83/c3ca27c363d104980f1c9cee1101cc8ba724ac8c28a033ede6aab89585b1/zstandard-0.25.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:933b65d7680ea337180733cf9e87293cc5500cc0eb3fc8769f4d3c88d724ec5c", size = 795254, upload-time = "2025-09-14T22:16:26.137Z" }, From 4b4c7e57187701447481006c92875e84d400eeea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=92=8B=E9=87=91=E9=BE=99=EF=BC=88=E5=A4=9C=E9=9B=A8?= =?UTF-8?q?=EF=BC=89?= <58286951@qq.com> Date: Wed, 11 Mar 2026 18:38:34 +0800 Subject: [PATCH 13/36] refactor(pipeline): merge exception handling into single strategy option - Replace hide-exception and block-failed-request-output with exception-handling - Add three strategies: hide, show-hint, show-error - Add failure-hint field for customizable error message - Add database migration dbm021 for config conversion - Remove wecom-stream stage (moved to adapter config) Co-Authored-By: Claude Opus 4.6 --- .../dbm021_merge_exception_handling.py | 74 +++++++++++++++++++ .../pkg/pipeline/process/handlers/chat.py | 11 ++- src/langbot/pkg/utils/constants.py | 2 +- .../templates/default-pipeline-config.json | 9 +-- .../templates/metadata/pipeline/output.yaml | 65 ++++++++-------- 5 files changed, 119 insertions(+), 42 deletions(-) create mode 100644 src/langbot/pkg/persistence/migrations/dbm021_merge_exception_handling.py diff --git a/src/langbot/pkg/persistence/migrations/dbm021_merge_exception_handling.py b/src/langbot/pkg/persistence/migrations/dbm021_merge_exception_handling.py new file mode 100644 index 000000000..59c1e357f --- /dev/null +++ b/src/langbot/pkg/persistence/migrations/dbm021_merge_exception_handling.py @@ -0,0 +1,74 @@ +from .. import migration + +import sqlalchemy +import json + + +@migration.migration_class(21) +class DBMigrateMergeExceptionHandling(migration.DBMigration): + """Merge hide-exception and block-failed-request-output into a single exception-handling select option, + and add failure-hint field. + + Conversion logic: + - block-failed-request-output=true -> exception-handling: hide + - hide-exception=true -> exception-handling: show-hint + - hide-exception=false -> exception-handling: show-error + """ + + async def upgrade(self): + """Upgrade""" + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines') + ) + pipelines = result.fetchall() + + current_version = self.ap.ver_mgr.get_current_version() + + for pipeline_row in pipelines: + uuid = pipeline_row[0] + config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1] + + if 'output' not in config: + config['output'] = {} + if 'misc' not in config['output']: + config['output']['misc'] = {} + + misc = config['output']['misc'] + + # Determine new exception-handling value from legacy fields + hide_exception = misc.get('hide-exception', True) + block_failed = misc.get('block-failed-request-output', False) + + if block_failed: + exception_handling = 'hide' + elif hide_exception: + exception_handling = 'show-hint' + else: + exception_handling = 'show-error' + + misc['exception-handling'] = exception_handling + + # Add failure-hint with default value + misc['failure-hint'] = 'Request failed.' + + # Remove legacy fields + misc.pop('hide-exception', None) + + if self.ap.persistence_mgr.db.name == 'postgresql': + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text( + 'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid' + ), + {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, + ) + else: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text( + 'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid' + ), + {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, + ) + + async def downgrade(self): + """Downgrade""" + pass diff --git a/src/langbot/pkg/pipeline/process/handlers/chat.py b/src/langbot/pkg/pipeline/process/handlers/chat.py index 7e130b04c..6f971333f 100644 --- a/src/langbot/pkg/pipeline/process/handlers/chat.py +++ b/src/langbot/pkg/pipeline/process/handlers/chat.py @@ -149,12 +149,19 @@ async def handle( self.ap.logger.error(f'Conversation({query.query_id}) Request Failed: {error_info}') traceback.print_exc() - hide_exception_info = query.pipeline_config['output']['misc']['hide-exception'] + exception_handling = query.pipeline_config['output']['misc'].get('exception-handling', 'show-hint') + + if exception_handling == 'show-error': + user_notice = f'{e}' + elif exception_handling == 'show-hint': + user_notice = query.pipeline_config['output']['misc'].get('failure-hint', 'Request failed.') + else: # hide + user_notice = None yield entities.StageProcessResult( result_type=entities.ResultType.INTERRUPT, new_query=query, - user_notice='请求失败' if hide_exception_info else f'{e}', + user_notice=user_notice, error_notice=f'{e}', debug_notice=traceback.format_exc(), ) diff --git a/src/langbot/pkg/utils/constants.py b/src/langbot/pkg/utils/constants.py index cd058a885..8d8e972a0 100644 --- a/src/langbot/pkg/utils/constants.py +++ b/src/langbot/pkg/utils/constants.py @@ -2,7 +2,7 @@ semantic_version = f'v{langbot.__version__}' -required_database_version = 20 +required_database_version = 21 """Tag the version of the database schema, used to check if the database needs to be migrated""" debug_mode = False diff --git a/src/langbot/templates/default-pipeline-config.json b/src/langbot/templates/default-pipeline-config.json index 470726e1d..1c6fdb641 100644 --- a/src/langbot/templates/default-pipeline-config.json +++ b/src/langbot/templates/default-pipeline-config.json @@ -94,16 +94,13 @@ "min": 0, "max": 0 }, - "wecom-stream": { - "chunk-batch-size": 2, - "flush-window-ms": 2000 - }, "misc": { - "hide-exception": true, + "exception-handling": "show-hint", + "failure-hint": "Request failed.", "at-sender": true, "quote-origin": true, "track-function-calls": false, "remove-think": false } } -} \ No newline at end of file +} diff --git a/src/langbot/templates/metadata/pipeline/output.yaml b/src/langbot/templates/metadata/pipeline/output.yaml index 5e1fc7b33..d5e0fae07 100644 --- a/src/langbot/templates/metadata/pipeline/output.yaml +++ b/src/langbot/templates/metadata/pipeline/output.yaml @@ -73,46 +73,44 @@ stages: type: integer required: true default: 0 - - name: wecom-stream - label: - en_US: WeCom Stream - zh_Hans: 企微流式 - description: - en_US: Stream tuning options for WeCom pull mode. Larger chunk batch size reduces refresh frequency and lowers the risk of rate limiting. - zh_Hans: 企业微信 Pull 流式的调优选项。Chunk 批大小越大,刷新越少,越不容易触发企微限流。 - config: - - name: chunk-batch-size - label: - en_US: Chunk Batch Size - zh_Hans: Chunk 批大小 - description: - en_US: Merge every N model chunks into one refreshed snapshot. - zh_Hans: 每累计 N 个模型 chunk 再输出一次新的流式快照。 - type: integer - required: true - default: 2 - - name: flush-window-ms - label: - en_US: Flush Window (ms) - zh_Hans: 时间窗口(毫秒) - description: - en_US: Emit a refreshed snapshot when content changed and the window elapsed, even if chunk batch size is not reached. - zh_Hans: 当内容有变化且时间窗口到达时,即使未达到 chunk 批大小,也会主动输出新的流式快照。 - type: integer - required: true - default: 2000 - name: misc label: en_US: Misc zh_Hans: 杂项 config: - - name: hide-exception + - name: exception-handling label: - en_US: Hide Exception - zh_Hans: 不输出异常信息给用户 - type: boolean + en_US: Exception Handling Strategy + zh_Hans: 异常处理策略 + description: + en_US: Controls how error messages are displayed to the user when an AI request fails + zh_Hans: 控制 AI 请求失败时向用户展示错误信息的方式 + type: select required: true - default: true + default: show-hint + options: + - name: show-error + label: + en_US: Show Full Error + zh_Hans: 显示完整报错信息 + - name: show-hint + label: + en_US: Show Failure Hint + zh_Hans: 仅文字提示 + - name: hide + label: + en_US: Hide All + zh_Hans: 不显示任何异常信息 + - name: failure-hint + label: + en_US: Failure Hint Text + zh_Hans: 失败提示文本 + description: + en_US: The text to display when a request fails. Only effective when Exception Handling Strategy is set to "Show Failure Hint" + zh_Hans: 请求失败时显示的提示文本,仅在异常处理策略设置为"仅文字提示"时生效 + type: string + required: false + default: 'Request failed.' - name: at-sender label: en_US: At Sender @@ -147,3 +145,4 @@ stages: type: boolean required: true default: false + From 0226f13f7e56e08a92018805a56e476f0fc3484f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=92=8B=E9=87=91=E9=BE=99=EF=BC=88=E5=A4=9C=E9=9B=A8?= =?UTF-8?q?=EF=BC=89?= <58286951@qq.com> Date: Wed, 11 Mar 2026 18:38:47 +0800 Subject: [PATCH 14/36] fix(rag): keep session context in retrieval_settings for plugin compatibility - Move filters out of retrieval_settings to avoid empty results - Pass sender_id and session_name in retrieval settings - Some plugins (e.g. LangRAG) pass filters directly to vector_search Co-Authored-By: Claude Opus 4.6 --- src/langbot/pkg/provider/runners/localagent.py | 8 +++++++- src/langbot/pkg/rag/knowledge/kbmgr.py | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/langbot/pkg/provider/runners/localagent.py b/src/langbot/pkg/provider/runners/localagent.py index f444529bb..52e78b9d4 100644 --- a/src/langbot/pkg/provider/runners/localagent.py +++ b/src/langbot/pkg/provider/runners/localagent.py @@ -74,7 +74,13 @@ async def run( self.ap.logger.warning(f'Knowledge base {kb_uuid} not found, skipping') continue - result = await kb.retrieve(user_message_text) + result = await kb.retrieve( + user_message_text, + settings={ + 'sender_id': str(query.sender_id), + 'session_name': f'{query.session.launcher_type.value}_{query.session.launcher_id}', + }, + ) if result: all_results.extend(result) diff --git a/src/langbot/pkg/rag/knowledge/kbmgr.py b/src/langbot/pkg/rag/knowledge/kbmgr.py index 5831da30b..8fadc341f 100644 --- a/src/langbot/pkg/rag/knowledge/kbmgr.py +++ b/src/langbot/pkg/rag/knowledge/kbmgr.py @@ -321,13 +321,19 @@ async def _retrieve( if not plugin_id: raise ValueError(f'No RAG plugin ID configured for KB {kb.uuid}. Retrieval failed.') + # Session context (e.g. session_name) stays in retrieval_settings + # for plugins that need it. Do NOT move them into filters, as filters + # are passed directly to vector_search by some plugins (e.g. LangRAG) + # and would cause empty results when the metadata field doesn't exist. + filters = settings.pop('filters', {}) + retrieval_context = { 'query': query, 'knowledge_base_id': kb.uuid, 'collection_id': kb.collection_id or kb.uuid, 'retrieval_settings': settings, 'creation_settings': kb.creation_settings or {}, - 'filters': settings.pop('filters', {}), + 'filters': filters, } result = await self.ap.plugin_connector.call_rag_retrieve( From 6ab32af2566ace4c66a8ffe794743eff396c7fd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=92=8B=E9=87=91=E9=BE=99=EF=BC=88=E5=A4=9C=E9=9B=A8?= =?UTF-8?q?=EF=BC=89?= <58286951@qq.com> Date: Wed, 11 Mar 2026 18:39:00 +0800 Subject: [PATCH 15/36] test(dify): update tests for adapter config and workflow_finished event - Update tests to use adapter config instead of pipeline wecom-stream - Add test for workflow_finished event handling - Add test for ignoring empty message chunks Co-Authored-By: Claude Opus 4.6 --- .../pipeline/test_wecombot_dify_minfix.py | 60 ++++++++++++++++++- 1 file changed, 57 insertions(+), 3 deletions(-) diff --git a/tests/unit_tests/pipeline/test_wecombot_dify_minfix.py b/tests/unit_tests/pipeline/test_wecombot_dify_minfix.py index 6fbcfe860..444ca2761 100644 --- a/tests/unit_tests/pipeline/test_wecombot_dify_minfix.py +++ b/tests/unit_tests/pipeline/test_wecombot_dify_minfix.py @@ -116,6 +116,7 @@ async def fake_chat_messages(**kwargs): runner.dify_client = SimpleNamespace(chat_messages=fake_chat_messages, base_url='https://example.com/v1') query = SimpleNamespace( + adapter=SimpleNamespace(config={'PullChunkBatchSize': 4, 'PullFlushWindowMs': 2000}), session=SimpleNamespace( using_conversation=SimpleNamespace(uuid='conv-1'), launcher_type=provider_session.LauncherTypes.PERSON, @@ -161,7 +162,7 @@ async def fake_chat_messages(**kwargs): runner.dify_client = SimpleNamespace(chat_messages=fake_chat_messages, base_url='https://example.com/v1') query = SimpleNamespace( - pipeline_config={'output': {'wecom-stream': {'chunk-batch-size': 3}}}, + adapter=SimpleNamespace(config={'PullChunkBatchSize': 3, 'PullFlushWindowMs': 2000}), session=SimpleNamespace( using_conversation=SimpleNamespace(uuid='conv-1'), launcher_type=provider_session.LauncherTypes.PERSON, @@ -217,7 +218,7 @@ def fake_monotonic(): runner.dify_client = SimpleNamespace(chat_messages=fake_chat_messages, base_url='https://example.com/v1') query = SimpleNamespace( - pipeline_config={'output': {'wecom-stream': {'chunk-batch-size': 8, 'flush-window-ms': 2000}}}, + adapter=SimpleNamespace(config={'PullChunkBatchSize': 8, 'PullFlushWindowMs': 2000}), session=SimpleNamespace( using_conversation=SimpleNamespace(uuid='conv-1'), launcher_type=provider_session.LauncherTypes.PERSON, @@ -233,10 +234,55 @@ def fake_monotonic(): assert [chunk.is_final for chunk in chunks] == [False, False, True] +@pytest.mark.asyncio +async def test_dify_chatflow_stream_ignores_empty_message_and_still_emits_final(): + DifyServiceAPIRunner = get_dify_runner() + app = Mock() + app.logger = Mock() + runner = DifyServiceAPIRunner( + app, + { + 'ai': { + 'dify-service-api': { + 'app-type': 'chat', + 'api-key': 'test-key', + 'base-url': 'https://example.com/v1', + 'base-prompt': '', + } + }, + 'output': {'misc': {'remove-think': False}}, + }, + ) + + async def fake_chat_messages(**kwargs): + for answer in ['你', '好', '!', '有', '什', '么', '我', '可', '以', '帮', '助', '您']: + yield {'event': 'message', 'answer': answer, 'conversation_id': 'conv-4'} + yield {'event': 'message', 'answer': '', 'conversation_id': 'conv-4'} + yield {'event': 'workflow_finished', 'conversation_id': 'conv-4', 'data': {'error': None}} + + runner.dify_client = SimpleNamespace(chat_messages=fake_chat_messages, base_url='https://example.com/v1') + + query = SimpleNamespace( + adapter=SimpleNamespace(config={'PullChunkBatchSize': 4, 'PullFlushWindowMs': 2000}), + session=SimpleNamespace( + using_conversation=SimpleNamespace(uuid='conv-1'), + launcher_type=provider_session.LauncherTypes.PERSON, + launcher_id='user-1', + ), + variables={}, + user_message=provider_message.Message(role='user', content='hello'), + ) + + chunks = [chunk async for chunk in runner._chat_messages_chunk(query)] + + assert [chunk.content for chunk in chunks] == ['你', '你好!有什', '你好!有什么我可以', '你好!有什么我可以帮助您'] + assert [chunk.is_final for chunk in chunks] == [False, False, False, True] + + @pytest.mark.asyncio async def test_stream_session_manager_keeps_latest_snapshot_only(): StreamChunk, StreamSessionManager = get_stream_types() - manager = StreamSessionManager(logger=Mock()) + manager = StreamSessionManager(logger=make_async_logger()) session, _ = manager.create_or_get({'msgid': 'msg-1', 'from': {'userid': 'user-1'}}) await manager.publish(session.stream_id, StreamChunk(content='a', is_final=False)) @@ -353,3 +399,11 @@ async def test_wecom_dispatch_exception_forces_finish(): assert isinstance(chunk, StreamChunk) assert chunk.is_final is True assert chunk.content == client.stream_error_final_text + + +def test_dify_stream_uses_wecom_adapter_defaults_when_config_missing(): + DifyServiceAPIRunner = get_dify_runner() + query = SimpleNamespace(adapter=SimpleNamespace(config={})) + + assert DifyServiceAPIRunner._get_stream_chunk_batch_size(query) == 4 + assert DifyServiceAPIRunner._get_stream_flush_window_ms(query) == 2000 From 66d47f660caa96f230e6d3fdcae2c25fc8e97e12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=92=8B=E9=87=91=E9=BE=99=EF=BC=88=E5=A4=9C=E9=9B=A8?= =?UTF-8?q?=EF=BC=89?= <58286951@qq.com> Date: Wed, 11 Mar 2026 20:52:45 +0800 Subject: [PATCH 16/36] chore(wecombot): align packaging files and trim debug logs --- .gitignore | 1 - Dockerfile | 12 +- docker/docker-compose.yaml | 12 +- pyproject.toml | 2 +- src/langbot/__init__.py | 2 +- src/langbot/libs/wecom_ai_bot_api/api.py | 156 ------------------ src/langbot/pkg/pipeline/respback/respback.py | 27 --- src/langbot/pkg/platform/sources/wecombot.py | 40 ----- uv.lock | 2 +- 9 files changed, 12 insertions(+), 242 deletions(-) diff --git a/.gitignore b/.gitignore index e517c1457..7d870c336 100644 --- a/.gitignore +++ b/.gitignore @@ -52,4 +52,3 @@ src/langbot/web/ /dist /build *.egg-info -.serena/ diff --git a/Dockerfile b/Dockerfile index 340f5a0c2..34afca5ea 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,12 +10,10 @@ FROM python:3.12.7-slim WORKDIR /app -ENV PYTHONPATH=/app/src +# 先复制依赖文件和后端运行必需源代码,尽量利用 Docker 层缓存 +COPY pyproject.toml uv.lock README.md LICENSE main.py ./ +COPY src ./src -# 先复制依赖文件,利用 Docker 层缓存 -COPY pyproject.toml uv.lock ./ - -# 安装系统依赖和 Python 依赖(排除开发依赖) RUN apt update \ && apt install gcc -y \ && python -m pip install --no-cache-dir uv \ @@ -25,9 +23,9 @@ RUN apt update \ && rm -rf /var/lib/apt/lists/* \ && touch /.dockerenv -# 再复制源代码,代码变化不会触发依赖重装 +# 再复制其余运行时文件,避免无关文件变更导致依赖重装 COPY . . COPY --from=node /app/web/out ./web/out -CMD [ "uv", "run", "--no-sync", "python", "-m", "langbot" ] +CMD [ "uv", "run", "--no-sync", "main.py" ] diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index eb8795f33..6971477dc 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -1,12 +1,11 @@ # Docker Compose configuration for LangBot # For Kubernetes deployment, see kubernetes.yaml and README_K8S.md +version: "3" + services: langbot_plugin_runtime: - build: - context: .. - dockerfile: Dockerfile - image: bjhx2003/langbot:feat-wecom + image: rockchin/langbot:latest container_name: langbot_plugin_runtime volumes: - ./data/plugins:/app/data/plugins @@ -20,10 +19,7 @@ services: - langbot_network langbot: - build: - context: .. - dockerfile: Dockerfile - image: bjhx2003/langbot:feat-wecom + image: rockchin/langbot:latest container_name: langbot volumes: - ./data:/app/data diff --git a/pyproject.toml b/pyproject.toml index 0d318b7be..9a2dfcd91 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "langbot" -version = "4.9.0.post3" +version = "4.9.0" description = "Production-grade platform for building agentic IM bots" readme = "README.md" license-files = ["LICENSE"] diff --git a/src/langbot/__init__.py b/src/langbot/__init__.py index c9642abab..970cb5491 100644 --- a/src/langbot/__init__.py +++ b/src/langbot/__init__.py @@ -1,3 +1,3 @@ """LangBot - Production-grade platform for building agentic IM bots""" -__version__ = '4.9.0.post3' +__version__ = '4.9.0' diff --git a/src/langbot/libs/wecom_ai_bot_api/api.py b/src/langbot/libs/wecom_ai_bot_api/api.py index 437d7de59..a1434a930 100644 --- a/src/langbot/libs/wecom_ai_bot_api/api.py +++ b/src/langbot/libs/wecom_ai_bot_api/api.py @@ -2,7 +2,6 @@ import base64 import json import time -import hashlib import traceback import uuid import xml.etree.ElementTree as ET @@ -19,17 +18,6 @@ from langbot.pkg.platform.logger import EventLogger -def _summarize_stream_text(content: str, tail_length: int = 32) -> dict[str, str | int]: - text = content or "" - encoded = text.encode('utf-8') - return { - "chars": len(text), - "bytes": len(encoded), - "tail_repr": repr(text[-tail_length:]), - "md5": hashlib.md5(encoded).hexdigest()[:12] if encoded else "0" * 12, - } - - @dataclass class StreamChunk: """描述单次推送给企业微信的流式片段。""" @@ -75,9 +63,6 @@ class StreamSession: # 缓存最近一次片段,处理重试或超时兜底 last_chunk: Optional[StreamChunk] = None - # 发布到企业微信 stream 的快照序号,便于串联日志 - publish_seq: int = 0 - class StreamSessionManager: """管理 stream 会话的生命周期,并负责队列的生产消费。""" @@ -148,16 +133,12 @@ async def publish(self, stream_id: str, chunk: StreamChunk) -> bool: return False session.last_access = time.time() - session.publish_seq += 1 - chunk.meta.setdefault('seq', session.publish_seq) session.last_chunk = chunk # 企业微信消费的是当前完整快照,保留最新片段即可,避免旧片段堆积导致显示延迟。 - cleared_count = 0 while not session.queue.empty(): try: session.queue.get_nowait() - cleared_count += 1 except asyncio.QueueEmpty: break @@ -170,22 +151,6 @@ async def publish(self, stream_id: str, chunk: StreamChunk) -> bool: if chunk.is_final: session.finished = True - summary = _summarize_stream_text(chunk.content) - await self.logger.debug( - '[wecom-stream] ' - f'action=publish ' - f'stream_id={stream_id or "-"} ' - f'msg_id={session.msg_id or "-"} ' - f'seq={chunk.meta.get("seq", -1)} ' - f'finish={str(chunk.is_final).lower()} ' - f'cleared={cleared_count} ' - f'queue_size={session.queue.qsize()} ' - f'content_chars={summary["chars"]} ' - f'content_bytes={summary["bytes"]} ' - f'content_tail={summary["tail_repr"]} ' - f'content_md5={summary["md5"]}' - ) - return True async def consume(self, stream_id: str, timeout: float = 0.5) -> Optional[StreamChunk]: @@ -213,46 +178,11 @@ async def consume(self, stream_id: str, timeout: float = 0.5) -> Optional[Stream if chunk.is_final: session.finished = True - summary = _summarize_stream_text(chunk.content) - await self.logger.debug( - '[wecom-stream] ' - f'action=consume ' - f'stream_id={stream_id or "-"} ' - f'msg_id={session.msg_id or "-"} ' - f'seq={chunk.meta.get("seq", -1)} ' - f'finish={str(chunk.is_final).lower()} ' - f'queue_size={session.queue.qsize()} ' - f'content_chars={summary["chars"]} ' - f'content_bytes={summary["bytes"]} ' - f'content_tail={summary["tail_repr"]} ' - f'content_md5={summary["md5"]}' - ) return chunk except asyncio.TimeoutError: if session.finished and session.last_chunk: - summary = _summarize_stream_text(session.last_chunk.content) - await self.logger.debug( - '[wecom-stream] ' - f'action=consume_timeout_last_chunk ' - f'stream_id={stream_id or "-"} ' - f'msg_id={session.msg_id or "-"} ' - f'seq={session.last_chunk.meta.get("seq", -1)} ' - f'finish={str(session.last_chunk.is_final).lower()} ' - f'queue_size={session.queue.qsize()} ' - f'content_chars={summary["chars"]} ' - f'content_bytes={summary["bytes"]} ' - f'content_tail={summary["tail_repr"]} ' - f'content_md5={summary["md5"]}' - ) return session.last_chunk - await self.logger.debug( - '[wecom-stream] ' - f'action=consume_timeout_empty ' - f'stream_id={stream_id or "-"} ' - f'msg_id={session.msg_id or "-"} ' - f'queue_size={session.queue.qsize()}' - ) return None def mark_finished(self, stream_id: str) -> None: @@ -331,47 +261,6 @@ def __init__( self.stream_timeout_final_text = '抱歉,处理超时,请稍后重试。' self.stream_error_final_text = '抱歉,处理失败,请稍后重试。' - async def _log_stream_debug( - self, - action: str, - stream_id: str, - session: Optional[StreamSession] = None, - source: str = '', - chunk: Optional[StreamChunk] = None, - ) -> None: - """记录流式会话关键路径日志,便于在消息记录中排查轮询与收口问题。""" - age_ms = -1 - msg_id = '' - if session: - msg_id = session.msg_id - age_ms = int((time.time() - session.created_at) * 1000) - - finish = False - seq = -1 - summary = _summarize_stream_text('') - if chunk: - finish = chunk.is_final - seq = int(chunk.meta.get('seq', -1)) - summary = _summarize_stream_text(chunk.content) - - queue_size = session.queue.qsize() if session else -1 - - await self.logger.debug( - '[wecom-stream] ' - f'action={action} ' - f'stream_id={stream_id or "-"} ' - f'msg_id={msg_id or "-"} ' - f'source={source or "-"} ' - f'seq={seq} ' - f'finish={str(finish).lower()} ' - f'age_ms={age_ms} ' - f'queue_size={queue_size} ' - f'content_chars={summary["chars"]} ' - f'content_bytes={summary["bytes"]} ' - f'content_tail={summary["tail_repr"]} ' - f'content_md5={summary["md5"]}' - ) - def _is_stream_lifetime_exceeded(self, session: StreamSession) -> bool: """判断当前 stream 是否已经超过最大生命周期。""" if session.finished: @@ -398,13 +287,6 @@ async def _force_finish_stream( session.finished = True session.last_access = time.time() - await self._log_stream_debug( - action='force_finish', - stream_id=stream_id, - session=session, - source=reason if published else f'{reason}_no_queue', - chunk=chunk, - ) return chunk def _resolve_followup_chunk( @@ -535,12 +417,6 @@ async def _handle_post_initial_response(self, msg_json: dict[str, Any], nonce: s if is_new: asyncio.create_task(self._dispatch_event(event)) - await self._log_stream_debug( - action='initial_response', - stream_id=session.stream_id, - session=session, - source='new' if is_new else 'reuse', - ) payload = self._build_stream_payload(session.stream_id, '', False) return await self._encrypt_and_reply(payload, nonce) @@ -567,12 +443,6 @@ async def _handle_post_followup_response(self, msg_json: dict[str, Any], nonce: session = self.stream_sessions.get_session(stream_id) if not session: chunk = StreamChunk(content=self.stream_error_final_text, is_final=True, meta={'reason': 'missing_session'}) - await self._log_stream_debug( - action='followup_response', - stream_id=stream_id, - source='missing_session', - chunk=chunk, - ) payload = self._build_stream_payload(stream_id, chunk.content, chunk.is_final) return await self._encrypt_and_reply(payload, nonce) @@ -601,22 +471,9 @@ async def _handle_post_followup_response(self, msg_json: dict[str, Any], nonce: if not chunk: chunk = self._resolve_followup_chunk(session, cached_content) if not chunk: - await self._log_stream_debug( - action='followup_response', - stream_id=stream_id, - session=session, - source='empty_response', - ) payload = self._build_stream_payload(stream_id, '', False) return await self._encrypt_and_reply(payload, nonce) - await self._log_stream_debug( - action='followup_response', - stream_id=stream_id, - session=session, - source=chunk.meta.get('reason', 'queue'), - chunk=chunk, - ) payload = self._build_stream_payload(stream_id, chunk.content, chunk.is_final) if chunk.is_final: self.stream_sessions.mark_finished(stream_id) @@ -944,19 +801,6 @@ async def push_stream_chunk(self, msg_id: str, content: str, is_final: bool = Fa # 根据 msg_id 找到对应 stream 会话,如果不存在说明当前消息非流式 stream_id = self.stream_sessions.get_stream_id_by_msg(msg_id) if not stream_id: - summary = _summarize_stream_text(content) - await self.logger.debug( - '[wecom-stream] ' - f'action=push_stream_chunk_missing_session ' - f'stream_id=- ' - f'msg_id={msg_id or "-"} ' - f'seq=-1 ' - f'finish={str(is_final).lower()} ' - f'content_chars={summary["chars"]} ' - f'content_bytes={summary["bytes"]} ' - f'content_tail={summary["tail_repr"]} ' - f'content_md5={summary["md5"]}' - ) return False chunk = StreamChunk(content=content, is_final=is_final) diff --git a/src/langbot/pkg/pipeline/respback/respback.py b/src/langbot/pkg/pipeline/respback/respback.py index a29ff1769..2f7bd6ad9 100644 --- a/src/langbot/pkg/pipeline/respback/respback.py +++ b/src/langbot/pkg/pipeline/respback/respback.py @@ -2,8 +2,6 @@ import random import asyncio -import hashlib - import langbot_plugin.api.entities.builtin.platform.events as platform_events import langbot_plugin.api.entities.builtin.platform.message as platform_message @@ -12,18 +10,6 @@ from .. import stage, entities import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query - -def _summarize_stream_text(content: str, tail_length: int = 32) -> dict[str, str | int]: - text = content or '' - encoded = text.encode('utf-8') - return { - 'chars': len(text), - 'bytes': len(encoded), - 'tail_repr': repr(text[-tail_length:]), - 'md5': hashlib.md5(encoded).hexdigest()[:12] if encoded else '0' * 12, - } - - @stage.stage_class('SendResponseBackStage') class SendResponseBackStage(stage.PipelineStage): """发送响应消息""" @@ -60,19 +46,6 @@ async def process(self, query: pipeline_query.Query, stage_inst_name: str) -> en (msg for msg in reversed(query.resp_messages) if isinstance(msg, provider_message.MessageChunk)), None, ) - stream_text = str(query.resp_message_chain[-1]) - summary = _summarize_stream_text(stream_text) - self.ap.logger.debug( - '[wecom-stream] ' - f'action=respback_reply_chunk ' - f'query_id={query.query_id or "-"} ' - f'resp_message_id={getattr(latest_chunk, "resp_message_id", "") or "-"} ' - f'finish={str(latest_chunk.is_final if latest_chunk else False).lower()} ' - f'content_chars={summary["chars"]} ' - f'content_bytes={summary["bytes"]} ' - f'content_tail={summary["tail_repr"]} ' - f'content_md5={summary["md5"]}' - ) await query.adapter.reply_message_chunk( message_source=query.message_event, bot_message=query.resp_messages[-1], diff --git a/src/langbot/pkg/platform/sources/wecombot.py b/src/langbot/pkg/platform/sources/wecombot.py index 131f933f8..d499013f8 100644 --- a/src/langbot/pkg/platform/sources/wecombot.py +++ b/src/langbot/pkg/platform/sources/wecombot.py @@ -2,7 +2,6 @@ import typing import asyncio import traceback -import hashlib import datetime import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter @@ -14,17 +13,6 @@ from langbot.libs.wecom_ai_bot_api.api import WecomBotClient -def _summarize_stream_text(content: str, tail_length: int = 32) -> dict[str, str | int]: - text = content or '' - encoded = text.encode('utf-8') - return { - 'chars': len(text), - 'bytes': len(encoded), - 'tail_repr': repr(text[-tail_length:]), - 'md5': hashlib.md5(encoded).hexdigest()[:12] if encoded else '0' * 12, - } - - class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverter): @staticmethod async def yiri2target(message_chain: platform_message.MessageChain): @@ -158,7 +146,6 @@ async def target2yiri(event: WecomBotEvent): return chain - class WecomBotEventConverter(abstract_platform_adapter.AbstractEventConverter): @staticmethod async def yiri2target(event: platform_events.MessageEvent): @@ -201,7 +188,6 @@ async def target2yiri(event: WecomBotEvent): except Exception: print(traceback.format_exc()) - class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): bot: WecomBotClient bot_account_id: str @@ -297,35 +283,9 @@ async def reply_message_chunk( # 转换为纯文本(智能机器人当前协议仅支持文本流) content = await self.message_converter.yiri2target(message) msg_id = message_source.source_platform_object.message_id - resp_message_id = getattr(bot_message, 'resp_message_id', '') if bot_message is not None else '' - summary = _summarize_stream_text(content) - - await self.logger.debug( - '[wecom-stream] ' - f'action=adapter_reply_chunk ' - f'msg_id={msg_id or "-"} ' - f'resp_message_id={resp_message_id or "-"} ' - f'finish={str(is_final).lower()} ' - f'content_chars={summary["chars"]} ' - f'content_bytes={summary["bytes"]} ' - f'content_tail={summary["tail_repr"]} ' - f'content_md5={summary["md5"]}' - ) - # 将片段推送到 WecomBotClient 中的队列,返回值用于判断是否走降级逻辑 success = await self.bot.push_stream_chunk(msg_id, content, is_final=is_final) if not success and is_final: - await self.logger.debug( - '[wecom-stream] ' - f'action=adapter_reply_chunk_fallback ' - f'msg_id={msg_id or "-"} ' - f'resp_message_id={resp_message_id or "-"} ' - f'finish=true ' - f'content_chars={summary["chars"]} ' - f'content_bytes={summary["bytes"]} ' - f'content_tail={summary["tail_repr"]} ' - f'content_md5={summary["md5"]}' - ) # 未命中流式队列时使用旧有 set_message 兜底 await self.bot.set_message(msg_id, content) return {'stream': success} diff --git a/uv.lock b/uv.lock index f32b47d24..edf976989 100644 --- a/uv.lock +++ b/uv.lock @@ -1832,7 +1832,7 @@ wheels = [ [[package]] name = "langbot" -version = "4.9.0.post3" +version = "4.9.0" source = { editable = "." } dependencies = [ { name = "aiocqhttp" }, From aed7c541871d4174ce94f60040f9f77b093c9672 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=92=8B=E9=87=91=E9=BE=99=EF=BC=88=E5=A4=9C=E9=9B=A8?= =?UTF-8?q?=EF=BC=89?= <58286951@qq.com> Date: Wed, 11 Mar 2026 21:45:25 +0800 Subject: [PATCH 17/36] fix(wecombot): restore compatibility defaults and wrapper behavior --- src/langbot/libs/wecom_ai_bot_api/api.py | 6 +- src/langbot/pkg/pipeline/wrapper/wrapper.py | 72 ++++++++----------- src/langbot/pkg/platform/sources/wecombot.py | 9 ++- .../pkg/platform/sources/wecombot.yaml | 4 +- src/langbot/pkg/provider/runners/difysvapi.py | 21 +++++- .../templates/default-pipeline-config.json | 3 +- .../templates/metadata/pipeline/output.yaml | 16 ++++- .../pipeline/test_wecombot_dify_minfix.py | 32 +++++++-- 8 files changed, 103 insertions(+), 60 deletions(-) diff --git a/src/langbot/libs/wecom_ai_bot_api/api.py b/src/langbot/libs/wecom_ai_bot_api/api.py index a1434a930..a70cdc970 100644 --- a/src/langbot/libs/wecom_ai_bot_api/api.py +++ b/src/langbot/libs/wecom_ai_bot_api/api.py @@ -216,10 +216,10 @@ def __init__( Corpid: str, logger: EventLogger, unified_mode: bool = False, - stream_poll_timeout: float = 0.15, + stream_poll_timeout: float = 0.5, stream_max_lifetime: float = 120, - pending_placeholder: str = 'AI 正在思考中,请稍候', - pending_placeholder_delay: float = 1.2, + pending_placeholder: str = '', + pending_placeholder_delay: float = 0.0, ): """企业微信智能机器人客户端。 diff --git a/src/langbot/pkg/pipeline/wrapper/wrapper.py b/src/langbot/pkg/pipeline/wrapper/wrapper.py index 981da3f8b..a1ebc97a2 100644 --- a/src/langbot/pkg/pipeline/wrapper/wrapper.py +++ b/src/langbot/pkg/pipeline/wrapper/wrapper.py @@ -8,7 +8,6 @@ import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query import langbot_plugin.api.entities.events as events -import langbot_plugin.api.entities.builtin.provider.message as provider_message @stage.stage_class('ResponseWrapper') @@ -51,9 +50,7 @@ async def process( else: if query.resp_messages[-1].role == 'assistant': result = query.resp_messages[-1] - - # 判断是否为流式中间 chunk(非最终 chunk),跳过插件事件以提升性能 - is_streaming_chunk = isinstance(result, provider_message.MessageChunk) and not result.is_final + session = await self.ap.sess_mgr.get_session(query) reply_text = '' @@ -61,49 +58,40 @@ async def process( reply_text = str(result.get_content_platform_message_chain()) # ============= 触发插件事件 =============== - # 流式中间 chunk 跳过插件事件,只在最终 chunk 或非流式消息时触发 - if is_streaming_chunk: - query.resp_message_chain.append(result.get_content_platform_message_chain()) + event = events.NormalMessageResponded( + launcher_type=query.launcher_type.value, + launcher_id=query.launcher_id, + sender_id=query.sender_id, + session=session, + prefix='', + response_text=reply_text, + finish_reason='stop', + funcs_called=[fc.function.name for fc in result.tool_calls] + if result.tool_calls is not None + else [], + query=query, + ) + + # Get bound plugins for filtering + bound_plugins = query.variables.get('_pipeline_bound_plugins', None) + event_ctx = await self.ap.plugin_connector.emit_event(event, bound_plugins) + + if event_ctx.is_prevented_default(): yield entities.StageProcessResult( - result_type=entities.ResultType.CONTINUE, + result_type=entities.ResultType.INTERRUPT, new_query=query, ) else: - session = await self.ap.sess_mgr.get_session(query) - event = events.NormalMessageResponded( - launcher_type=query.launcher_type.value, - launcher_id=query.launcher_id, - sender_id=query.sender_id, - session=session, - prefix='', - response_text=reply_text, - finish_reason='stop', - funcs_called=[fc.function.name for fc in result.tool_calls] - if result.tool_calls is not None - else [], - query=query, - ) + if event_ctx.event.reply_message_chain is not None: + query.resp_message_chain.append(event_ctx.event.reply_message_chain) - # Get bound plugins for filtering - bound_plugins = query.variables.get('_pipeline_bound_plugins', None) - event_ctx = await self.ap.plugin_connector.emit_event(event, bound_plugins) - - if event_ctx.is_prevented_default(): - yield entities.StageProcessResult( - result_type=entities.ResultType.INTERRUPT, - new_query=query, - ) else: - if event_ctx.event.reply_message_chain is not None: - query.resp_message_chain.append(event_ctx.event.reply_message_chain) + query.resp_message_chain.append(result.get_content_platform_message_chain()) - else: - query.resp_message_chain.append(result.get_content_platform_message_chain()) - - yield entities.StageProcessResult( - result_type=entities.ResultType.CONTINUE, - new_query=query, - ) + yield entities.StageProcessResult( + result_type=entities.ResultType.CONTINUE, + new_query=query, + ) if result.tool_calls is not None and len(result.tool_calls) > 0: # 有函数调用 function_names = [tc.function.name for tc in result.tool_calls] @@ -114,9 +102,7 @@ async def process( platform_message.MessageChain([platform_message.Plain(text=reply_text)]) ) - # 流式中间 chunk 跳过函数调用追踪事件 - if not is_streaming_chunk and query.pipeline_config['output']['misc']['track-function-calls']: - session = await self.ap.sess_mgr.get_session(query) + if query.pipeline_config['output']['misc']['track-function-calls']: event = events.NormalMessageResponded( launcher_type=query.launcher_type.value, launcher_id=query.launcher_id, diff --git a/src/langbot/pkg/platform/sources/wecombot.py b/src/langbot/pkg/platform/sources/wecombot.py index d499013f8..83940b11b 100644 --- a/src/langbot/pkg/platform/sources/wecombot.py +++ b/src/langbot/pkg/platform/sources/wecombot.py @@ -13,6 +13,9 @@ from langbot.libs.wecom_ai_bot_api.api import WecomBotClient +DEFAULT_PULL_PENDING_PLACEHOLDER = 'AI 正在思考中,请稍候' + + class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverter): @staticmethod async def yiri2target(message_chain: platform_message.MessageChain): @@ -211,11 +214,11 @@ def __init__(self, config: dict, logger: EventLogger): if missing_keys: raise Exception(f'WecomBot 缺少配置项: {missing_keys}') - pull_poll_timeout_ms = self._get_int_config(config, 'PullPollTimeoutMs', 200, 50, 2000) + pull_poll_timeout_ms = self._get_int_config(config, 'PullPollTimeoutMs', 500, 50, 2000) pull_stream_max_lifetime_ms = self._get_int_config(config, 'PullStreamMaxLifetimeMs', 300000, 1000, 600000) - pending_placeholder_enabled = config.get('PullPendingPlaceholderEnabled', True) + pending_placeholder_enabled = config.get('PullPendingPlaceholderEnabled', False) pending_placeholder_delay_ms = self._get_int_config(config, 'PullPendingPlaceholderDelayMs', 3000, 0, 10000) - pending_placeholder = config.get('PullPendingPlaceholder', 'AI 正在思考中,请稍候') + pending_placeholder = config.get('PullPendingPlaceholder', DEFAULT_PULL_PENDING_PLACEHOLDER) normalized_config = dict(config) normalized_config['PullPollTimeoutMs'] = pull_poll_timeout_ms diff --git a/src/langbot/pkg/platform/sources/wecombot.yaml b/src/langbot/pkg/platform/sources/wecombot.yaml index 0dcc24926..662578db1 100644 --- a/src/langbot/pkg/platform/sources/wecombot.yaml +++ b/src/langbot/pkg/platform/sources/wecombot.yaml @@ -48,7 +48,7 @@ spec: zh_Hans: LangBot 在响应企微 follow-up 轮询前,等待新 chunk 的时间。值越大越不容易出现空轮询和重复快照,但延迟会略增。 type: integer required: true - default: 200 + default: 500 - name: PullStreamMaxLifetimeMs label: en_US: Pull Stream Max Lifetime (ms) @@ -68,7 +68,7 @@ spec: zh_Hans: 开启后,若在延迟时间内未收到首字,则返回等待占位文案;关闭则不返回任何占位。 type: boolean required: false - default: true + default: false - name: PullPendingPlaceholderDelayMs label: en_US: Pull Pending Placeholder Delay (ms) diff --git a/src/langbot/pkg/provider/runners/difysvapi.py b/src/langbot/pkg/provider/runners/difysvapi.py index 4ed5ea83d..d6a02cfe2 100644 --- a/src/langbot/pkg/provider/runners/difysvapi.py +++ b/src/langbot/pkg/provider/runners/difysvapi.py @@ -77,12 +77,12 @@ def _get_stream_chunk_batch_size(query: pipeline_query.Query) -> int: pipeline_config = getattr(query, 'pipeline_config', {}) or {} output_config = pipeline_config.get('output', {}) or {} dify_stream_config = output_config.get('dify-stream', {}) or {} - value = dify_stream_config.get('chunk-batch-size', 4) + value = dify_stream_config.get('chunk-batch-size', 8) try: value = int(value) except (TypeError, ValueError): - value = 4 + value = 8 return max(1, min(20, value)) @staticmethod @@ -98,12 +98,20 @@ def _get_stream_flush_window_ms(query: pipeline_query.Query) -> int: value = 2000 return max(200, min(10000, value)) + @staticmethod + def _is_stream_flush_window_enabled(query: pipeline_query.Query) -> bool: + pipeline_config = getattr(query, 'pipeline_config', {}) or {} + output_config = pipeline_config.get('output', {}) or {} + dify_stream_config = output_config.get('dify-stream', {}) or {} + return bool(dify_stream_config.get('flush-window-enabled', False)) + @staticmethod def _should_emit_stream_snapshot( content: str, is_final: bool, pending_chunk_count: int, chunk_batch_size: int, + flush_window_enabled: bool, flush_window_ms: int, last_emitted_content: str, last_emit_at: float, @@ -120,6 +128,9 @@ def _should_emit_stream_snapshot( if pending_chunk_count >= chunk_batch_size: return True + if not flush_window_enabled: + return False + elapsed_ms = (time.monotonic() - last_emit_at) * 1000 return elapsed_ms >= flush_window_ms @@ -476,6 +487,7 @@ async def _chat_messages_chunk( remove_think = self.pipeline_config['output'].get('misc', '').get('remove-think') chunk_batch_size = self._get_stream_chunk_batch_size(query) + flush_window_enabled = self._is_stream_flush_window_enabled(query) flush_window_ms = self._get_stream_flush_window_ms(query) async for chunk in self.dify_client.chat_messages( @@ -525,6 +537,7 @@ async def _chat_messages_chunk( is_final=is_final, pending_chunk_count=pending_chunk_count, chunk_batch_size=chunk_batch_size, + flush_window_enabled=flush_window_enabled, flush_window_ms=flush_window_ms, last_emitted_content=last_emitted_content, last_emit_at=last_emit_at, @@ -594,6 +607,7 @@ async def _agent_chat_messages_chunk( remove_think = self.pipeline_config['output'].get('misc', '').get('remove-think') chunk_batch_size = self._get_stream_chunk_batch_size(query) + flush_window_enabled = self._is_stream_flush_window_enabled(query) flush_window_ms = self._get_stream_flush_window_ms(query) async for chunk in self.dify_client.chat_messages( @@ -682,6 +696,7 @@ async def _agent_chat_messages_chunk( is_final=is_final, pending_chunk_count=pending_chunk_count, chunk_batch_size=chunk_batch_size, + flush_window_enabled=flush_window_enabled, flush_window_ms=flush_window_ms, last_emitted_content=last_emitted_content, last_emit_at=last_emit_at, @@ -756,6 +771,7 @@ async def _workflow_messages_chunk( remove_think = self.pipeline_config['output'].get('misc', '').get('remove-think') chunk_batch_size = self._get_stream_chunk_batch_size(query) + flush_window_enabled = self._is_stream_flush_window_enabled(query) flush_window_ms = self._get_stream_flush_window_ms(query) async for chunk in self.dify_client.workflow_run( inputs=inputs, @@ -821,6 +837,7 @@ async def _workflow_messages_chunk( is_final=is_final, pending_chunk_count=pending_chunk_count, chunk_batch_size=chunk_batch_size, + flush_window_enabled=flush_window_enabled, flush_window_ms=flush_window_ms, last_emitted_content=last_emitted_content, last_emit_at=last_emit_at, diff --git a/src/langbot/templates/default-pipeline-config.json b/src/langbot/templates/default-pipeline-config.json index a2b1664f0..46a6a9b6f 100644 --- a/src/langbot/templates/default-pipeline-config.json +++ b/src/langbot/templates/default-pipeline-config.json @@ -103,7 +103,8 @@ "remove-think": false }, "dify-stream": { - "chunk-batch-size": 4, + "chunk-batch-size": 8, + "flush-window-enabled": false, "flush-window-ms": 2000 } } diff --git a/src/langbot/templates/metadata/pipeline/output.yaml b/src/langbot/templates/metadata/pipeline/output.yaml index 6b2c4a388..a21c485e2 100644 --- a/src/langbot/templates/metadata/pipeline/output.yaml +++ b/src/langbot/templates/metadata/pipeline/output.yaml @@ -90,7 +90,17 @@ stages: zh_Hans: 每累计 N 个 Dify chunk 后刷新一次流式快照。 type: integer required: true - default: 4 + default: 8 + - name: flush-window-enabled + label: + en_US: Flush Window Enabled + zh_Hans: 启用时间窗口刷新 + description: + en_US: Enable time-window based snapshot refresh in addition to chunk batch refresh. Disabled by default for backward compatibility. + zh_Hans: 在 chunk 批次刷新之外启用时间窗口刷新。默认关闭,以保持向后兼容。 + type: boolean + required: true + default: false - name: flush-window-ms label: en_US: Flush Window (ms) @@ -101,6 +111,10 @@ stages: type: integer required: true default: 2000 + show_if: + field: flush-window-enabled + operator: eq + value: true - name: misc label: en_US: Misc diff --git a/tests/unit_tests/pipeline/test_wecombot_dify_minfix.py b/tests/unit_tests/pipeline/test_wecombot_dify_minfix.py index f004279a8..d685e17fb 100644 --- a/tests/unit_tests/pipeline/test_wecombot_dify_minfix.py +++ b/tests/unit_tests/pipeline/test_wecombot_dify_minfix.py @@ -49,7 +49,11 @@ def make_async_logger(): ) -def make_dify_pipeline_config(chunk_batch_size: int = 4, flush_window_ms: int = 2000) -> dict: +def make_dify_pipeline_config( + chunk_batch_size: int = 8, + flush_window_enabled: bool = False, + flush_window_ms: int = 2000, +) -> dict: return { 'ai': { 'dify-service-api': { @@ -63,6 +67,7 @@ def make_dify_pipeline_config(chunk_batch_size: int = 4, flush_window_ms: int = 'misc': {'remove-think': False}, 'dify-stream': { 'chunk-batch-size': chunk_batch_size, + 'flush-window-enabled': flush_window_enabled, 'flush-window-ms': flush_window_ms, }, }, @@ -188,7 +193,7 @@ async def test_dify_stream_flushes_on_time_window_before_batch_threshold(monkeyp app.logger = Mock() runner = DifyServiceAPIRunner( app, - make_dify_pipeline_config(chunk_batch_size=8, flush_window_ms=2000), + make_dify_pipeline_config(chunk_batch_size=8, flush_window_enabled=True, flush_window_ms=2000), ) async def fake_chat_messages(**kwargs): @@ -259,8 +264,8 @@ async def fake_chat_messages(**kwargs): chunks = [chunk async for chunk in runner._chat_messages_chunk(query)] - assert [chunk.content for chunk in chunks] == ['你', '你好!有什', '你好!有什么我可以', '你好!有什么我可以帮助您'] - assert [chunk.is_final for chunk in chunks] == [False, False, False, True] + assert [chunk.content for chunk in chunks] == ['你', '你好!有什么我可以', '你好!有什么我可以帮助您'] + assert [chunk.is_final for chunk in chunks] == [False, False, True] @pytest.mark.asyncio @@ -385,6 +390,22 @@ async def test_wecom_dispatch_exception_forces_finish(): assert chunk.content == client.stream_error_final_text + + +def test_wecom_client_defaults_match_master_polling_behavior(): + WecomBotClient = get_wecom_client() + client = WecomBotClient( + Token='token', + EnCodingAESKey='aes', + Corpid='corp', + logger=Mock(), + ) + + assert client.stream_poll_timeout == 0.5 + assert client.pending_placeholder == '' + assert client.pending_placeholder_delay == 0.0 + + def test_dify_stream_uses_output_defaults_when_config_missing(): DifyServiceAPIRunner = get_dify_runner() app = Mock() @@ -392,5 +413,6 @@ def test_dify_stream_uses_output_defaults_when_config_missing(): runner = DifyServiceAPIRunner(app, make_dify_pipeline_config()) query = SimpleNamespace(pipeline_config={'output': {'misc': {'remove-think': False}}}) - assert runner._get_stream_chunk_batch_size(query) == 4 + assert runner._get_stream_chunk_batch_size(query) == 8 + assert runner._is_stream_flush_window_enabled(query) is False assert runner._get_stream_flush_window_ms(query) == 2000 From 7c0b86566c88467fa8718b47a7f0dfa74d37222c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=92=8B=E9=87=91=E9=BE=99=EF=BC=88=E5=A4=9C=E9=9B=A8?= =?UTF-8?q?=EF=BC=89?= <58286951@qq.com> Date: Wed, 11 Mar 2026 22:00:31 +0800 Subject: [PATCH 18/36] fix(difysvapi): restore workflow output compatibility in streaming --- src/langbot/pkg/provider/runners/difysvapi.py | 53 ++++++++++++++++--- 1 file changed, 46 insertions(+), 7 deletions(-) diff --git a/src/langbot/pkg/provider/runners/difysvapi.py b/src/langbot/pkg/provider/runners/difysvapi.py index d6a02cfe2..0e13b596e 100644 --- a/src/langbot/pkg/provider/runners/difysvapi.py +++ b/src/langbot/pkg/provider/runners/difysvapi.py @@ -72,6 +72,28 @@ def _process_thinking_content( content = f'\n{thinking_content}\n\n{content}'.strip() return content, thinking_content + def _extract_dify_text_output(self, value: typing.Any) -> str: + """Extract text content from Dify output payload.""" + if value is None: + return '' + if isinstance(value, dict): + content = value.get('content') + if isinstance(content, str): + return content + return json.dumps(value, ensure_ascii=False) + if isinstance(value, str): + text = value.strip() + if not text: + return '' + try: + parsed = json.loads(text) + except json.JSONDecodeError: + return value + if isinstance(parsed, dict) and isinstance(parsed.get('content'), str): + return parsed['content'] + return value + return str(value) + @staticmethod def _get_stream_chunk_batch_size(query: pipeline_query.Query) -> int: pipeline_config = getattr(query, 'pipeline_config', {}) or {} @@ -254,7 +276,8 @@ async def _chat_messages( if mode == 'workflow': if chunk['event'] == 'node_finished': if chunk['data']['node_type'] == 'answer': - content, _ = self._process_thinking_content(chunk['data']['outputs']['answer']) + answer = self._extract_dify_text_output(chunk['data']['outputs'].get('answer')) + content, _ = self._process_thinking_content(answer) yield provider_message.Message( role='assistant', @@ -467,6 +490,7 @@ async def _chat_messages_chunk( for f in upload_files ] + mode = 'basic' basic_mode_pending_chunk = '' inputs = {} @@ -500,11 +524,12 @@ async def _chat_messages_chunk( ): self.ap.logger.debug('dify-chat-chunk: ' + str(chunk)) - # if chunk['event'] == 'workflow_started': - # mode = 'workflow' - # if mode == 'workflow': - # elif mode == 'basic': - # 因为都只是返回的 message也没有工具调用什么的,暂时不分类 + if chunk['event'] == 'workflow_started': + mode = 'workflow' + elif chunk['event'] in ('node_started', 'node_finished', 'workflow_finished'): + # Some Dify deployments may omit workflow_started in streamed chunks. + mode = 'workflow' + if chunk['event'] == 'message': answer = chunk.get('answer', '') if answer != '': @@ -528,9 +553,23 @@ async def _chat_messages_chunk( else: basic_mode_pending_chunk += answer - if chunk['event'] in ('message_end', 'workflow_finished'): + if chunk['event'] == 'message_end': + is_final = True + stream_completed = True + elif chunk['event'] == 'workflow_finished': is_final = True stream_completed = True + if chunk['data'].get('error'): + raise errors.DifyAPIError(chunk['data']['error']) + + if mode == 'workflow' and chunk['event'] == 'node_finished': + if chunk['data'].get('node_type') == 'answer': + answer = self._extract_dify_text_output(chunk['data'].get('outputs', {}).get('answer')) + if answer: + basic_mode_pending_chunk = answer + + if final_snapshot_emitted and is_final: + continue if self._should_emit_stream_snapshot( content=basic_mode_pending_chunk, From 4786eee87e4a9cfb55374de19d4b55a1bf5e7370 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=92=8B=E9=87=91=E9=BE=99=EF=BC=88=E5=A4=9C=E9=9B=A8?= =?UTF-8?q?=EF=BC=89?= <58286951@qq.com> Date: Thu, 12 Mar 2026 10:12:59 +0800 Subject: [PATCH 19/36] chore: Remove unused assignment of `published` variable from `stream_sessions.publish` call. --- src/langbot/libs/wecom_ai_bot_api/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/langbot/libs/wecom_ai_bot_api/api.py b/src/langbot/libs/wecom_ai_bot_api/api.py index a70cdc970..5b66df806 100644 --- a/src/langbot/libs/wecom_ai_bot_api/api.py +++ b/src/langbot/libs/wecom_ai_bot_api/api.py @@ -278,7 +278,7 @@ async def _force_finish_stream( return None chunk = StreamChunk(content=content, is_final=True, meta={'reason': reason}) - published = await self.stream_sessions.publish(stream_id, chunk) + await self.stream_sessions.publish(stream_id, chunk) self.stream_sessions.mark_finished(stream_id) session = self.stream_sessions.get_session(stream_id) From 7d580cf253a95179521ebb14e2991b37e5b21aaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=9C=E9=9B=A8?= <58286951@qq.com> Date: Fri, 13 Mar 2026 18:53:32 +0800 Subject: [PATCH 20/36] refactor(wecombot): isolate pull stream policy and webhook-only visibility --- src/langbot/libs/wecom_ai_bot_api/api.py | 76 ++++-------- .../wecom_ai_bot_api/pull_stream_policy.py | 112 ++++++++++++++++++ src/langbot/pkg/platform/sources/wecombot.py | 9 +- .../pkg/platform/sources/wecombot.yaml | 49 ++++++-- .../pipeline/test_wecombot_dify_minfix.py | 57 +++++++++ 5 files changed, 237 insertions(+), 66 deletions(-) create mode 100644 src/langbot/libs/wecom_ai_bot_api/pull_stream_policy.py diff --git a/src/langbot/libs/wecom_ai_bot_api/api.py b/src/langbot/libs/wecom_ai_bot_api/api.py index 5b66df806..c7db498c4 100644 --- a/src/langbot/libs/wecom_ai_bot_api/api.py +++ b/src/langbot/libs/wecom_ai_bot_api/api.py @@ -15,6 +15,7 @@ from langbot.libs.wecom_ai_bot_api import wecombotevent from langbot.libs.wecom_ai_bot_api.WXBizMsgCrypt3 import WXBizMsgCrypt +from langbot.libs.wecom_ai_bot_api.pull_stream_policy import PullStreamPolicy from langbot.pkg.platform.logger import EventLogger @@ -260,12 +261,20 @@ def __init__( self.pending_placeholder_delay = max(0.0, pending_placeholder_delay) self.stream_timeout_final_text = '抱歉,处理超时,请稍后重试。' self.stream_error_final_text = '抱歉,处理失败,请稍后重试。' + self.pull_stream_policy = PullStreamPolicy( + stream_sessions=self.stream_sessions, + generated_content=self.generated_content, + stream_max_lifetime=self.stream_max_lifetime, + pending_placeholder=self.pending_placeholder, + pending_placeholder_delay=self.pending_placeholder_delay, + stream_timeout_final_text=self.stream_timeout_final_text, + stream_error_final_text=self.stream_error_final_text, + chunk_factory=StreamChunk, + ) def _is_stream_lifetime_exceeded(self, session: StreamSession) -> bool: - """判断当前 stream 是否已经超过最大生命周期。""" - if session.finished: - return False - return time.time() - session.created_at >= self.stream_max_lifetime + """Keep method signature stable while delegating policy details.""" + return self.pull_stream_policy.is_stream_lifetime_exceeded(session) async def _force_finish_stream( self, @@ -273,51 +282,16 @@ async def _force_finish_stream( content: str, reason: str, ) -> Optional[StreamChunk]: - """强制结束未正常收口的 stream,避免企微持续轮询后展示官方兜底文案。""" - if not stream_id: - return None - - chunk = StreamChunk(content=content, is_final=True, meta={'reason': reason}) - await self.stream_sessions.publish(stream_id, chunk) - self.stream_sessions.mark_finished(stream_id) - - session = self.stream_sessions.get_session(stream_id) - if session: - session.last_chunk = chunk - session.finished = True - session.last_access = time.time() - - return chunk + """Keep method signature stable while delegating policy details.""" + return await self.pull_stream_policy.force_finish_stream(stream_id, content, reason) def _resolve_followup_chunk( self, session: Optional[StreamSession], cached_content: Optional[str], ) -> Optional[StreamChunk]: - """为 follow-up 请求返回兜底片段,避免企微收到空内容后展示官方兜底文案。""" - if cached_content is not None: - return StreamChunk(content=cached_content, is_final=True, meta={'reason': 'cached_final'}) - - if session and session.last_chunk: - if 'reason' not in session.last_chunk.meta: - session.last_chunk.meta['reason'] = 'last_snapshot' - return session.last_chunk - - if session: - elapsed = time.time() - session.created_at - if elapsed < self.pending_placeholder_delay: - return None - - placeholder_chunk = StreamChunk( - content=self.pending_placeholder, - is_final=False, - meta={'reason': 'pending_placeholder'}, - ) - session.last_chunk = placeholder_chunk - session.last_access = time.time() - return placeholder_chunk - - return None + """Keep method signature stable while delegating policy details.""" + return self.pull_stream_policy.resolve_followup_chunk(session, cached_content) @staticmethod def _build_stream_payload(stream_id: str, content: str, finish: bool) -> dict[str, Any]: @@ -442,32 +416,28 @@ async def _handle_post_followup_response(self, msg_json: dict[str, Any], nonce: session = self.stream_sessions.get_session(stream_id) if not session: - chunk = StreamChunk(content=self.stream_error_final_text, is_final=True, meta={'reason': 'missing_session'}) + chunk = self.pull_stream_policy.create_missing_session_chunk() payload = self._build_stream_payload(stream_id, chunk.content, chunk.is_final) return await self._encrypt_and_reply(payload, nonce) if self._is_stream_lifetime_exceeded(session): - timeout_content = session.last_chunk.content if session.last_chunk else self.stream_timeout_final_text + timeout_content = self.pull_stream_policy.resolve_timeout_content(session) chunk = await self._force_finish_stream(stream_id, timeout_content, 'max_lifetime_exceeded') payload = self._build_stream_payload(stream_id, chunk.content if chunk else '', True) return await self._encrypt_and_reply(payload, nonce) - consume_timeout = self.stream_poll_timeout - if not session.last_chunk and not session.finished and self.pending_placeholder_delay > 0: - remaining_delay = self.pending_placeholder_delay - (time.time() - session.created_at) - if remaining_delay > 0: - consume_timeout = max(consume_timeout, remaining_delay) + consume_timeout = self.pull_stream_policy.resolve_consume_timeout(session, self.stream_poll_timeout) chunk = await self.stream_sessions.consume(stream_id, timeout=consume_timeout) if not chunk: if self._is_stream_lifetime_exceeded(session): - timeout_content = session.last_chunk.content if session.last_chunk else self.stream_timeout_final_text + timeout_content = self.pull_stream_policy.resolve_timeout_content(session) chunk = await self._force_finish_stream(stream_id, timeout_content, 'max_lifetime_exceeded') cached_content = None - if not chunk and session.msg_id: - cached_content = self.generated_content.pop(session.msg_id, None) + if not chunk: + cached_content = self.pull_stream_policy.pop_cached_content(session) if not chunk: chunk = self._resolve_followup_chunk(session, cached_content) if not chunk: diff --git a/src/langbot/libs/wecom_ai_bot_api/pull_stream_policy.py b/src/langbot/libs/wecom_ai_bot_api/pull_stream_policy.py new file mode 100644 index 000000000..e54b802a8 --- /dev/null +++ b/src/langbot/libs/wecom_ai_bot_api/pull_stream_policy.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import time +from typing import Any, Callable, Optional + + +class PullStreamPolicy: + """Encapsulate pull-stream fallback and closure strategy. + + This class is intentionally independent from the transport layer, so that + merge conflicts in ``api.py`` can be reduced when upstream modifies webhook + request/response handling. + """ + + def __init__( + self, + *, + stream_sessions, + generated_content: dict[str, str], + stream_max_lifetime: float, + pending_placeholder: str, + pending_placeholder_delay: float, + stream_timeout_final_text: str, + stream_error_final_text: str, + chunk_factory: Callable[..., Any], + ) -> None: + self.stream_sessions = stream_sessions + self.generated_content = generated_content + self.stream_max_lifetime = max(1.0, float(stream_max_lifetime)) + self.pending_placeholder = pending_placeholder or '' + self.pending_placeholder_delay = max(0.0, float(pending_placeholder_delay)) + self.stream_timeout_final_text = stream_timeout_final_text + self.stream_error_final_text = stream_error_final_text + self._chunk_factory = chunk_factory + + def create_chunk(self, content: str, is_final: bool = False, reason: str | None = None): + meta = {'reason': reason} if reason else {} + return self._chunk_factory(content=content, is_final=is_final, meta=meta) + + def is_stream_lifetime_exceeded(self, session) -> bool: + if session.finished: + return False + return time.time() - session.created_at >= self.stream_max_lifetime + + def resolve_timeout_content(self, session) -> str: + if session and session.last_chunk: + return session.last_chunk.content + return self.stream_timeout_final_text + + async def force_finish_stream(self, stream_id: str, content: str, reason: str): + if not stream_id: + return None + + chunk = self.create_chunk(content=content, is_final=True, reason=reason) + await self.stream_sessions.publish(stream_id, chunk) + self.stream_sessions.mark_finished(stream_id) + + session = self.stream_sessions.get_session(stream_id) + if session: + session.last_chunk = chunk + session.finished = True + session.last_access = time.time() + + return chunk + + def resolve_consume_timeout(self, session, default_timeout: float) -> float: + consume_timeout = default_timeout + if not session.last_chunk and not session.finished and self.pending_placeholder_delay > 0: + remaining_delay = self.pending_placeholder_delay - (time.time() - session.created_at) + if remaining_delay > 0: + consume_timeout = max(consume_timeout, remaining_delay) + return consume_timeout + + def pop_cached_content(self, session) -> Optional[str]: + if not session or not session.msg_id: + return None + return self.generated_content.pop(session.msg_id, None) + + def resolve_followup_chunk(self, session, cached_content: Optional[str]): + if cached_content is not None: + return self.create_chunk(content=cached_content, is_final=True, reason='cached_final') + + if session and session.last_chunk: + if 'reason' not in session.last_chunk.meta: + session.last_chunk.meta['reason'] = 'last_snapshot' + return session.last_chunk + + if not session: + return None + + if not self.pending_placeholder: + return None + + elapsed = time.time() - session.created_at + if elapsed < self.pending_placeholder_delay: + return None + + placeholder_chunk = self.create_chunk( + content=self.pending_placeholder, + is_final=False, + reason='pending_placeholder', + ) + session.last_chunk = placeholder_chunk + session.last_access = time.time() + return placeholder_chunk + + def create_missing_session_chunk(self): + return self.create_chunk( + content=self.stream_error_final_text, + is_final=True, + reason='missing_session', + ) diff --git a/src/langbot/pkg/platform/sources/wecombot.py b/src/langbot/pkg/platform/sources/wecombot.py index 83940b11b..c1a2d914b 100644 --- a/src/langbot/pkg/platform/sources/wecombot.py +++ b/src/langbot/pkg/platform/sources/wecombot.py @@ -209,10 +209,14 @@ def _get_int_config(config: dict, key: str, default: int, min_value: int, max_va return max(min_value, min(max_value, value)) def __init__(self, config: dict, logger: EventLogger): + enable_webhook = config.get('enable-webhook', True) + if not enable_webhook: + raise Exception('WecomBot websocket mode is not supported in this branch yet. Please enable webhook mode.') + required_keys = ['Token', 'EncodingAESKey', 'Corpid', 'BotId'] - missing_keys = [key for key in required_keys if key not in config] + missing_keys = [key for key in required_keys if not config.get(key)] if missing_keys: - raise Exception(f'WecomBot 缺少配置项: {missing_keys}') + raise Exception(f'WecomBot webhook mode missing config: {missing_keys}') pull_poll_timeout_ms = self._get_int_config(config, 'PullPollTimeoutMs', 500, 50, 2000) pull_stream_max_lifetime_ms = self._get_int_config(config, 'PullStreamMaxLifetimeMs', 300000, 1000, 600000) @@ -221,6 +225,7 @@ def __init__(self, config: dict, logger: EventLogger): pending_placeholder = config.get('PullPendingPlaceholder', DEFAULT_PULL_PENDING_PLACEHOLDER) normalized_config = dict(config) + normalized_config['enable-webhook'] = True normalized_config['PullPollTimeoutMs'] = pull_poll_timeout_ms normalized_config['PullStreamMaxLifetimeMs'] = pull_stream_max_lifetime_ms normalized_config['PullPendingPlaceholderEnabled'] = pending_placeholder_enabled diff --git a/src/langbot/pkg/platform/sources/wecombot.yaml b/src/langbot/pkg/platform/sources/wecombot.yaml index 662578db1..caa8f9fd3 100644 --- a/src/langbot/pkg/platform/sources/wecombot.yaml +++ b/src/langbot/pkg/platform/sources/wecombot.yaml @@ -11,34 +11,58 @@ metadata: icon: wecombot.png spec: config: + - name: BotId + label: + en_US: BotId + zh_Hans: 机器人ID (BotId) + type: string + required: true + default: "" + - name: enable-webhook + label: + en_US: Enable Webhook Mode + zh_Hans: 启用 Webhook 模式 + description: + en_US: Switch between Webhook mode and WebSocket mode config. This branch currently supports Webhook mode only. + zh_Hans: 在 Webhook / WebSocket 配置之间切换展示。当前分支仅支持 Webhook 模式。 + type: boolean + required: true + default: true + - name: Secret + label: + en_US: Secret + zh_Hans: 机器人密钥 (Secret) + description: + en_US: Reserved for WebSocket mode. + zh_Hans: WebSocket 模式预留配置项。 + type: string + required: false + default: "" + visibleOn: ${enable-webhook} == false - name: Corpid label: en_US: Corpid zh_Hans: 企业ID type: string - required: true + required: false default: "" + visibleOn: ${enable-webhook} == true - name: Token label: en_US: Token zh_Hans: 令牌 (Token) type: string - required: true + required: false default: "" + visibleOn: ${enable-webhook} == true - name: EncodingAESKey label: en_US: EncodingAESKey zh_Hans: 消息加解密密钥 (EncodingAESKey) type: string - required: true - default: "" - - name: BotId - label: - en_US: BotId - zh_Hans: 机器人ID - type: string required: false default: "" + visibleOn: ${enable-webhook} == true - name: PullPollTimeoutMs label: en_US: Pull Poll Timeout (ms) @@ -49,6 +73,7 @@ spec: type: integer required: true default: 500 + visibleOn: ${enable-webhook} == true - name: PullStreamMaxLifetimeMs label: en_US: Pull Stream Max Lifetime (ms) @@ -59,6 +84,7 @@ spec: type: integer required: true default: 300000 + visibleOn: ${enable-webhook} == true - name: PullPendingPlaceholderEnabled label: en_US: Pull Pending Placeholder Enabled @@ -69,6 +95,7 @@ spec: type: boolean required: false default: false + visibleOn: ${enable-webhook} == true - name: PullPendingPlaceholderDelayMs label: en_US: Pull Pending Placeholder Delay (ms) @@ -79,7 +106,7 @@ spec: type: integer required: false default: 3000 - visibleOn: ${PullPendingPlaceholderEnabled} == true + visibleOn: ${enable-webhook} == true && ${PullPendingPlaceholderEnabled} == true - name: PullPendingPlaceholder label: en_US: Pull Pending Placeholder @@ -90,7 +117,7 @@ spec: type: string required: false default: "AI 正在思考中,请稍候" - visibleOn: ${PullPendingPlaceholderEnabled} == true + visibleOn: ${enable-webhook} == true && ${PullPendingPlaceholderEnabled} == true execution: python: path: ./wecombot.py diff --git a/tests/unit_tests/pipeline/test_wecombot_dify_minfix.py b/tests/unit_tests/pipeline/test_wecombot_dify_minfix.py index d685e17fb..c25d8bca3 100644 --- a/tests/unit_tests/pipeline/test_wecombot_dify_minfix.py +++ b/tests/unit_tests/pipeline/test_wecombot_dify_minfix.py @@ -49,6 +49,25 @@ def make_async_logger(): ) +def make_valid_event_logger(): + from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger + + class _Logger(AbstractEventLogger): + async def info(self, *args, **kwargs): + return None + + async def debug(self, *args, **kwargs): + return None + + async def warning(self, *args, **kwargs): + return None + + async def error(self, *args, **kwargs): + return None + + return _Logger() + + def make_dify_pipeline_config( chunk_batch_size: int = 8, flush_window_enabled: bool = False, @@ -416,3 +435,41 @@ def test_dify_stream_uses_output_defaults_when_config_missing(): assert runner._get_stream_chunk_batch_size(query) == 8 assert runner._is_stream_flush_window_enabled(query) is False assert runner._get_stream_flush_window_ms(query) == 2000 + + +def get_wecom_adapter(): + return import_module('langbot.pkg.platform.sources.wecombot').WecomBotAdapter + + +def test_wecombot_adapter_rejects_websocket_mode_in_current_branch(): + WecomBotAdapter = get_wecom_adapter() + + with pytest.raises(Exception, match='websocket mode is not supported'): + WecomBotAdapter( + { + 'BotId': 'bot-id', + 'enable-webhook': False, + 'Secret': 'secret', + }, + make_async_logger(), + ) + + +def test_wecombot_adapter_webhook_mode_normalizes_pull_config(): + WecomBotAdapter = get_wecom_adapter() + + adapter = WecomBotAdapter( + { + 'BotId': 'bot-id', + 'enable-webhook': True, + 'Token': 'token', + 'EncodingAESKey': 'encoding-aes-key', + 'Corpid': 'corp-id', + }, + make_valid_event_logger(), + ) + + assert adapter.config['enable-webhook'] is True + assert adapter.config['PullPollTimeoutMs'] == 500 + assert adapter.config['PullStreamMaxLifetimeMs'] == 300000 + assert adapter.config['PullPendingPlaceholderEnabled'] is False From 8f97d938369a3760d794b01e3fe5eb31c971f679 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=9C=E9=9B=A8?= <58286951@qq.com> Date: Mon, 16 Mar 2026 14:52:13 +0800 Subject: [PATCH 21/36] style(wecombot): format files with ruff --- src/langbot/libs/wecom_ai_bot_api/api.py | 1 - src/langbot/pkg/pipeline/respback/respback.py | 1 + src/langbot/pkg/platform/sources/wecombot.py | 2 ++ 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/langbot/libs/wecom_ai_bot_api/api.py b/src/langbot/libs/wecom_ai_bot_api/api.py index 30f38bacc..0663b1f02 100644 --- a/src/langbot/libs/wecom_ai_bot_api/api.py +++ b/src/langbot/libs/wecom_ai_bot_api/api.py @@ -638,7 +638,6 @@ async def _handle_post_initial_response(self, msg_json: dict[str, Any], nonce: s 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) diff --git a/src/langbot/pkg/pipeline/respback/respback.py b/src/langbot/pkg/pipeline/respback/respback.py index 2f7bd6ad9..62723dce7 100644 --- a/src/langbot/pkg/pipeline/respback/respback.py +++ b/src/langbot/pkg/pipeline/respback/respback.py @@ -10,6 +10,7 @@ from .. import stage, entities import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query + @stage.stage_class('SendResponseBackStage') class SendResponseBackStage(stage.PipelineStage): """发送响应消息""" diff --git a/src/langbot/pkg/platform/sources/wecombot.py b/src/langbot/pkg/platform/sources/wecombot.py index c1a2d914b..95eb75cfa 100644 --- a/src/langbot/pkg/platform/sources/wecombot.py +++ b/src/langbot/pkg/platform/sources/wecombot.py @@ -149,6 +149,7 @@ async def target2yiri(event: WecomBotEvent): return chain + class WecomBotEventConverter(abstract_platform_adapter.AbstractEventConverter): @staticmethod async def yiri2target(event: platform_events.MessageEvent): @@ -191,6 +192,7 @@ async def target2yiri(event: WecomBotEvent): except Exception: print(traceback.format_exc()) + class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): bot: WecomBotClient bot_account_id: str From c3e175bdaf7f6d8a254307ca3bc745f6ea1e6be8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=9C=E9=9B=A8?= <58286951@qq.com> Date: Mon, 16 Mar 2026 15:18:21 +0800 Subject: [PATCH 22/36] fix(wecombot): preserve websocket compatibility with pull enhancements --- src/langbot/pkg/platform/sources/wecombot.py | 166 ++++++++++-------- .../pkg/platform/sources/wecombot.yaml | 21 ++- .../pipeline/test_wecombot_dify_minfix.py | 26 +-- 3 files changed, 117 insertions(+), 96 deletions(-) diff --git a/src/langbot/pkg/platform/sources/wecombot.py b/src/langbot/pkg/platform/sources/wecombot.py index 95eb75cfa..41841e972 100644 --- a/src/langbot/pkg/platform/sources/wecombot.py +++ b/src/langbot/pkg/platform/sources/wecombot.py @@ -11,6 +11,7 @@ from ..logger import EventLogger from langbot.libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent from langbot.libs.wecom_ai_bot_api.api import WecomBotClient +from langbot.libs.wecom_ai_bot_api.ws_client import WecomBotWsClient DEFAULT_PULL_PENDING_PLACEHOLDER = 'AI 正在思考中,请稍候' @@ -194,12 +195,13 @@ async def target2yiri(event: WecomBotEvent): class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): - bot: WecomBotClient + bot: typing.Union[WecomBotClient, WecomBotWsClient] bot_account_id: str message_converter: WecomBotMessageConverter = WecomBotMessageConverter() event_converter: WecomBotEventConverter = WecomBotEventConverter() config: dict bot_uuid: str = None + _ws_mode: bool = False @staticmethod def _get_int_config(config: dict, key: str, default: int, min_value: int, max_value: int) -> int: @@ -211,45 +213,52 @@ def _get_int_config(config: dict, key: str, default: int, min_value: int, max_va return max(min_value, min(max_value, value)) def __init__(self, config: dict, logger: EventLogger): - enable_webhook = config.get('enable-webhook', True) - if not enable_webhook: - raise Exception('WecomBot websocket mode is not supported in this branch yet. Please enable webhook mode.') - - required_keys = ['Token', 'EncodingAESKey', 'Corpid', 'BotId'] - missing_keys = [key for key in required_keys if not config.get(key)] - if missing_keys: - raise Exception(f'WecomBot webhook mode missing config: {missing_keys}') + enable_webhook = config.get('enable-webhook', False) + normalized_config = dict(config) - pull_poll_timeout_ms = self._get_int_config(config, 'PullPollTimeoutMs', 500, 50, 2000) - pull_stream_max_lifetime_ms = self._get_int_config(config, 'PullStreamMaxLifetimeMs', 300000, 1000, 600000) - pending_placeholder_enabled = config.get('PullPendingPlaceholderEnabled', False) - pending_placeholder_delay_ms = self._get_int_config(config, 'PullPendingPlaceholderDelayMs', 3000, 0, 10000) - pending_placeholder = config.get('PullPendingPlaceholder', DEFAULT_PULL_PENDING_PLACEHOLDER) + if not enable_webhook: + bot = WecomBotWsClient( + bot_id=config['BotId'], + secret=config['Secret'], + logger=logger, + encoding_aes_key=config.get('EncodingAESKey', ''), + ) + ws_mode = True + else: + required_keys = ['Token', 'EncodingAESKey', 'Corpid'] + missing_keys = [key for key in required_keys if key not in config or not config[key]] + if missing_keys: + raise Exception(f'WecomBot webhook mode missing config: {missing_keys}') + + pull_poll_timeout_ms = self._get_int_config(config, 'PullPollTimeoutMs', 500, 50, 2000) + pull_stream_max_lifetime_ms = self._get_int_config(config, 'PullStreamMaxLifetimeMs', 300000, 1000, 600000) + pending_placeholder_enabled = config.get('PullPendingPlaceholderEnabled', False) + pending_placeholder_delay_ms = self._get_int_config(config, 'PullPendingPlaceholderDelayMs', 3000, 0, 10000) + pending_placeholder = config.get('PullPendingPlaceholder', DEFAULT_PULL_PENDING_PLACEHOLDER) + + normalized_config['PullPollTimeoutMs'] = pull_poll_timeout_ms + normalized_config['PullStreamMaxLifetimeMs'] = pull_stream_max_lifetime_ms + normalized_config['PullPendingPlaceholderEnabled'] = pending_placeholder_enabled + normalized_config['PullPendingPlaceholderDelayMs'] = pending_placeholder_delay_ms + normalized_config['PullPendingPlaceholder'] = pending_placeholder + + effective_placeholder_delay = pending_placeholder_delay_ms / 1000 if pending_placeholder_enabled else 0 + effective_placeholder = pending_placeholder if pending_placeholder_enabled else '' + + bot = WecomBotClient( + Token=config['Token'], + EnCodingAESKey=config['EncodingAESKey'], + Corpid=config['Corpid'], + logger=logger, + unified_mode=True, + stream_poll_timeout=pull_poll_timeout_ms / 1000, + stream_max_lifetime=pull_stream_max_lifetime_ms / 1000, + pending_placeholder=effective_placeholder, + pending_placeholder_delay=effective_placeholder_delay, + ) + ws_mode = False - normalized_config = dict(config) - normalized_config['enable-webhook'] = True - normalized_config['PullPollTimeoutMs'] = pull_poll_timeout_ms - normalized_config['PullStreamMaxLifetimeMs'] = pull_stream_max_lifetime_ms - normalized_config['PullPendingPlaceholderEnabled'] = pending_placeholder_enabled - normalized_config['PullPendingPlaceholderDelayMs'] = pending_placeholder_delay_ms - normalized_config['PullPendingPlaceholder'] = pending_placeholder - - # 如果未开启首字等待占位,则将延迟设为0且占位文案设为空 - effective_placeholder_delay = pending_placeholder_delay_ms / 1000 if pending_placeholder_enabled else 0 - effective_placeholder = pending_placeholder if pending_placeholder_enabled else '' - - bot = WecomBotClient( - Token=config['Token'], - EnCodingAESKey=config['EncodingAESKey'], - Corpid=config['Corpid'], - logger=logger, - unified_mode=True, - stream_poll_timeout=pull_poll_timeout_ms / 1000, - stream_max_lifetime=pull_stream_max_lifetime_ms / 1000, - pending_placeholder=effective_placeholder, - pending_placeholder_delay=effective_placeholder_delay, - ) - bot_account_id = config['BotId'] + bot_account_id = config.get('BotId', '') super().__init__( config=normalized_config, @@ -257,6 +266,7 @@ def __init__(self, config: dict, logger: EventLogger): bot=bot, bot_account_id=bot_account_id, ) + self._ws_mode = ws_mode async def reply_message( self, @@ -265,7 +275,15 @@ async def reply_message( quote_origin: bool = False, ): content = await self.message_converter.yiri2target(message) - await self.bot.set_message(message_source.source_platform_object.message_id, content) + if self._ws_mode: + event = message_source.source_platform_object + req_id = event.get('req_id', '') + if req_id: + await self.bot.reply_text(req_id, content) + else: + await self.bot.set_message(event.message_id, content) + else: + await self.bot.set_message(message_source.source_platform_object.message_id, content) async def reply_message_chunk( self, @@ -275,30 +293,22 @@ async def reply_message_chunk( 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} + + if self._ws_mode: + success = await self.bot.push_stream_chunk(msg_id, content, is_final=is_final) + if not success and is_final: + event = message_source.source_platform_object + req_id = event.get('req_id', '') + if req_id: + await self.bot.reply_text(req_id, content) + return {'stream': success} + else: + success = await self.bot.push_stream_chunk(msg_id, content, is_final=is_final) + if not success and is_final: + await self.bot.set_message(msg_id, content) + return {'stream': success} async def is_stream_output_supported(self) -> bool: """智能机器人侧默认开启流式能力。 @@ -315,7 +325,11 @@ async def create_message_card(self, message_id: str, event) -> bool: return False async def send_message(self, target_type, target_id, message): - pass + if self._ws_mode: + content = await self.message_converter.yiri2target(message) + await self.bot.send_message(target_id, content) + else: + pass def register_listener( self, @@ -344,29 +358,25 @@ def set_bot_uuid(self, bot_uuid: str): self.bot_uuid = bot_uuid async def handle_unified_webhook(self, bot_uuid: str, path: str, request): - """处理统一 webhook 请求。 - - Args: - bot_uuid: Bot 的 UUID - path: 子路径(如果有的话) - request: Quart Request 对象 - - Returns: - 响应数据 - """ + if self._ws_mode: + return None return await self.bot.handle_unified_webhook(request) async def run_async(self): - # 统一 webhook 模式下,不启动独立的 Quart 应用 - # 保持运行但不启动独立端口 + if self._ws_mode: + await self.bot.connect() + else: - async def keep_alive(): - while True: - await asyncio.sleep(1) + async def keep_alive(): + while True: + await asyncio.sleep(1) - await keep_alive() + await keep_alive() async def kill(self) -> bool: + if self._ws_mode: + await self.bot.disconnect() + return True return False async def unregister_listener( diff --git a/src/langbot/pkg/platform/sources/wecombot.yaml b/src/langbot/pkg/platform/sources/wecombot.yaml index caa8f9fd3..a53174b9d 100644 --- a/src/langbot/pkg/platform/sources/wecombot.yaml +++ b/src/langbot/pkg/platform/sources/wecombot.yaml @@ -21,20 +21,20 @@ spec: - name: enable-webhook label: en_US: Enable Webhook Mode - zh_Hans: 启用 Webhook 模式 + zh_Hans: 启用Webhook模式 description: - en_US: Switch between Webhook mode and WebSocket mode config. This branch currently supports Webhook mode only. - zh_Hans: 在 Webhook / WebSocket 配置之间切换展示。当前分支仅支持 Webhook 模式。 + en_US: If enabled, the bot will use webhook mode to receive messages. Otherwise, it will use WS long connection mode + zh_Hans: 如果启用,机器人将使用 Webhook 模式接收消息。否则,将使用 WS 长连接模式 type: boolean required: true - default: true + default: false - name: Secret label: en_US: Secret zh_Hans: 机器人密钥 (Secret) description: - en_US: Reserved for WebSocket mode. - zh_Hans: WebSocket 模式预留配置项。 + en_US: Required for WebSocket long connection mode + zh_Hans: 使用 WS 长连接模式时必填 type: string required: false default: "" @@ -43,6 +43,9 @@ spec: label: en_US: Corpid zh_Hans: 企业ID + description: + en_US: Required for Webhook mode + zh_Hans: 使用 Webhook 模式时必填 type: string required: false default: "" @@ -51,6 +54,9 @@ spec: label: en_US: Token zh_Hans: 令牌 (Token) + description: + en_US: Required for Webhook mode + zh_Hans: 使用 Webhook 模式时必填 type: string required: false default: "" @@ -59,6 +65,9 @@ spec: label: en_US: EncodingAESKey zh_Hans: 消息加解密密钥 (EncodingAESKey) + description: + en_US: Required for Webhook mode. Optional for WebSocket mode (used for file decryption) + zh_Hans: 使用 Webhook 模式时必填。WebSocket 模式下可选(用于文件解密) type: string required: false default: "" diff --git a/tests/unit_tests/pipeline/test_wecombot_dify_minfix.py b/tests/unit_tests/pipeline/test_wecombot_dify_minfix.py index c25d8bca3..94eec5139 100644 --- a/tests/unit_tests/pipeline/test_wecombot_dify_minfix.py +++ b/tests/unit_tests/pipeline/test_wecombot_dify_minfix.py @@ -409,8 +409,6 @@ async def test_wecom_dispatch_exception_forces_finish(): assert chunk.content == client.stream_error_final_text - - def test_wecom_client_defaults_match_master_polling_behavior(): WecomBotClient = get_wecom_client() client = WecomBotClient( @@ -441,18 +439,22 @@ def get_wecom_adapter(): return import_module('langbot.pkg.platform.sources.wecombot').WecomBotAdapter -def test_wecombot_adapter_rejects_websocket_mode_in_current_branch(): +def test_wecombot_adapter_supports_websocket_mode_from_master(): WecomBotAdapter = get_wecom_adapter() + ws_module = import_module('langbot.libs.wecom_ai_bot_api.ws_client') - with pytest.raises(Exception, match='websocket mode is not supported'): - WecomBotAdapter( - { - 'BotId': 'bot-id', - 'enable-webhook': False, - 'Secret': 'secret', - }, - make_async_logger(), - ) + adapter = WecomBotAdapter( + { + 'BotId': 'bot-id', + 'enable-webhook': False, + 'Secret': 'secret', + }, + make_valid_event_logger(), + ) + + assert adapter._ws_mode is True + assert isinstance(adapter.bot, ws_module.WecomBotWsClient) + assert adapter.config['enable-webhook'] is False def test_wecombot_adapter_webhook_mode_normalizes_pull_config(): From 5cbd6047a847fb4b59df390ea45167c9e7041564 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=9C=E9=9B=A8?= <58286951@qq.com> Date: Mon, 16 Mar 2026 16:24:22 +0800 Subject: [PATCH 23/36] refactor(wecombot): minimize drift from master config behavior --- src/langbot/pkg/platform/sources/wecombot.py | 4 ---- src/langbot/pkg/platform/sources/wecombot.yaml | 4 ---- 2 files changed, 8 deletions(-) diff --git a/src/langbot/pkg/platform/sources/wecombot.py b/src/langbot/pkg/platform/sources/wecombot.py index 41841e972..c087a5cca 100644 --- a/src/langbot/pkg/platform/sources/wecombot.py +++ b/src/langbot/pkg/platform/sources/wecombot.py @@ -320,10 +320,6 @@ async def is_stream_output_supported(self) -> bool: 流水线执行阶段会调用此方法以确认是否启用流式。""" return True - async def create_message_card(self, message_id: str, event) -> bool: - """企微智能机器人不需要创建卡片,流式消息直接通过 stream 协议推送。""" - return False - async def send_message(self, target_type, target_id, message): if self._ws_mode: content = await self.message_converter.yiri2target(message) diff --git a/src/langbot/pkg/platform/sources/wecombot.yaml b/src/langbot/pkg/platform/sources/wecombot.yaml index a53174b9d..65e243e59 100644 --- a/src/langbot/pkg/platform/sources/wecombot.yaml +++ b/src/langbot/pkg/platform/sources/wecombot.yaml @@ -38,7 +38,6 @@ spec: type: string required: false default: "" - visibleOn: ${enable-webhook} == false - name: Corpid label: en_US: Corpid @@ -49,7 +48,6 @@ spec: type: string required: false default: "" - visibleOn: ${enable-webhook} == true - name: Token label: en_US: Token @@ -60,7 +58,6 @@ spec: type: string required: false default: "" - visibleOn: ${enable-webhook} == true - name: EncodingAESKey label: en_US: EncodingAESKey @@ -71,7 +68,6 @@ spec: type: string required: false default: "" - visibleOn: ${enable-webhook} == true - name: PullPollTimeoutMs label: en_US: Pull Poll Timeout (ms) From 7791dd1386bd5fdf9708ceed0c05422c64af7f21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=9C=E9=9B=A8?= <58286951@qq.com> Date: Mon, 16 Mar 2026 17:23:24 +0800 Subject: [PATCH 24/36] fix(plugin): tolerate older runtime sdk without vector list --- src/langbot/pkg/plugin/handler.py | 26 +++++++++---------- .../test_plugin_runtime_handler_compat.py | 20 ++++++++++++++ 2 files changed, 33 insertions(+), 13 deletions(-) create mode 100644 tests/unit_tests/plugin/test_plugin_runtime_handler_compat.py diff --git a/src/langbot/pkg/plugin/handler.py b/src/langbot/pkg/plugin/handler.py index bdcd024df..2f293d981 100644 --- a/src/langbot/pkg/plugin/handler.py +++ b/src/langbot/pkg/plugin/handler.py @@ -555,19 +555,19 @@ async def vector_delete(data: dict[str, Any]) -> handler.ActionResponse: except Exception as e: return _make_rag_error_response(e, 'VectorStoreError', collection_id=collection_id) - @self.action(PluginToRuntimeAction.VECTOR_LIST) - async def vector_list(data: dict[str, Any]) -> handler.ActionResponse: - collection_id = data['collection_id'] - filters = data.get('filters') - limit = data.get('limit', 20) - offset = data.get('offset', 0) - try: - items, total = await self.ap.rag_runtime_service.vector_list( - collection_id, filters, limit, offset - ) - return handler.ActionResponse.success(data={'items': items, 'total': total}) - except Exception as e: - return _make_rag_error_response(e, 'VectorStoreError', collection_id=collection_id) + if hasattr(PluginToRuntimeAction, 'VECTOR_LIST'): + + @self.action(PluginToRuntimeAction.VECTOR_LIST) + async def vector_list(data: dict[str, Any]) -> handler.ActionResponse: + collection_id = data['collection_id'] + filters = data.get('filters') + limit = data.get('limit', 20) + offset = data.get('offset', 0) + try: + items, total = await self.ap.rag_runtime_service.vector_list(collection_id, filters, limit, offset) + return handler.ActionResponse.success(data={'items': items, 'total': total}) + except Exception as e: + return _make_rag_error_response(e, 'VectorStoreError', collection_id=collection_id) @self.action(PluginToRuntimeAction.GET_KNOWLEDEGE_FILE_STREAM) async def get_knowledge_file_stream(data: dict[str, Any]) -> handler.ActionResponse: diff --git a/tests/unit_tests/plugin/test_plugin_runtime_handler_compat.py b/tests/unit_tests/plugin/test_plugin_runtime_handler_compat.py new file mode 100644 index 000000000..79582f26c --- /dev/null +++ b/tests/unit_tests/plugin/test_plugin_runtime_handler_compat.py @@ -0,0 +1,20 @@ +from unittest.mock import AsyncMock, MagicMock + + +def test_runtime_handler_allows_missing_vector_list_action_for_older_plugin_sdk(): + from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction + from src.langbot.pkg.plugin.handler import RuntimeConnectionHandler + + assert not hasattr(PluginToRuntimeAction, 'VECTOR_LIST') + + mock_connection = MagicMock() + mock_app = MagicMock() + mock_app.logger = MagicMock() + + handler = RuntimeConnectionHandler( + connection=mock_connection, + disconnect_callback=AsyncMock(return_value=False), + ap=mock_app, + ) + + assert handler is not None From 5e4f21b14d2bc25d4a54fdbd9ed91a4cca0df972 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=9C=E9=9B=A8?= <58286951@qq.com> Date: Thu, 26 Mar 2026 18:38:52 +0800 Subject: [PATCH 25/36] feat(wecomcs): add redis streams scheduler and kf app support --- Dockerfile | 5 +- docker/docker-compose.yaml | 15 + docs/tdd-wecomcs-redis-two-layer-streams.md | 485 ++++++++++++++++++ docs/wecomcs-redis-startup-guide.md | 152 ++++++ pyproject.toml | 1 + src/langbot/libs/wecom_ai_bot_api/api.py | 45 +- .../libs/wecom_customer_service_api/api.py | 368 ++++++++++--- .../wecomcsevent.py | 5 +- .../api/http/controller/groups/webhooks.py | 8 + src/langbot/pkg/api/http/service/bot.py | 1 + src/langbot/pkg/cache/__init__.py | 0 src/langbot/pkg/cache/redis_mgr.py | 142 +++++ src/langbot/pkg/core/app.py | 3 + src/langbot/pkg/core/stages/build_app.py | 5 + src/langbot/pkg/entity/persistence/wecomcs.py | 25 + .../dbm025_wecomcs_cursor_checkpoint.py | 48 ++ .../pkg/platform/sources/wecom_kf_app.py | 13 + .../pkg/platform/sources/wecom_kf_app.yaml | 139 +++++ src/langbot/pkg/platform/sources/wecombot.py | 3 +- src/langbot/pkg/platform/sources/wecomcs.py | 103 +++- src/langbot/pkg/platform/sources/wecomcs.yaml | 67 ++- src/langbot/pkg/platform/wecomcs/__init__.py | 0 .../pkg/platform/wecomcs/config_resolver.py | 160 ++++++ .../pkg/platform/wecomcs/cursor_store.py | 96 ++++ .../pkg/platform/wecomcs/message_publisher.py | 43 ++ .../platform/wecomcs/message_state_store.py | 146 ++++++ .../pkg/platform/wecomcs/message_worker.py | 63 +++ src/langbot/pkg/platform/wecomcs/models.py | 37 ++ .../wecomcs/pull_trigger_publisher.py | 41 ++ .../pkg/platform/wecomcs/pull_worker.py | 216 ++++++++ .../pkg/platform/wecomcs/retry_scheduler.py | 78 +++ src/langbot/pkg/platform/wecomcs/runtime.py | 165 ++++++ src/langbot/pkg/platform/wecomcs/sharding.py | 21 + .../pkg/platform/wecomcs/state_store.py | 185 +++++++ .../pkg/platform/wecomcs/token_cache.py | 120 +++++ src/langbot/pkg/utils/constants.py | 2 +- src/langbot/templates/config.yaml | 25 + .../platform/test_wecom_kf_app_adapter.py | 108 ++++ .../platform/test_wecomcs_adapter_config.py | 176 +++++++ .../platform/test_wecomcs_cursor_store.py | 45 ++ .../platform/test_wecomcs_message_layer.py | 178 +++++++ .../platform/test_wecomcs_pull_worker.py | 289 +++++++++++ .../platform/test_wecomcs_retry_scheduler.py | 56 ++ .../platform/test_wecomcs_runtime.py | 303 +++++++++++ .../platform/test_wecomcs_sharding.py | 71 +++ .../platform/test_wecomcs_state_store.py | 132 +++++ .../platform/test_wecomcs_token_cache.py | 164 ++++++ .../unit_tests/test_wecom_customer_service.py | 415 +++++++++++++++ uv.lock | 23 + .../home/bots/components/bot-form/BotForm.tsx | 1 + .../dynamic-form/DynamicFormComponent.tsx | 136 ++--- .../dynamic-form/DynamicFormItemConfig.ts | 2 + web/src/app/infra/entities/form/dynamic.ts | 1 + 53 files changed, 4963 insertions(+), 168 deletions(-) create mode 100644 docs/tdd-wecomcs-redis-two-layer-streams.md create mode 100644 docs/wecomcs-redis-startup-guide.md create mode 100644 src/langbot/pkg/cache/__init__.py create mode 100644 src/langbot/pkg/cache/redis_mgr.py create mode 100644 src/langbot/pkg/entity/persistence/wecomcs.py create mode 100644 src/langbot/pkg/persistence/migrations/dbm025_wecomcs_cursor_checkpoint.py create mode 100644 src/langbot/pkg/platform/sources/wecom_kf_app.py create mode 100644 src/langbot/pkg/platform/sources/wecom_kf_app.yaml create mode 100644 src/langbot/pkg/platform/wecomcs/__init__.py create mode 100644 src/langbot/pkg/platform/wecomcs/config_resolver.py create mode 100644 src/langbot/pkg/platform/wecomcs/cursor_store.py create mode 100644 src/langbot/pkg/platform/wecomcs/message_publisher.py create mode 100644 src/langbot/pkg/platform/wecomcs/message_state_store.py create mode 100644 src/langbot/pkg/platform/wecomcs/message_worker.py create mode 100644 src/langbot/pkg/platform/wecomcs/models.py create mode 100644 src/langbot/pkg/platform/wecomcs/pull_trigger_publisher.py create mode 100644 src/langbot/pkg/platform/wecomcs/pull_worker.py create mode 100644 src/langbot/pkg/platform/wecomcs/retry_scheduler.py create mode 100644 src/langbot/pkg/platform/wecomcs/runtime.py create mode 100644 src/langbot/pkg/platform/wecomcs/sharding.py create mode 100644 src/langbot/pkg/platform/wecomcs/state_store.py create mode 100644 src/langbot/pkg/platform/wecomcs/token_cache.py create mode 100644 tests/unit_tests/platform/test_wecom_kf_app_adapter.py create mode 100644 tests/unit_tests/platform/test_wecomcs_adapter_config.py create mode 100644 tests/unit_tests/platform/test_wecomcs_cursor_store.py create mode 100644 tests/unit_tests/platform/test_wecomcs_message_layer.py create mode 100644 tests/unit_tests/platform/test_wecomcs_pull_worker.py create mode 100644 tests/unit_tests/platform/test_wecomcs_retry_scheduler.py create mode 100644 tests/unit_tests/platform/test_wecomcs_runtime.py create mode 100644 tests/unit_tests/platform/test_wecomcs_sharding.py create mode 100644 tests/unit_tests/platform/test_wecomcs_state_store.py create mode 100644 tests/unit_tests/platform/test_wecomcs_token_cache.py create mode 100644 tests/unit_tests/test_wecom_customer_service.py diff --git a/Dockerfile b/Dockerfile index 34afca5ea..8a9259d3a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,4 @@ +# ========== 前端构建阶段 ========== FROM node:22-alpine AS node WORKDIR /app @@ -14,6 +15,7 @@ WORKDIR /app COPY pyproject.toml uv.lock README.md LICENSE main.py ./ COPY src ./src +# 安装系统依赖和 Python 依赖(排除开发依赖) RUN apt update \ && apt install gcc -y \ && python -m pip install --no-cache-dir uv \ @@ -23,9 +25,6 @@ RUN apt update \ && rm -rf /var/lib/apt/lists/* \ && touch /.dockerenv -# 再复制其余运行时文件,避免无关文件变更导致依赖重装 -COPY . . - COPY --from=node /app/web/out ./web/out CMD [ "uv", "run", "--no-sync", "main.py" ] diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 6971477dc..12ac9538b 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -4,6 +4,21 @@ version: "3" services: + redis: + image: redis:7-alpine + container_name: langbot_redis + restart: on-failure + command: ["redis-server", "--appendonly", "yes"] + volumes: + - ./data/redis:/data + environment: + - TZ=Asia/Shanghai + ports: + - 6379:6379 + networks: + - langbot_network + + langbot_plugin_runtime: image: rockchin/langbot:latest container_name: langbot_plugin_runtime diff --git a/docs/tdd-wecomcs-redis-two-layer-streams.md b/docs/tdd-wecomcs-redis-two-layer-streams.md new file mode 100644 index 000000000..0c572d7e9 --- /dev/null +++ b/docs/tdd-wecomcs-redis-two-layer-streams.md @@ -0,0 +1,485 @@ +# TDD - 企业微信客服 Redis 两层 Streams 可靠消息架构 + +| 字段 | 值 | +| --- | --- | +| Tech Lead | TBD | +| Product Manager | TBD | +| Team | 平台后端 / 企业微信适配维护者 | +| Epic/Ticket | TBD | +| Figma/Design | N/A | +| Status | Draft | +| Created | 2026-03-25 | +| Last Updated | 2026-03-25 | + +## 背景与前提说明 + +本文档基于当前 LangBot 主线现状与历史分支 `feature/wecom-message-scheduler` 的经验整理。当前主线已具备企业微信客服 webhook 立即返回 `success` 的基础能力,但还缺少完整的 Redis 基础设施、持久化 token 缓存、两层异步调度、重试编排与可观测性闭环。 + +当前文档中的人员信息与工单信息暂以 `TBD` 标注,不影响技术设计评审;在进入实现阶段前应补齐负责人、Epic 和发布窗口信息。 + +## Context + +LangBot 当前的企业微信客服链路已经支持统一 webhook 路由与基础消息处理,但其核心路径仍然偏向“请求驱动型”的串行处理:回调到达后,需要完成解密、拉取 `sync_msg`、事件转换、进入 pipeline、调用 Dify、再发送回复。这种模式在功能上可用,但在生产环境下对消息可靠性和系统弹性存在明显限制。 + +历史分支 `feature/wecom-message-scheduler` 曾尝试使用 Kafka + MySQL 构建一套企业微信客服消息调度器,其目标是解决 1 秒内多条消息丢失、cursor 管理不完整、缺少幂等与重试的问题。该分支提供了正确的问题建模方向,但引入 Kafka 与专用 MySQL 数据源的接入成本较高,与当前 LangBot 主线“尽量轻量化、易部署、低侵入”的演进方向不完全一致。 + +本次设计希望在不引入 Kafka 的前提下,复用 Docker 环境更容易补入的 Redis,构建一套“两层 Streams + 锁 + ZSET + KV”的企业微信客服消息可靠处理架构。该方案既要满足消息可靠性目标,也要降低部署和维护复杂度,并为未来横向扩展留下清晰的演进路径。 + +## Problem Statement & Motivation + +### 我们要解决的问题 + +- **问题 1:access_token 缓存不完整** + - 当前仅具备进程内缓存与失效码触发刷新,没有基于 `expires_in` 的跨进程共享缓存。 + - 影响:多实例或重启场景下容易重复调用 `gettoken`,增加被频率限制的风险。 + +- **问题 2:消息拉取与业务处理耦合过深** + - webhook 虽已能快速返回,但后续消息拉取、业务处理、回复发送仍缺少统一的异步调度基础设施。 + - 影响:重试、排队、积压、可观测性与恢复能力不足。 + +- **问题 3:同一 `open_kfid` 的 cursor 语义要求串行,但系统缺少显式调度模型** + - 企业微信客服的 `sync_msg` 是按 `open_kfid + cursor` 增量拉取,不能安全地按用户并行推进 cursor。 + - 影响:若未来直接堆并发,容易出现 cursor 竞争、重复拉取、消息跳过。 + +- **问题 4:缺少分层并发模型** + - 当前链路没有明确区分“拉取阶段的串行一致性”和“业务处理阶段的可并行性”。 + - 影响:系统不是吞吐不足,就是一致性不足,难以同时满足可靠性和性能。 + +- **问题 5:缺少统一重试与观测能力** + - 目前没有基于消息状态的延迟重试、积压监控、失败统计与 pending 恢复机制。 + - 影响:遇到 Dify 超时、企业微信接口短暂抖动、Redis 消费者异常退出时,恢复依赖人工排查。 + +### Why Now + +- 企业微信客服已经是实际使用链路,消息可靠性问题会直接影响 Dify 调用成功率和用户回复体验。 +- 当前项目仍未引入 Redis,正适合在企业微信客服场景下先补一套最小可用的缓存与异步调度基础设施。 +- 历史 Kafka 方案已经验证了问题模型,但没有合入主线;现在可以基于 Redis 以更低复杂度把关键收益先落地。 + +### 不解决的影响 + +- **业务影响**:企业微信客服回复可能继续出现延迟、不回、重试不稳定等现象。 +- **技术影响**:后续越往系统里加并发,越容易破坏 cursor 一致性,形成隐性技术债。 +- **运维影响**:生产问题难定位,无法快速判断是 webhook 抖动、拉取失败、Dify 超时还是发送失败。 + +## Scope + +### ✅ In Scope + +- 为 LangBot Docker Compose 增加 Redis 服务,并为应用补充 Redis 连接配置。 +- 在 LangBot 内新增 Redis 基础设施封装,用于 KV、Streams、锁、ZSET 操作。 +- 将企业微信客服 `access_token` 缓存迁移到 Redis KV,并基于 `expires_in` 管理 TTL。 +- 引入两层 Redis Streams: + - 第一层:`pull-trigger`,负责 webhook 后的消息拉取调度。 + - 第二层:`message-process`,负责拉取后的业务处理与回复。 +- 为两层 stream 均引入“固定分片数 `% n`”策略,且 `n` 必须可配置。 +- 为第一层引入 `open_kfid` 级别分布式锁,确保同一客服账号的 cursor 串行推进。 +- 为第二层引入基于会话键的分片策略,实现可控并发处理。 +- 引入基于 Redis ZSET 的延迟重试机制,至少覆盖拉取任务与消息处理任务。 +- 增加观测指标、日志结构与基础告警建议。 +- 增加针对 token 缓存、分片路由、锁互斥、重试流程的单元/集成测试。 + +### ❌ Out of Scope + +- 引入 Kafka、RabbitMQ 或其他外部 MQ。 +- 为每个 `open_kfid` 动态创建独立 stream。 +- 在第一阶段做全平台通用消息调度中间件。 +- 实现“严格意义上的端到端 exactly-once”。 +- 在本期支持多 region / 多机房一致性调度。 +- 改造所有现有平台适配器统一切换到 Redis Streams。 + +### 🔮 Future Considerations + +- 将 Redis 调度基础设施抽象成跨平台通用组件,供其他 webhook 型适配器复用。 +- 增加后台管理接口,查看企业微信客服消息积压、失败与重试状态。 +- 如果未来吞吐显著提升,再评估是否迁移到 Kafka。 + +## Technical Solution + +### 设计原则 + +- **拉取阶段串行**:同一 `open_kfid` 的 `sync_msg` 与 cursor 推进必须串行。 +- **处理阶段并行**:拉取后的消息可以按会话维度分片并行处理。 +- **固定分片数量**:不为每个 `open_kfid` 动态建 stream,统一采用固定 `N` 个分片 stream。 +- **配置化并发**:第一层与第二层的 shard 数分别独立配置。 +- **Redis 优先**:KV 负责 token/cursor/state,Streams 负责队列,ZSET 负责延迟重试,锁负责互斥。 +- **最小侵入主线**:尽量围绕当前 `WecomCSClient` 与 `WecomCSAdapter` 增量改造。 + +### 核心组件 + +- **RedisManager**:应用级 Redis 连接与基础操作封装。 +- **WecomCSTokenCache**:企业微信客服 token 的读取、刷新、TTL 管理。 +- **WecomCSPullTriggerPublisher**:webhook 解密后将拉取任务写入第一层 Streams。 +- **WecomCSPullWorker**:消费第一层 Streams,按 `open_kfid` 串行拉取 `sync_msg`。 +- **WecomCSMessagePublisher**:将已拉取、已去重的单条消息发布到第二层 Streams。 +- **WecomCSMessageWorker**:消费第二层 Streams,进入 LangBot 现有 pipeline / Dify / 回复链路。 +- **WecomCSRetryScheduler**:负责 ZSET 延迟重试的投递与恢复。 +- **WecomCSStateStore**:Redis 中的 cursor、幂等键、运行状态与 pending 管理。 + +### 配置模型 + +建议在实例配置中新增类似配置: + +```yaml +wecomcs_scheduler: + enabled: true + redis_url: redis://redis:6379/0 + token_refresh_skew_seconds: 300 + pull_stream_shard_count: 8 + process_stream_shard_count: 16 + pull_consumer_group: wecomcs-pull-group + process_consumer_group: wecomcs-process-group + retry_poll_interval_seconds: 3 + retry_max_attempts: 3 + retry_backoff_seconds: [15, 30, 45] + dedupe_ttl_seconds: 604800 + lock_ttl_seconds: 60 +``` + +### 分片策略 + +#### 第一层:Pull Trigger Streams + +固定创建以下 stream: + +- `stream:wecomcs:pull-trigger:0` +- `stream:wecomcs:pull-trigger:1` +- ... +- `stream:wecomcs:pull-trigger:{N1-1}` + +其中: +- `N1 = pull_stream_shard_count` +- 路由规则:`shard = hash(bot_uuid + ':' + open_kfid) % N1` + +**设计原因**: +- 不创建动态 stream,降低运维复杂度。 +- 同一个 `open_kfid` 永远进入固定分片。 +- 第一层消费者每个分片单线程消费,外加 `open_kfid` 锁,确保 cursor 语义稳定。 + +#### 第二层:Message Process Streams + +固定创建以下 stream: + +- `stream:wecomcs:message-process:0` +- `stream:wecomcs:message-process:1` +- ... +- `stream:wecomcs:message-process:{N2-1}` + +其中: +- `N2 = process_stream_shard_count` +- 路由键:`session_key = bot_uuid + ':' + open_kfid + ':' + external_userid` +- 路由规则:`shard = hash(session_key) % N2` + +**设计原因**: +- 同一用户会落到固定分片,天然获得顺序性。 +- 不同用户可以并行进入不同分片,提高 Dify 调用与回复发送并发度。 +- 第二层的并发能力由 `N2` 直接控制,运维可按压力调整。 + +### 为什么不按 `open_kfid` 动态建 stream + +- stream 数量会随企业微信客服账号数膨胀,管理成本高。 +- 每个 stream 的 consumer group、pending、清理与恢复逻辑都会变复杂。 +- 固定分片 stream + 锁已经能满足“串行一致性 + 可配置并发”的核心需求。 + +### 为什么不在第一层按 user_id 再分片 + +- `sync_msg` 的增量边界是 `open_kfid + cursor`,不是 `external_userid`。 +- 若按 user_id 分片拉取,会出现多个工作单元竞争同一个 cursor 的问题。 +- 因此,第一层必须围绕 `open_kfid` 串行;只有第二层业务处理才适合按用户并行。 + +### 锁模型 + +#### 第一层锁 + +- 锁键:`lock:wecomcs:pull:{bot_uuid}:{open_kfid}` +- 使用 Redis `SET key value NX EX` 获取锁。 +- 持锁范围: + - 读取 cursor + - 调用 `sync_msg` + - 处理 `has_more / next_cursor` + - 将单条消息写入第二层 stream + - 更新 cursor + +**要求**:锁释放必须在 finally 中完成;若 worker 崩溃,依赖 TTL 自动回收。 + +#### 第二层是否需要锁 + +V1 默认不增加额外会话锁,采用“每个分片单 worker”保证分片内顺序。如果未来需要在单分片内增加 worker 并发,再引入: + +- `lock:wecomcs:process:{bot_uuid}:{open_kfid}:{external_userid}` + +### 状态与 key 设计 + +#### Token 缓存 + +- Key:`langbot:wecomcs:access_token:{corpid}` +- Value: + +```json +{ + "access_token": "...", + "expires_at": 1774370000 +} +``` + +- TTL:`expires_in - token_refresh_skew_seconds` +- 兜底:若接口返回 `40014` 或 `42001`,立即删除 key 并强制刷新。 + +#### Cursor + +- Key:`langbot:wecomcs:cursor:{bot_uuid}:{open_kfid}` +- Value:`next_cursor` +- 无固定 TTL,持久保存。 + +#### 幂等 + +- Key:`langbot:wecomcs:dedupe:{bot_uuid}:{msgid}` +- Value:`1` +- 写入方式:`SETNX` +- TTL:`dedupe_ttl_seconds`,建议 7 天。 + +#### Retry ZSET + +- Key:`zset:wecomcs:retry` +- Score:下次重试时间戳 +- Value:JSON 或引用 ID,需包含 `job_type`、`payload`、`retry_count` + +### 处理流程 + +#### 流程 A:Webhook → 第一层 Pull Trigger + +1. 统一 webhook 收到企业微信客服 POST。 +2. 解密 XML,得到 `token` 和 `open_kfid`。 +3. 将 `pull-trigger` 任务写入对应分片 stream。 +4. 立即返回 `success`。 + +#### 流程 B:第一层 Pull Worker + +1. 消费分片 stream 中的 trigger 任务。 +2. 获取 `open_kfid` 锁。 +3. 读取 Redis cursor。 +4. 通过 `WecomCSTokenCache` 获取可用 access token。 +5. 调用 `kf/sync_msg`,按 `has_more / next_cursor` 循环拉取。 +6. 对每条消息执行幂等校验:`SETNX dedupe key`。 +7. 新消息写入第二层 `message-process` stream。 +8. 全部成功后更新 cursor。 +9. ACK 第一层 stream 消息并释放锁。 + +#### 流程 C:第二层 Message Worker + +1. 消费 `message-process` 分片 stream。 +2. 将 Redis 中的消息 payload 转为 `WecomCSEvent`。 +3. 进入当前 LangBot 既有 pipeline。 +4. 调用 Dify 或其他 provider 生成回复。 +5. 通过企业微信客服接口回复消息。 +6. 成功后 ACK 第二层 stream 消息。 +7. 失败则写入重试 ZSET。 + +#### 流程 D:Retry Scheduler + +1. 周期性扫描 `zset:wecomcs:retry` 中 `score <= now` 的任务。 +2. 根据 `job_type` 决定重新投递到第一层或第二层 stream。 +3. 若重试次数超过阈值,记录最终失败日志与统计指标。 + +### 架构图 + +```mermaid +graph TD + A[企业微信客服 Webhook] --> B[Webhook Router] + B --> C[解密 XML] + C --> D[XADD Pull Trigger Stream Shard] + D --> E[立即返回 success] + + D --> F[Pull Worker] + F --> G[Redis 锁: open_kfid] + G --> H[Redis Cursor] + G --> I[Redis Token Cache] + F --> J[sync_msg 循环拉取] + J --> K[SETNX 幂等] + K --> L[XADD Message Process Stream Shard] + J --> M[更新 Cursor] + + L --> N[Message Worker] + N --> O[LangBot Pipeline] + O --> P[Dify / Provider] + P --> Q[企业微信客服 send_msg] + + N --> R[失败写入 Retry ZSET] + R --> S[Retry Scheduler] + S --> D + S --> L +``` + +### API / 结构契约 + +#### Pull Trigger 消息结构 + +```json +{ + "job_type": "pull_trigger", + "bot_uuid": "c23ad8f0-53bc-43f5-a59a-b4e8004fc0a7", + "open_kfid": "wk4DEcYgAACRz0Ycgnkp4afFSpLjKzJw", + "callback_token": "ENC...", + "callback_id": "uuid", + "created_at": 1774370000 +} +``` + +#### Message Process 消息结构 + +```json +{ + "job_type": "message_process", + "bot_uuid": "c23ad8f0-53bc-43f5-a59a-b4e8004fc0a7", + "open_kfid": "wk4DEcYgAACRz0Ycgnkp4afFSpLjKzJw", + "external_userid": "woAJ2GCAAAXtWyujaWJHDD...", + "msgid": "msg-123", + "msgtype": "text", + "send_time": 1774370000, + "payload": { + "msgtype": "text", + "text": { + "content": "你好" + } + } +} +``` + +## Risks + +| 风险 | 影响 | 概率 | 缓解方案 | +| --- | --- | --- | --- | +| Redis 故障导致调度不可用 | 高 | 中 | Redis 挂卷持久化;关键路径日志告警;保留 webhook 快速失败可观测性 | +| 第一层锁实现不严谨导致 cursor 竞争 | 高 | 中 | 统一封装锁;增加互斥测试;锁 TTL + finally 释放 | +| 第二层分片数配置过小导致积压 | 中 | 中 | 将 `process_stream_shard_count` 配置化;通过 backlog 指标动态调参 | +| 幂等 TTL 过短导致重复消息重新处理 | 中 | 低 | 默认 7 天 TTL;提供配置项;记录重复消息计数 | +| 重试策略不合理导致风暴重试 | 高 | 中 | 限制最大重试次数;指数或固定退避;重试与死信指标报警 | +| Redis Streams pending 长时间堆积 | 中 | 中 | 增加 pending 巡检与 claim 逻辑;监控每层 stream backlog | +| Dify 响应慢拖慢第二层吞吐 | 中 | 高 | 第二层独立分片;适当增加 N2;增加 provider 超时与限流 | + +## Security Considerations + +- Redis 仅在内网容器网络中暴露,不对公网开放。 +- Redis 连接信息应通过配置或环境变量注入,禁止写死在代码中。 +- `access_token` 不记录完整值到日志中,只允许打码输出。 +- 重试 payload 中若包含用户消息内容,应避免记录敏感全文到 error 日志。 +- 锁与 stream key 命名需包含业务命名空间,避免与未来其他平台冲突。 +- 如后续将 Redis 暴露到共享基础设施,应开启密码认证和 ACL。 + +## Testing Strategy + +### 单元测试 + +- `WecomCSTokenCache`: + - 命中缓存直接返回 + - 基于 `expires_in` 设置 TTL + - `40014 / 42001` 时强制刷新 +- 分片路由器: + - 第一层同一 `open_kfid` 总是命中同一 shard + - 第二层同一 `external_userid` 总是命中同一 shard + - `N` 配置变化后分片范围合法 +- Redis 锁: + - 同一 `open_kfid` 并发只允许一个 worker 进入拉取逻辑 +- Retry 调度器: + - 15/30/45 秒重试正确入队 + - 超过最大次数后停止重试 + +### 集成测试 + +- webhook → 第一层 stream → 第二层 stream → pipeline 的最小闭环 +- 第一层 worker 拉取多页 `sync_msg` 并正确更新 cursor +- 第二层 worker 调用模拟 provider 失败后进入 ZSET 重试,再次处理成功 +- Redis 重启恢复后,cursor 与 token 缓存行为符合预期 + +### 回归测试 + +- 当前企业微信客服文本消息正常进入 LangBot pipeline +- 当前图片消息在两层架构下仍能正确转换与处理 +- 当前 `send_text_msg` token 刷新逻辑不回退 + +## Monitoring & Observability + +### 核心指标 + +| 指标 | 类型 | 建议告警阈值 | +| --- | --- | --- | +| `wecomcs.pull_trigger_backlog` | Gauge | > 100 持续 5 分钟 | +| `wecomcs.message_process_backlog` | Gauge | > 500 持续 5 分钟 | +| `wecomcs.pull_job_latency_ms` | Histogram | p95 > 3000ms | +| `wecomcs.message_job_latency_ms` | Histogram | p95 > 15000ms | +| `wecomcs.retry_queue_size` | Gauge | > 100 持续 10 分钟 | +| `wecomcs.token_refresh_total` | Counter | 异常突增时观察 | +| `wecomcs.token_refresh_error_total` | Counter | > 5 / 5min | +| `wecomcs.sync_msg_error_total` | Counter | > 5 / 5min | +| `wecomcs.reply_error_total` | Counter | > 5 / 5min | + +### 结构化日志字段 + +```json +{ + "module": "wecomcs-scheduler", + "layer": "pull|process|retry", + "bot_uuid": "...", + "open_kfid": "...", + "external_userid": "...", + "msgid": "...", + "stream": "...", + "shard": 3, + "action": "pull|publish|process|retry|ack", + "duration_ms": 120, + "result": "success|error" +} +``` + +## Rollback Plan + +### 回滚触发条件 + +- 接入 Redis Streams 后企业微信客服回复成功率明显下降。 +- 第二层积压持续增长且 15 分钟内无法恢复。 +- 第一层锁或 cursor 逻辑异常导致消息重复或跳过。 +- Redis 服务异常导致 webhook 后处理链路不可用。 + +### 回滚步骤 + +1. 关闭 `wecomcs_scheduler.enabled` feature switch。 +2. 让企业微信客服回退到当前主线的后台任务处理模式。 +3. 暂停 Streams worker 与 Retry Scheduler。 +4. 保留 Redis 中已有状态用于事后排查,不立即清理 key。 +5. 观察 webhook 基础链路是否恢复稳定。 + +### 数据回滚说明 + +- Redis 中的 token、cursor、retry key 都属于运行时状态,无需数据库 down migration。 +- 若回滚后重新启用新方案,应先确认 cursor 是否需要沿用或重建。 + +## Dependencies + +- Docker Compose 中新增 Redis 容器与持久化卷。 +- Python 依赖新增 `redis` 异步客户端。 +- LangBot 应用启动阶段增加 RedisManager 初始化。 +- 企业微信客服适配器链路增加调度组件注入。 + +## Open Questions + +- 是否需要在 V1 中把 cursor 从 Redis 再同步落库,用于极端场景审计? +- 第二层是否在 V1 就支持 pending claim 恢复,还是先依赖单 worker + ZSET 重试? +- 是否需要为不同 bot 维度暴露独立监控面板? + +## Roadmap / Timeline + +| 阶段 | 交付物 | 预计时长 | +| --- | --- | --- | +| Phase 1 | Redis 基础设施 + token 缓存 | 2-3 天 | +| Phase 2 | 第一层 pull-trigger stream + `open_kfid` 锁 + cursor | 3-4 天 | +| Phase 3 | 第二层 message-process stream + pipeline 对接 | 3-4 天 | +| Phase 4 | Retry ZSET + 观测 + 回归测试 | 2-3 天 | +| Phase 5 | 灰度验证与回滚预案演练 | 1-2 天 | + +## Approval & Sign-off + +| 角色 | 姓名 | 状态 | 日期 | 备注 | +| --- | --- | --- | --- | --- | +| Tech Lead | TBD | 待评审 | - | - | +| 平台维护者 | TBD | 待评审 | - | - | +| 产品/项目负责人 | TBD | 待评审 | - | - | + diff --git a/docs/wecomcs-redis-startup-guide.md b/docs/wecomcs-redis-startup-guide.md new file mode 100644 index 000000000..37fff56b6 --- /dev/null +++ b/docs/wecomcs-redis-startup-guide.md @@ -0,0 +1,152 @@ +# 企业微信客服 Redis 启动说明 + +## 1. 服务器 compose 关键配置 + +当前项目里的 `docker/docker-compose.yaml` 已经包含 Redis: + +- `redis:7-alpine` +- 开启 `appendonly yes` +- 挂载 `./data/redis:/data` + +这意味着: + +- Redis 数据会写到宿主机 `data/redis` +- 容器重建后数据仍然保留 +- access_token、消息状态、Streams、重试队列都不会因为容器重建直接丢失 + +## 2. data/config.yaml 需要开启的配置 + +运行时实际读取的是 `data/config.yaml`,不是模板文件。 + +至少加上: + +```yaml +redis: + enabled: true + url: 'redis://redis:6379/0' + key_prefix: 'langbot' + +wecomcs_scheduler: + enabled: true + token_refresh_skew_seconds: 300 + pull_stream_shard_count: 2 + process_stream_shard_count: 4 + pull_consumer_group: 'wecomcs-pull-group' + process_consumer_group: 'wecomcs-process-group' + stream_block_ms: 1000 + stream_batch_size: 10 + retry_poll_interval_seconds: 3 + retry_max_attempts: 3 + retry_backoff_seconds: [15, 30, 45] + message_state_ttl_seconds: 604800 + cursor_bootstrap_mode: 'latest' + lock_ttl_seconds: 60 +``` + +## 3. 推荐部署命令 + +### 更新 LangBot,但不要动 Redis + +如果服务名叫 `langbot`,推荐这样: + +```bash +docker compose pull langbot +docker compose up -d --no-deps langbot +``` + +如果是本地重新构建镜像: + +```bash +docker compose build langbot +docker compose up -d --no-deps langbot +``` + +### 不建议这样做 + +```bash +docker compose down +``` + +因为它会把整个 compose 里的容器都停掉,Redis 也会一起停。 + +## 4. `docker compose up -d` 到底会不会重启 Redis + +通常情况下: + +```bash +docker compose up -d +``` + +**如果 Redis 服务定义没有变化,一般不会被重建。** + +但为了避免误操作,线上更新 LangBot 时,还是建议使用: + +```bash +docker compose up -d --no-deps langbot +``` + +这样最稳,语义也最清楚: + +- 只更新 LangBot +- 不处理依赖服务 +- 不碰 Redis + +## 5. 本次实现后的状态存储方式 + +- `cursor`:存数据库,持久保留 +- 最近消息状态:存 Redis,默认保留 7 天 +- Redis Streams:用于异步处理链路 + +## 6. 联调时建议重点看这些日志 + +- `[wecomcs][cursor-store]` +- `[wecomcs][state]` +- `[wecomcs][message-state]` +- `[wecomcs][pull-worker]` +- `[wecomcs][message-worker]` +- `[wecomcs][runtime]` +- `[wecomcs][retry]` +- `[wecomcs][token-cache]` + +## 7. 这次新增的关键行为 + +### 冷启动默认跳过历史 backlog + +当某个 `open_kfid` 还没有 cursor 时: + +- 默认 `cursor_bootstrap_mode: latest` +- 先把 cursor 推进到最新位点 +- 跳过更早历史页 +- 但会保留 bootstrap 末页中的最新消息继续进入处理链路 + +这样可以避免新部署后把前几天的消息重新消费,同时不吞掉用户刚刚发来的第一条真实消息。 + +### 最近消息状态机 + +消息状态最小集: + +- `queued` +- `processing` +- `done` +- `failed` + +用于避免重复处理,并辅助排查问题。 + + +## 8. Bot 级高级配置 + +现在企业微信客服 bot 页面支持直接配置以下高级参数,未修改时默认沿用系统默认值: + +- 历史消息过滤时间窗口(秒) +- 昵称查询超时(秒) +- 重试次数 +- 重试间隔 +- pull 锁超时(秒) + +其中 `retry_backoff_seconds` 在 bot 页面中使用逗号分隔格式,例如:`15,30,45`。 + +配置优先级为: + +1. bot 页面填写的配置 +2. `wecomcs_scheduler` 全局默认配置 +3. 代码内置默认值 diff --git a/pyproject.toml b/pyproject.toml index d01e76231..b112ca8be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,7 @@ dependencies = [ "quart>=0.20.0", "quart-cors>=0.8.0", "requests>=2.32.3", + "redis>=6.0.0", "slack-sdk>=3.35.0", "sqlalchemy[asyncio]>=2.0.40", "sqlmodel>=0.0.24", diff --git a/src/langbot/libs/wecom_ai_bot_api/api.py b/src/langbot/libs/wecom_ai_bot_api/api.py index 0663b1f02..338d1fb93 100644 --- a/src/langbot/libs/wecom_ai_bot_api/api.py +++ b/src/langbot/libs/wecom_ai_bot_api/api.py @@ -1,6 +1,7 @@ import asyncio import base64 import json +import logging import time import traceback import uuid @@ -18,6 +19,8 @@ from langbot.libs.wecom_ai_bot_api.pull_stream_policy import PullStreamPolicy from langbot.pkg.platform.logger import EventLogger +_logger = logging.getLogger('langbot') + @dataclass class StreamChunk: @@ -621,14 +624,18 @@ async def _handle_post_initial_response(self, msg_json: dict[str, Any], nonce: s 首次回调时调用,立即返回带 `stream_id` 的响应。 """ session, is_new = self.stream_sessions.create_or_get(msg_json) + await self.logger.debug(f'[wecombot] 流式会话: stream_id={session.stream_id}, is_new={is_new}') message_data = await self.get_message(msg_json) if message_data: message_data['stream_id'] = session.stream_id try: event = wecombotevent.WecomBotEvent(message_data) + await self.logger.debug( + f'[wecombot] 事件对象: type={event.type}, userid={event.userid}, content={event.content[:50] if event.content else "N/A"}' + ) except Exception: - await self.logger.error(traceback.format_exc()) + await self.logger.error(f'[wecombot] 事件构建失败: {traceback.format_exc()}') await self._force_finish_stream( session.stream_id, self.stream_error_final_text, @@ -636,7 +643,10 @@ async def _handle_post_initial_response(self, msg_json: dict[str, Any], nonce: s ) else: if is_new: + await self.logger.debug(f'[wecombot] 启动异步任务处理消息: stream_id={session.stream_id}') asyncio.create_task(self._dispatch_event(event)) + else: + await self.logger.warning(f'[wecombot] get_message返回空数据: msg_json={msg_json}') payload = self._build_stream_payload(session.stream_id, '', False) return await self._encrypt_and_reply(payload, nonce) @@ -724,6 +734,8 @@ async def _handle_callback_internal(self, req): req: Quart Request 对象 """ try: + _logger.debug(f'[wecombot] 收到回调请求: method={req.method}') + self.wxcpt = WXBizMsgCrypt(self.Token, self.EnCodingAESKey, '') if req.method == 'GET': @@ -735,7 +747,7 @@ async def _handle_callback_internal(self, req): return Response('', status=405) except Exception: - await self.logger.error(traceback.format_exc()) + _logger.error(f'[wecombot] 回调处理异常: {traceback.format_exc()}') return Response('Internal Server Error', status=500) async def _handle_get_callback(self, req) -> tuple[Response, int] | Response: @@ -746,13 +758,16 @@ async def _handle_get_callback(self, req) -> tuple[Response, int] | Response: nonce = unquote(req.args.get('nonce', '')) echostr = unquote(req.args.get('echostr', '')) + _logger.debug(f'[wecombot] GET验证请求: msg_signature={msg_signature[:20]}..., nonce={nonce}') + if not all([msg_signature, timestamp, nonce, echostr]): - await self.logger.error('请求参数缺失') + _logger.error('[wecombot] GET验证请求参数缺失') return Response('缺少参数', status=400) ret, decrypted_str = self.wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr) + _logger.debug(f'[wecombot] GET验证结果: ret={ret}') if ret != 0: - await self.logger.error('验证URL失败') + _logger.error(f'[wecombot] 验证URL失败: ret={ret}') return Response('验证失败', status=403) return Response(decrypted_str, mimetype='text/plain') @@ -772,23 +787,33 @@ async def _handle_post_callback(self, req) -> tuple[Response, int] | Response: timestamp = unquote(req.args.get('timestamp', '')) nonce = unquote(req.args.get('nonce', '')) + _logger.debug(f'[wecombot] POST回调请求: msg_signature={msg_signature[:20] if msg_signature else "N/A"}..., nonce={nonce}') + encrypted_json = await req.get_json() encrypted_msg = (encrypted_json or {}).get('encrypt', '') if not encrypted_msg: - await self.logger.error("请求体中缺少 'encrypt' 字段") + _logger.error("[wecombot] 请求体中缺少 'encrypt' 字段") return Response('Bad Request', status=400) + _logger.debug(f'[wecombot] 加密消息长度: {len(encrypted_msg)}') + xml_post_data = f'' ret, decrypted_xml = self.wxcpt.DecryptMsg(xml_post_data, msg_signature, timestamp, nonce) + _logger.debug(f'[wecombot] 解密结果: ret={ret}') if ret != 0: - await self.logger.error('解密失败') + _logger.error(f'[wecombot] 解密失败: ret={ret}') return Response('解密失败', status=400) msg_json = json.loads(decrypted_xml) + _logger.debug( + f'[wecombot] 解密后消息: msgtype={msg_json.get("msgtype")}, chattype={msg_json.get("chattype")}, msgid={msg_json.get("msgid")}' + ) if msg_json.get('msgtype') == 'stream': + _logger.debug(f'[wecombot] 收到流式刷新请求: stream_id={msg_json.get("stream", {}).get("id")}') return await self._handle_post_followup_response(msg_json, nonce) + _logger.debug('[wecombot] 收到首包消息,准备创建流式会话') return await self._handle_post_initial_response(msg_json, nonce) async def get_message(self, msg_json): @@ -802,14 +827,20 @@ async def _handle_message(self, event: wecombotevent.WecomBotEvent): message_id = event.message_id if message_id in self.msg_id_map.keys(): self.msg_id_map[message_id] += 1 + await self.logger.debug(f'[wecombot] 消息重复,跳过: message_id={message_id}') return self.msg_id_map[message_id] = 1 msg_type = event.type + await self.logger.debug(f'[wecombot] 处理消息: message_id={message_id}, type={msg_type}') if msg_type in self._message_handlers: + handler_count = len(self._message_handlers[msg_type]) + await self.logger.debug(f'[wecombot] 找到{handler_count}个处理器: type={msg_type}') for handler in self._message_handlers[msg_type]: await handler(event) + else: + await self.logger.warning(f'[wecombot] 未找到处理器: type={msg_type}') except Exception: - print(traceback.format_exc()) + await self.logger.error(f'[wecombot] 消息处理异常: {traceback.format_exc()}') async def push_stream_chunk(self, msg_id: str, content: str, is_final: bool = False) -> bool: """将流水线片段推送到 stream 会话。 diff --git a/src/langbot/libs/wecom_customer_service_api/api.py b/src/langbot/libs/wecom_customer_service_api/api.py index 70270b727..92521fc07 100644 --- a/src/langbot/libs/wecom_customer_service_api/api.py +++ b/src/langbot/libs/wecom_customer_service_api/api.py @@ -1,3 +1,6 @@ +import asyncio +import hashlib +import logging from quart import request from ..wecom_api.WXBizMsgCrypt3 import WXBizMsgCrypt import base64 @@ -11,6 +14,14 @@ import langbot_plugin.api.entities.builtin.platform.message as platform_message import aiofiles import time +from ...pkg.platform.wecomcs.token_cache import WecomCSTokenCache +from ...pkg.platform.wecomcs.pull_trigger_publisher import WecomCSPullTriggerPublisher + +_logger = logging.getLogger('langbot') + + +class WecomCSInvalidSyncMsgTokenError(Exception): + """Raised when WeCom customer service sync_msg token is invalid or expired.""" class WecomCSClient: @@ -35,6 +46,36 @@ def __init__( self.unified_mode = unified_mode self.app = Quart(__name__) + redis_mgr = getattr(getattr(logger, 'ap', None), 'redis_mgr', None) if logger else None + scheduler_config = getattr(getattr(logger, 'ap', None), 'instance_config', None) + refresh_skew_seconds = 300 + if scheduler_config is not None: + refresh_skew_seconds = ( + scheduler_config.data.get('wecomcs_scheduler', {}).get('token_refresh_skew_seconds', 300) + ) + secret_fingerprint = hashlib.sha256(secret.encode('utf-8')).hexdigest()[:16] if secret else '' + self.token_cache = WecomCSTokenCache( + corpid=corpid, + redis_mgr=redis_mgr, + refresh_skew_seconds=refresh_skew_seconds, + secret_fingerprint=secret_fingerprint, + ) + self.bot_uuid = '' + self.state_store = None + self.scheduler_enabled = False + self.pull_trigger_publisher = None + if scheduler_config is not None: + scheduler_settings = scheduler_config.data.get('wecomcs_scheduler', {}) + self.scheduler_enabled = bool(scheduler_settings.get('enabled', False)) + pull_stream_shard_count = int(scheduler_settings.get('pull_stream_shard_count', 8) or 8) + if self.scheduler_enabled and redis_mgr is not None and redis_mgr.is_available(): + self.pull_trigger_publisher = WecomCSPullTriggerPublisher(redis_mgr, pull_stream_shard_count) + + # 用于防止并发获取 access_token + self._token_lock = asyncio.Lock() + # 统一 webhook 下会快速返回 success,因此需要持有后台任务引用避免被提前回收 + self._background_tasks: set[asyncio.Task] = set() + # Customer info cache: {external_userid: (info_dict, timestamp)} self._customer_cache: dict[str, tuple[dict, float]] = {} self._cache_ttl = 60 # Cache TTL in seconds (1 minute) @@ -50,17 +91,17 @@ def __init__( } async def get_pic_url(self, media_id: str): - if not await self.check_access_token(): - self.access_token = await self.get_access_token(self.secret) + await self.ensure_access_token() url = f'{self.base_url}/media/get?access_token={self.access_token}&media_id={media_id}' - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(timeout=10.0) as client: response = await client.get(url) if response.headers.get('Content-Type', '').startswith('application/json'): data = response.json() if data.get('errcode') in [40014, 42001]: - self.access_token = await self.get_access_token(self.secret) + await self.token_cache.invalidate() + self.access_token = '' return await self.get_pic_url(media_id) else: raise Exception('Failed to get image: ' + str(data)) @@ -74,67 +115,139 @@ async def get_pic_url(self, media_id: str): # access——token操作 async def check_access_token(self): - return bool(self.access_token and self.access_token.strip()) + cached_token = await self.token_cache.get_cached_token() + if cached_token: + self.access_token = cached_token + return True + return False async def check_access_token_for_contacts(self): return bool(self.access_token_for_contacts and self.access_token_for_contacts.strip()) - async def get_access_token(self, secret): + async def ensure_access_token(self): + """确保 access_token 有效,使用锁防止并发获取。""" + if await self.check_access_token(): + return self.access_token + + async with self._token_lock: + # 中文注释:双重检查可以避免并发场景下重复请求企业微信 token 接口。 + if await self.check_access_token(): + return self.access_token + + _logger.debug('[wecomcs] access_token为空,正在获取...') + self.access_token = await self.token_cache.get_or_refresh(self._fetch_main_access_token_data) + return self.access_token + + async def refresh_access_token(self): + """强制刷新主 access_token。""" + async with self._token_lock: + # 中文注释:收到 40014 / 42001 时必须绕过本地缓存,强制重新拉取 token。 + await self.token_cache.invalidate() + self.access_token = await self.token_cache.get_or_refresh(self._fetch_main_access_token_data, force_refresh=True) + return self.access_token + + async def _request_access_token(self, secret: str): + """实际请求指定 secret 对应的 access_token 数据。""" url = f'{self.base_url}/gettoken?corpid={self.corpid}&corpsecret={secret}' - async with httpx.AsyncClient() as client: - response = await client.get(url) - data = response.json() - if 'access_token' in data: - return data['access_token'] - else: + _logger.debug(f'[wecomcs] 获取access_token: corpid={self.corpid[:10] if self.corpid else "N/A"}...') + print(f'[wecomcs] 获取access_token: corpid={self.corpid[:10] if self.corpid else "N/A"}...', flush=True) + try: + async with httpx.AsyncClient(timeout=10.0) as client: + _logger.debug(f'[wecomcs] 正在请求: {url}') + print('[wecomcs] 正在请求 gettoken API...', flush=True) + response = await client.get(url) + print('[wecomcs] gettoken请求完成,正在解析响应...', flush=True) + _logger.debug(f'[wecomcs] gettoken响应状态: {response.status_code}') + print(f'[wecomcs] gettoken响应状态: {response.status_code}', flush=True) + data = response.json() + _logger.debug(f'[wecomcs] gettoken响应: errcode={data.get("errcode")}, errmsg={data.get("errmsg")}') + print(f'[wecomcs] gettoken响应: errcode={data.get("errcode")}, errmsg={data.get("errmsg")}', flush=True) + if 'access_token' in data: + return { + 'access_token': data['access_token'], + 'expires_in': int(data.get('expires_in', 7200) or 7200), + } + + _logger.error(f'[wecomcs] 未获取access token: {data}') raise Exception(f'未获取access token: {data}') + except Exception as e: + _logger.error(f'[wecomcs] get_access_token异常: {traceback.format_exc()}') + print(f'[wecomcs] get_access_token异常: {e}', flush=True) + raise - async def get_detailed_message_list(self, xml_msg: str): - # 在本方法中解析消息,并且获得消息的具体内容 - if isinstance(xml_msg, bytes): - xml_msg = xml_msg.decode('utf-8') - root = ET.fromstring(xml_msg) - token = root.find('Token').text - open_kfid = root.find('OpenKfId').text - - # if open_kfid in self.openkfid_list: - # return None - # else: - # self.openkfid_list.append(open_kfid) + async def _fetch_main_access_token_data(self): + return await self._request_access_token(self.secret) - if not await self.check_access_token(): - self.access_token = await self.get_access_token(self.secret) + async def get_access_token(self, secret): + """兼容旧接口。""" + if secret != self.secret: + token_data = await self._request_access_token(secret) + return token_data['access_token'] + return await self.ensure_access_token() + + async def fetch_sync_msg_page(self, callback_token: str, open_kfid: str, cursor: str | None = None): + await self.ensure_access_token() url = self.base_url + '/kf/sync_msg?access_token=' + self.access_token - async with httpx.AsyncClient() as client: - params = { - 'token': token, - 'voice_format': 0, - 'open_kfid': open_kfid, - } + params = { + 'token': callback_token, + 'voice_format': 0, + 'open_kfid': open_kfid, + } + if cursor: + params['cursor'] = cursor + + async with httpx.AsyncClient(timeout=10.0) as client: response = await client.post(url, json=params) data = response.json() - if data['errcode'] == 40014 or data['errcode'] == 42001: - self.access_token = await self.get_access_token(self.secret) - return await self.get_detailed_message_list(xml_msg) - if data['errcode'] != 0: + if data.get('errcode') in [40014, 42001]: + await self.refresh_access_token() + return await self.fetch_sync_msg_page(callback_token, open_kfid, cursor) + if data.get('errcode') == 95007: + _logger.error(f'[wecomcs] sync_msg失败: {data}') + raise WecomCSInvalidSyncMsgTokenError(data.get('errmsg', 'invalid msg token')) + if data.get('errcode') != 0: + _logger.error(f'[wecomcs] sync_msg失败: {data}') raise Exception('Failed to get message') - last_msg_data = data['msg_list'][-1] - open_kfid = last_msg_data.get('open_kfid') - # 进行获取图片操作 - if last_msg_data.get('msgtype') == 'image': - media_id = last_msg_data.get('image').get('media_id') - picurl = await self.get_pic_url(media_id) - last_msg_data['picurl'] = picurl - # await self.change_service_status(userid=external_userid,openkfid=open_kfid,servicer=servicer) - return last_msg_data + msg_list = data.get('msg_list') or [] + for msg_data in msg_list: + if msg_data.get('msgtype') == 'image' and msg_data.get('image'): + media_id = msg_data['image'].get('media_id') + if media_id: + msg_data['picurl'] = await self.get_pic_url(media_id) + + return { + 'msg_list': msg_list, + 'next_cursor': data.get('next_cursor', ''), + 'has_more': bool(data.get('has_more', False)), + } + + async def get_detailed_message_list(self, xml_msg: str): + # 中文注释:企业微信一次回调可能携带多条消息,不能只取最后一条,否则会丢消息。 + try: + if isinstance(xml_msg, bytes): + xml_msg = xml_msg.decode('utf-8') + root = ET.fromstring(xml_msg) + callback_token = root.find('Token').text + open_kfid = root.find('OpenKfId').text + _logger.debug(f'[wecomcs] sync_msg参数: token={callback_token[:20] if callback_token else "N/A"}..., open_kfid={open_kfid}') + + page = await self.fetch_sync_msg_page(callback_token, open_kfid) + msg_list = page.get('msg_list') or [] + if not msg_list: + _logger.warning('[wecomcs] sync_msg返回空消息列表') + return [] + return msg_list + except Exception: + _logger.error(f'[wecomcs] get_detailed_message_list异常: {traceback.format_exc()}') + raise async def change_service_status(self, userid: str, openkfid: str, servicer: str): if not await self.check_access_token(): - self.access_token = await self.get_access_token(self.secret) + await self.ensure_access_token() url = self.base_url + '/kf/service_state/get?access_token=' + self.access_token - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(timeout=10.0) as client: params = { 'open_kfid': openkfid, 'external_userid': userid, @@ -144,16 +257,16 @@ async def change_service_status(self, userid: str, openkfid: str, servicer: str) response = await client.post(url, json=params) data = response.json() if data['errcode'] == 40014 or data['errcode'] == 42001: - self.access_token = await self.get_access_token(self.secret) + await self.refresh_access_token() return await self.change_service_status(userid, openkfid) if data['errcode'] != 0: raise Exception('Failed to change service status: ' + str(data)) async def send_image(self, user_id: str, agent_id: int, media_id: str): if not await self.check_access_token(): - self.access_token = await self.get_access_token(self.secret) + await self.ensure_access_token() url = self.base_url + '/media/upload?access_token=' + self.access_token - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(timeout=10.0) as client: params = { 'touser': user_id, 'toparty': '', @@ -176,7 +289,7 @@ async def send_image(self, user_id: str, agent_id: int, media_id: str): # 企业微信错误码40014和42001,代表accesstoken问题 if data['errcode'] == 40014 or data['errcode'] == 42001: - self.access_token = await self.get_access_token(self.secret) + await self.refresh_access_token() return await self.send_image(user_id, agent_id, media_id) if data['errcode'] != 0: @@ -184,7 +297,7 @@ async def send_image(self, user_id: str, agent_id: int, media_id: str): async def send_text_msg(self, open_kfid: str, external_userid: str, msgid: str, content: str): if not await self.check_access_token(): - self.access_token = await self.get_access_token(self.secret) + await self.ensure_access_token() url = f'{self.base_url}/kf/send_msg?access_token={self.access_token}' @@ -198,16 +311,18 @@ async def send_text_msg(self, open_kfid: str, external_userid: str, msgid: str, }, } - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(timeout=10.0) as client: response = await client.post(url, json=payload) data = response.json() if data['errcode'] == 40014 or data['errcode'] == 42001: - self.access_token = await self.get_access_token(self.secret) + await self.refresh_access_token() return await self.send_text_msg(open_kfid, external_userid, msgid, content) if data['errcode'] != 0: - await self.logger.error(f'发送消息失败:{data}') - raise Exception('Failed to send message') + await self.logger.error( + f"[wecomcs] 发送消息失败: errcode={data.get('errcode')}, errmsg={data.get('errmsg')}, open_kfid={open_kfid}, external_userid={external_userid}, msgid={msgid}" + ) + raise Exception(f'Failed to send message: {data}') return data async def handle_callback_request(self): @@ -233,42 +348,90 @@ async def _handle_callback_internal(self, req): req: Quart Request 对象 """ try: + _logger.debug(f'[wecomcs] 收到回调请求: method={req.method}') + msg_signature = req.args.get('msg_signature') timestamp = req.args.get('timestamp') nonce = req.args.get('nonce') try: wxcpt = WXBizMsgCrypt(self.token, self.aes, self.corpid) except Exception as e: + _logger.error(f'[wecomcs] 加密组件初始化失败: {e}') raise Exception(f'初始化失败,错误码: {e}') if req.method == 'GET': echostr = req.args.get('echostr') ret, reply_echo_str = wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr) + _logger.debug(f'[wecomcs] GET验证结果: ret={ret}') if ret != 0: raise Exception(f'验证失败,错误码: {ret}') return reply_echo_str elif req.method == 'POST': encrypt_msg = await req.data + _logger.debug(f'[wecomcs] POST数据长度: {len(encrypt_msg)}') + ret, xml_msg = wxcpt.DecryptMsg(encrypt_msg, msg_signature, timestamp, nonce) + _logger.debug(f'[wecomcs] 解密结果: ret={ret}') if ret != 0: raise Exception(f'消息解密失败,错误码: {ret}') - # 解析消息并处理 - message_data = await self.get_detailed_message_list(xml_msg) - if message_data is not None: - event = WecomCSEvent.from_payload(message_data) - if event: - await self._handle_message(event) - + _logger.debug(f'[wecomcs] 解密后XML: {xml_msg[:500] if xml_msg else "空"}') + + if self.scheduler_enabled and self.pull_trigger_publisher and self.bot_uuid: + try: + stream_name, payload = await self.pull_trigger_publisher.publish_from_xml( + self.bot_uuid, + xml_msg, + webhook_received_at=int(time.time()), + ) + _logger.debug( + f'[wecomcs] 已发布pull-trigger: stream={stream_name}, open_kfid={payload.get("open_kfid")}' + ) + return 'success' + except Exception: + _logger.error(f'[wecomcs] 发布pull-trigger失败,降级为本地后台处理: {traceback.format_exc()}') + + # 中文注释:企业微信客服回调需要尽快返回 success,避免企业微信因为超时反复重试。 + self._create_background_task(self._process_callback_xml(xml_msg), description='process_wecomcs_callback') return 'success' except Exception as e: - if self.logger: - await self.logger.error(f'Error in handle_callback_request: {traceback.format_exc()}') - else: - traceback.print_exc() + _logger.error(f'[wecomcs] 回调处理异常: {traceback.format_exc()}') return f'Error processing request: {str(e)}', 400 + def _create_background_task(self, coro, description: str): + """创建后台任务并做好异常回收。""" + task = asyncio.create_task(coro, name=description) + self._background_tasks.add(task) + + def _on_done(done_task: asyncio.Task): + self._background_tasks.discard(done_task) + try: + done_task.result() + except Exception: + _logger.error(f'[wecomcs] 后台任务执行异常: {traceback.format_exc()}') + + task.add_done_callback(_on_done) + return task + + async def _process_callback_xml(self, xml_msg: str): + message_list = await self.get_detailed_message_list(xml_msg) + _logger.debug(f'[wecomcs] 消息详情: {message_list}') + + if not message_list: + _logger.debug('[wecomcs] get_detailed_message_list返回空列表') + return + + for message_data in message_list: + event = WecomCSEvent.from_payload(message_data) + _logger.debug( + f'[wecomcs] 事件对象: type={event.type if event else "N/A"}, user_id={event.user_id if event else "N/A"}' + ) + if event: + await self._handle_message(event) + else: + _logger.warning(f'[wecomcs] 事件解析返回None, payload={message_data}') + async def run_task(self, host: str, port: int, *args, **kwargs): """ 启动 Quart 应用。 @@ -293,9 +456,14 @@ async def _handle_message(self, event: WecomCSEvent): 处理消息事件。 """ msg_type = event.type + _logger.debug(f'[wecomcs] _handle_message: msg_type={msg_type}, handlers_keys={list(self._message_handlers.keys())}') if msg_type in self._message_handlers: + _logger.debug(f'[wecomcs] 找到处理器, handler_count={len(self._message_handlers[msg_type])}') for handler in self._message_handlers[msg_type]: + _logger.debug(f'[wecomcs] 调用处理器: {handler.__name__ if hasattr(handler, "__name__") else handler}') await handler(event) + else: + _logger.warning(f'[wecomcs] 没有找到消息类型 {msg_type} 的处理器') @staticmethod async def get_image_type(image_bytes: bytes) -> str: @@ -320,7 +488,7 @@ async def upload_to_work(self, image: platform_message.Image): 获取 media_id """ if not await self.check_access_token(): - self.access_token = await self.get_access_token(self.secret) + await self.ensure_access_token() url = self.base_url + '/media/upload?access_token=' + self.access_token + '&type=file' file_bytes = None @@ -361,12 +529,12 @@ async def upload_to_work(self, image: platform_message.Image): ) # 上传文件 - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(timeout=10.0) as client: response = await client.post(url, headers=headers, content=body) data = response.json() if data['errcode'] == 40014 or data['errcode'] == 42001: - self.access_token = await self.get_access_token(self.secret) - media_id = await self.upload_to_work(image) + await self.refresh_access_token() + return await self.upload_to_work(image) if data.get('errcode', 0) != 0: raise Exception('failed to upload file') @@ -374,7 +542,7 @@ async def upload_to_work(self, image: platform_message.Image): return media_id async def download_image_to_bytes(self, url: str) -> bytes: - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(timeout=10.0) as client: response = await client.get(url) response.raise_for_status() return response.content @@ -396,16 +564,23 @@ async def get_customer_info(self, external_userid: str) -> dict | None: Returns: Customer info dict with 'nickname', 'avatar', etc., or None if not found. """ - # Check cache first + _logger.debug(f'[wecomcs] get_customer_info开始: user_id={external_userid}') + + # 中文注释:这里把缓存、token、接口请求三段耗时拆开打印,便于定位究竟卡在哪一步。 + started_at = time.perf_counter() current_time = time.time() if external_userid in self._customer_cache: cached_info, cached_time = self._customer_cache[external_userid] if current_time - cached_time < self._cache_ttl: + total_elapsed_ms = (time.perf_counter() - started_at) * 1000 + _logger.debug( + f'[wecomcs] get_customer_info缓存命中: user_id={external_userid}, total_elapsed_ms={total_elapsed_ms:.2f}' + ) return cached_info - # Cache miss or expired, fetch from API - if not await self.check_access_token(): - self.access_token = await self.get_access_token(self.secret) + token_started_at = time.perf_counter() + await self.ensure_access_token() + token_elapsed_ms = (time.perf_counter() - token_started_at) * 1000 url = f'{self.base_url}/kf/customer/batchget?access_token={self.access_token}' @@ -413,23 +588,50 @@ async def get_customer_info(self, external_userid: str) -> dict | None: 'external_userid_list': [external_userid], } - async with httpx.AsyncClient() as client: - response = await client.post(url, json=payload) - data = response.json() + _logger.debug(f'[wecomcs] get_customer_info: url={url[:60]}...') + try: + request_started_at = time.perf_counter() + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.post(url, json=payload) + data = response.json() + request_elapsed_ms = (time.perf_counter() - request_started_at) * 1000 + total_elapsed_ms = (time.perf_counter() - started_at) * 1000 + _logger.debug( + f'[wecomcs] get_customer_info响应: errcode={data.get("errcode")}, token_elapsed_ms={token_elapsed_ms:.2f}, request_elapsed_ms={request_elapsed_ms:.2f}, total_elapsed_ms={total_elapsed_ms:.2f}' + ) if data.get('errcode') in [40014, 42001]: - self.access_token = await self.get_access_token(self.secret) + await self.token_cache.invalidate() + self.access_token = '' return await self.get_customer_info(external_userid) if data.get('errcode', 0) != 0: - if self.logger: - await self.logger.warning(f'Failed to get customer info: {data}') + _logger.warning( + f'[wecomcs] get_customer_info业务错误: external_userid={external_userid}, errcode={data.get("errcode")}, errmsg={data.get("errmsg")}, payload={data}' + ) + return None + + invalid_external_userid = data.get('invalid_external_userid') or [] + if external_userid in invalid_external_userid: + _logger.warning( + f'[wecomcs] get_customer_info命中invalid_external_userid: external_userid={external_userid}, invalid_external_userid={invalid_external_userid}' + ) return None customer_list = data.get('customer_list', []) if customer_list: customer_info = customer_list[0] - # Store in cache + # 中文注释:成功结果写入短期缓存,减少同一个客户短时间内重复查资料。 self._customer_cache[external_userid] = (customer_info, current_time) return customer_info + + _logger.warning( + f'[wecomcs] get_customer_info返回空customer_list: external_userid={external_userid}, payload={data}' + ) + return None + except Exception as e: + total_elapsed_ms = (time.perf_counter() - started_at) * 1000 + _logger.error( + f'[wecomcs] get_customer_info异常: external_userid={external_userid}, error_type={type(e).__name__}, error={e}, total_elapsed_ms={total_elapsed_ms:.2f}' + ) return None diff --git a/src/langbot/libs/wecom_customer_service_api/wecomcsevent.py b/src/langbot/libs/wecom_customer_service_api/wecomcsevent.py index ee830a731..7411def3b 100644 --- a/src/langbot/libs/wecom_customer_service_api/wecomcsevent.py +++ b/src/langbot/libs/wecom_customer_service_api/wecomcsevent.py @@ -86,7 +86,10 @@ def message(self) -> Optional[str]: Optional[str]: 消息内容。 """ if self.get('msgtype') == 'text': - return self.get('text').get('content', '') + text_obj = self.get('text') + if text_obj and isinstance(text_obj, dict): + return text_obj.get('content', '') + return '' else: return None diff --git a/src/langbot/pkg/api/http/controller/groups/webhooks.py b/src/langbot/pkg/api/http/controller/groups/webhooks.py index ec46c7447..3beb8b9d0 100644 --- a/src/langbot/pkg/api/http/controller/groups/webhooks.py +++ b/src/langbot/pkg/api/http/controller/groups/webhooks.py @@ -30,23 +30,31 @@ async def _dispatch_webhook(self, bot_uuid: str, path: str): 适配器返回的响应 """ try: + self.ap.logger.debug(f'[webhook] 收到请求: bot_uuid={bot_uuid}, path={path}, method={quart.request.method}') + runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid) if not runtime_bot: + self.ap.logger.warning(f'[webhook] Bot未找到: bot_uuid={bot_uuid}') return quart.jsonify({'error': 'Bot not found'}), 404 if not runtime_bot.enable: + self.ap.logger.warning(f'[webhook] Bot已禁用: bot_uuid={bot_uuid}') return quart.jsonify({'error': 'Bot is disabled'}), 403 if not hasattr(runtime_bot.adapter, 'handle_unified_webhook'): + self.ap.logger.warning(f'[webhook] Adapter不支持unified_webhook: bot_uuid={bot_uuid}') return quart.jsonify({'error': 'Adapter does not support unified webhook'}), 501 + self.ap.logger.debug(f'[webhook] 分发到adapter: bot_uuid={bot_uuid}, adapter={type(runtime_bot.adapter).__name__}') + response = await runtime_bot.adapter.handle_unified_webhook( bot_uuid=bot_uuid, path=path, request=quart.request, ) + self.ap.logger.debug(f'[webhook] adapter响应完成: bot_uuid={bot_uuid}') return response except Exception as e: diff --git a/src/langbot/pkg/api/http/service/bot.py b/src/langbot/pkg/api/http/service/bot.py index 332c8ec7b..145ac8275 100644 --- a/src/langbot/pkg/api/http/service/bot.py +++ b/src/langbot/pkg/api/http/service/bot.py @@ -66,6 +66,7 @@ async def get_runtime_bot_info(self, bot_uuid: str, include_secret: bool = True) 'qqofficial', 'slack', 'wecomcs', + 'wecom_kf_app', 'LINE', 'lark', ]: diff --git a/src/langbot/pkg/cache/__init__.py b/src/langbot/pkg/cache/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/langbot/pkg/cache/redis_mgr.py b/src/langbot/pkg/cache/redis_mgr.py new file mode 100644 index 000000000..74d220543 --- /dev/null +++ b/src/langbot/pkg/cache/redis_mgr.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +import json +import logging +from typing import Any + +from redis import asyncio as redis_asyncio + +if False: # pragma: no cover + from ..core import app + + +_logger = logging.getLogger("langbot") + + +class RedisManager: + """Application-level Redis manager.""" + + def __init__(self, ap: "app.Application"): + self.ap = ap + self.client: redis_asyncio.Redis | None = None + self.enabled = False + self.key_prefix = "langbot" + + async def initialize(self): + redis_config = self.ap.instance_config.data.get("redis", {}) + self.enabled = bool(redis_config.get("enabled", False)) + self.key_prefix = redis_config.get("key_prefix", "langbot") + + if not self.enabled: + self.ap.logger.info("Redis disabled by config.") + return + + redis_url = redis_config.get("url", "redis://127.0.0.1:6379/0") + self.client = redis_asyncio.from_url(redis_url, decode_responses=True) + await self.client.ping() + self.ap.logger.info(f"Initialized Redis manager: url={redis_url}") + + def build_key(self, key: str) -> str: + return f"{self.key_prefix}:{key}" + + def is_available(self) -> bool: + return self.enabled and self.client is not None + + async def get(self, key: str) -> str | None: + if not self.is_available(): + return None + return await self.client.get(self.build_key(key)) + + async def set(self, key: str, value: str, ex: int | None = None): + if not self.is_available(): + return + await self.client.set(self.build_key(key), value, ex=ex) + + async def delete(self, key: str): + if not self.is_available(): + return + await self.client.delete(self.build_key(key)) + + async def set_if_not_exists(self, key: str, value: str, ex: int | None = None) -> bool: + if not self.is_available(): + return False + return bool(await self.client.set(self.build_key(key), value, ex=ex, nx=True)) + + async def xadd(self, stream: str, fields: dict[str, str], maxlen: int | None = None) -> str | None: + if not self.is_available(): + return None + if maxlen is None: + return await self.client.xadd(self.build_key(stream), fields) + return await self.client.xadd(self.build_key(stream), fields, maxlen=maxlen, approximate=True) + + async def xrange(self, stream: str, min_id: str = '-', max_id: str = '+', count: int | None = None): + if not self.is_available(): + return [] + return await self.client.xrange(self.build_key(stream), min=min_id, max=max_id, count=count) + + async def xgroup_create(self, stream: str, group: str, id: str = '0', mkstream: bool = True): + if not self.is_available(): + return + try: + await self.client.xgroup_create(self.build_key(stream), group, id=id, mkstream=mkstream) + except Exception as exc: + if 'BUSYGROUP' not in str(exc): + raise + + async def xreadgroup( + self, + group: str, + consumer: str, + streams: dict[str, str], + count: int | None = None, + block_ms: int | None = None, + ): + if not self.is_available(): + return [] + mapped_streams = {self.build_key(name): stream_id for name, stream_id in streams.items()} + return await self.client.xreadgroup( + groupname=group, + consumername=consumer, + streams=mapped_streams, + count=count, + block=block_ms, + ) + + async def xack(self, stream: str, group: str, message_id: str): + if not self.is_available(): + return 0 + return await self.client.xack(self.build_key(stream), group, message_id) + + async def zadd(self, key: str, mapping: dict[str, float]): + if not self.is_available(): + return 0 + return await self.client.zadd(self.build_key(key), mapping) + + async def zrangebyscore(self, key: str, min_score: float, max_score: float): + if not self.is_available(): + return [] + return await self.client.zrangebyscore(self.build_key(key), min_score, max_score) + + async def zrem(self, key: str, member: str): + if not self.is_available(): + return 0 + return await self.client.zrem(self.build_key(key), member) + + async def get_json(self, key: str) -> dict[str, Any] | None: + raw_value = await self.get(key) + if not raw_value: + return None + + try: + return json.loads(raw_value) + except json.JSONDecodeError: + _logger.warning(f"[redis] invalid json payload for key={key}") + return None + + async def set_json(self, key: str, value: dict[str, Any], ex: int | None = None): + await self.set(key, json.dumps(value, ensure_ascii=False), ex=ex) + + async def close(self): + if self.client is None: + return + await self.client.aclose() diff --git a/src/langbot/pkg/core/app.py b/src/langbot/pkg/core/app.py index 12849f2a9..6f3b2c3f1 100644 --- a/src/langbot/pkg/core/app.py +++ b/src/langbot/pkg/core/app.py @@ -34,6 +34,7 @@ from ..discover import engine as discover_engine from ..storage import mgr as storagemgr +from ..cache.redis_mgr import RedisManager from ..utils import logcache from . import taskmgr from . import entities as core_entities @@ -123,6 +124,8 @@ class Application: storage_mgr: storagemgr.StorageMgr = None + redis_mgr: RedisManager = None + # ========= HTTP Services ========= user_service: user_service.UserService = None diff --git a/src/langbot/pkg/core/stages/build_app.py b/src/langbot/pkg/core/stages/build_app.py index 62f0ae7b5..455be69bf 100644 --- a/src/langbot/pkg/core/stages/build_app.py +++ b/src/langbot/pkg/core/stages/build_app.py @@ -30,6 +30,7 @@ from ...api.http.service import monitoring as monitoring_service from ...discover import engine as discover_engine from ...storage import mgr as storagemgr +from ...cache.redis_mgr import RedisManager from ...utils import logcache from ...vector import mgr as vectordb_mgr from .. import taskmgr @@ -99,6 +100,10 @@ async def run(self, ap: app.Application): await storage_mgr_inst.initialize() ap.storage_mgr = storage_mgr_inst + redis_mgr_inst = RedisManager(ap) + await redis_mgr_inst.initialize() + ap.redis_mgr = redis_mgr_inst + persistence_mgr_inst = persistencemgr.PersistenceManager(ap) ap.persistence_mgr = persistence_mgr_inst await persistence_mgr_inst.initialize() diff --git a/src/langbot/pkg/entity/persistence/wecomcs.py b/src/langbot/pkg/entity/persistence/wecomcs.py new file mode 100644 index 000000000..4608bb78c --- /dev/null +++ b/src/langbot/pkg/entity/persistence/wecomcs.py @@ -0,0 +1,25 @@ +import sqlalchemy + +from .base import Base + + +class WecomCSCursorCheckpoint(Base): + """Persistent cursor checkpoint for WeCom customer service sync.""" + + __tablename__ = 'wecomcs_cursor_checkpoints' + + id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True) + bot_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True) + open_kfid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True) + cursor = sqlalchemy.Column(sqlalchemy.Text, nullable=False, default='') + bootstrapped = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False) + created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) + updated_at = sqlalchemy.Column( + sqlalchemy.DateTime, + nullable=False, + server_default=sqlalchemy.func.now(), + onupdate=sqlalchemy.func.now(), + ) + __table_args__ = ( + sqlalchemy.UniqueConstraint('bot_uuid', 'open_kfid', name='uq_wecomcs_cursor_bot_openkfid'), + ) diff --git a/src/langbot/pkg/persistence/migrations/dbm025_wecomcs_cursor_checkpoint.py b/src/langbot/pkg/persistence/migrations/dbm025_wecomcs_cursor_checkpoint.py new file mode 100644 index 000000000..007d22e22 --- /dev/null +++ b/src/langbot/pkg/persistence/migrations/dbm025_wecomcs_cursor_checkpoint.py @@ -0,0 +1,48 @@ +import sqlalchemy + +from .. import migration + + +@migration.migration_class(25) +class DBMigrateWecomCSCursorCheckpoint(migration.DBMigration): + """Create wecomcs cursor checkpoint table.""" + + async def upgrade(self): + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text( + ''' + CREATE TABLE IF NOT EXISTS wecomcs_cursor_checkpoints ( + id INTEGER PRIMARY KEY, + bot_uuid VARCHAR(255) NOT NULL, + open_kfid VARCHAR(255) NOT NULL, + cursor TEXT NOT NULL DEFAULT '', + bootstrapped BOOLEAN NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + ''' + ) + ) + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text( + 'CREATE UNIQUE INDEX IF NOT EXISTS uq_wecomcs_cursor_bot_openkfid ON wecomcs_cursor_checkpoints(bot_uuid, open_kfid)' + ) + ) + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text( + 'CREATE INDEX IF NOT EXISTS idx_wecomcs_cursor_bot_uuid ON wecomcs_cursor_checkpoints(bot_uuid)' + ) + ) + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text( + 'CREATE INDEX IF NOT EXISTS idx_wecomcs_cursor_open_kfid ON wecomcs_cursor_checkpoints(open_kfid)' + ) + ) + + async def downgrade(self): + try: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text('DROP TABLE IF EXISTS wecomcs_cursor_checkpoints') + ) + except Exception: + pass diff --git a/src/langbot/pkg/platform/sources/wecom_kf_app.py b/src/langbot/pkg/platform/sources/wecom_kf_app.py new file mode 100644 index 000000000..3036cca74 --- /dev/null +++ b/src/langbot/pkg/platform/sources/wecom_kf_app.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from .wecomcs import WecomCSAdapter + + +class WecomKFAppAdapter(WecomCSAdapter): + """企微应用授权管理微信客服适配器。""" + + def __init__(self, config: dict, logger): + # 中文注释:当前官方“企微应用授权管理微信客服”模式在收发消息协议上仍然沿用 + # 微信客服 kf/* 接口,因此第一版直接复用 WecomCSAdapter 的成熟实现, + # 只把后台可选类型和产品语义独立出来,避免继续混用 wecom / wecomcs。 + super().__init__(config, logger) diff --git a/src/langbot/pkg/platform/sources/wecom_kf_app.yaml b/src/langbot/pkg/platform/sources/wecom_kf_app.yaml new file mode 100644 index 000000000..6336cda3f --- /dev/null +++ b/src/langbot/pkg/platform/sources/wecom_kf_app.yaml @@ -0,0 +1,139 @@ +apiVersion: v1 +kind: MessagePlatformAdapter +metadata: + name: wecom_kf_app + label: + en_US: WeComKFApp + zh_Hans: 企微应用授权管理微信客服 + description: + en_US: WeCom app managed customer service adapter + zh_Hans: 使用企微应用授权管理微信客服的适配器 + icon: wecom.png +spec: + config: + - name: corpid + label: + en_US: Corpid + zh_Hans: 企业ID(Corpid) + type: string + required: true + default: "" + - name: secret + label: + en_US: Secret + zh_Hans: 应用密钥(Secret) + description: + en_US: "The secret of the WeCom app that has permission to call WeCom customer service APIs." + zh_Hans: 可调用微信客服接口的企微应用 Secret。需先在微信客服后台配置为可调用接口的应用。 + type: string + required: true + default: "" + - name: token + label: + en_US: Token + zh_Hans: 回调令牌(Token) + type: string + required: true + default: "" + - name: EncodingAESKey + label: + en_US: EncodingAESKey + zh_Hans: 回调加解密密钥(EncodingAESKey) + type: string + required: true + default: "" + - name: api_base_url + label: + en_US: API Base URL + zh_Hans: API 基础 URL + description: + en_US: API Base URL, used for accessing the WeCom API. If you are deploying in an internal network environment and accessing the WeCom Customer Service API through a reverse proxy, please fill in this item according to the documentation. + zh_Hans: 可选,若您部署在内网环境并通过反向代理访问企业微信 API,可根据文档修改此项。 + type: string + required: false + default: "https://qyapi.weixin.qq.com/cgi-bin" + section: + en_US: Access Requirements + zh_Hans: 接入要求 + - name: app_visibility_notice + label: + en_US: App Visibility Notice + zh_Hans: 应用可见范围说明 + description: + en_US: "The customer service account must be API-managed, and the assigned servicer must be within the app visibility scope, otherwise errors such as 60030 may occur." + zh_Hans: 客服账号需已开启 API 管理,且接待人员必须在应用可见范围内,否则可能返回 60030 等错误。 + type: string + required: false + default: "无需填写,仅作说明" + section: + en_US: Access Requirements + zh_Hans: 接入要求 + - name: history_message_drop_threshold_seconds + label: + en_US: History Message Window + zh_Hans: 历史消息过滤时间窗口 + description: + en_US: "Advanced setting. During bootstrap or recovery pull, messages older than this window will be dropped. Unit: seconds." + zh_Hans: 高级配置。冷启动或恢复拉取时,超过该时间窗口的历史消息会被丢弃,单位秒。 + type: integer + required: false + default: 90 + section: + en_US: Advanced Settings + zh_Hans: 高级配置 + - name: nickname_lookup_timeout_seconds + label: + en_US: Nickname Lookup Timeout + zh_Hans: 昵称查询超时 + description: + en_US: "Advanced setting. Timeout for customer nickname lookup. Unit: seconds." + zh_Hans: 高级配置。客户昵称查询超时时间,单位秒。 + type: float + required: false + default: 30.0 + section: + en_US: Advanced Settings + zh_Hans: 高级配置 + - name: retry_max_attempts + label: + en_US: Retry Attempts + zh_Hans: 重试次数 + description: + en_US: Advanced setting. Maximum retry attempts for failed pull/process jobs. + zh_Hans: 高级配置。pull/process 失败任务的最大重试次数。 + type: integer + required: false + default: 3 + section: + en_US: Advanced Settings + zh_Hans: 高级配置 + - name: retry_backoff_seconds + label: + en_US: Retry Backoff Seconds + zh_Hans: 重试间隔 + description: + en_US: Advanced setting. Comma-separated retry backoff seconds, for example 15,30,45. + zh_Hans: 高级配置。逗号分隔的重试间隔秒数,例如 15,30,45。 + type: string + required: false + default: "15,30,45" + section: + en_US: Advanced Settings + zh_Hans: 高级配置 + - name: lock_ttl_seconds + label: + en_US: Pull Lock Timeout + zh_Hans: pull 锁超时 + description: + en_US: Advanced setting. Pull lock TTL in seconds. + zh_Hans: 高级配置。pull 锁的超时时间,单位秒。 + type: integer + required: false + default: 60 + section: + en_US: Advanced Settings + zh_Hans: 高级配置 +execution: + python: + path: ./wecom_kf_app.py + attr: WecomKFAppAdapter diff --git a/src/langbot/pkg/platform/sources/wecombot.py b/src/langbot/pkg/platform/sources/wecombot.py index c087a5cca..22a1ab9fb 100644 --- a/src/langbot/pkg/platform/sources/wecombot.py +++ b/src/langbot/pkg/platform/sources/wecombot.py @@ -336,9 +336,10 @@ def register_listener( ): async def on_message(event: WecomBotEvent): try: + await self.logger.debug(f'[wecombot] adapter收到消息: type={event.type}, userid={event.userid}') return await callback(await self.event_converter.target2yiri(event), self) except Exception: - await self.logger.error(f'Error in wecombot callback: {traceback.format_exc()}') + await self.logger.error(f'[wecombot] adapter回调异常: {traceback.format_exc()}') print(traceback.format_exc()) try: diff --git a/src/langbot/pkg/platform/sources/wecomcs.py b/src/langbot/pkg/platform/sources/wecomcs.py index 9af809f7d..5e3881beb 100644 --- a/src/langbot/pkg/platform/sources/wecomcs.py +++ b/src/langbot/pkg/platform/sources/wecomcs.py @@ -2,6 +2,7 @@ import typing import asyncio import traceback +import time import datetime import pydantic @@ -14,6 +15,8 @@ import langbot_plugin.api.entities.builtin.platform.events as platform_events from langbot_plugin.api.entities.builtin.command import errors as command_errors import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger +from ..wecomcs.config_resolver import resolve_wecomcs_runtime_settings +from ..wecomcs.runtime import WecomCSSchedulerRuntime class WecomMessageConverter(abstract_platform_adapter.AbstractMessageConverter): @@ -92,16 +95,38 @@ async def target2yiri(event: WecomCSEvent, bot: WecomCSClient = None): Returns: platform_events.FriendMessage: 转换后的 FriendMessage 对象。 """ - # Try to get customer nickname from WeChat API + import logging + _logger = logging.getLogger('langbot') + + _logger.debug(f'[wecomcs] target2yiri开始: event.type={event.type}, user_id={event.user_id}') + + # 中文注释:昵称查询只是增强信息,不能阻塞主消息链路,因此这里做短超时保护并打印总耗时。 nickname = str(event.user_id) if bot and event.user_id: + lookup_started_at = time.perf_counter() try: - customer_info = await bot.get_customer_info(event.user_id) + _logger.debug(f'[wecomcs] 正在获取用户昵称: user_id={event.user_id}') + timeout_seconds = float(getattr(bot, 'nickname_lookup_timeout_seconds', 30.0) or 30.0) + customer_info = await asyncio.wait_for(bot.get_customer_info(event.user_id), timeout=timeout_seconds) if customer_info and customer_info.get('nickname'): nickname = customer_info.get('nickname') - except Exception: + elapsed_ms = (time.perf_counter() - lookup_started_at) * 1000 + _logger.debug(f'[wecomcs] 用户昵称查询完成: user_id={event.user_id}, nickname={nickname}, elapsed_ms={elapsed_ms:.2f}') + except asyncio.TimeoutError: + timeout_seconds = float(getattr(bot, 'nickname_lookup_timeout_seconds', 30.0) or 30.0) + elapsed_ms = (time.perf_counter() - lookup_started_at) * 1000 + _logger.warning( + f'[wecomcs] 获取用户昵称超时: user_id={event.user_id}, timeout_seconds={timeout_seconds}, elapsed_ms={elapsed_ms:.2f}' + ) + except Exception as e: + elapsed_ms = (time.perf_counter() - lookup_started_at) * 1000 + _logger.warning( + f'[wecomcs] 获取用户昵称异常: user_id={event.user_id}, error_type={type(e).__name__}, error={e}, elapsed_ms={elapsed_ms:.2f}' + ) pass # Fall back to user_id as nickname + _logger.debug(f'[wecomcs] target2yiri: event.type={event.type}, message={event.message[:30] if event.message else "N/A"}...') + # 转换消息链 if event.type == 'text': yiri_chain = await WecomMessageConverter.target2yiri(event.message, event.message_id) @@ -133,6 +158,8 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): message_converter: WecomMessageConverter = WecomMessageConverter() event_converter: WecomEventConverter = WecomEventConverter() bot_uuid: str = None + scheduler_runtime: WecomCSSchedulerRuntime | None = None + resolved_wecomcs_runtime_settings: dict = pydantic.Field(default_factory=dict, exclude=True) def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger): required_keys = [ @@ -145,6 +172,12 @@ def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventL if missing_keys: raise command_errors.ParamNotEnoughError('企业微信客服缺少相关配置项,请查看文档或联系管理员') + ap = getattr(logger, 'ap', None) + global_scheduler_config = {} + if ap is not None and getattr(ap, 'instance_config', None) is not None: + global_scheduler_config = ap.instance_config.data.get('wecomcs_scheduler', {}) + resolved_runtime_settings = resolve_wecomcs_runtime_settings(config, global_scheduler_config) + bot = WecomCSClient( corpid=config['corpid'], secret=config['secret'], @@ -154,6 +187,8 @@ def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventL unified_mode=True, api_base_url=config.get('api_base_url', 'https://qyapi.weixin.qq.com/cgi-bin'), ) + # 中文注释:昵称查询发生在消息转换阶段,不依赖 Redis 调度是否启用,所以在适配器初始化时就写入解析后的超时值。 + bot.nickname_lookup_timeout_seconds = float(resolved_runtime_settings['nickname_lookup_timeout_seconds']) super().__init__( config=config, @@ -162,6 +197,7 @@ def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventL listeners={}, bot=bot, ) + self.resolved_wecomcs_runtime_settings = resolved_runtime_settings async def reply_message( self, @@ -173,13 +209,29 @@ async def reply_message( content_list = await WecomMessageConverter.yiri2target(message, self.bot) for content in content_list: - if content['type'] == 'text': + if content['type'] != 'text': + continue + + try: await self.bot.send_text_msg( open_kfid=Wecom_event.receiver_id, external_userid=Wecom_event.user_id, msgid=Wecom_event.message_id, content=content['content'], ) + state_store = getattr(self.bot, 'state_store', None) + if state_store and self.bot_uuid: + await state_store.mark_reply_success(self.bot_uuid, Wecom_event.receiver_id, Wecom_event.message_id) + except Exception as exc: + state_store = getattr(self.bot, 'state_store', None) + if state_store and self.bot_uuid: + await state_store.mark_reply_failed( + self.bot_uuid, + Wecom_event.receiver_id, + Wecom_event.message_id, + error=str(exc), + ) + raise async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): pass @@ -187,6 +239,25 @@ async def send_message(self, target_type: str, target_id: str, message: platform def set_bot_uuid(self, bot_uuid: str): """设置 bot UUID(用于生成 webhook URL)""" self.bot_uuid = bot_uuid + self.bot.bot_uuid = bot_uuid + + ap = getattr(self.logger, 'ap', None) + if ap is None or getattr(ap, 'redis_mgr', None) is None or not ap.redis_mgr.is_available(): + return + + scheduler_config = dict(ap.instance_config.data.get('wecomcs_scheduler', {})) + scheduler_config.update(self.resolved_wecomcs_runtime_settings) + if not scheduler_config.get('enabled', False): + return + + self.scheduler_runtime = WecomCSSchedulerRuntime( + bot_uuid=bot_uuid, + client=self.bot, + redis_mgr=ap.redis_mgr, + scheduler_config=scheduler_config, + persistence_mgr=ap.persistence_mgr, + ) + self.bot.state_store = self.scheduler_runtime.state_store def register_listener( self, @@ -195,16 +266,27 @@ def register_listener( [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None ], ): + import logging + _logger = logging.getLogger('langbot') + async def on_message(event: WecomCSEvent): self.bot_account_id = event.receiver_id try: - return await callback(await self.event_converter.target2yiri(event, self.bot), self) - except Exception: - await self.logger.error(f'Error in wecomcs callback: {traceback.format_exc()}') + _logger.debug(f'[wecomcs] adapter收到消息: type={event.type}, user_id={event.user_id}') + yiri_event = await self.event_converter.target2yiri(event, self.bot) + _logger.debug(f'[wecomcs] 转换后事件: {type(yiri_event).__name__}, sender_id={yiri_event.sender.id if yiri_event else "N/A"}') + result = await callback(yiri_event, self) + _logger.debug(f'[wecomcs] callback完成: result={result}') + return result + except Exception as e: + _logger.error(f'[wecomcs] adapter回调异常: {traceback.format_exc()}') + print(f'[wecomcs] adapter回调异常: {e}', flush=True) if event_type == platform_events.FriendMessage: + _logger.debug(f'[wecomcs] 注册 FriendMessage 监听器, 当前handlers: {list(self.bot._message_handlers.keys())}') self.bot.on_message('text')(on_message) self.bot.on_message('image')(on_message) + _logger.debug(f'[wecomcs] 注册后 handlers: {list(self.bot._message_handlers.keys())}') elif event_type == platform_events.GroupMessage: pass @@ -222,9 +304,12 @@ async def handle_unified_webhook(self, bot_uuid: str, path: str, request): return await self.bot.handle_unified_webhook(request) async def run_async(self): + if self.scheduler_runtime is not None: + await self.scheduler_runtime.run() + return + # 统一 webhook 模式下,不启动独立的 Quart 应用 # 保持运行但不启动独立端口 - async def keep_alive(): while True: await asyncio.sleep(1) @@ -232,6 +317,8 @@ async def keep_alive(): await keep_alive() async def kill(self) -> bool: + if self.scheduler_runtime is not None: + await self.scheduler_runtime.stop() return False async def is_muted(self, group_id: int) -> bool: diff --git a/src/langbot/pkg/platform/sources/wecomcs.yaml b/src/langbot/pkg/platform/sources/wecomcs.yaml index a1be068ea..268d3ab24 100644 --- a/src/langbot/pkg/platform/sources/wecomcs.yaml +++ b/src/langbot/pkg/platform/sources/wecomcs.yaml @@ -49,7 +49,72 @@ spec: type: string required: false default: "https://qyapi.weixin.qq.com/cgi-bin" + - name: history_message_drop_threshold_seconds + label: + en_US: History Message Window + zh_Hans: 历史消息过滤时间窗口 + description: + en_US: "Advanced setting. During bootstrap or recovery pull, messages older than this window will be dropped. Unit: seconds." + zh_Hans: 高级配置。冷启动或恢复拉取时,超过该时间窗口的历史消息会被丢弃,单位秒。 + type: integer + required: false + default: 90 + section: + en_US: Advanced Settings + zh_Hans: 高级配置 + - name: nickname_lookup_timeout_seconds + label: + en_US: Nickname Lookup Timeout + zh_Hans: 昵称查询超时 + description: + en_US: "Advanced setting. Timeout for customer nickname lookup. Unit: seconds." + zh_Hans: 高级配置。客户昵称查询超时时间,单位秒。 + type: float + required: false + default: 30.0 + section: + en_US: Advanced Settings + zh_Hans: 高级配置 + - name: retry_max_attempts + label: + en_US: Retry Attempts + zh_Hans: 重试次数 + description: + en_US: Advanced setting. Maximum retry attempts for failed pull/process jobs. + zh_Hans: 高级配置。pull/process 失败任务的最大重试次数。 + type: integer + required: false + default: 3 + section: + en_US: Advanced Settings + zh_Hans: 高级配置 + - name: retry_backoff_seconds + label: + en_US: Retry Backoff Seconds + zh_Hans: 重试间隔 + description: + en_US: Advanced setting. Comma-separated retry backoff seconds, for example 15,30,45. + zh_Hans: 高级配置。逗号分隔的重试间隔秒数,例如 15,30,45。 + type: string + required: false + default: "15,30,45" + section: + en_US: Advanced Settings + zh_Hans: 高级配置 + - name: lock_ttl_seconds + label: + en_US: Pull Lock Timeout + zh_Hans: pull 锁超时 + description: + en_US: Advanced setting. Pull lock TTL in seconds. + zh_Hans: 高级配置。pull 锁的超时时间,单位秒。 + type: integer + required: false + default: 60 + section: + en_US: Advanced Settings + zh_Hans: 高级配置 execution: python: path: ./wecomcs.py - attr: WecomCSAdapter \ No newline at end of file + attr: WecomCSAdapter diff --git a/src/langbot/pkg/platform/wecomcs/__init__.py b/src/langbot/pkg/platform/wecomcs/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/langbot/pkg/platform/wecomcs/config_resolver.py b/src/langbot/pkg/platform/wecomcs/config_resolver.py new file mode 100644 index 000000000..c5b635f22 --- /dev/null +++ b/src/langbot/pkg/platform/wecomcs/config_resolver.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +import logging +from typing import Any + + +_logger = logging.getLogger('langbot') + + +WECOMCS_RUNTIME_DEFAULTS = { + 'history_message_drop_threshold_seconds': 90, + 'nickname_lookup_timeout_seconds': 30.0, + 'retry_max_attempts': 3, + 'retry_backoff_seconds': [15, 30, 45], + 'lock_ttl_seconds': 60, +} + + +def _coerce_int(raw_value: Any, *, minimum: int, field_name: str, source: str) -> int | None: + try: + parsed = int(raw_value) + except (TypeError, ValueError): + if raw_value not in (None, ''): + _logger.warning( + f'[wecomcs][config] 整数配置非法,回退上层默认: field={field_name}, source={source}, raw_value={raw_value}' + ) + return None + if parsed < minimum: + _logger.warning( + f'[wecomcs][config] 整数配置低于最小值,回退上层默认: field={field_name}, source={source}, raw_value={raw_value}, minimum={minimum}' + ) + return None + return parsed + + +def _coerce_float(raw_value: Any, *, minimum: float, field_name: str, source: str) -> float | None: + try: + parsed = float(raw_value) + except (TypeError, ValueError): + if raw_value not in (None, ''): + _logger.warning( + f'[wecomcs][config] 浮点配置非法,回退上层默认: field={field_name}, source={source}, raw_value={raw_value}' + ) + return None + if parsed < minimum: + _logger.warning( + f'[wecomcs][config] 浮点配置低于最小值,回退上层默认: field={field_name}, source={source}, raw_value={raw_value}, minimum={minimum}' + ) + return None + return parsed + + +def _coerce_retry_backoff(raw_value: Any, *, source: str) -> list[int] | None: + if raw_value in (None, ''): + return None + + if isinstance(raw_value, str): + parts = [item.strip() for item in raw_value.split(',') if item.strip()] + elif isinstance(raw_value, (list, tuple)): + parts = list(raw_value) + else: + _logger.warning( + f'[wecomcs][config] 重试间隔配置类型非法,回退上层默认: source={source}, raw_value={raw_value}' + ) + return None + + parsed_values: list[int] = [] + for item in parts: + try: + parsed = int(item) + except (TypeError, ValueError): + _logger.warning( + f'[wecomcs][config] 重试间隔配置包含非法项,回退上层默认: source={source}, raw_value={raw_value}' + ) + return None + if parsed <= 0: + _logger.warning( + f'[wecomcs][config] 重试间隔配置包含非正整数,回退上层默认: source={source}, raw_value={raw_value}' + ) + return None + parsed_values.append(parsed) + + return parsed_values or None + + +# 中文注释:bot 配置优先级最高,其次是全局默认,最后才是代码默认值。 +def resolve_wecomcs_runtime_settings(bot_config: dict[str, Any], global_scheduler_config: dict[str, Any]) -> dict[str, Any]: + resolved = dict(global_scheduler_config or {}) + + resolved['history_message_drop_threshold_seconds'] = ( + _coerce_int( + bot_config.get('history_message_drop_threshold_seconds'), + minimum=0, + field_name='history_message_drop_threshold_seconds', + source='bot', + ) + or _coerce_int( + global_scheduler_config.get('history_message_drop_threshold_seconds'), + minimum=0, + field_name='history_message_drop_threshold_seconds', + source='global', + ) + or WECOMCS_RUNTIME_DEFAULTS['history_message_drop_threshold_seconds'] + ) + + resolved['retry_max_attempts'] = ( + _coerce_int( + bot_config.get('retry_max_attempts'), + minimum=0, + field_name='retry_max_attempts', + source='bot', + ) + or _coerce_int( + global_scheduler_config.get('retry_max_attempts'), + minimum=0, + field_name='retry_max_attempts', + source='global', + ) + or WECOMCS_RUNTIME_DEFAULTS['retry_max_attempts'] + ) + + resolved['lock_ttl_seconds'] = ( + _coerce_int( + bot_config.get('lock_ttl_seconds'), + minimum=1, + field_name='lock_ttl_seconds', + source='bot', + ) + or _coerce_int( + global_scheduler_config.get('lock_ttl_seconds'), + minimum=1, + field_name='lock_ttl_seconds', + source='global', + ) + or WECOMCS_RUNTIME_DEFAULTS['lock_ttl_seconds'] + ) + + resolved['nickname_lookup_timeout_seconds'] = ( + _coerce_float( + bot_config.get('nickname_lookup_timeout_seconds'), + minimum=0.1, + field_name='nickname_lookup_timeout_seconds', + source='bot', + ) + or _coerce_float( + global_scheduler_config.get('nickname_lookup_timeout_seconds'), + minimum=0.1, + field_name='nickname_lookup_timeout_seconds', + source='global', + ) + or WECOMCS_RUNTIME_DEFAULTS['nickname_lookup_timeout_seconds'] + ) + + resolved['retry_backoff_seconds'] = ( + _coerce_retry_backoff(bot_config.get('retry_backoff_seconds'), source='bot') + or _coerce_retry_backoff(global_scheduler_config.get('retry_backoff_seconds'), source='global') + or list(WECOMCS_RUNTIME_DEFAULTS['retry_backoff_seconds']) + ) + + return resolved diff --git a/src/langbot/pkg/platform/wecomcs/cursor_store.py b/src/langbot/pkg/platform/wecomcs/cursor_store.py new file mode 100644 index 000000000..463f6dbd0 --- /dev/null +++ b/src/langbot/pkg/platform/wecomcs/cursor_store.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import logging + +import sqlalchemy + +from ...entity.persistence import wecomcs as persistence_wecomcs +from .models import WecomCSCursorCheckpoint + + +_logger = logging.getLogger('langbot') + + +class WecomCSCursorStore: + """Persist WeCom customer service cursor checkpoints in database.""" + + def __init__(self, persistence_mgr = None): + self.persistence_mgr = persistence_mgr + + def is_available(self) -> bool: + return self.persistence_mgr is not None + + async def get_checkpoint(self, bot_uuid: str, open_kfid: str) -> WecomCSCursorCheckpoint | None: + if not self.is_available(): + return None + + result = await self.persistence_mgr.execute_async( + sqlalchemy.select( + persistence_wecomcs.WecomCSCursorCheckpoint.id, + persistence_wecomcs.WecomCSCursorCheckpoint.bot_uuid, + persistence_wecomcs.WecomCSCursorCheckpoint.open_kfid, + persistence_wecomcs.WecomCSCursorCheckpoint.cursor, + persistence_wecomcs.WecomCSCursorCheckpoint.bootstrapped, + ) + .where(persistence_wecomcs.WecomCSCursorCheckpoint.bot_uuid == bot_uuid) + .where(persistence_wecomcs.WecomCSCursorCheckpoint.open_kfid == open_kfid) + ) + row = result.mappings().one_or_none() + if row is None: + return None + + # 中文注释:AsyncConnection 执行 ORM select 时,不能用 scalar_one_or_none(),否则会拿到第一列 id。 + return WecomCSCursorCheckpoint( + bot_uuid=str(row['bot_uuid']), + open_kfid=str(row['open_kfid']), + cursor=str(row['cursor'] or ''), + bootstrapped=bool(row['bootstrapped']), + ) + + async def save_checkpoint(self, bot_uuid: str, open_kfid: str, cursor: str, bootstrapped: bool): + if not self.is_available(): + return + + current = await self.get_checkpoint(bot_uuid, open_kfid) + values = { + 'cursor': cursor, + 'bootstrapped': bootstrapped, + } + if current is None: + await self.persistence_mgr.execute_async( + sqlalchemy.insert(persistence_wecomcs.WecomCSCursorCheckpoint).values( + { + 'bot_uuid': bot_uuid, + 'open_kfid': open_kfid, + **values, + } + ) + ) + _logger.debug( + f'[wecomcs][cursor-store] 创建cursor检查点: bot_uuid={bot_uuid}, open_kfid={open_kfid}, bootstrapped={bootstrapped}, cursor={cursor}' + ) + return + + await self.persistence_mgr.execute_async( + sqlalchemy.update(persistence_wecomcs.WecomCSCursorCheckpoint) + .where(persistence_wecomcs.WecomCSCursorCheckpoint.bot_uuid == bot_uuid) + .where(persistence_wecomcs.WecomCSCursorCheckpoint.open_kfid == open_kfid) + .values(values) + ) + _logger.debug( + f'[wecomcs][cursor-store] 更新cursor检查点: bot_uuid={bot_uuid}, open_kfid={open_kfid}, bootstrapped={bootstrapped}, cursor={cursor}' + ) + + + async def delete_checkpoint(self, bot_uuid: str, open_kfid: str): + if not self.is_available(): + return + + await self.persistence_mgr.execute_async( + sqlalchemy.delete(persistence_wecomcs.WecomCSCursorCheckpoint) + .where(persistence_wecomcs.WecomCSCursorCheckpoint.bot_uuid == bot_uuid) + .where(persistence_wecomcs.WecomCSCursorCheckpoint.open_kfid == open_kfid) + ) + _logger.debug( + f'[wecomcs][cursor-store] 删除cursor检查点: bot_uuid={bot_uuid}, open_kfid={open_kfid}' + ) diff --git a/src/langbot/pkg/platform/wecomcs/message_publisher.py b/src/langbot/pkg/platform/wecomcs/message_publisher.py new file mode 100644 index 000000000..4d0f0399f --- /dev/null +++ b/src/langbot/pkg/platform/wecomcs/message_publisher.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import json +import logging + +from ...cache.redis_mgr import RedisManager +from .sharding import resolve_process_shard + + +_logger = logging.getLogger("langbot") + + +class WecomCSMessagePublisher: + """企业微信客服第二层消息发布器。""" + + def __init__(self, redis_mgr: RedisManager, shard_count: int): + self.redis_mgr = redis_mgr + self.shard_count = shard_count + + async def publish_message(self, bot_uuid: str, msg_data: dict) -> tuple[str, dict[str, str]]: + if not self.redis_mgr.is_available(): + raise RuntimeError('Redis is unavailable for message publishing') + + open_kfid = str(msg_data.get('open_kfid', '') or '') + external_userid = str(msg_data.get('external_userid', '') or '') + if not open_kfid or not external_userid: + raise ValueError('open_kfid and external_userid are required') + + shard = resolve_process_shard(bot_uuid, open_kfid, external_userid, self.shard_count) + stream_name = f'wecomcs:message-process:{shard}' + payload = { + 'job_type': 'message_process', + 'bot_uuid': bot_uuid, + 'open_kfid': open_kfid, + 'external_userid': external_userid, + 'msgid': str(msg_data.get('msgid', '') or ''), + 'msgtype': str(msg_data.get('msgtype', '') or ''), + 'send_time': str(msg_data.get('send_time', '') or ''), + 'payload': json.dumps(msg_data, ensure_ascii=False), + } + await self.redis_mgr.xadd(stream_name, payload) + _logger.debug(f'[wecomcs][message-publisher] 发布message-process: bot_uuid={bot_uuid}, open_kfid={open_kfid}, external_userid={external_userid}, shard={shard}, stream={stream_name}') + return stream_name, payload diff --git a/src/langbot/pkg/platform/wecomcs/message_state_store.py b/src/langbot/pkg/platform/wecomcs/message_state_store.py new file mode 100644 index 000000000..ae1938145 --- /dev/null +++ b/src/langbot/pkg/platform/wecomcs/message_state_store.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +import json +import logging +import time + +from .models import WecomCSMessageState + + +_logger = logging.getLogger('langbot') + + +class WecomCSMessageStateStore: + """Store recent WeCom customer service message states in Redis.""" + + ACTIVE_STATUSES = {'queued', 'processing', 'done', 'ignored'} + + def __init__(self, redis_mgr = None, ttl_seconds: int = 604800): + self.redis_mgr = redis_mgr + self.ttl_seconds = ttl_seconds + + def is_available(self) -> bool: + return self.redis_mgr is not None and self.redis_mgr.is_available() + + def _key(self, bot_uuid: str, open_kfid: str, msgid: str) -> str: + return f'wecomcs:msg:{bot_uuid}:{open_kfid}:{msgid}' + + async def get_state(self, bot_uuid: str, open_kfid: str, msgid: str) -> dict | None: + if not self.is_available(): + return None + if hasattr(self.redis_mgr, 'get_json'): + state = await self.redis_mgr.get_json(self._key(bot_uuid, open_kfid, msgid)) + else: + raw_value = await self.redis_mgr.get(self._key(bot_uuid, open_kfid, msgid)) + state = json.loads(raw_value) if raw_value else None + if state: + _logger.debug( + f'[wecomcs][message-state] 读取消息状态: bot_uuid={bot_uuid}, open_kfid={open_kfid}, msgid={msgid}, process_status={state.get("process_status")}, reply_status={state.get("reply_status")}' + ) + return state + + async def save_state(self, state: WecomCSMessageState | dict): + if not self.is_available(): + return + payload = state.to_dict() if isinstance(state, WecomCSMessageState) else dict(state) + if hasattr(self.redis_mgr, 'set_json'): + await self.redis_mgr.set_json( + self._key(payload['bot_uuid'], payload['open_kfid'], payload['msgid']), + payload, + ex=self.ttl_seconds, + ) + else: + await self.redis_mgr.set( + self._key(payload['bot_uuid'], payload['open_kfid'], payload['msgid']), + json.dumps(payload, ensure_ascii=False), + ex=self.ttl_seconds, + ) + _logger.debug( + f'[wecomcs][message-state] 保存消息状态: bot_uuid={payload.get("bot_uuid")}, open_kfid={payload.get("open_kfid")}, msgid={payload.get("msgid")}, process_status={payload.get("process_status")}, reply_status={payload.get("reply_status")}' + ) + + async def reserve_for_queue(self, bot_uuid: str, open_kfid: str, msg_data: dict) -> bool: + if not self.is_available(): + return True + + msgid = str(msg_data.get('msgid', '') or '') + if not msgid: + return False + + current = await self.get_state(bot_uuid, open_kfid, msgid) + if current and current.get('process_status') in self.ACTIVE_STATUSES: + return False + + now_ts = int(time.time()) + content_preview = '' + text_data = msg_data.get('text') or {} + if isinstance(text_data, dict): + content_preview = str(text_data.get('content', '') or '')[:200] + + next_retry_count = int((current or {}).get('retry_count', 0) or 0) + if current and current.get('process_status') == 'failed': + next_retry_count += 1 + + state = WecomCSMessageState( + bot_uuid=bot_uuid, + open_kfid=open_kfid, + msgid=msgid, + external_userid=str(msg_data.get('external_userid', '') or ''), + msgtype=str(msg_data.get('msgtype', '') or ''), + send_time=int(msg_data.get('send_time', 0) or 0) or None, + process_status='queued', + reply_status=str((current or {}).get('reply_status', 'pending') or 'pending'), + retry_count=next_retry_count, + last_error_stage='', + last_error='', + first_seen_at=int((current or {}).get('first_seen_at', now_ts) or now_ts), + queued_at=now_ts, + processing_at=None, + done_at=None, + reply_at=int((current or {}).get('reply_at', 0) or 0) or None, + failed_at=None, + updated_at=now_ts, + content_preview=content_preview or str((current or {}).get('content_preview', '') or ''), + ) + await self.save_state(state) + return True + + async def update_status( + self, + bot_uuid: str, + open_kfid: str, + msgid: str, + *, + process_status: str | None = None, + reply_status: str | None = None, + last_error_stage: str | None = None, + last_error: str | None = None, + ) -> dict | None: + if not self.is_available(): + return None + + current = await self.get_state(bot_uuid, open_kfid, msgid) + if current is None: + return None + + now_ts = int(time.time()) + current['updated_at'] = now_ts + if process_status: + current['process_status'] = process_status + if process_status == 'processing': + current['processing_at'] = now_ts + elif process_status == 'done': + current['done_at'] = now_ts + elif process_status == 'failed': + current['failed_at'] = now_ts + if reply_status: + current['reply_status'] = reply_status + if reply_status in {'success', 'failed'}: + current['reply_at'] = now_ts + if last_error_stage is not None: + current['last_error_stage'] = last_error_stage + if last_error is not None: + current['last_error'] = last_error + + await self.save_state(current) + return current diff --git a/src/langbot/pkg/platform/wecomcs/message_worker.py b/src/langbot/pkg/platform/wecomcs/message_worker.py new file mode 100644 index 000000000..8d8fae064 --- /dev/null +++ b/src/langbot/pkg/platform/wecomcs/message_worker.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import json +import logging + +from langbot.libs.wecom_customer_service_api.wecomcsevent import WecomCSEvent + +from .state_store import WecomCSStateStore + + +_logger = logging.getLogger("langbot") + + +class WecomCSMessageWorker: + """企业微信客服第二层消息 worker。""" + + def __init__(self, client, state_store: WecomCSStateStore | None = None, *, bot_uuid: str = ''): + self.client = client + self.state_store = state_store + self.bot_uuid = bot_uuid + + async def handle_stream_entry(self, stream_fields: dict) -> bool: + payload = stream_fields.get('payload') + if not payload: + return False + try: + message_payload = json.loads(payload) + except json.JSONDecodeError: + return False + _logger.debug( + f'[wecomcs][message-worker] 收到第二层消息: msgid={message_payload.get("msgid")}, open_kfid={message_payload.get("open_kfid")}, external_userid={message_payload.get("external_userid")}' + ) + return await self.handle_message(message_payload) + + async def handle_message(self, message_payload: dict) -> bool: + # 中文注释:第二层只负责把消息 payload 恢复成事件对象,然后复用当前 client 的消息派发逻辑。 + event = WecomCSEvent.from_payload(message_payload) + if not event: + return False + + if not event.type or not event.user_id or not event.receiver_id: + return False + + if self.state_store and self.bot_uuid: + await self.state_store.mark_message_processing(self.bot_uuid, event.receiver_id, event.message_id) + + try: + await self.client._handle_message(event) + except Exception as exc: + if self.state_store and self.bot_uuid: + await self.state_store.mark_message_failed( + self.bot_uuid, + event.receiver_id, + event.message_id, + stage='message_dispatch', + error=str(exc), + ) + raise + + if self.state_store and self.bot_uuid: + await self.state_store.mark_message_done(self.bot_uuid, event.receiver_id, event.message_id) + _logger.debug(f'[wecomcs][message-worker] 第二层消息处理完成: msgid={event.message_id}, open_kfid={event.receiver_id}, external_userid={event.user_id}') + return True diff --git a/src/langbot/pkg/platform/wecomcs/models.py b/src/langbot/pkg/platform/wecomcs/models.py new file mode 100644 index 000000000..533718581 --- /dev/null +++ b/src/langbot/pkg/platform/wecomcs/models.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass + + +@dataclass +class WecomCSCursorCheckpoint: + bot_uuid: str + open_kfid: str + cursor: str + bootstrapped: bool + + +@dataclass +class WecomCSMessageState: + bot_uuid: str + open_kfid: str + msgid: str + external_userid: str + msgtype: str + send_time: int | None + process_status: str + reply_status: str + retry_count: int + last_error_stage: str + last_error: str + first_seen_at: int + queued_at: int | None + processing_at: int | None + done_at: int | None + reply_at: int | None + failed_at: int | None + updated_at: int + content_preview: str + + def to_dict(self) -> dict: + return asdict(self) diff --git a/src/langbot/pkg/platform/wecomcs/pull_trigger_publisher.py b/src/langbot/pkg/platform/wecomcs/pull_trigger_publisher.py new file mode 100644 index 000000000..03e44ab55 --- /dev/null +++ b/src/langbot/pkg/platform/wecomcs/pull_trigger_publisher.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import logging +import xml.etree.ElementTree as ET + +from ...cache.redis_mgr import RedisManager +from .sharding import resolve_pull_shard + + +_logger = logging.getLogger("langbot") + + +class WecomCSPullTriggerPublisher: + """企业微信客服 pull-trigger 发布器。""" + + def __init__(self, redis_mgr: RedisManager, shard_count: int): + self.redis_mgr = redis_mgr + self.shard_count = shard_count + + async def publish_from_xml(self, bot_uuid: str, xml_msg: str, webhook_received_at: int | None = None) -> tuple[str, dict[str, str]]: + if isinstance(xml_msg, bytes): + xml_msg = xml_msg.decode('utf-8') + + root = ET.fromstring(xml_msg) + callback_token = root.findtext('Token') or '' + open_kfid = root.findtext('OpenKfId') or '' + if not callback_token or not open_kfid: + raise ValueError('Token and OpenKfId are required in callback XML') + + shard = resolve_pull_shard(bot_uuid, open_kfid, self.shard_count) + stream_name = f'wecomcs:pull-trigger:{shard}' + payload = { + 'job_type': 'pull_trigger', + 'bot_uuid': bot_uuid, + 'open_kfid': open_kfid, + 'callback_token': callback_token, + 'webhook_received_at': str(int(webhook_received_at or 0)), + } + await self.redis_mgr.xadd(stream_name, payload) + _logger.debug(f'[wecomcs][pull-trigger-publisher] 发布pull-trigger: bot_uuid={bot_uuid}, open_kfid={open_kfid}, shard={shard}, stream={stream_name}') + return stream_name, payload diff --git a/src/langbot/pkg/platform/wecomcs/pull_worker.py b/src/langbot/pkg/platform/wecomcs/pull_worker.py new file mode 100644 index 000000000..f072ed39b --- /dev/null +++ b/src/langbot/pkg/platform/wecomcs/pull_worker.py @@ -0,0 +1,216 @@ +from __future__ import annotations + +import uuid +import logging +from collections.abc import Awaitable, Callable + +from langbot.libs.wecom_customer_service_api.api import WecomCSInvalidSyncMsgTokenError + +from .state_store import WecomCSStateStore + + +_logger = logging.getLogger("langbot") + + +class PullLockNotAcquiredError(Exception): + """Raised when pull lock is not acquired for an open_kfid.""" + + +class WecomCSPullWorker: + """企业微信客服第一层拉取 worker。""" + + def __init__( + self, + client, + state_store: WecomCSStateStore, + on_message: Callable[[dict], Awaitable[None]], + *, + message_state_ttl_seconds: int = 604800, + lock_ttl_seconds: int = 60, + history_message_drop_threshold_seconds: int = 90, + ): + self.client = client + self.state_store = state_store + self.on_message = on_message + self.message_state_ttl_seconds = message_state_ttl_seconds + self.lock_ttl_seconds = lock_ttl_seconds + self.history_message_drop_threshold_seconds = max(0, history_message_drop_threshold_seconds) + + + @staticmethod + def _normalize_timestamp_seconds(timestamp_value) -> int | None: + try: + normalized = int(str(timestamp_value or '').strip()) + except (TypeError, ValueError): + return None + if normalized <= 0: + return None + # 中文注释:企业微信不同场景可能返回秒或毫秒时间戳,这里统一折算成秒。 + return normalized // 1000 if normalized > 10**12 else normalized + + def _filter_bootstrap_messages_by_time_window(self, msg_list: list[dict], webhook_received_at: int | None) -> list[dict]: + if self.history_message_drop_threshold_seconds <= 0 or webhook_received_at is None: + return msg_list + + keep_messages: list[dict] = [] + dropped_count = 0 + lower_bound = webhook_received_at - self.history_message_drop_threshold_seconds + for msg_data in msg_list: + send_time = self._normalize_timestamp_seconds(msg_data.get('send_time')) + if send_time is None or send_time >= lower_bound: + keep_messages.append(msg_data) + continue + dropped_count += 1 + + if dropped_count > 0: + _logger.debug( + f'[wecomcs][pull-worker] bootstrap时间窗口过滤历史消息: kept={len(keep_messages)}, dropped={dropped_count}, webhook_received_at={webhook_received_at}, threshold_seconds={self.history_message_drop_threshold_seconds}' + ) + return keep_messages + + async def _dispatch_messages(self, bot_uuid: str, open_kfid: str, msg_list: list[dict]) -> int: + processed_count = 0 + for msg_data in msg_list: + msgid = str(msg_data.get('msgid', '') or '') + if not msgid: + continue + + # 中文注释:第一层只负责保序投递到第二层,重复消息依赖状态机提前拦截。 + reserved = await self.state_store.reserve_message_for_queue(bot_uuid, open_kfid, msg_data) + if not reserved: + _logger.debug( + f'[wecomcs][pull-worker] 跳过重复消息: bot_uuid={bot_uuid}, open_kfid={open_kfid}, msgid={msgid}' + ) + continue + + try: + await self.on_message(msg_data) + except Exception as exc: + await self.state_store.mark_message_failed( + bot_uuid, + open_kfid, + msgid, + stage='publish_process_stream', + error=str(exc), + ) + raise + + processed_count += 1 + _logger.debug(f'[wecomcs][pull-worker] 已转发消息到第二层: bot_uuid={bot_uuid}, open_kfid={open_kfid}, msgid={msgid}') + + return processed_count + + async def _bootstrap_latest_cursor(self, bot_uuid: str, open_kfid: str, callback_token: str, webhook_received_at: int | None = None) -> tuple[str, list[dict]]: + current_cursor = '' + final_cursor = '' + latest_page_messages: list[dict] = [] + + while True: + _logger.debug( + f'[wecomcs][pull-worker] 执行latest bootstrap: bot_uuid={bot_uuid}, open_kfid={open_kfid}, cursor={current_cursor}' + ) + page = await self.client.fetch_sync_msg_page( + callback_token=callback_token, + open_kfid=open_kfid, + cursor=current_cursor or None, + ) + msg_list = page.get('msg_list') or [] + next_cursor = str(page.get('next_cursor') or '') + has_more = bool(page.get('has_more', False)) + latest_page_messages = msg_list + if next_cursor: + final_cursor = next_cursor + + if not has_more or not next_cursor: + if not final_cursor: + final_cursor = current_cursor + break + + current_cursor = next_cursor + + # 中文注释:latest 模式仍跳过更早历史页,但保留 bootstrap 末页中的最近消息,避免吞掉用户首条真实消息。 + latest_page_messages = self._filter_bootstrap_messages_by_time_window(latest_page_messages, webhook_received_at) + return final_cursor, latest_page_messages + + async def handle_trigger(self, trigger_payload: dict) -> int: + bot_uuid = str(trigger_payload.get('bot_uuid', '') or '') + open_kfid = str(trigger_payload.get('open_kfid', '') or '') + callback_token = str(trigger_payload.get('callback_token', '') or '') + webhook_received_at = self._normalize_timestamp_seconds(trigger_payload.get('webhook_received_at')) + if not bot_uuid or not open_kfid or not callback_token: + raise ValueError('bot_uuid, open_kfid and callback_token are required') + + _logger.debug(f'[wecomcs][pull-worker] 开始处理trigger: bot_uuid={bot_uuid}, open_kfid={open_kfid}') + + lock_owner = str(uuid.uuid4()) + acquired = await self.state_store.acquire_pull_lock( + bot_uuid, + open_kfid, + lock_owner, + self.lock_ttl_seconds, + ) + if not acquired: + raise PullLockNotAcquiredError(f"pull lock not acquired: {bot_uuid}:{open_kfid}") + + try: + current_cursor = await self.state_store.get_cursor(bot_uuid, open_kfid) + is_bootstrapped = await self.state_store.is_bootstrapped(bot_uuid, open_kfid) + if not current_cursor and not is_bootstrapped and self.state_store.cursor_bootstrap_mode == 'latest': + final_cursor, latest_messages = await self._bootstrap_latest_cursor(bot_uuid, open_kfid, callback_token, webhook_received_at) + processed_count = await self._dispatch_messages(bot_uuid, open_kfid, latest_messages) + await self.state_store.mark_bootstrapped(bot_uuid, open_kfid, final_cursor) + _logger.debug( + f'[wecomcs][pull-worker] latest bootstrap完成,保留末页消息: bot_uuid={bot_uuid}, open_kfid={open_kfid}, processed_count={processed_count}, final_cursor={final_cursor}' + ) + return processed_count + + final_cursor = current_cursor + processed_count = 0 + + while True: + _logger.debug(f'[wecomcs][pull-worker] 拉取消息页: bot_uuid={bot_uuid}, open_kfid={open_kfid}, cursor={current_cursor}') + try: + page = await self.client.fetch_sync_msg_page( + callback_token=callback_token, + open_kfid=open_kfid, + cursor=current_cursor or None, + ) + except WecomCSInvalidSyncMsgTokenError: + if current_cursor: + _logger.warning( + f'[wecomcs][pull-worker] 检测到失效cursor/token组合,清理检查点并改用当前webhook重新拉取: bot_uuid={bot_uuid}, open_kfid={open_kfid}, stale_cursor={current_cursor}' + ) + await self.state_store.clear_checkpoint(bot_uuid, open_kfid) + current_cursor = '' + final_cursor = '' + if self.state_store.cursor_bootstrap_mode == 'latest': + final_cursor, latest_messages = await self._bootstrap_latest_cursor(bot_uuid, open_kfid, callback_token, webhook_received_at) + processed_count += await self._dispatch_messages(bot_uuid, open_kfid, latest_messages) + await self.state_store.mark_bootstrapped(bot_uuid, open_kfid, final_cursor) + _logger.debug( + f'[wecomcs][pull-worker] 失效cursor恢复完成,保留末页消息: bot_uuid={bot_uuid}, open_kfid={open_kfid}, processed_count={processed_count}, final_cursor={final_cursor}' + ) + return processed_count + continue + raise + + msg_list = page.get('msg_list') or [] + _logger.debug(f'[wecomcs][pull-worker] 拉取结果: bot_uuid={bot_uuid}, open_kfid={open_kfid}, msg_count={len(msg_list)}, next_cursor={page.get("next_cursor", "")}, has_more={page.get("has_more", False)}') + next_cursor = str(page.get('next_cursor') or '') + has_more = bool(page.get('has_more', False)) + + processed_count += await self._dispatch_messages(bot_uuid, open_kfid, msg_list) + + if next_cursor: + final_cursor = next_cursor + current_cursor = next_cursor + + if not has_more or not next_cursor: + break + + await self.state_store.mark_bootstrapped(bot_uuid, open_kfid, final_cursor) + + _logger.debug(f'[wecomcs][pull-worker] trigger处理完成: bot_uuid={bot_uuid}, open_kfid={open_kfid}, processed_count={processed_count}, final_cursor={final_cursor}') + return processed_count + finally: + await self.state_store.release_pull_lock(bot_uuid, open_kfid, lock_owner) diff --git a/src/langbot/pkg/platform/wecomcs/retry_scheduler.py b/src/langbot/pkg/platform/wecomcs/retry_scheduler.py new file mode 100644 index 000000000..b1d8c0224 --- /dev/null +++ b/src/langbot/pkg/platform/wecomcs/retry_scheduler.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import json +import logging +import time +import uuid + +from ...cache.redis_mgr import RedisManager + + +_logger = logging.getLogger("langbot") + + +class WecomCSRetryScheduler: + """企业微信客服简化版延迟重试调度器。""" + + def __init__( + self, + redis_mgr: RedisManager, + *, + retry_zset_key: str = 'wecomcs:retry', + retry_backoff_seconds: list[int] | None = None, + retry_max_attempts: int = 3, + ): + self.redis_mgr = redis_mgr + self.retry_zset_key = retry_zset_key + self.retry_backoff_seconds = retry_backoff_seconds or [15, 30, 45] + self.retry_max_attempts = retry_max_attempts + + @staticmethod + def _normalize_stream_fields(stream_fields: dict[str, str]) -> dict[str, str]: + # 中文注释:Redis Stream 字段最终都会按字符串保存,这里提前规范化,避免重试回投时类型漂移。 + return {str(key): '' if value is None else str(value) for key, value in dict(stream_fields).items()} + + async def schedule_retry(self, target_stream: str, stream_fields: dict[str, str], retry_count: int = 0, error: str = '') -> bool: + next_retry_count = retry_count + 1 + if next_retry_count > self.retry_max_attempts: + return False + + delay_index = min(next_retry_count - 1, len(self.retry_backoff_seconds) - 1) + delay_seconds = self.retry_backoff_seconds[delay_index] + retry_at = int(time.time()) + delay_seconds + payload = { + 'retry_id': str(uuid.uuid4()), + 'target_stream': target_stream, + 'stream_fields': self._normalize_stream_fields(stream_fields), + 'retry_count': next_retry_count, + 'error': error, + } + await self.redis_mgr.zadd(self.retry_zset_key, {json.dumps(payload, ensure_ascii=False): retry_at}) + _logger.debug(f'[wecomcs][retry] 安排重试: target_stream={target_stream}, retry_count={next_retry_count}, retry_at={retry_at}, error={error}') + return True + + async def poll_due_jobs(self, now_ts: int | None = None) -> list[dict]: + now_ts = int(now_ts or time.time()) + members = await self.redis_mgr.zrangebyscore(self.retry_zset_key, 0, now_ts) + jobs = [] + for member in members: + try: + jobs.append({'member': member, 'payload': json.loads(member)}) + except json.JSONDecodeError: + await self.redis_mgr.zrem(self.retry_zset_key, member) + return jobs + + async def replay_due_jobs(self, now_ts: int | None = None) -> int: + jobs = await self.poll_due_jobs(now_ts=now_ts) + replayed = 0 + for job in jobs: + payload = job['payload'] + replay_fields = self._normalize_stream_fields(payload['stream_fields']) + replay_fields['retry_count'] = str(payload.get('retry_count', 0)) + if payload.get('error'): + replay_fields['last_error'] = str(payload['error']) + await self.redis_mgr.xadd(payload['target_stream'], replay_fields) + await self.redis_mgr.zrem(self.retry_zset_key, job['member']) + _logger.debug(f'[wecomcs][retry] 回投重试任务: target_stream={payload["target_stream"]}, retry_count={payload.get("retry_count")}, error={payload.get("error", "")}') + replayed += 1 + return replayed diff --git a/src/langbot/pkg/platform/wecomcs/runtime.py b/src/langbot/pkg/platform/wecomcs/runtime.py new file mode 100644 index 000000000..0ff7acbaf --- /dev/null +++ b/src/langbot/pkg/platform/wecomcs/runtime.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +import asyncio +import logging +import socket +from ...cache.redis_mgr import RedisManager +from langbot.libs.wecom_customer_service_api.api import WecomCSInvalidSyncMsgTokenError +from .message_publisher import WecomCSMessagePublisher +from .message_worker import WecomCSMessageWorker +from .pull_worker import PullLockNotAcquiredError, WecomCSPullWorker +from .state_store import WecomCSStateStore +from .retry_scheduler import WecomCSRetryScheduler + + +_logger = logging.getLogger("langbot") + + +class WecomCSSchedulerRuntime: + """企业微信客服 Redis 两层 Streams 运行时。""" + + def __init__(self, bot_uuid: str, client, redis_mgr: RedisManager, scheduler_config: dict, persistence_mgr = None): + self.bot_uuid = bot_uuid + self.client = client + self.redis_mgr = redis_mgr + self.scheduler_config = scheduler_config + self.running = False + self.state_store = WecomCSStateStore( + redis_mgr, + persistence_mgr, + message_state_ttl_seconds=int( + scheduler_config.get('message_state_ttl_seconds', scheduler_config.get('dedupe_ttl_seconds', 604800)) or 604800 + ), + cursor_bootstrap_mode=str(scheduler_config.get('cursor_bootstrap_mode', 'latest') or 'latest'), + ) + self.message_publisher = WecomCSMessagePublisher( + redis_mgr, + int(scheduler_config.get('process_stream_shard_count', 16) or 16), + ) + self.message_worker = WecomCSMessageWorker(client, self.state_store, bot_uuid=bot_uuid) + self.pull_worker = WecomCSPullWorker( + client, + self.state_store, + self._publish_message, + message_state_ttl_seconds=int( + scheduler_config.get('message_state_ttl_seconds', scheduler_config.get('dedupe_ttl_seconds', 604800)) or 604800 + ), + lock_ttl_seconds=int(scheduler_config.get('lock_ttl_seconds', 60) or 60), + history_message_drop_threshold_seconds=int(scheduler_config.get('history_message_drop_threshold_seconds', 90) or 90), + ) + self.pull_stream_shard_count = int(scheduler_config.get('pull_stream_shard_count', 8) or 8) + self.process_stream_shard_count = int(scheduler_config.get('process_stream_shard_count', 16) or 16) + self.pull_consumer_group = scheduler_config.get('pull_consumer_group', 'wecomcs-pull-group') + self.process_consumer_group = scheduler_config.get('process_consumer_group', 'wecomcs-process-group') + self.stream_block_ms = int(scheduler_config.get('stream_block_ms', 1000) or 1000) + self.stream_batch_size = int(scheduler_config.get('stream_batch_size', 10) or 10) + self.consumer_name = f'{socket.gethostname()}-{self.bot_uuid}' + self.retry_poll_interval_seconds = int(scheduler_config.get('retry_poll_interval_seconds', 3) or 3) + self.retry_scheduler = WecomCSRetryScheduler( + redis_mgr, + retry_backoff_seconds=list(scheduler_config.get('retry_backoff_seconds', [15, 30, 45]) or [15, 30, 45]), + retry_max_attempts=int(scheduler_config.get('retry_max_attempts', 3) or 3), + ) + + async def _publish_message(self, msg_data: dict): + await self.message_publisher.publish_message(self.bot_uuid, msg_data) + + def _pull_streams(self) -> list[str]: + return [f'wecomcs:pull-trigger:{index}' for index in range(self.pull_stream_shard_count)] + + def _process_streams(self) -> list[str]: + return [f'wecomcs:message-process:{index}' for index in range(self.process_stream_shard_count)] + + @staticmethod + def _extract_retry_count(fields: dict[str, str]) -> int: + try: + return int(str(fields.get('retry_count', '0') or '0')) + except (TypeError, ValueError): + return 0 + + async def initialize(self): + for stream_name in self._pull_streams(): + await self.redis_mgr.xgroup_create(stream_name, self.pull_consumer_group, id='0', mkstream=True) + for stream_name in self._process_streams(): + await self.redis_mgr.xgroup_create(stream_name, self.process_consumer_group, id='0', mkstream=True) + + async def run(self): + await self.initialize() + self.running = True + _logger.debug(f'[wecomcs][runtime] 启动调度运行时: bot_uuid={self.bot_uuid}, pull_shards={self.pull_stream_shard_count}, process_shards={self.process_stream_shard_count}') + await asyncio.gather(self._run_pull_loop(), self._run_process_loop(), self._run_retry_loop()) + + async def stop(self): + self.running = False + + async def _run_pull_loop(self): + streams = {stream_name: '>' for stream_name in self._pull_streams()} + while self.running: + entries = await self.redis_mgr.xreadgroup( + self.pull_consumer_group, + self.consumer_name, + streams, + count=self.stream_batch_size, + block_ms=self.stream_block_ms, + ) + if not entries: + continue + + for stream_name, messages in entries: + logical_stream_name = stream_name.split(':', 1)[1] if stream_name.startswith(self.redis_mgr.key_prefix + ':') else stream_name + for message_id, fields in messages: + retry_count = self._extract_retry_count(fields) + try: + _logger.debug(f'[wecomcs][runtime] pull-loop消费消息: stream={logical_stream_name}, message_id={message_id}, bot_uuid={fields.get("bot_uuid")}, open_kfid={fields.get("open_kfid")}') + await self.pull_worker.handle_trigger(fields) + except PullLockNotAcquiredError as exc: + _logger.debug(f'[wecomcs][runtime] pull-loop未拿到锁,安排重试: stream={logical_stream_name}, message_id={message_id}, error={exc}') + await self.retry_scheduler.schedule_retry(logical_stream_name, fields, retry_count=retry_count, error=str(exc)) + except WecomCSInvalidSyncMsgTokenError as exc: + _logger.warning(f'[wecomcs][runtime] pull-loop检测到不可重试的invalid msg token,直接ACK等待新webhook: stream={logical_stream_name}, message_id={message_id}, error={exc}') + except Exception as exc: + _logger.debug(f'[wecomcs][runtime] pull-loop处理异常,安排重试: stream={logical_stream_name}, message_id={message_id}, error={exc}') + await self.retry_scheduler.schedule_retry(logical_stream_name, fields, retry_count=retry_count, error=str(exc)) + finally: + await self.redis_mgr.xack(logical_stream_name, self.pull_consumer_group, message_id) + _logger.debug(f'[wecomcs][runtime] pull-loop已ACK: stream={logical_stream_name}, message_id={message_id}') + + async def _run_process_loop(self): + streams = {stream_name: '>' for stream_name in self._process_streams()} + while self.running: + entries = await self.redis_mgr.xreadgroup( + self.process_consumer_group, + self.consumer_name, + streams, + count=self.stream_batch_size, + block_ms=self.stream_block_ms, + ) + if not entries: + continue + + for stream_name, messages in entries: + logical_stream_name = stream_name.split(':', 1)[1] if stream_name.startswith(self.redis_mgr.key_prefix + ':') else stream_name + for message_id, fields in messages: + retry_count = self._extract_retry_count(fields) + try: + _logger.debug(f'[wecomcs][runtime] process-loop消费消息: stream={logical_stream_name}, message_id={message_id}') + handled = await self.message_worker.handle_stream_entry(fields) + if not handled: + _logger.debug(f'[wecomcs][runtime] process-loop消息无效,直接ACK: stream={logical_stream_name}, message_id={message_id}') + await self.redis_mgr.xack(logical_stream_name, self.process_consumer_group, message_id) + continue + except Exception as exc: + _logger.debug(f'[wecomcs][runtime] process-loop处理异常,安排重试: stream={logical_stream_name}, message_id={message_id}, error={exc}') + await self.retry_scheduler.schedule_retry(logical_stream_name, fields, retry_count=retry_count, error=str(exc)) + finally: + await self.redis_mgr.xack(logical_stream_name, self.process_consumer_group, message_id) + _logger.debug(f'[wecomcs][runtime] process-loop已ACK: stream={logical_stream_name}, message_id={message_id}') + + + async def _run_retry_loop(self): + while self.running: + replayed = await self.retry_scheduler.replay_due_jobs() + if replayed > 0: + _logger.debug(f'[wecomcs][runtime] retry-loop回投完成: bot_uuid={self.bot_uuid}, replayed={replayed}') + if replayed <= 0: + await asyncio.sleep(self.retry_poll_interval_seconds) diff --git a/src/langbot/pkg/platform/wecomcs/sharding.py b/src/langbot/pkg/platform/wecomcs/sharding.py new file mode 100644 index 000000000..5c94ff035 --- /dev/null +++ b/src/langbot/pkg/platform/wecomcs/sharding.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +import hashlib + + +def _stable_hash(value: str) -> int: + return int(hashlib.sha256(value.encode("utf-8")).hexdigest(), 16) + + +def resolve_pull_shard(bot_uuid: str, open_kfid: str, shard_count: int) -> int: + if shard_count <= 0: + raise ValueError("pull shard_count must be greater than 0") + route_key = f"{bot_uuid}:{open_kfid}" + return _stable_hash(route_key) % shard_count + + +def resolve_process_shard(bot_uuid: str, open_kfid: str, external_userid: str, shard_count: int) -> int: + if shard_count <= 0: + raise ValueError("process shard_count must be greater than 0") + route_key = f"{bot_uuid}:{open_kfid}:{external_userid}" + return _stable_hash(route_key) % shard_count diff --git a/src/langbot/pkg/platform/wecomcs/state_store.py b/src/langbot/pkg/platform/wecomcs/state_store.py new file mode 100644 index 000000000..6faabefc9 --- /dev/null +++ b/src/langbot/pkg/platform/wecomcs/state_store.py @@ -0,0 +1,185 @@ +from __future__ import annotations + +import json +import logging + +from .cursor_store import WecomCSCursorStore +from .message_state_store import WecomCSMessageStateStore + + +_logger = logging.getLogger("langbot") + + +class WecomCSStateStore: + """企业微信客服调度状态存储。""" + + def __init__( + self, + redis_mgr, + persistence_mgr = None, + *, + cursor_store: WecomCSCursorStore | None = None, + message_state_ttl_seconds: int = 604800, + cursor_bootstrap_mode: str = 'latest', + ): + self.redis_mgr = redis_mgr + self.cursor_store = cursor_store or WecomCSCursorStore(persistence_mgr) + self.message_state_store = WecomCSMessageStateStore(redis_mgr, ttl_seconds=message_state_ttl_seconds) + self.cursor_bootstrap_mode = cursor_bootstrap_mode + + def _pull_lock_key(self, bot_uuid: str, open_kfid: str) -> str: + return f"wecomcs:pull_lock:{bot_uuid}:{open_kfid}" + + def _cursor_fallback_key(self, bot_uuid: str, open_kfid: str) -> str: + return f'wecomcs:cursor_checkpoint:{bot_uuid}:{open_kfid}' + + @staticmethod + def _checkpoint_cursor(checkpoint) -> str: + if checkpoint is None: + return '' + if isinstance(checkpoint, dict): + return str(checkpoint.get('cursor', '') or '') + return str(getattr(checkpoint, 'cursor', '') or '') + + @staticmethod + def _checkpoint_bootstrapped(checkpoint) -> bool: + if checkpoint is None: + return False + if isinstance(checkpoint, dict): + return bool(checkpoint.get('bootstrapped', False)) + return bool(getattr(checkpoint, 'bootstrapped', False)) + + async def _get_fallback_checkpoint(self, bot_uuid: str, open_kfid: str): + if not self.redis_mgr or not self.redis_mgr.is_available(): + return None + raw_value = await self.redis_mgr.get(self._cursor_fallback_key(bot_uuid, open_kfid)) + if not raw_value: + return None + try: + return json.loads(raw_value) + except json.JSONDecodeError: + return None + + async def _save_fallback_checkpoint(self, bot_uuid: str, open_kfid: str, cursor: str, bootstrapped: bool): + if not self.redis_mgr or not self.redis_mgr.is_available(): + return + payload = { + 'bot_uuid': bot_uuid, + 'open_kfid': open_kfid, + 'cursor': cursor, + 'bootstrapped': bootstrapped, + } + await self.redis_mgr.set(self._cursor_fallback_key(bot_uuid, open_kfid), json.dumps(payload, ensure_ascii=False)) + + + def _cursor_store_available(self) -> bool: + if self.cursor_store is None: + return False + if hasattr(self.cursor_store, 'is_available'): + return bool(self.cursor_store.is_available()) + return True + async def get_checkpoint(self, bot_uuid: str, open_kfid: str): + if self._cursor_store_available(): + checkpoint = await self.cursor_store.get_checkpoint(bot_uuid, open_kfid) + if checkpoint is not None: + return checkpoint + return await self._get_fallback_checkpoint(bot_uuid, open_kfid) + + async def get_cursor(self, bot_uuid: str, open_kfid: str) -> str: + checkpoint = await self.get_checkpoint(bot_uuid, open_kfid) + cursor = self._checkpoint_cursor(checkpoint) + _logger.debug(f'[wecomcs][state] 读取cursor: bot_uuid={bot_uuid}, open_kfid={open_kfid}, cursor={cursor}') + return cursor + + async def set_cursor(self, bot_uuid: str, open_kfid: str, cursor: str): + if self._cursor_store_available(): + await self.cursor_store.save_checkpoint(bot_uuid, open_kfid, cursor, True) + else: + await self._save_fallback_checkpoint(bot_uuid, open_kfid, cursor, True) + _logger.debug(f'[wecomcs][state] 更新cursor: bot_uuid={bot_uuid}, open_kfid={open_kfid}, cursor={cursor}') + + async def is_bootstrapped(self, bot_uuid: str, open_kfid: str) -> bool: + checkpoint = await self.get_checkpoint(bot_uuid, open_kfid) + return self._checkpoint_bootstrapped(checkpoint) + + async def mark_bootstrapped(self, bot_uuid: str, open_kfid: str, cursor: str): + if self._cursor_store_available(): + await self.cursor_store.save_checkpoint(bot_uuid, open_kfid, cursor, True) + else: + await self._save_fallback_checkpoint(bot_uuid, open_kfid, cursor, True) + _logger.debug(f'[wecomcs][state] 标记bootstrap完成: bot_uuid={bot_uuid}, open_kfid={open_kfid}, cursor={cursor}') + + async def clear_checkpoint(self, bot_uuid: str, open_kfid: str): + if self._cursor_store_available() and hasattr(self.cursor_store, 'delete_checkpoint'): + await self.cursor_store.delete_checkpoint(bot_uuid, open_kfid) + else: + if self.redis_mgr and self.redis_mgr.is_available(): + await self.redis_mgr.delete(self._cursor_fallback_key(bot_uuid, open_kfid)) + _logger.debug(f'[wecomcs][state] 清理cursor检查点: bot_uuid={bot_uuid}, open_kfid={open_kfid}') + + async def acquire_pull_lock(self, bot_uuid: str, open_kfid: str, owner: str, ttl_seconds: int) -> bool: + if not self.redis_mgr or not self.redis_mgr.is_available(): + return False + acquired = await self.redis_mgr.set_if_not_exists( + self._pull_lock_key(bot_uuid, open_kfid), + owner, + ex=ttl_seconds, + ) + _logger.debug(f'[wecomcs][state] 获取pull锁: bot_uuid={bot_uuid}, open_kfid={open_kfid}, owner={owner}, acquired={acquired}') + return acquired + + async def release_pull_lock(self, bot_uuid: str, open_kfid: str, owner: str) -> bool: + if not self.redis_mgr or not self.redis_mgr.is_available(): + return False + lock_key = self._pull_lock_key(bot_uuid, open_kfid) + current_owner = await self.redis_mgr.get(lock_key) + if current_owner != owner: + _logger.debug(f'[wecomcs][state] 释放pull锁失败(所有者不匹配): bot_uuid={bot_uuid}, open_kfid={open_kfid}, owner={owner}, current_owner={current_owner}') + return False + await self.redis_mgr.delete(lock_key) + _logger.debug(f'[wecomcs][state] 释放pull锁成功: bot_uuid={bot_uuid}, open_kfid={open_kfid}, owner={owner}') + return True + + async def get_message_state(self, bot_uuid: str, open_kfid: str, msgid: str) -> dict | None: + return await self.message_state_store.get_state(bot_uuid, open_kfid, msgid) + + async def reserve_message_for_queue(self, bot_uuid: str, open_kfid: str, msg_data: dict) -> bool: + return await self.message_state_store.reserve_for_queue(bot_uuid, open_kfid, msg_data) + + async def mark_message_processing(self, bot_uuid: str, open_kfid: str, msgid: str): + await self.message_state_store.update_status(bot_uuid, open_kfid, msgid, process_status='processing') + + async def mark_message_done(self, bot_uuid: str, open_kfid: str, msgid: str): + await self.message_state_store.update_status(bot_uuid, open_kfid, msgid, process_status='done', last_error_stage='', last_error='') + + async def mark_message_failed(self, bot_uuid: str, open_kfid: str, msgid: str, *, stage: str, error: str): + await self.message_state_store.update_status( + bot_uuid, + open_kfid, + msgid, + process_status='failed', + last_error_stage=stage, + last_error=error, + ) + + async def mark_reply_success(self, bot_uuid: str, open_kfid: str, msgid: str): + await self.message_state_store.update_status(bot_uuid, open_kfid, msgid, reply_status='success', last_error_stage='', last_error='') + + async def mark_reply_failed(self, bot_uuid: str, open_kfid: str, msgid: str, *, error: str): + await self.message_state_store.update_status( + bot_uuid, + open_kfid, + msgid, + reply_status='failed', + last_error_stage='reply_message', + last_error=error, + ) + + async def mark_message_once(self, bot_uuid: str, msgid: str, ttl_seconds: int) -> bool: + # 中文注释:保留旧接口兼容测试和旧逻辑,内部等价于直接写入已处理状态。 + if not self.redis_mgr or not self.redis_mgr.is_available(): + return False + key = f'wecomcs:legacy_dedupe:{bot_uuid}:{msgid}' + marked = await self.redis_mgr.set_if_not_exists(key, '1', ex=ttl_seconds) + _logger.debug(f'[wecomcs][state] 兼容幂等标记: bot_uuid={bot_uuid}, msgid={msgid}, marked={marked}') + return marked diff --git a/src/langbot/pkg/platform/wecomcs/token_cache.py b/src/langbot/pkg/platform/wecomcs/token_cache.py new file mode 100644 index 000000000..4fa0130bf --- /dev/null +++ b/src/langbot/pkg/platform/wecomcs/token_cache.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +import asyncio +import logging +import time +from collections.abc import Awaitable, Callable + +from ...cache.redis_mgr import RedisManager + + +_logger = logging.getLogger("langbot") + + +class WecomCSTokenCache: + """企业微信客服 access token 缓存。""" + + def __init__( + self, + corpid: str, + redis_mgr: RedisManager | None = None, + refresh_skew_seconds: int = 300, + secret_fingerprint: str = '', + ): + self.corpid = corpid + self.redis_mgr = redis_mgr + self.refresh_skew_seconds = max(0, refresh_skew_seconds) + # 中文注释:同一个 corpid 下可能存在不同 secret 对应不同 access_token, + # 这里把 secret 指纹纳入缓存维度,避免 wecomcs / wecom_kf_app 互串 token。 + self.secret_fingerprint = str(secret_fingerprint or '').strip() + self._token_lock = asyncio.Lock() + self._local_cache: dict[str, int | str] = { + "access_token": "", + "expires_at": 0, + } + + def _cache_key(self) -> str: + if self.secret_fingerprint: + return f"wecomcs:access_token:{self.corpid}:{self.secret_fingerprint}" + return f"wecomcs:access_token:{self.corpid}" + + def _is_valid_payload(self, payload: dict | None) -> bool: + # 中文注释:本地和 Redis 都统一按 expires_at 判断,避免只凭是否有字符串误判 token 有效。 + if not payload: + return False + access_token = str(payload.get("access_token", "")).strip() + expires_at = int(payload.get("expires_at", 0) or 0) + return bool(access_token) and expires_at > int(time.time()) + + async def _read_cached_payload(self) -> dict | None: + if self._is_valid_payload(self._local_cache): + _logger.debug(f'[wecomcs][token-cache] 本地缓存命中: corpid={self.corpid[:10]}...') + return dict(self._local_cache) + + if self.redis_mgr and self.redis_mgr.is_available(): + payload = await self.redis_mgr.get_json(self._cache_key()) + if self._is_valid_payload(payload): + _logger.debug(f'[wecomcs][token-cache] Redis缓存命中: corpid={self.corpid[:10]}...') + self._local_cache = { + "access_token": str(payload["access_token"]), + "expires_at": int(payload["expires_at"]), + } + return dict(self._local_cache) + + return None + + async def get_cached_token(self) -> str | None: + payload = await self._read_cached_payload() + if not payload: + return None + return str(payload["access_token"]) + + async def get_or_refresh( + self, + fetcher: Callable[[], Awaitable[dict]], + *, + force_refresh: bool = False, + ) -> str: + if not force_refresh: + cached_token = await self.get_cached_token() + if cached_token: + return cached_token + + async with self._token_lock: + if not force_refresh: + cached_token = await self.get_cached_token() + if cached_token: + return cached_token + + # 中文注释:fetcher 负责真实请求企业微信接口,缓存层只负责生命周期和存储。 + token_data = await fetcher() + access_token = str(token_data.get("access_token", "")).strip() + expires_in = int(token_data.get("expires_in", 7200) or 7200) + if not access_token: + raise ValueError("access_token is missing in token_data") + + now = int(time.time()) + expires_at = now + expires_in + ttl_seconds = max(1, expires_in - self.refresh_skew_seconds) + payload = { + "access_token": access_token, + "expires_at": expires_at, + } + + _logger.debug( + f'[wecomcs][token-cache] 刷新token成功: corpid={self.corpid[:10]}..., ttl_seconds={ttl_seconds}, expires_at={expires_at}' + ) + self._local_cache = payload + if self.redis_mgr and self.redis_mgr.is_available(): + await self.redis_mgr.set_json(self._cache_key(), payload, ex=ttl_seconds) + + return access_token + + async def invalidate(self): + _logger.debug(f'[wecomcs][token-cache] 失效token缓存: corpid={self.corpid[:10]}...') + self._local_cache = { + "access_token": "", + "expires_at": 0, + } + if self.redis_mgr and self.redis_mgr.is_available(): + await self.redis_mgr.delete(self._cache_key()) diff --git a/src/langbot/pkg/utils/constants.py b/src/langbot/pkg/utils/constants.py index ea0a682f1..4fad9069b 100644 --- a/src/langbot/pkg/utils/constants.py +++ b/src/langbot/pkg/utils/constants.py @@ -2,7 +2,7 @@ semantic_version = f'v{langbot.__version__}' -required_database_version = 24 +required_database_version = 25 """Tag the version of the database schema, used to check if the database needs to be migrated""" debug_mode = False diff --git a/src/langbot/templates/config.yaml b/src/langbot/templates/config.yaml index a75a32fc5..23c64927c 100644 --- a/src/langbot/templates/config.yaml +++ b/src/langbot/templates/config.yaml @@ -87,3 +87,28 @@ space: oauth_authorize_url: 'https://space.langbot.app/auth/authorize' disable_models_service: false disable_telemetry: false + +redis: + enabled: false + url: 'redis://127.0.0.1:6379/0' + key_prefix: 'langbot' +wecomcs_scheduler: + enabled: false + token_refresh_skew_seconds: 300 + pull_stream_shard_count: 8 + process_stream_shard_count: 16 + pull_consumer_group: 'wecomcs-pull-group' + process_consumer_group: 'wecomcs-process-group' + stream_block_ms: 1000 + stream_batch_size: 10 + retry_poll_interval_seconds: 3 + retry_max_attempts: 3 + retry_backoff_seconds: + - 15 + - 30 + - 45 + dedupe_ttl_seconds: 604800 + message_state_ttl_seconds: 604800 + history_message_drop_threshold_seconds: 90 + cursor_bootstrap_mode: 'latest' + lock_ttl_seconds: 60 diff --git a/tests/unit_tests/platform/test_wecom_kf_app_adapter.py b/tests/unit_tests/platform/test_wecom_kf_app_adapter.py new file mode 100644 index 000000000..f3812a5ca --- /dev/null +++ b/tests/unit_tests/platform/test_wecom_kf_app_adapter.py @@ -0,0 +1,108 @@ +import yaml +import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger + +from langbot.pkg.api.http.service.bot import BotService + + +def test_wecom_kf_app_adapter_inherits_wecomcs_adapter(): + from langbot.pkg.platform.sources.wecom_kf_app import WecomKFAppAdapter + from langbot.pkg.platform.sources.wecomcs import WecomCSAdapter + + assert issubclass(WecomKFAppAdapter, WecomCSAdapter) + + +def test_wecom_kf_app_manifest_points_to_adapter_class(): + with open('src/langbot/pkg/platform/sources/wecom_kf_app.yaml', encoding='utf-8') as fp: + data = yaml.safe_load(fp) + + assert data['metadata']['name'] == 'wecom_kf_app' + assert data['execution']['python']['path'] == './wecom_kf_app.py' + assert data['execution']['python']['attr'] == 'WecomKFAppAdapter' + + +class FakePlatformManager: + async def get_bot_by_uuid(self, bot_uuid: str): + return None + + +class FakeBotService(BotService): + async def get_bot(self, bot_uuid: str, include_secret: bool = True): + return { + 'uuid': bot_uuid, + 'adapter': 'wecom_kf_app', + 'adapter_config': {}, + 'name': 'demo', + 'description': 'demo', + 'enable': True, + } + + +class FakeApp: + def __init__(self): + self.platform_mgr = FakePlatformManager() + self.instance_config = type( + 'Cfg', + (), + {'data': {'api': {'webhook_prefix': 'http://127.0.0.1:5300', 'extra_webhook_prefix': ''}}}, + )() + + +async def test_get_runtime_bot_info_exposes_webhook_url_for_wecom_kf_app(): + service = FakeBotService(FakeApp()) + + runtime_info = await service.get_runtime_bot_info('bot-1') + + assert runtime_info['adapter_runtime_values']['webhook_url'] == '/bots/bot-1' + assert runtime_info['adapter_runtime_values']['webhook_full_url'] == 'http://127.0.0.1:5300/bots/bot-1' + + +class FakeRedisManager: + def is_available(self) -> bool: + return True + + +class FakeInstanceConfig: + def __init__(self): + self.data = {'wecomcs_scheduler': {'enabled': True}} + + +class FakeAdapterApp: + def __init__(self): + self.instance_config = FakeInstanceConfig() + self.redis_mgr = FakeRedisManager() + self.persistence_mgr = object() + + +class FakeLogger(abstract_platform_logger.AbstractEventLogger): + def __init__(self): + self.ap = FakeAdapterApp() + + async def error(self, *args, **kwargs): + return None + + async def warning(self, *args, **kwargs): + return None + + async def info(self, *args, **kwargs): + return None + + async def debug(self, *args, **kwargs): + return None + + +def test_wecom_kf_app_adapter_creates_wecomcs_client(): + from langbot.libs.wecom_customer_service_api.api import WecomCSClient + from langbot.pkg.platform.sources.wecom_kf_app import WecomKFAppAdapter + + adapter = WecomKFAppAdapter( + { + 'corpid': 'corp-id', + 'secret': 'secret', + 'token': 'token', + 'EncodingAESKey': 'aes', + 'api_base_url': 'https://qyapi.weixin.qq.com/cgi-bin', + }, + FakeLogger(), + ) + + assert isinstance(adapter.bot, WecomCSClient) diff --git a/tests/unit_tests/platform/test_wecomcs_adapter_config.py b/tests/unit_tests/platform/test_wecomcs_adapter_config.py new file mode 100644 index 000000000..263b4dad8 --- /dev/null +++ b/tests/unit_tests/platform/test_wecomcs_adapter_config.py @@ -0,0 +1,176 @@ +import pytest + +import langbot.pkg.platform.sources.wecomcs as wecomcs_source +import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger +from langbot.pkg.platform.sources.wecomcs import WecomCSAdapter, WecomEventConverter + + +class FakeRedisManager: + def is_available(self) -> bool: + return True + + +class FakeInstanceConfig: + def __init__(self, scheduler_config: dict): + self.data = {'wecomcs_scheduler': scheduler_config} + + +class FakeApp: + def __init__(self, scheduler_config: dict): + self.instance_config = FakeInstanceConfig(scheduler_config) + self.redis_mgr = FakeRedisManager() + self.persistence_mgr = object() + + +class FakeLogger(abstract_platform_logger.AbstractEventLogger): + def __init__(self, scheduler_config: dict): + self.ap = FakeApp(scheduler_config) + + async def error(self, *args, **kwargs): + return None + + async def warning(self, *args, **kwargs): + return None + + async def info(self, *args, **kwargs): + return None + + async def debug(self, *args, **kwargs): + return None + + +@pytest.fixture +def base_config() -> dict: + return { + 'corpid': 'corp-id', + 'secret': 'secret', + 'token': 'token', + 'EncodingAESKey': 'aes', + 'api_base_url': 'https://qyapi.weixin.qq.com/cgi-bin', + } + + +@pytest.mark.asyncio +async def test_wecomcs_bot_config_overrides_global_scheduler_settings(base_config): + logger = FakeLogger( + { + 'enabled': True, + 'history_message_drop_threshold_seconds': 180, + 'retry_max_attempts': 7, + 'retry_backoff_seconds': [9, 18, 27], + 'lock_ttl_seconds': 99, + 'nickname_lookup_timeout_seconds': 5.0, + } + ) + adapter = WecomCSAdapter( + { + **base_config, + 'history_message_drop_threshold_seconds': 45, + 'retry_max_attempts': 2, + 'retry_backoff_seconds': '3,6,9', + 'lock_ttl_seconds': 12, + 'nickname_lookup_timeout_seconds': 1.25, + }, + logger, + ) + + adapter.set_bot_uuid('bot-1') + + assert adapter.scheduler_runtime is not None + assert adapter.scheduler_runtime.scheduler_config['history_message_drop_threshold_seconds'] == 45 + assert adapter.scheduler_runtime.scheduler_config['retry_max_attempts'] == 2 + assert adapter.scheduler_runtime.scheduler_config['retry_backoff_seconds'] == [3, 6, 9] + assert adapter.scheduler_runtime.scheduler_config['lock_ttl_seconds'] == 12 + assert adapter.bot.nickname_lookup_timeout_seconds == 1.25 + + +@pytest.mark.asyncio +async def test_wecomcs_adapter_falls_back_to_global_scheduler_settings(base_config): + logger = FakeLogger( + { + 'enabled': True, + 'history_message_drop_threshold_seconds': 150, + 'retry_max_attempts': 5, + 'retry_backoff_seconds': [10, 20], + 'lock_ttl_seconds': 88, + 'nickname_lookup_timeout_seconds': 4.0, + } + ) + adapter = WecomCSAdapter(base_config, logger) + + adapter.set_bot_uuid('bot-1') + + assert adapter.scheduler_runtime is not None + assert adapter.scheduler_runtime.scheduler_config['history_message_drop_threshold_seconds'] == 150 + assert adapter.scheduler_runtime.scheduler_config['retry_max_attempts'] == 5 + assert adapter.scheduler_runtime.scheduler_config['retry_backoff_seconds'] == [10, 20] + assert adapter.scheduler_runtime.scheduler_config['lock_ttl_seconds'] == 88 + assert adapter.bot.nickname_lookup_timeout_seconds == 4.0 + + +@pytest.mark.asyncio +async def test_wecomcs_adapter_falls_back_to_code_defaults_when_configs_missing(base_config): + logger = FakeLogger({'enabled': True}) + adapter = WecomCSAdapter(base_config, logger) + + adapter.set_bot_uuid('bot-1') + + assert adapter.scheduler_runtime is not None + assert adapter.scheduler_runtime.scheduler_config['history_message_drop_threshold_seconds'] == 90 + assert adapter.scheduler_runtime.scheduler_config['retry_max_attempts'] == 3 + assert adapter.scheduler_runtime.scheduler_config['retry_backoff_seconds'] == [15, 30, 45] + assert adapter.scheduler_runtime.scheduler_config['lock_ttl_seconds'] == 60 + assert adapter.bot.nickname_lookup_timeout_seconds == 30.0 + + +@pytest.mark.asyncio +async def test_wecomcs_adapter_uses_defaults_when_retry_backoff_string_invalid(base_config): + logger = FakeLogger( + { + 'enabled': True, + 'retry_backoff_seconds': [11, 22, 33], + } + ) + adapter = WecomCSAdapter( + { + **base_config, + 'retry_backoff_seconds': 'abc, ,x', + }, + logger, + ) + + adapter.set_bot_uuid('bot-1') + + assert adapter.scheduler_runtime is not None + assert adapter.scheduler_runtime.scheduler_config['retry_backoff_seconds'] == [11, 22, 33] + + +@pytest.mark.asyncio +async def test_target2yiri_uses_bot_configured_nickname_timeout(monkeypatch): + captured = {} + + async def fake_wait_for(coro, timeout): + captured['timeout'] = timeout + result = await coro + return result + + class FakeBot: + nickname_lookup_timeout_seconds = 1.75 + + async def get_customer_info(self, external_userid: str): + return {'nickname': 'Tester'} + + class FakeEvent: + type = 'text' + user_id = 'user-1' + message = 'hello' + message_id = 'msg-1' + timestamp = 123456 + receiver_id = 'kf-1' + + monkeypatch.setattr(wecomcs_source.asyncio, 'wait_for', fake_wait_for) + + result = await WecomEventConverter.target2yiri(FakeEvent(), FakeBot()) + + assert captured['timeout'] == 1.75 + assert result.sender.nickname == 'Tester' diff --git a/tests/unit_tests/platform/test_wecomcs_cursor_store.py b/tests/unit_tests/platform/test_wecomcs_cursor_store.py new file mode 100644 index 000000000..f4c1707eb --- /dev/null +++ b/tests/unit_tests/platform/test_wecomcs_cursor_store.py @@ -0,0 +1,45 @@ +import pytest +import sqlalchemy +from sqlalchemy.ext.asyncio import create_async_engine + +from langbot.pkg.entity.persistence.base import Base +from langbot.pkg.entity.persistence.wecomcs import WecomCSCursorCheckpoint as PersistenceCheckpoint +from langbot.pkg.platform.wecomcs.cursor_store import WecomCSCursorStore + + +class FakePersistenceManager: + def __init__(self, engine): + self.engine = engine + + async def execute_async(self, *args, **kwargs): + async with self.engine.connect() as conn: + result = await conn.execute(*args, **kwargs) + await conn.commit() + return result + + +@pytest.mark.asyncio +async def test_cursor_store_returns_checkpoint_object_instead_of_scalar_id(): + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + await conn.execute( + sqlalchemy.insert(PersistenceCheckpoint).values( + bot_uuid="bot-1", + open_kfid="kf-1", + cursor="cursor-1", + bootstrapped=True, + ) + ) + + store = WecomCSCursorStore(FakePersistenceManager(engine)) + + checkpoint = await store.get_checkpoint("bot-1", "kf-1") + + assert checkpoint is not None + assert checkpoint.bot_uuid == "bot-1" + assert checkpoint.open_kfid == "kf-1" + assert checkpoint.cursor == "cursor-1" + assert checkpoint.bootstrapped is True + + await engine.dispose() diff --git a/tests/unit_tests/platform/test_wecomcs_message_layer.py b/tests/unit_tests/platform/test_wecomcs_message_layer.py new file mode 100644 index 000000000..7d526254d --- /dev/null +++ b/tests/unit_tests/platform/test_wecomcs_message_layer.py @@ -0,0 +1,178 @@ +import pytest + +from langbot.pkg.platform.wecomcs.message_publisher import WecomCSMessagePublisher +from langbot.pkg.platform.wecomcs.message_worker import WecomCSMessageWorker + + +class FakeRedisManager: + def __init__(self): + self.entries: list[tuple[str, dict[str, str]]] = [] + + def is_available(self) -> bool: + return True + + async def xadd(self, stream: str, fields: dict[str, str], maxlen: int | None = None): + self.entries.append((stream, fields)) + return '1-0' + + +def test_message_publisher_routes_message_to_process_stream(): + redis_mgr = FakeRedisManager() + publisher = WecomCSMessagePublisher(redis_mgr, shard_count=16) + + msg_data = { + 'open_kfid': 'kf-1', + 'external_userid': 'user-1', + 'msgid': 'msg-1', + 'msgtype': 'text', + 'send_time': 123456, + 'text': {'content': 'hello'}, + } + + import asyncio + + stream_name, payload = asyncio.run(publisher.publish_message('bot-1', msg_data)) + + assert stream_name.startswith('wecomcs:message-process:') + assert payload['job_type'] == 'message_process' + assert payload['external_userid'] == 'user-1' + assert redis_mgr.entries[0][0] == stream_name + + +@pytest.mark.asyncio +async def test_message_worker_dispatches_to_client_handle_message(): + dispatched: list[str] = [] + + class FakeClient: + async def _handle_message(self, event): + dispatched.append(event.message_id) + + worker = WecomCSMessageWorker(FakeClient()) + ok = await worker.handle_message( + { + 'msgtype': 'text', + 'external_userid': 'user-1', + 'open_kfid': 'kf-1', + 'msgid': 'msg-1', + 'send_time': 111, + 'text': {'content': 'hello'}, + } + ) + + assert ok is True + assert dispatched == ['msg-1'] + + +@pytest.mark.asyncio +async def test_message_worker_returns_false_for_invalid_payload(): + class FakeClient: + async def _handle_message(self, event): + raise AssertionError('should not dispatch invalid payload') + + worker = WecomCSMessageWorker(FakeClient()) + ok = await worker.handle_message({'foo': 'bar'}) + + assert ok is False + + +@pytest.mark.asyncio +async def test_message_worker_updates_state_for_success_and_failure(): + from langbot.pkg.platform.wecomcs.state_store import WecomCSStateStore + + class FakeRedisManagerWithJson(FakeRedisManager): + def __init__(self): + super().__init__() + self.values = {} + self.expirations = {} + + async def get(self, key: str): + return self.values.get(key) + + async def set(self, key: str, value: str, ex: int | None = None): + self.values[key] = value + self.expirations[key] = ex + + async def delete(self, key: str): + self.values.pop(key, None) + self.expirations.pop(key, None) + + async def set_if_not_exists(self, key: str, value: str, ex: int | None = None) -> bool: + if key in self.values: + return False + self.values[key] = value + self.expirations[key] = ex + return True + + async def get_json(self, key: str): + import json + raw = self.values.get(key) + return json.loads(raw) if raw else None + + async def set_json(self, key: str, value: dict, ex: int | None = None): + import json + self.values[key] = json.dumps(value, ensure_ascii=False) + self.expirations[key] = ex + + redis_mgr = FakeRedisManagerWithJson() + store = WecomCSStateStore(redis_mgr, message_state_ttl_seconds=600) + await store.reserve_message_for_queue( + 'bot-1', + 'kf-1', + { + 'msgid': 'msg-1', + 'external_userid': 'user-1', + 'msgtype': 'text', + 'text': {'content': 'hello'}, + }, + ) + + class SuccessClient: + async def _handle_message(self, event): + return None + + worker = WecomCSMessageWorker(SuccessClient(), store, bot_uuid='bot-1') + ok = await worker.handle_message( + { + 'msgtype': 'text', + 'external_userid': 'user-1', + 'open_kfid': 'kf-1', + 'msgid': 'msg-1', + 'send_time': 111, + 'text': {'content': 'hello'}, + } + ) + + assert ok is True + state = await store.get_message_state('bot-1', 'kf-1', 'msg-1') + assert state['process_status'] == 'done' + + await store.reserve_message_for_queue( + 'bot-1', + 'kf-1', + { + 'msgid': 'msg-2', + 'external_userid': 'user-2', + 'msgtype': 'text', + 'text': {'content': 'hello'}, + }, + ) + + class FailedClient: + async def _handle_message(self, event): + raise RuntimeError('boom') + + worker = WecomCSMessageWorker(FailedClient(), store, bot_uuid='bot-1') + with pytest.raises(RuntimeError): + await worker.handle_message( + { + 'msgtype': 'text', + 'external_userid': 'user-2', + 'open_kfid': 'kf-1', + 'msgid': 'msg-2', + 'send_time': 111, + 'text': {'content': 'hello'}, + } + ) + + state = await store.get_message_state('bot-1', 'kf-1', 'msg-2') + assert state['process_status'] == 'failed' diff --git a/tests/unit_tests/platform/test_wecomcs_pull_worker.py b/tests/unit_tests/platform/test_wecomcs_pull_worker.py new file mode 100644 index 000000000..29924915a --- /dev/null +++ b/tests/unit_tests/platform/test_wecomcs_pull_worker.py @@ -0,0 +1,289 @@ +import pytest + +from langbot.libs.wecom_customer_service_api.api import WecomCSInvalidSyncMsgTokenError +from langbot.pkg.platform.wecomcs.pull_worker import PullLockNotAcquiredError, WecomCSPullWorker +from langbot.pkg.platform.wecomcs.state_store import WecomCSStateStore + + +class FakeRedisManager: + def __init__(self): + self.values: dict[str, str] = {} + self.expirations: dict[str, int | None] = {} + + def is_available(self) -> bool: + return True + + async def get(self, key: str): + return self.values.get(key) + + async def set(self, key: str, value: str, ex: int | None = None): + self.values[key] = value + self.expirations[key] = ex + + async def delete(self, key: str): + self.values.pop(key, None) + self.expirations.pop(key, None) + + async def set_if_not_exists(self, key: str, value: str, ex: int | None = None) -> bool: + if key in self.values: + return False + self.values[key] = value + self.expirations[key] = ex + return True + + +class FakeWecomClient: + def __init__(self): + self.pages = { + None: { + 'msg_list': [ + {'msgid': 'msg-1', 'msgtype': 'text'}, + {'msgid': 'msg-2', 'msgtype': 'text'}, + ], + 'next_cursor': 'cursor-2', + 'has_more': True, + }, + 'cursor-2': { + 'msg_list': [ + {'msgid': 'msg-3', 'msgtype': 'text'}, + ], + 'next_cursor': 'cursor-3', + 'has_more': False, + }, + } + + async def fetch_sync_msg_page(self, callback_token: str, open_kfid: str, cursor: str | None = None): + return self.pages[cursor] + + +@pytest.mark.asyncio +async def test_pull_worker_processes_all_pages_and_updates_cursor(): + redis_mgr = FakeRedisManager() + store = WecomCSStateStore(redis_mgr, cursor_bootstrap_mode='replay') + client = FakeWecomClient() + handled_messages: list[str] = [] + + async def on_message(message_data: dict): + handled_messages.append(message_data['msgid']) + + worker = WecomCSPullWorker(client, store, on_message, message_state_ttl_seconds=600, lock_ttl_seconds=60) + processed_count = await worker.handle_trigger( + { + 'bot_uuid': 'bot-1', + 'open_kfid': 'kf-1', + 'callback_token': 'token-1', + } + ) + + assert processed_count == 3 + assert handled_messages == ['msg-1', 'msg-2', 'msg-3'] + assert await store.get_cursor('bot-1', 'kf-1') == 'cursor-3' + + +@pytest.mark.asyncio +async def test_pull_worker_skips_duplicates_using_state_store(): + redis_mgr = FakeRedisManager() + store = WecomCSStateStore(redis_mgr, cursor_bootstrap_mode='replay') + client = FakeWecomClient() + handled_messages: list[str] = [] + + await store.reserve_message_for_queue('bot-1', 'kf-1', {'msgid': 'msg-1', 'external_userid': 'user-1', 'msgtype': 'text'}) + + async def on_message(message_data: dict): + handled_messages.append(message_data['msgid']) + + worker = WecomCSPullWorker(client, store, on_message, message_state_ttl_seconds=600, lock_ttl_seconds=60) + processed_count = await worker.handle_trigger( + { + 'bot_uuid': 'bot-1', + 'open_kfid': 'kf-1', + 'callback_token': 'token-1', + } + ) + + assert processed_count == 2 + assert handled_messages == ['msg-2', 'msg-3'] + + +@pytest.mark.asyncio +async def test_pull_worker_raises_when_lock_not_acquired(): + redis_mgr = FakeRedisManager() + store = WecomCSStateStore(redis_mgr, cursor_bootstrap_mode='replay') + client = FakeWecomClient() + + await store.acquire_pull_lock('bot-1', 'kf-1', 'other-owner', 60) + + async def on_message(message_data: dict): + raise AssertionError('on_message should not be called when lock is not acquired') + + worker = WecomCSPullWorker(client, store, on_message, message_state_ttl_seconds=600, lock_ttl_seconds=60) + + with pytest.raises(PullLockNotAcquiredError): + await worker.handle_trigger( + { + 'bot_uuid': 'bot-1', + 'open_kfid': 'kf-1', + 'callback_token': 'token-1', + } + ) + + +@pytest.mark.asyncio +async def test_pull_worker_bootstrap_latest_skips_history_and_updates_cursor(): + redis_mgr = FakeRedisManager() + store = WecomCSStateStore(redis_mgr, cursor_bootstrap_mode='latest') + client = FakeWecomClient() + handled_messages: list[str] = [] + + async def on_message(message_data: dict): + handled_messages.append(message_data['msgid']) + + worker = WecomCSPullWorker(client, store, on_message, message_state_ttl_seconds=600, lock_ttl_seconds=60) + processed_count = await worker.handle_trigger( + { + 'bot_uuid': 'bot-1', + 'open_kfid': 'kf-1', + 'callback_token': 'token-1', + } + ) + + assert processed_count == 1 + assert handled_messages == ['msg-3'] + assert await store.get_cursor('bot-1', 'kf-1') == 'cursor-3' + assert await store.is_bootstrapped('bot-1', 'kf-1') is True + + +@pytest.mark.asyncio +async def test_pull_worker_clears_stale_cursor_and_restarts_with_current_token(): + class InvalidCursorClient(FakeWecomClient): + def __init__(self): + super().__init__() + self.calls: list[str | None] = [] + + async def fetch_sync_msg_page(self, callback_token: str, open_kfid: str, cursor: str | None = None): + self.calls.append(cursor) + if cursor == 'stale-cursor': + raise WecomCSInvalidSyncMsgTokenError('invalid msg token') + return await super().fetch_sync_msg_page(callback_token, open_kfid, cursor) + + redis_mgr = FakeRedisManager() + store = WecomCSStateStore(redis_mgr, cursor_bootstrap_mode='latest') + client = InvalidCursorClient() + handled_messages: list[str] = [] + + await store.mark_bootstrapped('bot-1', 'kf-1', 'stale-cursor') + + async def on_message(message_data: dict): + handled_messages.append(message_data['msgid']) + + worker = WecomCSPullWorker(client, store, on_message, message_state_ttl_seconds=600, lock_ttl_seconds=60) + processed_count = await worker.handle_trigger( + { + 'bot_uuid': 'bot-1', + 'open_kfid': 'kf-1', + 'callback_token': 'fresh-token', + } + ) + + assert processed_count == 1 + assert handled_messages == ['msg-3'] + assert client.calls == ['stale-cursor', None, 'cursor-2'] + assert await store.get_cursor('bot-1', 'kf-1') == 'cursor-3' + + +@pytest.mark.asyncio +async def test_pull_worker_bootstrap_latest_drops_old_history_by_send_time_window(): + class WindowedClient: + def __init__(self): + self.pages = { + None: { + 'msg_list': [ + {'msgid': 'old-1', 'msgtype': 'text', 'send_time': 700}, + ], + 'next_cursor': 'cursor-2', + 'has_more': True, + }, + 'cursor-2': { + 'msg_list': [ + {'msgid': 'old-2', 'msgtype': 'text', 'send_time': 890}, + {'msgid': 'recent-1', 'msgtype': 'text', 'send_time': 955}, + {'msgid': 'recent-2', 'msgtype': 'text', 'send_time': 980}, + ], + 'next_cursor': 'cursor-3', + 'has_more': False, + }, + } + + async def fetch_sync_msg_page(self, callback_token: str, open_kfid: str, cursor: str | None = None): + return self.pages[cursor] + + redis_mgr = FakeRedisManager() + store = WecomCSStateStore(redis_mgr, cursor_bootstrap_mode='latest') + client = WindowedClient() + handled_messages: list[str] = [] + + async def on_message(message_data: dict): + handled_messages.append(message_data['msgid']) + + worker = WecomCSPullWorker( + client, + store, + on_message, + message_state_ttl_seconds=600, + lock_ttl_seconds=60, + history_message_drop_threshold_seconds=60, + ) + processed_count = await worker.handle_trigger( + { + 'bot_uuid': 'bot-1', + 'open_kfid': 'kf-1', + 'callback_token': 'token-1', + 'webhook_received_at': '1000', + } + ) + + assert processed_count == 2 + assert handled_messages == ['recent-1', 'recent-2'] + assert await store.get_cursor('bot-1', 'kf-1') == 'cursor-3' + + +@pytest.mark.asyncio +async def test_pull_worker_replay_mode_does_not_drop_messages_by_history_window(): + class ReplayClient: + async def fetch_sync_msg_page(self, callback_token: str, open_kfid: str, cursor: str | None = None): + return { + 'msg_list': [ + {'msgid': 'old-1', 'msgtype': 'text', 'send_time': 700}, + {'msgid': 'recent-1', 'msgtype': 'text', 'send_time': 980}, + ], + 'next_cursor': 'cursor-1', + 'has_more': False, + } + + redis_mgr = FakeRedisManager() + store = WecomCSStateStore(redis_mgr, cursor_bootstrap_mode='replay') + client = ReplayClient() + handled_messages: list[str] = [] + + async def on_message(message_data: dict): + handled_messages.append(message_data['msgid']) + + worker = WecomCSPullWorker( + client, + store, + on_message, + message_state_ttl_seconds=600, + lock_ttl_seconds=60, + history_message_drop_threshold_seconds=60, + ) + processed_count = await worker.handle_trigger( + { + 'bot_uuid': 'bot-1', + 'open_kfid': 'kf-1', + 'callback_token': 'token-1', + 'webhook_received_at': '1000', + } + ) + + assert processed_count == 2 + assert handled_messages == ['old-1', 'recent-1'] diff --git a/tests/unit_tests/platform/test_wecomcs_retry_scheduler.py b/tests/unit_tests/platform/test_wecomcs_retry_scheduler.py new file mode 100644 index 000000000..fd9440fa2 --- /dev/null +++ b/tests/unit_tests/platform/test_wecomcs_retry_scheduler.py @@ -0,0 +1,56 @@ +import pytest + +from langbot.pkg.platform.wecomcs.retry_scheduler import WecomCSRetryScheduler + + +class FakeRedisManager: + def __init__(self): + self.zset: dict[str, float] = {} + self.stream_entries: list[tuple[str, dict[str, str]]] = [] + + async def zadd(self, key: str, mapping: dict[str, float]): + self.zset.update(mapping) + return 1 + + async def zrangebyscore(self, key: str, min_score: float, max_score: float): + return [member for member, score in self.zset.items() if min_score <= score <= max_score] + + async def zrem(self, key: str, member: str): + self.zset.pop(member, None) + return 1 + + async def xadd(self, stream: str, fields: dict[str, str], maxlen: int | None = None): + self.stream_entries.append((stream, fields)) + return '1-0' + + +@pytest.mark.asyncio +async def test_retry_scheduler_schedules_and_replays_job(): + redis_mgr = FakeRedisManager() + scheduler = WecomCSRetryScheduler(redis_mgr, retry_backoff_seconds=[15, 30], retry_max_attempts=3) + + scheduled = await scheduler.schedule_retry('wecomcs:pull-trigger:0', {'bot_uuid': 'bot-1'}, retry_count=0, error='boom') + + assert scheduled is True + assert len(redis_mgr.zset) == 1 + + replayed = await scheduler.replay_due_jobs(now_ts=9999999999) + + assert replayed == 1 + assert redis_mgr.stream_entries == [ + ( + 'wecomcs:pull-trigger:0', + {'bot_uuid': 'bot-1', 'retry_count': '1', 'last_error': 'boom'}, + ) + ] + assert redis_mgr.zset == {} + + +@pytest.mark.asyncio +async def test_retry_scheduler_stops_after_max_attempts(): + redis_mgr = FakeRedisManager() + scheduler = WecomCSRetryScheduler(redis_mgr, retry_backoff_seconds=[15], retry_max_attempts=2) + + assert await scheduler.schedule_retry('stream-a', {'foo': 'bar'}, retry_count=0) is True + assert await scheduler.schedule_retry('stream-a', {'foo': 'bar'}, retry_count=1) is True + assert await scheduler.schedule_retry('stream-a', {'foo': 'bar'}, retry_count=2) is False diff --git a/tests/unit_tests/platform/test_wecomcs_runtime.py b/tests/unit_tests/platform/test_wecomcs_runtime.py new file mode 100644 index 000000000..5b32d3db8 --- /dev/null +++ b/tests/unit_tests/platform/test_wecomcs_runtime.py @@ -0,0 +1,303 @@ +import pytest + +from langbot.libs.wecom_customer_service_api.api import WecomCSInvalidSyncMsgTokenError +from langbot.pkg.platform.wecomcs.runtime import WecomCSSchedulerRuntime + + +class FakeRedisManager: + def __init__(self): + self.key_prefix = 'langbot' + self.groups: list[tuple[str, str]] = [] + self.xreadgroup_calls: list[tuple[str, str, dict[str, str]]] = [] + self.acks: list[tuple[str, str, str]] = [] + self.pull_messages = [ + ( + 'langbot:wecomcs:pull-trigger:0', + [ + ('1-0', {'bot_uuid': 'bot-1', 'open_kfid': 'kf-1', 'callback_token': 'token-1'}), + ], + ) + ] + self.process_messages = [ + ( + 'langbot:wecomcs:message-process:0', + [ + ('2-0', {'payload': '{"msgtype":"text","external_userid":"user-1","open_kfid":"kf-1","msgid":"msg-1","send_time":111,"text":{"content":"hello"}}'}), + ], + ) + ] + + def is_available(self): + return True + + async def xgroup_create(self, stream: str, group: str, id: str = '0', mkstream: bool = True): + self.groups.append((stream, group)) + + async def xreadgroup(self, group: str, consumer: str, streams: dict[str, str], count=None, block_ms=None): + self.xreadgroup_calls.append((group, consumer, streams)) + if group == 'pull-group' and self.pull_messages: + return [self.pull_messages.pop(0)] + if group == 'process-group' and self.process_messages: + return [self.process_messages.pop(0)] + return [] + + async def xack(self, stream: str, group: str, message_id: str): + self.acks.append((stream, group, message_id)) + + +@pytest.mark.asyncio +async def test_runtime_initializes_consumer_groups(): + runtime = WecomCSSchedulerRuntime( + 'bot-1', + client=object(), + redis_mgr=FakeRedisManager(), + scheduler_config={ + 'pull_stream_shard_count': 2, + 'process_stream_shard_count': 3, + 'pull_consumer_group': 'pull-group', + 'process_consumer_group': 'process-group', + }, + ) + + await runtime.initialize() + + assert ('wecomcs:pull-trigger:0', 'pull-group') in runtime.redis_mgr.groups + assert ('wecomcs:pull-trigger:1', 'pull-group') in runtime.redis_mgr.groups + assert ('wecomcs:message-process:2', 'process-group') in runtime.redis_mgr.groups + + +@pytest.mark.asyncio +async def test_runtime_pull_loop_handles_and_acks_messages(monkeypatch): + redis_mgr = FakeRedisManager() + + class FakeClient: + pass + + runtime = WecomCSSchedulerRuntime( + 'bot-1', + client=FakeClient(), + redis_mgr=redis_mgr, + scheduler_config={ + 'pull_stream_shard_count': 1, + 'process_stream_shard_count': 1, + 'pull_consumer_group': 'pull-group', + 'process_consumer_group': 'process-group', + 'stream_batch_size': 1, + 'stream_block_ms': 1, + }, + ) + + handled = [] + + async def fake_handle_trigger(payload): + handled.append(payload) + runtime.running = False + return 1 + + runtime.pull_worker.handle_trigger = fake_handle_trigger + runtime.running = True + await runtime._run_pull_loop() + + assert handled[0]['open_kfid'] == 'kf-1' + assert ('wecomcs:pull-trigger:0', 'pull-group', '1-0') in redis_mgr.acks + + +@pytest.mark.asyncio +async def test_runtime_process_loop_handles_and_acks_messages(): + redis_mgr = FakeRedisManager() + + class FakeClient: + pass + + runtime = WecomCSSchedulerRuntime( + 'bot-1', + client=FakeClient(), + redis_mgr=redis_mgr, + scheduler_config={ + 'pull_stream_shard_count': 1, + 'process_stream_shard_count': 1, + 'pull_consumer_group': 'pull-group', + 'process_consumer_group': 'process-group', + 'stream_batch_size': 1, + 'stream_block_ms': 1, + }, + ) + + async def fake_handle_stream_entry(fields): + runtime.running = False + return True + + runtime.message_worker.handle_stream_entry = fake_handle_stream_entry + runtime.running = True + await runtime._run_process_loop() + + assert ('wecomcs:message-process:0', 'process-group', '2-0') in redis_mgr.acks + + +@pytest.mark.asyncio +async def test_runtime_pull_loop_schedules_retry_and_acks_on_failure(): + redis_mgr = FakeRedisManager() + + class FakeClient: + pass + + runtime = WecomCSSchedulerRuntime( + 'bot-1', + client=FakeClient(), + redis_mgr=redis_mgr, + scheduler_config={ + 'pull_stream_shard_count': 1, + 'process_stream_shard_count': 1, + 'pull_consumer_group': 'pull-group', + 'process_consumer_group': 'process-group', + 'stream_batch_size': 1, + 'stream_block_ms': 1, + 'retry_backoff_seconds': [1], + 'retry_max_attempts': 3, + }, + ) + + scheduled = [] + + async def fake_schedule_retry(target_stream, fields, retry_count=0, error=''): + scheduled.append((target_stream, fields, error)) + runtime.running = False + return True + + async def fake_handle_trigger(payload): + raise RuntimeError('pull failed') + + runtime.retry_scheduler.schedule_retry = fake_schedule_retry + runtime.pull_worker.handle_trigger = fake_handle_trigger + runtime.running = True + await runtime._run_pull_loop() + + assert scheduled[0][0] == 'wecomcs:pull-trigger:0' + assert ('wecomcs:pull-trigger:0', 'pull-group', '1-0') in redis_mgr.acks + + +@pytest.mark.asyncio +async def test_runtime_pull_loop_uses_retry_count_from_stream_fields_on_failure(): + redis_mgr = FakeRedisManager() + redis_mgr.pull_messages = [ + ( + 'langbot:wecomcs:pull-trigger:0', + [ + ('1-0', {'bot_uuid': 'bot-1', 'open_kfid': 'kf-1', 'callback_token': 'token-1', 'retry_count': '2'}), + ], + ) + ] + + class FakeClient: + pass + + runtime = WecomCSSchedulerRuntime( + 'bot-1', + client=FakeClient(), + redis_mgr=redis_mgr, + scheduler_config={ + 'pull_stream_shard_count': 1, + 'process_stream_shard_count': 1, + 'pull_consumer_group': 'pull-group', + 'process_consumer_group': 'process-group', + 'stream_batch_size': 1, + 'stream_block_ms': 1, + 'retry_backoff_seconds': [1], + 'retry_max_attempts': 5, + }, + ) + + scheduled = [] + + async def fake_schedule_retry(target_stream, fields, retry_count=0, error=''): + scheduled.append((target_stream, fields, retry_count, error)) + runtime.running = False + return True + + async def fake_handle_trigger(payload): + raise RuntimeError('pull failed again') + + runtime.retry_scheduler.schedule_retry = fake_schedule_retry + runtime.pull_worker.handle_trigger = fake_handle_trigger + runtime.running = True + await runtime._run_pull_loop() + + assert scheduled[0][0] == 'wecomcs:pull-trigger:0' + assert scheduled[0][2] == 2 + assert ('wecomcs:pull-trigger:0', 'pull-group', '1-0') in redis_mgr.acks + + +@pytest.mark.asyncio +async def test_runtime_retry_loop_replays_due_jobs_and_stops(): + redis_mgr = FakeRedisManager() + + class FakeClient: + pass + + runtime = WecomCSSchedulerRuntime( + 'bot-1', + client=FakeClient(), + redis_mgr=redis_mgr, + scheduler_config={ + 'pull_stream_shard_count': 1, + 'process_stream_shard_count': 1, + 'pull_consumer_group': 'pull-group', + 'process_consumer_group': 'process-group', + 'retry_poll_interval_seconds': 1, + }, + ) + + replay_calls = [] + + async def fake_replay_due_jobs(): + replay_calls.append('called') + runtime.running = False + return 1 + + runtime.retry_scheduler.replay_due_jobs = fake_replay_due_jobs + runtime.running = True + await runtime._run_retry_loop() + + assert replay_calls == ['called'] + + +@pytest.mark.asyncio +async def test_runtime_pull_loop_does_not_retry_invalid_sync_msg_token(): + redis_mgr = FakeRedisManager() + + class FakeClient: + pass + + runtime = WecomCSSchedulerRuntime( + 'bot-1', + client=FakeClient(), + redis_mgr=redis_mgr, + scheduler_config={ + 'pull_stream_shard_count': 1, + 'process_stream_shard_count': 1, + 'pull_consumer_group': 'pull-group', + 'process_consumer_group': 'process-group', + 'stream_batch_size': 1, + 'stream_block_ms': 1, + 'retry_backoff_seconds': [1], + 'retry_max_attempts': 3, + }, + ) + + scheduled = [] + + async def fake_schedule_retry(target_stream, fields, retry_count=0, error=''): + scheduled.append((target_stream, fields, retry_count, error)) + return True + + async def fake_handle_trigger(payload): + runtime.running = False + raise WecomCSInvalidSyncMsgTokenError('invalid msg token') + + runtime.retry_scheduler.schedule_retry = fake_schedule_retry + runtime.pull_worker.handle_trigger = fake_handle_trigger + runtime.running = True + await runtime._run_pull_loop() + + assert scheduled == [] + assert ('wecomcs:pull-trigger:0', 'pull-group', '1-0') in redis_mgr.acks diff --git a/tests/unit_tests/platform/test_wecomcs_sharding.py b/tests/unit_tests/platform/test_wecomcs_sharding.py new file mode 100644 index 000000000..33b8428fe --- /dev/null +++ b/tests/unit_tests/platform/test_wecomcs_sharding.py @@ -0,0 +1,71 @@ +import pytest + +from langbot.pkg.platform.wecomcs.sharding import resolve_process_shard, resolve_pull_shard +from langbot.pkg.platform.wecomcs.state_store import WecomCSStateStore + + +class FakeRedisManager: + def __init__(self): + self.values: dict[str, str] = {} + self.expirations: dict[str, int | None] = {} + + def is_available(self) -> bool: + return True + + async def get(self, key: str): + return self.values.get(key) + + async def set(self, key: str, value: str, ex: int | None = None): + self.values[key] = value + self.expirations[key] = ex + + async def delete(self, key: str): + self.values.pop(key, None) + self.expirations.pop(key, None) + + async def set_if_not_exists(self, key: str, value: str, ex: int | None = None) -> bool: + if key in self.values: + return False + self.values[key] = value + self.expirations[key] = ex + return True + + +def test_pull_shard_is_stable_for_same_open_kfid(): + shard_a = resolve_pull_shard('bot-1', 'kf-1', 8) + shard_b = resolve_pull_shard('bot-1', 'kf-1', 8) + + assert shard_a == shard_b + assert 0 <= shard_a < 8 + + +def test_process_shard_is_stable_for_same_session_key(): + shard_a = resolve_process_shard('bot-1', 'kf-1', 'user-1', 16) + shard_b = resolve_process_shard('bot-1', 'kf-1', 'user-1', 16) + + assert shard_a == shard_b + assert 0 <= shard_a < 16 + + +def test_pull_shard_count_must_be_positive(): + with pytest.raises(ValueError): + resolve_pull_shard('bot-1', 'kf-1', 0) + + +@pytest.mark.asyncio +async def test_state_store_manages_cursor_lock_and_dedupe(): + redis_mgr = FakeRedisManager() + store = WecomCSStateStore(redis_mgr) + + assert await store.get_cursor('bot-1', 'kf-1') == '' + + await store.set_cursor('bot-1', 'kf-1', 'cursor-1') + assert await store.get_cursor('bot-1', 'kf-1') == 'cursor-1' + + assert await store.acquire_pull_lock('bot-1', 'kf-1', 'owner-a', 60) is True + assert await store.acquire_pull_lock('bot-1', 'kf-1', 'owner-b', 60) is False + assert await store.release_pull_lock('bot-1', 'kf-1', 'owner-b') is False + assert await store.release_pull_lock('bot-1', 'kf-1', 'owner-a') is True + + assert await store.mark_message_once('bot-1', 'msg-1', 600) is True + assert await store.mark_message_once('bot-1', 'msg-1', 600) is False diff --git a/tests/unit_tests/platform/test_wecomcs_state_store.py b/tests/unit_tests/platform/test_wecomcs_state_store.py new file mode 100644 index 000000000..8de5241c6 --- /dev/null +++ b/tests/unit_tests/platform/test_wecomcs_state_store.py @@ -0,0 +1,132 @@ +import json + +import pytest + +from langbot.pkg.platform.wecomcs.state_store import WecomCSStateStore + + +class FakeRedisManager: + def __init__(self): + self.values: dict[str, str] = {} + self.expirations: dict[str, int | None] = {} + + def is_available(self) -> bool: + return True + + async def get(self, key: str): + return self.values.get(key) + + async def set(self, key: str, value: str, ex: int | None = None): + self.values[key] = value + self.expirations[key] = ex + + async def delete(self, key: str): + self.values.pop(key, None) + self.expirations.pop(key, None) + + async def set_if_not_exists(self, key: str, value: str, ex: int | None = None) -> bool: + if key in self.values: + return False + self.values[key] = value + self.expirations[key] = ex + return True + + async def get_json(self, key: str): + raw = self.values.get(key) + if not raw: + return None + return json.loads(raw) + + async def set_json(self, key: str, value: dict, ex: int | None = None): + self.values[key] = json.dumps(value, ensure_ascii=False) + self.expirations[key] = ex + + +class FakeCursorStore: + def __init__(self): + self.items: dict[tuple[str, str], dict] = {} + + async def get_checkpoint(self, bot_uuid: str, open_kfid: str): + return self.items.get((bot_uuid, open_kfid)) + + async def save_checkpoint(self, bot_uuid: str, open_kfid: str, cursor: str, bootstrapped: bool): + self.items[(bot_uuid, open_kfid)] = { + 'bot_uuid': bot_uuid, + 'open_kfid': open_kfid, + 'cursor': cursor, + 'bootstrapped': bootstrapped, + } + + +@pytest.mark.asyncio +async def test_state_store_persists_cursor_in_cursor_store(): + redis_mgr = FakeRedisManager() + cursor_store = FakeCursorStore() + store = WecomCSStateStore(redis_mgr, cursor_store=cursor_store) + + await store.set_cursor('bot-1', 'kf-1', 'cursor-1') + + assert await store.get_cursor('bot-1', 'kf-1') == 'cursor-1' + assert await store.is_bootstrapped('bot-1', 'kf-1') is True + + +@pytest.mark.asyncio +async def test_state_store_tracks_message_status_with_ttl(): + redis_mgr = FakeRedisManager() + store = WecomCSStateStore(redis_mgr, message_state_ttl_seconds=123) + + reserved = await store.reserve_message_for_queue( + 'bot-1', + 'kf-1', + { + 'msgid': 'msg-1', + 'external_userid': 'user-1', + 'msgtype': 'text', + 'send_time': 111, + 'text': {'content': 'hello world'}, + }, + ) + + assert reserved is True + state = await store.get_message_state('bot-1', 'kf-1', 'msg-1') + assert state['process_status'] == 'queued' + assert redis_mgr.expirations['wecomcs:msg:bot-1:kf-1:msg-1'] == 123 + + await store.mark_message_processing('bot-1', 'kf-1', 'msg-1') + await store.mark_message_done('bot-1', 'kf-1', 'msg-1') + await store.mark_reply_success('bot-1', 'kf-1', 'msg-1') + + state = await store.get_message_state('bot-1', 'kf-1', 'msg-1') + assert state['process_status'] == 'done' + assert state['reply_status'] == 'success' + + +@pytest.mark.asyncio +async def test_state_store_allows_failed_message_to_requeue(): + redis_mgr = FakeRedisManager() + store = WecomCSStateStore(redis_mgr, message_state_ttl_seconds=123) + + await store.reserve_message_for_queue( + 'bot-1', + 'kf-1', + { + 'msgid': 'msg-1', + 'external_userid': 'user-1', + 'msgtype': 'text', + }, + ) + await store.mark_message_failed('bot-1', 'kf-1', 'msg-1', stage='publish', error='boom') + + reserved = await store.reserve_message_for_queue( + 'bot-1', + 'kf-1', + { + 'msgid': 'msg-1', + 'external_userid': 'user-1', + 'msgtype': 'text', + }, + ) + + assert reserved is True + state = await store.get_message_state('bot-1', 'kf-1', 'msg-1') + assert state['process_status'] == 'queued' diff --git a/tests/unit_tests/platform/test_wecomcs_token_cache.py b/tests/unit_tests/platform/test_wecomcs_token_cache.py new file mode 100644 index 000000000..8bff187a0 --- /dev/null +++ b/tests/unit_tests/platform/test_wecomcs_token_cache.py @@ -0,0 +1,164 @@ +import time + +import pytest + +from langbot.pkg.platform.wecomcs.token_cache import WecomCSTokenCache + + +class FakeRedisManager: + def __init__(self, enabled: bool = True): + self.enabled = enabled + self.values: dict[str, dict] = {} + self.expirations: dict[str, int | None] = {} + self.deleted_keys: list[str] = [] + + def is_available(self) -> bool: + return self.enabled + + async def get_json(self, key: str): + return self.values.get(key) + + async def set_json(self, key: str, value: dict, ex: int | None = None): + self.values[key] = value + self.expirations[key] = ex + + async def delete(self, key: str): + self.deleted_keys.append(key) + self.values.pop(key, None) + self.expirations.pop(key, None) + + +@pytest.mark.asyncio +async def test_token_cache_stores_payload_in_redis_with_skew_ttl(): + redis_mgr = FakeRedisManager() + cache = WecomCSTokenCache(corpid='corp-1', redis_mgr=redis_mgr, refresh_skew_seconds=300) + fetch_count = 0 + + async def fake_fetcher(): + nonlocal fetch_count + fetch_count += 1 + # 中文注释:模拟企业微信返回 token 与 expires_in,验证 Redis TTL 会扣掉安全缓冲时间。 + return { + 'access_token': 'token-1', + 'expires_in': 7200, + } + + token = await cache.get_or_refresh(fake_fetcher) + + assert token == 'token-1' + assert fetch_count == 1 + assert redis_mgr.values['wecomcs:access_token:corp-1']['access_token'] == 'token-1' + assert redis_mgr.expirations['wecomcs:access_token:corp-1'] == 6900 + + +@pytest.mark.asyncio +async def test_token_cache_reuses_cached_value_without_refetch(): + redis_mgr = FakeRedisManager() + cache = WecomCSTokenCache(corpid='corp-2', redis_mgr=redis_mgr, refresh_skew_seconds=300) + fetch_count = 0 + + async def fake_fetcher(): + nonlocal fetch_count + fetch_count += 1 + return { + 'access_token': 'token-2', + 'expires_in': 7200, + } + + first_token = await cache.get_or_refresh(fake_fetcher) + second_token = await cache.get_or_refresh(fake_fetcher) + + assert first_token == 'token-2' + assert second_token == 'token-2' + assert fetch_count == 1 + + +@pytest.mark.asyncio +async def test_token_cache_invalidate_clears_redis_and_refetches(): + redis_mgr = FakeRedisManager() + cache = WecomCSTokenCache(corpid='corp-3', redis_mgr=redis_mgr, refresh_skew_seconds=300) + fetch_count = 0 + + async def fake_fetcher(): + nonlocal fetch_count + fetch_count += 1 + return { + 'access_token': f'token-{fetch_count}', + 'expires_in': 7200, + } + + first_token = await cache.get_or_refresh(fake_fetcher) + await cache.invalidate() + second_token = await cache.get_or_refresh(fake_fetcher) + + assert first_token == 'token-1' + assert second_token == 'token-2' + assert redis_mgr.deleted_keys == ['wecomcs:access_token:corp-3'] + + +@pytest.mark.asyncio +async def test_token_cache_falls_back_to_local_memory_when_redis_disabled(): + redis_mgr = FakeRedisManager(enabled=False) + cache = WecomCSTokenCache(corpid='corp-4', redis_mgr=redis_mgr, refresh_skew_seconds=300) + + async def fake_fetcher(): + return { + 'access_token': 'memory-token', + 'expires_in': 7200, + } + + token = await cache.get_or_refresh(fake_fetcher) + cached_token = await cache.get_cached_token() + + assert token == 'memory-token' + assert cached_token == 'memory-token' + assert redis_mgr.values == {} + + +@pytest.mark.asyncio +async def test_token_cache_ignores_expired_payload(): + redis_mgr = FakeRedisManager() + redis_mgr.values['wecomcs:access_token:corp-5'] = { + 'access_token': 'expired-token', + 'expires_at': int(time.time()) - 1, + } + cache = WecomCSTokenCache(corpid='corp-5', redis_mgr=redis_mgr, refresh_skew_seconds=300) + + async def fake_fetcher(): + return { + 'access_token': 'fresh-token', + 'expires_in': 7200, + } + + token = await cache.get_or_refresh(fake_fetcher) + + assert token == 'fresh-token' + + +@pytest.mark.asyncio +async def test_token_cache_isolated_by_secret_fingerprint(): + redis_mgr = FakeRedisManager() + cache_a = WecomCSTokenCache(corpid='corp-6', redis_mgr=redis_mgr, refresh_skew_seconds=300, secret_fingerprint='secret-a') + cache_b = WecomCSTokenCache(corpid='corp-6', redis_mgr=redis_mgr, refresh_skew_seconds=300, secret_fingerprint='secret-b') + + async def fake_fetcher_a(): + return { + 'access_token': 'token-a', + 'expires_in': 7200, + } + + async def fake_fetcher_b(): + return { + 'access_token': 'token-b', + 'expires_in': 7200, + } + + token_a = await cache_a.get_or_refresh(fake_fetcher_a) + token_b = await cache_b.get_or_refresh(fake_fetcher_b) + + assert token_a == 'token-a' + assert token_b == 'token-b' + assert sorted(redis_mgr.values.keys()) == [ + 'wecomcs:access_token:corp-6:secret-a', + 'wecomcs:access_token:corp-6:secret-b', + ] diff --git a/tests/unit_tests/test_wecom_customer_service.py b/tests/unit_tests/test_wecom_customer_service.py new file mode 100644 index 000000000..e510e545a --- /dev/null +++ b/tests/unit_tests/test_wecom_customer_service.py @@ -0,0 +1,415 @@ +import asyncio +import logging + +import pytest + +from langbot.libs.wecom_customer_service_api.api import WecomCSClient, WecomCSInvalidSyncMsgTokenError +import langbot.libs.wecom_customer_service_api.api as wecomcs_api + + +class DummyLogger: + async def error(self, *args, **kwargs): + return None + + async def info(self, *args, **kwargs): + return None + + async def debug(self, *args, **kwargs): + return None + + +class DummyRequest: + def __init__(self, method: str, payload: bytes = b''): + self.method = method + self.args = { + 'msg_signature': 'signature', + 'timestamp': '123', + 'nonce': '456', + 'echostr': 'echo', + } + self._payload = payload + + @property + async def data(self): + return self._payload + + +class FakeWXBizMsgCrypt: + def __init__(self, token, aes, corpid): + self.token = token + self.aes = aes + self.corpid = corpid + + def VerifyURL(self, msg_signature, timestamp, nonce, echostr): + return 0, echostr + + def DecryptMsg(self, encrypt_msg, msg_signature, timestamp, nonce): + return 0, '' + + +@pytest.fixture +def client(): + return WecomCSClient( + corpid='corp-id', + secret='secret', + token='token', + EncodingAESKey='aes', + logger=DummyLogger(), + unified_mode=True, + ) + + +@pytest.mark.asyncio +async def test_callback_returns_success_and_processes_all_messages_in_background(monkeypatch, client): + handled_events: list[tuple[str, str]] = [] + + @client.on_message('text') + async def handle_text(event): + handled_events.append((event.type, event.user_id)) + + @client.on_message('image') + async def handle_image(event): + handled_events.append((event.type, event.user_id)) + + async def fake_get_detailed_message_list(xml_msg: str): + # 中文注释:这里模拟企业微信一次 sync_msg 返回多条消息,验证不会只处理最后一条。 + await asyncio.sleep(0) + return [ + { + 'msgtype': 'text', + 'external_userid': 'user-1', + 'open_kfid': 'kf-1', + 'msgid': 'msg-1', + 'send_time': 111, + 'text': {'content': 'hello'}, + }, + { + 'msgtype': 'event', + 'external_userid': 'user-1', + 'open_kfid': 'kf-1', + 'msgid': 'msg-2', + 'send_time': 112, + }, + { + 'msgtype': 'image', + 'external_userid': 'user-2', + 'open_kfid': 'kf-1', + 'msgid': 'msg-3', + 'send_time': 113, + 'picurl': 'data:image/png;base64,ZmFrZQ==', + }, + ] + + monkeypatch.setattr(wecomcs_api, 'WXBizMsgCrypt', FakeWXBizMsgCrypt) + monkeypatch.setattr(client, 'get_detailed_message_list', fake_get_detailed_message_list) + + response = await client._handle_callback_internal(DummyRequest(method='POST')) + + assert response == 'success' + + if client._background_tasks: + await asyncio.gather(*list(client._background_tasks)) + + assert handled_events == [('text', 'user-1'), ('image', 'user-2')] + + +@pytest.mark.asyncio +async def test_send_text_msg_refreshes_token_after_expired_response(monkeypatch, client): + client.access_token = 'stale-token' + client.token_cache._local_cache = { + 'access_token': 'stale-token', + 'expires_at': 4102444800, + } + request_urls: list[str] = [] + refresh_called = 0 + + async def fake_refresh_access_token(): + nonlocal refresh_called + refresh_called += 1 + client.access_token = 'fresh-token' + client.token_cache._local_cache = { + 'access_token': 'fresh-token', + 'expires_at': 4102444800, + } + return client.access_token + + class FakeResponse: + def __init__(self, payload): + self._payload = payload + + def json(self): + return self._payload + + class FakeAsyncClient: + def __init__(self, *args, **kwargs): + return None + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + async def post(self, url, json=None, headers=None, content=None): + request_urls.append(url) + if 'stale-token' in url: + return FakeResponse({'errcode': 42001, 'errmsg': 'token expired'}) + return FakeResponse({'errcode': 0, 'errmsg': 'ok'}) + + monkeypatch.setattr(client, 'refresh_access_token', fake_refresh_access_token) + monkeypatch.setattr(wecomcs_api.httpx, 'AsyncClient', FakeAsyncClient) + + result = await client.send_text_msg( + open_kfid='kf-1', + external_userid='user-1', + msgid='msg-1', + content='reply', + ) + + assert result == {'errcode': 0, 'errmsg': 'ok'} + assert refresh_called == 1 + assert len(request_urls) == 2 + assert 'stale-token' in request_urls[0] + assert 'fresh-token' in request_urls[1] + + +@pytest.mark.asyncio +async def test_get_detailed_message_list_returns_all_messages(monkeypatch, client): + client.access_token = 'token-1' + + async def fake_ensure_access_token(): + return client.access_token + + async def fake_get_pic_url(media_id: str): + return f'data:image/png;base64,{media_id}' + + class FakeResponse: + def json(self): + return { + 'errcode': 0, + 'errmsg': 'ok', + 'msg_list': [ + { + 'msgtype': 'text', + 'external_userid': 'user-1', + 'open_kfid': 'kf-1', + 'msgid': 'msg-1', + 'send_time': 111, + 'text': {'content': 'hello'}, + }, + { + 'msgtype': 'image', + 'external_userid': 'user-2', + 'open_kfid': 'kf-1', + 'msgid': 'msg-2', + 'send_time': 112, + 'image': {'media_id': 'media-1'}, + }, + ], + } + + class FakeAsyncClient: + def __init__(self, *args, **kwargs): + return None + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + async def post(self, url, json=None, headers=None, content=None): + return FakeResponse() + + monkeypatch.setattr(client, 'ensure_access_token', fake_ensure_access_token) + monkeypatch.setattr(client, 'get_pic_url', fake_get_pic_url) + monkeypatch.setattr(wecomcs_api.httpx, 'AsyncClient', FakeAsyncClient) + + xml = '' + result = await client.get_detailed_message_list(xml) + + assert len(result) == 2 + assert result[0]['msgtype'] == 'text' + assert result[1]['msgtype'] == 'image' + assert result[1]['picurl'] == 'data:image/png;base64,media-1' + + +@pytest.mark.asyncio +async def test_callback_publishes_pull_trigger_when_scheduler_enabled(monkeypatch, client): + published: list[tuple[str, str, int | None]] = [] + + class FakePublisher: + async def publish_from_xml(self, bot_uuid: str, xml_msg: str, webhook_received_at: int | None = None): + published.append((bot_uuid, xml_msg, webhook_received_at)) + return 'wecomcs:pull-trigger:0', {'open_kfid': 'kf-1', 'webhook_received_at': str(webhook_received_at or '')} + + monkeypatch.setattr(wecomcs_api, 'WXBizMsgCrypt', FakeWXBizMsgCrypt) + client.scheduler_enabled = True + client.bot_uuid = 'bot-1' + client.pull_trigger_publisher = FakePublisher() + + response = await client._handle_callback_internal(DummyRequest(method='POST')) + + assert response == 'success' + assert len(published) == 1 + assert published[0][0] == 'bot-1' + assert isinstance(published[0][2], int) + assert client._background_tasks == set() + + +@pytest.mark.asyncio +async def test_fetch_sync_msg_page_raises_invalid_sync_msg_token_error(monkeypatch, client): + client.access_token = 'token-1' + + async def fake_ensure_access_token(): + return client.access_token + + class FakeResponse: + def json(self): + return { + 'errcode': 95007, + 'errmsg': 'invalid msg token', + 'msg_list': [], + } + + class FakeAsyncClient: + def __init__(self, *args, **kwargs): + return None + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + async def post(self, url, json=None, headers=None, content=None): + return FakeResponse() + + monkeypatch.setattr(client, 'ensure_access_token', fake_ensure_access_token) + monkeypatch.setattr(wecomcs_api.httpx, 'AsyncClient', FakeAsyncClient) + + with pytest.raises(WecomCSInvalidSyncMsgTokenError): + await client.fetch_sync_msg_page('callback-token', 'kf-1', 'cursor-1') + + +@pytest.mark.asyncio +async def test_get_customer_info_returns_none_for_invalid_external_userid(monkeypatch, client): + client.access_token = 'token-1' + + async def fake_ensure_access_token(): + return client.access_token + + class FakeResponse: + def json(self): + return { + 'errcode': 0, + 'errmsg': 'ok', + 'customer_list': [], + 'invalid_external_userid': ['user-1'], + } + + class FakeAsyncClient: + def __init__(self, *args, **kwargs): + return None + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + async def post(self, url, json=None, headers=None, content=None): + return FakeResponse() + + monkeypatch.setattr(client, 'ensure_access_token', fake_ensure_access_token) + monkeypatch.setattr(wecomcs_api.httpx, 'AsyncClient', FakeAsyncClient) + + result = await client.get_customer_info('user-1') + + assert result is None + + +@pytest.mark.asyncio +async def test_get_customer_info_returns_none_when_customer_list_empty(monkeypatch, client): + client.access_token = 'token-1' + + async def fake_ensure_access_token(): + return client.access_token + + class FakeResponse: + def json(self): + return { + 'errcode': 0, + 'errmsg': 'ok', + 'customer_list': [], + 'invalid_external_userid': [], + } + + class FakeAsyncClient: + def __init__(self, *args, **kwargs): + return None + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + async def post(self, url, json=None, headers=None, content=None): + return FakeResponse() + + monkeypatch.setattr(client, 'ensure_access_token', fake_ensure_access_token) + monkeypatch.setattr(wecomcs_api.httpx, 'AsyncClient', FakeAsyncClient) + + result = await client.get_customer_info('user-1') + + assert result is None + + +@pytest.mark.asyncio +async def test_get_customer_info_logs_elapsed_time(monkeypatch, client, caplog): + client.access_token = 'token-1' + + async def fake_ensure_access_token(): + return client.access_token + + class FakeResponse: + def json(self): + return { + 'errcode': 0, + 'errmsg': 'ok', + 'customer_list': [ + { + 'external_userid': 'user-1', + 'nickname': 'Tester', + } + ], + 'invalid_external_userid': [], + } + + class FakeAsyncClient: + def __init__(self, *args, **kwargs): + return None + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + async def post(self, url, json=None, headers=None, content=None): + return FakeResponse() + + perf_values = iter([10.0, 10.0, 10.2, 10.2, 10.45, 10.45]) + + monkeypatch.setattr(client, 'ensure_access_token', fake_ensure_access_token) + monkeypatch.setattr(wecomcs_api.httpx, 'AsyncClient', FakeAsyncClient) + monkeypatch.setattr(wecomcs_api.time, 'perf_counter', lambda: next(perf_values)) + + with caplog.at_level(logging.DEBUG, logger='langbot'): + result = await client.get_customer_info('user-1') + + assert result == {'external_userid': 'user-1', 'nickname': 'Tester'} + assert 'token_elapsed_ms=200.00' in caplog.text + assert 'request_elapsed_ms=250.00' in caplog.text + assert 'total_elapsed_ms=450.00' in caplog.text diff --git a/uv.lock b/uv.lock index bfac5ff43..f6c0c9eb4 100644 --- a/uv.lock +++ b/uv.lock @@ -291,6 +291,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2e/e9/eb6a5db5ac505d5d45715388e92bced7a5bb556facc4d0865d192823f2d2/async_lru-2.1.0-py3-none-any.whl", hash = "sha256:fa12dcf99a42ac1280bc16c634bbaf06883809790f6304d85cdab3f666f33a7e", size = 6933, upload-time = "2026-01-17T22:52:17.389Z" }, ] +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, +] + [[package]] name = "asyncpg" version = "0.31.0" @@ -1890,6 +1899,7 @@ dependencies = [ { name = "qq-botpy-rc" }, { name = "quart" }, { name = "quart-cors" }, + { name = "redis" }, { name = "requests" }, { name = "ruff" }, { name = "slack-sdk" }, @@ -1969,6 +1979,7 @@ requires-dist = [ { name = "qq-botpy-rc", specifier = ">=1.2.1.6" }, { name = "quart", specifier = ">=0.20.0" }, { name = "quart-cors", specifier = ">=0.8.0" }, + { name = "redis", specifier = ">=6.0.0" }, { name = "requests", specifier = ">=2.32.3" }, { name = "ruff", specifier = ">=0.11.9" }, { name = "slack-sdk", specifier = ">=3.35.0" }, @@ -4500,6 +4511,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ea/31/da390a5a10674481dea2909178973de81fa3a246c0eedcc0e1e4114f52f8/quart_cors-0.8.0-py3-none-any.whl", hash = "sha256:62dc811768e2e1704d2b99d5880e3eb26fc776832305a19ea53db66f63837767", size = 8698, upload-time = "2024-12-27T20:34:29.511Z" }, ] +[[package]] +name = "redis" +version = "7.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7b/7f/3759b1d0d72b7c92f0d70ffd9dc962b7b7b5ee74e135f9d7d8ab06b8a318/redis-7.4.0.tar.gz", hash = "sha256:64a6ea7bf567ad43c964d2c30d82853f8df927c5c9017766c55a1d1ed95d18ad", size = 4943913, upload-time = "2026-03-24T09:14:37.53Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/3a/95deec7db1eb53979973ebd156f3369a72732208d1391cd2e5d127062a32/redis-7.4.0-py3-none-any.whl", hash = "sha256:a9c74a5c893a5ef8455a5adb793a31bb70feb821c86eccb62eebef5a19c429ec", size = 409772, upload-time = "2026-03-24T09:14:35.968Z" }, +] + [[package]] name = "referencing" version = "0.37.0" diff --git a/web/src/app/home/bots/components/bot-form/BotForm.tsx b/web/src/app/home/bots/components/bot-form/BotForm.tsx index b15cb4055..01b4531a0 100644 --- a/web/src/app/home/bots/components/bot-form/BotForm.tsx +++ b/web/src/app/home/bots/components/bot-form/BotForm.tsx @@ -280,6 +280,7 @@ export default function BotForm({ type: parseDynamicFormItemType(item.type), options: item.options, show_if: item.show_if, + section: item.section, }), ), ); diff --git a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx index 20de6646e..1f99161fe 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx +++ b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx @@ -196,6 +196,31 @@ export default function DynamicFormComponent({ const onSubmitRef = useRef(onSubmit); onSubmitRef.current = onSubmit; + const visibleItemConfigList = itemConfigList.filter((config) => { + if (config.show_if) { + const dependValue = + watchedValues[config.show_if.field as keyof typeof watchedValues] !== + undefined + ? watchedValues[config.show_if.field as keyof typeof watchedValues] + : externalDependentValues?.[config.show_if.field]; + + if (config.show_if.operator === 'eq' && dependValue !== config.show_if.value) { + return false; + } + if (config.show_if.operator === 'neq' && dependValue === config.show_if.value) { + return false; + } + if ( + config.show_if.operator === 'in' && + Array.isArray(config.show_if.value) && + !config.show_if.value.includes(dependValue) + ) { + return false; + } + } + return true; + }); + // 监听表单值变化 useEffect(() => { // Emit initial form values immediately so the parent always has a valid snapshot, @@ -238,74 +263,59 @@ export default function DynamicFormComponent({ return (
- {itemConfigList.map((config) => { - if (config.show_if) { - const dependValue = - watchedValues[ - config.show_if.field as keyof typeof watchedValues - ] !== undefined - ? watchedValues[ - config.show_if.field as keyof typeof watchedValues - ] - : externalDependentValues?.[config.show_if.field]; - - if ( - config.show_if.operator === 'eq' && - dependValue !== config.show_if.value - ) { - return null; - } - if ( - config.show_if.operator === 'neq' && - dependValue === config.show_if.value - ) { - return null; - } - if ( - config.show_if.operator === 'in' && - Array.isArray(config.show_if.value) && - !config.show_if.value.includes(dependValue) - ) { - return null; - } - } - + {visibleItemConfigList.map((config, index) => { // All fields are disabled when editing (creation_settings are immutable) const isFieldDisabled = !!isEditing; + const currentSection = config.section + ? extractI18nObject(config.section) + : ''; + const previousSection = + index > 0 && visibleItemConfigList[index - 1].section + ? extractI18nObject(visibleItemConfigList[index - 1].section!) + : ''; + const showSectionHeader = Boolean(currentSection) && currentSection !== previousSection; return ( - ( - - - {extractI18nObject(config.label)}{' '} - {config.required && *} - - -
- -
-
- {config.description && ( -

- {extractI18nObject(config.description)} -

- )} - -
+
+ {showSectionHeader && ( +
+

+ {currentSection} +

+
)} - /> + ( + + + {extractI18nObject(config.label)}{' '} + {config.required && *} + + +
+ +
+
+ {config.description && ( +

+ {extractI18nObject(config.description)} +

+ )} + +
+ )} + /> +
); })}
diff --git a/web/src/app/home/components/dynamic-form/DynamicFormItemConfig.ts b/web/src/app/home/components/dynamic-form/DynamicFormItemConfig.ts index 18ff3a0b6..243967ab9 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormItemConfig.ts +++ b/web/src/app/home/components/dynamic-form/DynamicFormItemConfig.ts @@ -14,6 +14,7 @@ export class DynamicFormItemConfig implements IDynamicFormItemSchema { required: boolean; type: DynamicFormItemType; description?: I18nObject; + section?: I18nObject; options?: IDynamicFormItemOption[]; show_if?: IShowIfCondition; @@ -25,6 +26,7 @@ export class DynamicFormItemConfig implements IDynamicFormItemSchema { this.required = params.required; this.type = params.type; this.description = params.description; + this.section = params.section; this.options = params.options; this.show_if = params.show_if; } diff --git a/web/src/app/infra/entities/form/dynamic.ts b/web/src/app/infra/entities/form/dynamic.ts index 3f57b0f91..fe67160a9 100644 --- a/web/src/app/infra/entities/form/dynamic.ts +++ b/web/src/app/infra/entities/form/dynamic.ts @@ -15,6 +15,7 @@ export interface IDynamicFormItemSchema { required: boolean; type: DynamicFormItemType; description?: I18nObject; + section?: I18nObject; options?: IDynamicFormItemOption[]; show_if?: IShowIfCondition; From 93e4c586c4b82629934034721dea8342c7632960 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=9C=E9=9B=A8?= <58286951@qq.com> Date: Fri, 27 Mar 2026 12:30:56 +0800 Subject: [PATCH 26/36] fix(wecomcs): isolate bot streams and pipeline sessions --- .../libs/wecom_customer_service_api/api.py | 319 ++++++++++-------- .../api/http/controller/groups/webhooks.py | 10 +- .../pkg/platform/wecomcs/config_resolver.py | 7 + .../pkg/platform/wecomcs/message_publisher.py | 3 +- .../wecomcs/pull_trigger_publisher.py | 3 +- src/langbot/pkg/platform/wecomcs/runtime.py | 38 ++- .../pkg/platform/wecomcs/stream_keys.py | 21 ++ .../pkg/platform/wecomcs/token_cache.py | 11 +- .../pkg/provider/session/sessionmgr.py | 14 +- .../pipeline/test_session_manager.py | 71 ++++ .../platform/test_wecomcs_adapter_config.py | 6 + .../platform/test_wecomcs_message_layer.py | 2 +- .../platform/test_wecomcs_retry_scheduler.py | 7 +- .../platform/test_wecomcs_runtime.py | 79 ++++- .../unit_tests/test_wecom_customer_service.py | 110 +++++- 15 files changed, 531 insertions(+), 170 deletions(-) create mode 100644 src/langbot/pkg/platform/wecomcs/stream_keys.py create mode 100644 tests/unit_tests/pipeline/test_session_manager.py diff --git a/src/langbot/libs/wecom_customer_service_api/api.py b/src/langbot/libs/wecom_customer_service_api/api.py index 92521fc07..71b93e741 100644 --- a/src/langbot/libs/wecom_customer_service_api/api.py +++ b/src/langbot/libs/wecom_customer_service_api/api.py @@ -1,6 +1,7 @@ import asyncio import hashlib import logging +import threading from quart import request from ..wecom_api.WXBizMsgCrypt3 import WXBizMsgCrypt import base64 @@ -78,7 +79,11 @@ def __init__( # Customer info cache: {external_userid: (info_dict, timestamp)} self._customer_cache: dict[str, tuple[dict, float]] = {} - self._cache_ttl = 60 # Cache TTL in seconds (1 minute) + self._cache_ttl = 600 # Cache TTL in seconds (10 minutes) + + # 中文注释:AsyncClient 仅在同一线程 + 同一事件循环内复用,避免跨线程共享。 + self._http_clients: dict[tuple[int, int], httpx.AsyncClient] = {} + self._http_client_lock = asyncio.Lock() # 只有在非统一模式下才注册独立路由 if not self.unified_mode: @@ -90,28 +95,66 @@ def __init__( 'example': [], } + def _corpid_short(self) -> str: + return self.corpid[:10] if self.corpid else 'N/A' + + def _secret_fingerprint_short(self) -> str: + fingerprint = getattr(self.token_cache, 'secret_fingerprint', '') or '' + return fingerprint[:8] if fingerprint else 'N/A' + + def _diag_context(self, *, open_kfid: str = '', bot_uuid: str = '') -> str: + return ( + f"bot_uuid={bot_uuid or self.bot_uuid or 'N/A'}, " + f"corpid={self._corpid_short()}, " + f"secret_fp={self._secret_fingerprint_short()}, " + f"open_kfid={open_kfid or 'N/A'}" + ) + + async def _get_http_client(self) -> httpx.AsyncClient: + loop = asyncio.get_running_loop() + client_key = (threading.get_ident(), id(loop)) + + async with self._http_client_lock: + client = self._http_clients.get(client_key) + if client is not None and not getattr(client, 'is_closed', False): + return client + + client = httpx.AsyncClient(timeout=10.0) + self._http_clients[client_key] = client + return client + + async def aclose_http_clients(self): + async with self._http_client_lock: + clients = list(self._http_clients.values()) + self._http_clients.clear() + + for client in clients: + try: + await client.aclose() + except Exception: + continue + async def get_pic_url(self, media_id: str): await self.ensure_access_token() url = f'{self.base_url}/media/get?access_token={self.access_token}&media_id={media_id}' - async with httpx.AsyncClient(timeout=10.0) as client: - response = await client.get(url) - if response.headers.get('Content-Type', '').startswith('application/json'): - data = response.json() - if data.get('errcode') in [40014, 42001]: - await self.token_cache.invalidate() - self.access_token = '' - return await self.get_pic_url(media_id) - else: - raise Exception('Failed to get image: ' + str(data)) - - # 否则是图片,转成 base64 - image_bytes = response.content - content_type = response.headers.get('Content-Type', '') - base64_str = base64.b64encode(image_bytes).decode('utf-8') - base64_str = f'data:{content_type};base64,{base64_str}' - return base64_str + client = await self._get_http_client() + response = await client.get(url) + if response.headers.get('Content-Type', '').startswith('application/json'): + data = response.json() + if data.get('errcode') in [40014, 42001]: + await self.token_cache.invalidate() + self.access_token = '' + return await self.get_pic_url(media_id) + raise Exception('Failed to get image: ' + str(data)) + + # 否则是图片,转成 base64 + image_bytes = response.content + content_type = response.headers.get('Content-Type', '') + base64_str = base64.b64encode(image_bytes).decode('utf-8') + base64_str = f'data:{content_type};base64,{base64_str}' + return base64_str # access——token操作 async def check_access_token(self): @@ -149,27 +192,27 @@ async def refresh_access_token(self): async def _request_access_token(self, secret: str): """实际请求指定 secret 对应的 access_token 数据。""" url = f'{self.base_url}/gettoken?corpid={self.corpid}&corpsecret={secret}' - _logger.debug(f'[wecomcs] 获取access_token: corpid={self.corpid[:10] if self.corpid else "N/A"}...') - print(f'[wecomcs] 获取access_token: corpid={self.corpid[:10] if self.corpid else "N/A"}...', flush=True) + _logger.debug(f'[wecomcs] 获取access_token: {self._diag_context()}') + print(f'[wecomcs] 获取access_token: {self._diag_context()}', flush=True) try: - async with httpx.AsyncClient(timeout=10.0) as client: - _logger.debug(f'[wecomcs] 正在请求: {url}') - print('[wecomcs] 正在请求 gettoken API...', flush=True) - response = await client.get(url) - print('[wecomcs] gettoken请求完成,正在解析响应...', flush=True) - _logger.debug(f'[wecomcs] gettoken响应状态: {response.status_code}') - print(f'[wecomcs] gettoken响应状态: {response.status_code}', flush=True) - data = response.json() - _logger.debug(f'[wecomcs] gettoken响应: errcode={data.get("errcode")}, errmsg={data.get("errmsg")}') - print(f'[wecomcs] gettoken响应: errcode={data.get("errcode")}, errmsg={data.get("errmsg")}', flush=True) - if 'access_token' in data: - return { - 'access_token': data['access_token'], - 'expires_in': int(data.get('expires_in', 7200) or 7200), - } - - _logger.error(f'[wecomcs] 未获取access token: {data}') - raise Exception(f'未获取access token: {data}') + client = await self._get_http_client() + _logger.debug(f'[wecomcs] 正在请求access_token接口: {self._diag_context()} url={url}') + print('[wecomcs] 正在请求 gettoken API...', flush=True) + response = await client.get(url) + print('[wecomcs] gettoken请求完成,正在解析响应...', flush=True) + _logger.debug(f'[wecomcs] gettoken响应状态: {response.status_code}') + print(f'[wecomcs] gettoken响应状态: {response.status_code}', flush=True) + data = response.json() + _logger.debug(f'[wecomcs] gettoken响应: errcode={data.get("errcode")}, errmsg={data.get("errmsg")}, {self._diag_context()}') + print(f'[wecomcs] gettoken响应: errcode={data.get("errcode")}, errmsg={data.get("errmsg")}', flush=True) + if 'access_token' in data: + return { + 'access_token': data['access_token'], + 'expires_in': int(data.get('expires_in', 7200) or 7200), + } + + _logger.error(f'[wecomcs] 未获取access token: {data}') + raise Exception(f'未获取access token: {data}') except Exception as e: _logger.error(f'[wecomcs] get_access_token异常: {traceback.format_exc()}') print(f'[wecomcs] get_access_token异常: {e}', flush=True) @@ -197,31 +240,31 @@ async def fetch_sync_msg_page(self, callback_token: str, open_kfid: str, cursor: if cursor: params['cursor'] = cursor - async with httpx.AsyncClient(timeout=10.0) as client: - response = await client.post(url, json=params) - data = response.json() - if data.get('errcode') in [40014, 42001]: - await self.refresh_access_token() - return await self.fetch_sync_msg_page(callback_token, open_kfid, cursor) - if data.get('errcode') == 95007: - _logger.error(f'[wecomcs] sync_msg失败: {data}') - raise WecomCSInvalidSyncMsgTokenError(data.get('errmsg', 'invalid msg token')) - if data.get('errcode') != 0: - _logger.error(f'[wecomcs] sync_msg失败: {data}') - raise Exception('Failed to get message') - - msg_list = data.get('msg_list') or [] - for msg_data in msg_list: - if msg_data.get('msgtype') == 'image' and msg_data.get('image'): - media_id = msg_data['image'].get('media_id') - if media_id: - msg_data['picurl'] = await self.get_pic_url(media_id) - - return { - 'msg_list': msg_list, - 'next_cursor': data.get('next_cursor', ''), - 'has_more': bool(data.get('has_more', False)), - } + client = await self._get_http_client() + response = await client.post(url, json=params) + data = response.json() + if data.get('errcode') in [40014, 42001]: + await self.refresh_access_token() + return await self.fetch_sync_msg_page(callback_token, open_kfid, cursor) + if data.get('errcode') == 95007: + _logger.error(f'[wecomcs] sync_msg失败: {self._diag_context(open_kfid=open_kfid)}, payload={data}') + raise WecomCSInvalidSyncMsgTokenError(data.get('errmsg', 'invalid msg token')) + if data.get('errcode') != 0: + _logger.error(f'[wecomcs] sync_msg失败: {self._diag_context(open_kfid=open_kfid)}, payload={data}') + raise Exception('Failed to get message') + + msg_list = data.get('msg_list') or [] + for msg_data in msg_list: + if msg_data.get('msgtype') == 'image' and msg_data.get('image'): + media_id = msg_data['image'].get('media_id') + if media_id: + msg_data['picurl'] = await self.get_pic_url(media_id) + + return { + 'msg_list': msg_list, + 'next_cursor': data.get('next_cursor', ''), + 'has_more': bool(data.get('has_more', False)), + } async def get_detailed_message_list(self, xml_msg: str): # 中文注释:企业微信一次回调可能携带多条消息,不能只取最后一条,否则会丢消息。 @@ -231,7 +274,7 @@ async def get_detailed_message_list(self, xml_msg: str): root = ET.fromstring(xml_msg) callback_token = root.find('Token').text open_kfid = root.find('OpenKfId').text - _logger.debug(f'[wecomcs] sync_msg参数: token={callback_token[:20] if callback_token else "N/A"}..., open_kfid={open_kfid}') + _logger.debug(f'[wecomcs] sync_msg参数: token={callback_token[:20] if callback_token else "N/A"}..., {self._diag_context(open_kfid=open_kfid)}') page = await self.fetch_sync_msg_page(callback_token, open_kfid) msg_list = page.get('msg_list') or [] @@ -247,53 +290,53 @@ async def change_service_status(self, userid: str, openkfid: str, servicer: str) if not await self.check_access_token(): await self.ensure_access_token() url = self.base_url + '/kf/service_state/get?access_token=' + self.access_token - async with httpx.AsyncClient(timeout=10.0) as client: - params = { - 'open_kfid': openkfid, - 'external_userid': userid, - 'service_state': 1, - 'servicer_userid': servicer, - } - response = await client.post(url, json=params) - data = response.json() - if data['errcode'] == 40014 or data['errcode'] == 42001: - await self.refresh_access_token() - return await self.change_service_status(userid, openkfid) - if data['errcode'] != 0: - raise Exception('Failed to change service status: ' + str(data)) + client = await self._get_http_client() + params = { + 'open_kfid': openkfid, + 'external_userid': userid, + 'service_state': 1, + 'servicer_userid': servicer, + } + response = await client.post(url, json=params) + data = response.json() + if data['errcode'] == 40014 or data['errcode'] == 42001: + await self.refresh_access_token() + return await self.change_service_status(userid, openkfid) + if data['errcode'] != 0: + raise Exception('Failed to change service status: ' + str(data)) async def send_image(self, user_id: str, agent_id: int, media_id: str): if not await self.check_access_token(): await self.ensure_access_token() url = self.base_url + '/media/upload?access_token=' + self.access_token - async with httpx.AsyncClient(timeout=10.0) as client: - params = { - 'touser': user_id, - 'toparty': '', - 'totag': '', - 'agentid': agent_id, - 'msgtype': 'image', - 'image': { - 'media_id': media_id, - }, - 'safe': 0, - 'enable_id_trans': 0, - 'enable_duplicate_check': 0, - 'duplicate_check_interval': 1800, - } - try: - response = await client.post(url, json=params) - data = response.json() - except Exception as e: - raise Exception('Failed to send image: ' + str(e)) + client = await self._get_http_client() + params = { + 'touser': user_id, + 'toparty': '', + 'totag': '', + 'agentid': agent_id, + 'msgtype': 'image', + 'image': { + 'media_id': media_id, + }, + 'safe': 0, + 'enable_id_trans': 0, + 'enable_duplicate_check': 0, + 'duplicate_check_interval': 1800, + } + try: + response = await client.post(url, json=params) + data = response.json() + except Exception as e: + raise Exception('Failed to send image: ' + str(e)) - # 企业微信错误码40014和42001,代表accesstoken问题 - if data['errcode'] == 40014 or data['errcode'] == 42001: - await self.refresh_access_token() - return await self.send_image(user_id, agent_id, media_id) + # 企业微信错误码40014和42001,代表accesstoken问题 + if data['errcode'] == 40014 or data['errcode'] == 42001: + await self.refresh_access_token() + return await self.send_image(user_id, agent_id, media_id) - if data['errcode'] != 0: - raise Exception('Failed to send image: ' + str(data)) + if data['errcode'] != 0: + raise Exception('Failed to send image: ' + str(data)) async def send_text_msg(self, open_kfid: str, external_userid: str, msgid: str, content: str): if not await self.check_access_token(): @@ -311,19 +354,19 @@ async def send_text_msg(self, open_kfid: str, external_userid: str, msgid: str, }, } - async with httpx.AsyncClient(timeout=10.0) as client: - response = await client.post(url, json=payload) + client = await self._get_http_client() + response = await client.post(url, json=payload) - data = response.json() - if data['errcode'] == 40014 or data['errcode'] == 42001: - await self.refresh_access_token() - return await self.send_text_msg(open_kfid, external_userid, msgid, content) - if data['errcode'] != 0: - await self.logger.error( - f"[wecomcs] 发送消息失败: errcode={data.get('errcode')}, errmsg={data.get('errmsg')}, open_kfid={open_kfid}, external_userid={external_userid}, msgid={msgid}" - ) - raise Exception(f'Failed to send message: {data}') - return data + data = response.json() + if data['errcode'] == 40014 or data['errcode'] == 42001: + await self.refresh_access_token() + return await self.send_text_msg(open_kfid, external_userid, msgid, content) + if data['errcode'] != 0: + await self.logger.error( + f"[wecomcs] 发送消息失败: errcode={data.get('errcode')}, errmsg={data.get('errmsg')}, external_userid={external_userid}, msgid={msgid}, {self._diag_context(open_kfid=open_kfid)}" + ) + raise Exception(f'Failed to send message: {data}') + return data async def handle_callback_request(self): """处理回调请求(独立端口模式,使用全局 request)。""" @@ -348,7 +391,7 @@ async def _handle_callback_internal(self, req): req: Quart Request 对象 """ try: - _logger.debug(f'[wecomcs] 收到回调请求: method={req.method}') + _logger.debug(f'[wecomcs] 收到回调请求: method={req.method}, {self._diag_context()}') msg_signature = req.args.get('msg_signature') timestamp = req.args.get('timestamp') @@ -386,7 +429,7 @@ async def _handle_callback_internal(self, req): webhook_received_at=int(time.time()), ) _logger.debug( - f'[wecomcs] 已发布pull-trigger: stream={stream_name}, open_kfid={payload.get("open_kfid")}' + f'[wecomcs] 已发布pull-trigger: stream={stream_name}, {self._diag_context(open_kfid=str(payload.get("open_kfid") or ""))}' ) return 'success' except Exception: @@ -529,23 +572,23 @@ async def upload_to_work(self, image: platform_message.Image): ) # 上传文件 - async with httpx.AsyncClient(timeout=10.0) as client: - response = await client.post(url, headers=headers, content=body) - data = response.json() - if data['errcode'] == 40014 or data['errcode'] == 42001: - await self.refresh_access_token() - return await self.upload_to_work(image) - if data.get('errcode', 0) != 0: - raise Exception('failed to upload file') - - media_id = data.get('media_id') - return media_id + client = await self._get_http_client() + response = await client.post(url, headers=headers, content=body) + data = response.json() + if data['errcode'] == 40014 or data['errcode'] == 42001: + await self.refresh_access_token() + return await self.upload_to_work(image) + if data.get('errcode', 0) != 0: + raise Exception('failed to upload file') + + media_id = data.get('media_id') + return media_id async def download_image_to_bytes(self, url: str) -> bytes: - async with httpx.AsyncClient(timeout=10.0) as client: - response = await client.get(url) - response.raise_for_status() - return response.content + client = await self._get_http_client() + response = await client.get(url) + response.raise_for_status() + return response.content # 进行media_id的获取 async def get_media_id(self, image: platform_message.Image): @@ -556,7 +599,7 @@ async def get_customer_info(self, external_userid: str) -> dict | None: """ Get customer information by external_userid with caching. - Uses a 1-minute cache to avoid repeated API calls for the same user. + Uses a 10-minute cache to avoid repeated API calls for the same user. Args: external_userid: The external user ID of the customer. @@ -591,9 +634,9 @@ async def get_customer_info(self, external_userid: str) -> dict | None: _logger.debug(f'[wecomcs] get_customer_info: url={url[:60]}...') try: request_started_at = time.perf_counter() - async with httpx.AsyncClient(timeout=10.0) as client: - response = await client.post(url, json=payload) - data = response.json() + client = await self._get_http_client() + response = await client.post(url, json=payload) + data = response.json() request_elapsed_ms = (time.perf_counter() - request_started_at) * 1000 total_elapsed_ms = (time.perf_counter() - started_at) * 1000 _logger.debug( @@ -621,7 +664,7 @@ async def get_customer_info(self, external_userid: str) -> dict | None: customer_list = data.get('customer_list', []) if customer_list: customer_info = customer_list[0] - # 中文注释:成功结果写入短期缓存,减少同一个客户短时间内重复查资料。 + # 中文注释:成功结果写入 10 分钟缓存,减少同一个客户短时间内重复查资料。 self._customer_cache[external_userid] = (customer_info, current_time) return customer_info diff --git a/src/langbot/pkg/api/http/controller/groups/webhooks.py b/src/langbot/pkg/api/http/controller/groups/webhooks.py index 3beb8b9d0..3454945b8 100644 --- a/src/langbot/pkg/api/http/controller/groups/webhooks.py +++ b/src/langbot/pkg/api/http/controller/groups/webhooks.py @@ -46,7 +46,15 @@ async def _dispatch_webhook(self, bot_uuid: str, path: str): self.ap.logger.warning(f'[webhook] Adapter不支持unified_webhook: bot_uuid={bot_uuid}') return quart.jsonify({'error': 'Adapter does not support unified webhook'}), 501 - self.ap.logger.debug(f'[webhook] 分发到adapter: bot_uuid={bot_uuid}, adapter={type(runtime_bot.adapter).__name__}') + adapter_bot = getattr(runtime_bot.adapter, 'bot', None) + adapter_corpid = getattr(adapter_bot, 'corpid', '') if adapter_bot else '' + adapter_corpid_short = adapter_corpid[:10] if adapter_corpid else 'N/A' + adapter_token_cache = getattr(adapter_bot, 'token_cache', None) + adapter_secret_fp = getattr(adapter_token_cache, 'secret_fingerprint', '') if adapter_token_cache else '' + adapter_secret_fp_short = adapter_secret_fp[:8] if adapter_secret_fp else 'N/A' + self.ap.logger.debug( + f'[webhook] 分发到adapter: bot_uuid={bot_uuid}, adapter={type(runtime_bot.adapter).__name__}, corpid={adapter_corpid_short}, secret_fp={adapter_secret_fp_short}' + ) response = await runtime_bot.adapter.handle_unified_webhook( bot_uuid=bot_uuid, diff --git a/src/langbot/pkg/platform/wecomcs/config_resolver.py b/src/langbot/pkg/platform/wecomcs/config_resolver.py index c5b635f22..3b19f3515 100644 --- a/src/langbot/pkg/platform/wecomcs/config_resolver.py +++ b/src/langbot/pkg/platform/wecomcs/config_resolver.py @@ -13,6 +13,8 @@ 'retry_max_attempts': 3, 'retry_backoff_seconds': [15, 30, 45], 'lock_ttl_seconds': 60, + 'pull_stream_shard_count': 1, + 'process_stream_shard_count': 1, } @@ -157,4 +159,9 @@ def resolve_wecomcs_runtime_settings(bot_config: dict[str, Any], global_schedule or list(WECOMCS_RUNTIME_DEFAULTS['retry_backoff_seconds']) ) + # English comment: We currently pin WeCom CS scheduling to one pull stream and one process stream per bot. + # This keeps routing deterministic and avoids cross-bot queue coupling until a broader runtime redesign lands. + resolved['pull_stream_shard_count'] = WECOMCS_RUNTIME_DEFAULTS['pull_stream_shard_count'] + resolved['process_stream_shard_count'] = WECOMCS_RUNTIME_DEFAULTS['process_stream_shard_count'] + return resolved diff --git a/src/langbot/pkg/platform/wecomcs/message_publisher.py b/src/langbot/pkg/platform/wecomcs/message_publisher.py index 4d0f0399f..73678d6b2 100644 --- a/src/langbot/pkg/platform/wecomcs/message_publisher.py +++ b/src/langbot/pkg/platform/wecomcs/message_publisher.py @@ -5,6 +5,7 @@ from ...cache.redis_mgr import RedisManager from .sharding import resolve_process_shard +from .stream_keys import build_process_stream_name _logger = logging.getLogger("langbot") @@ -27,7 +28,7 @@ async def publish_message(self, bot_uuid: str, msg_data: dict) -> tuple[str, dic raise ValueError('open_kfid and external_userid are required') shard = resolve_process_shard(bot_uuid, open_kfid, external_userid, self.shard_count) - stream_name = f'wecomcs:message-process:{shard}' + stream_name = build_process_stream_name(bot_uuid, shard) payload = { 'job_type': 'message_process', 'bot_uuid': bot_uuid, diff --git a/src/langbot/pkg/platform/wecomcs/pull_trigger_publisher.py b/src/langbot/pkg/platform/wecomcs/pull_trigger_publisher.py index 03e44ab55..9a73b6fc8 100644 --- a/src/langbot/pkg/platform/wecomcs/pull_trigger_publisher.py +++ b/src/langbot/pkg/platform/wecomcs/pull_trigger_publisher.py @@ -5,6 +5,7 @@ from ...cache.redis_mgr import RedisManager from .sharding import resolve_pull_shard +from .stream_keys import build_pull_stream_name _logger = logging.getLogger("langbot") @@ -28,7 +29,7 @@ async def publish_from_xml(self, bot_uuid: str, xml_msg: str, webhook_received_a raise ValueError('Token and OpenKfId are required in callback XML') shard = resolve_pull_shard(bot_uuid, open_kfid, self.shard_count) - stream_name = f'wecomcs:pull-trigger:{shard}' + stream_name = build_pull_stream_name(bot_uuid, shard) payload = { 'job_type': 'pull_trigger', 'bot_uuid': bot_uuid, diff --git a/src/langbot/pkg/platform/wecomcs/runtime.py b/src/langbot/pkg/platform/wecomcs/runtime.py index 0ff7acbaf..032efbe02 100644 --- a/src/langbot/pkg/platform/wecomcs/runtime.py +++ b/src/langbot/pkg/platform/wecomcs/runtime.py @@ -10,6 +10,13 @@ from .pull_worker import PullLockNotAcquiredError, WecomCSPullWorker from .state_store import WecomCSStateStore from .retry_scheduler import WecomCSRetryScheduler +from .stream_keys import ( + build_process_consumer_group_name, + build_process_stream_name, + build_pull_consumer_group_name, + build_pull_stream_name, + build_retry_zset_key, +) _logger = logging.getLogger("langbot") @@ -49,14 +56,21 @@ def __init__(self, bot_uuid: str, client, redis_mgr: RedisManager, scheduler_con ) self.pull_stream_shard_count = int(scheduler_config.get('pull_stream_shard_count', 8) or 8) self.process_stream_shard_count = int(scheduler_config.get('process_stream_shard_count', 16) or 16) - self.pull_consumer_group = scheduler_config.get('pull_consumer_group', 'wecomcs-pull-group') - self.process_consumer_group = scheduler_config.get('process_consumer_group', 'wecomcs-process-group') + self.pull_consumer_group = build_pull_consumer_group_name( + str(scheduler_config.get('pull_consumer_group', 'wecomcs-pull-group') or 'wecomcs-pull-group'), + bot_uuid, + ) + self.process_consumer_group = build_process_consumer_group_name( + str(scheduler_config.get('process_consumer_group', 'wecomcs-process-group') or 'wecomcs-process-group'), + bot_uuid, + ) self.stream_block_ms = int(scheduler_config.get('stream_block_ms', 1000) or 1000) self.stream_batch_size = int(scheduler_config.get('stream_batch_size', 10) or 10) self.consumer_name = f'{socket.gethostname()}-{self.bot_uuid}' self.retry_poll_interval_seconds = int(scheduler_config.get('retry_poll_interval_seconds', 3) or 3) self.retry_scheduler = WecomCSRetryScheduler( redis_mgr, + retry_zset_key=build_retry_zset_key(bot_uuid), retry_backoff_seconds=list(scheduler_config.get('retry_backoff_seconds', [15, 30, 45]) or [15, 30, 45]), retry_max_attempts=int(scheduler_config.get('retry_max_attempts', 3) or 3), ) @@ -65,10 +79,16 @@ async def _publish_message(self, msg_data: dict): await self.message_publisher.publish_message(self.bot_uuid, msg_data) def _pull_streams(self) -> list[str]: - return [f'wecomcs:pull-trigger:{index}' for index in range(self.pull_stream_shard_count)] + return [build_pull_stream_name(self.bot_uuid, index) for index in range(self.pull_stream_shard_count)] def _process_streams(self) -> list[str]: - return [f'wecomcs:message-process:{index}' for index in range(self.process_stream_shard_count)] + return [build_process_stream_name(self.bot_uuid, index) for index in range(self.process_stream_shard_count)] + + def _matches_runtime_bot(self, fields: dict[str, str]) -> bool: + payload_bot_uuid = str(fields.get('bot_uuid', '') or '') + if not payload_bot_uuid: + return True + return payload_bot_uuid == self.bot_uuid @staticmethod def _extract_retry_count(fields: dict[str, str]) -> int: @@ -111,6 +131,11 @@ async def _run_pull_loop(self): retry_count = self._extract_retry_count(fields) try: _logger.debug(f'[wecomcs][runtime] pull-loop消费消息: stream={logical_stream_name}, message_id={message_id}, bot_uuid={fields.get("bot_uuid")}, open_kfid={fields.get("open_kfid")}') + if not self._matches_runtime_bot(fields): + _logger.error( + f'[wecomcs][runtime] pull-loop检测到跨bot消息,直接跳过: stream={logical_stream_name}, message_id={message_id}, runtime_bot_uuid={self.bot_uuid}, payload_bot_uuid={fields.get("bot_uuid")}' + ) + continue await self.pull_worker.handle_trigger(fields) except PullLockNotAcquiredError as exc: _logger.debug(f'[wecomcs][runtime] pull-loop未拿到锁,安排重试: stream={logical_stream_name}, message_id={message_id}, error={exc}') @@ -143,6 +168,11 @@ async def _run_process_loop(self): retry_count = self._extract_retry_count(fields) try: _logger.debug(f'[wecomcs][runtime] process-loop消费消息: stream={logical_stream_name}, message_id={message_id}') + if not self._matches_runtime_bot(fields): + _logger.error( + f'[wecomcs][runtime] process-loop检测到跨bot消息,直接跳过: stream={logical_stream_name}, message_id={message_id}, runtime_bot_uuid={self.bot_uuid}, payload_bot_uuid={fields.get("bot_uuid")}' + ) + continue handled = await self.message_worker.handle_stream_entry(fields) if not handled: _logger.debug(f'[wecomcs][runtime] process-loop消息无效,直接ACK: stream={logical_stream_name}, message_id={message_id}') diff --git a/src/langbot/pkg/platform/wecomcs/stream_keys.py b/src/langbot/pkg/platform/wecomcs/stream_keys.py new file mode 100644 index 000000000..bf372447e --- /dev/null +++ b/src/langbot/pkg/platform/wecomcs/stream_keys.py @@ -0,0 +1,21 @@ +from __future__ import annotations + + +def build_pull_stream_name(bot_uuid: str, shard: int) -> str: + return f"wecomcs:{bot_uuid}:pull-trigger:{shard}" + + +def build_process_stream_name(bot_uuid: str, shard: int) -> str: + return f"wecomcs:{bot_uuid}:message-process:{shard}" + + +def build_retry_zset_key(bot_uuid: str) -> str: + return f"wecomcs:{bot_uuid}:retry" + + +def build_pull_consumer_group_name(base_group: str, bot_uuid: str) -> str: + return f"{base_group}:{bot_uuid}" + + +def build_process_consumer_group_name(base_group: str, bot_uuid: str) -> str: + return f"{base_group}:{bot_uuid}" diff --git a/src/langbot/pkg/platform/wecomcs/token_cache.py b/src/langbot/pkg/platform/wecomcs/token_cache.py index 4fa0130bf..3f66b506f 100644 --- a/src/langbot/pkg/platform/wecomcs/token_cache.py +++ b/src/langbot/pkg/platform/wecomcs/token_cache.py @@ -38,6 +38,9 @@ def _cache_key(self) -> str: return f"wecomcs:access_token:{self.corpid}:{self.secret_fingerprint}" return f"wecomcs:access_token:{self.corpid}" + def _secret_fingerprint_short(self) -> str: + return self.secret_fingerprint[:8] if self.secret_fingerprint else 'N/A' + def _is_valid_payload(self, payload: dict | None) -> bool: # 中文注释:本地和 Redis 都统一按 expires_at 判断,避免只凭是否有字符串误判 token 有效。 if not payload: @@ -48,13 +51,13 @@ def _is_valid_payload(self, payload: dict | None) -> bool: async def _read_cached_payload(self) -> dict | None: if self._is_valid_payload(self._local_cache): - _logger.debug(f'[wecomcs][token-cache] 本地缓存命中: corpid={self.corpid[:10]}...') + _logger.debug(f'[wecomcs][token-cache] 本地缓存命中: corpid={self.corpid[:10]}..., secret_fp={self._secret_fingerprint_short()}') return dict(self._local_cache) if self.redis_mgr and self.redis_mgr.is_available(): payload = await self.redis_mgr.get_json(self._cache_key()) if self._is_valid_payload(payload): - _logger.debug(f'[wecomcs][token-cache] Redis缓存命中: corpid={self.corpid[:10]}...') + _logger.debug(f'[wecomcs][token-cache] Redis缓存命中: corpid={self.corpid[:10]}..., secret_fp={self._secret_fingerprint_short()}') self._local_cache = { "access_token": str(payload["access_token"]), "expires_at": int(payload["expires_at"]), @@ -102,7 +105,7 @@ async def get_or_refresh( } _logger.debug( - f'[wecomcs][token-cache] 刷新token成功: corpid={self.corpid[:10]}..., ttl_seconds={ttl_seconds}, expires_at={expires_at}' + f'[wecomcs][token-cache] 刷新token成功: corpid={self.corpid[:10]}..., secret_fp={self._secret_fingerprint_short()}, ttl_seconds={ttl_seconds}, expires_at={expires_at}' ) self._local_cache = payload if self.redis_mgr and self.redis_mgr.is_available(): @@ -111,7 +114,7 @@ async def get_or_refresh( return access_token async def invalidate(self): - _logger.debug(f'[wecomcs][token-cache] 失效token缓存: corpid={self.corpid[:10]}...') + _logger.debug(f'[wecomcs][token-cache] 失效token缓存: corpid={self.corpid[:10]}..., secret_fp={self._secret_fingerprint_short()}') self._local_cache = { "access_token": "", "expires_at": 0, diff --git a/src/langbot/pkg/provider/session/sessionmgr.py b/src/langbot/pkg/provider/session/sessionmgr.py index 8d7823651..97d250cf7 100644 --- a/src/langbot/pkg/provider/session/sessionmgr.py +++ b/src/langbot/pkg/provider/session/sessionmgr.py @@ -18,14 +18,18 @@ class SessionManager: def __init__(self, ap: app.Application): self.ap = ap self.session_list = [] + self._session_keys: dict[int, tuple[str | None, provider_session.LauncherTypes, int | str]] = {} async def initialize(self): pass async def get_session(self, query: pipeline_query.Query) -> provider_session.Session: """获取会话""" + query_key = (query.bot_uuid, query.launcher_type, query.launcher_id) + for session in self.session_list: - if query.launcher_type == session.launcher_type and query.launcher_id == session.launcher_id: + session_key = self._session_keys.get(id(session)) + if session_key == query_key: return session session_concurrency = self.ap.instance_config.data['concurrency']['session'] @@ -37,6 +41,8 @@ async def get_session(self, query: pipeline_query.Query) -> provider_session.Ses ) session._semaphore = asyncio.Semaphore(session_concurrency) self.session_list.append(session) + # Keep the bot dimension outside the shared plugin SDK Session model. + self._session_keys[id(session)] = query_key return session async def get_conversation( @@ -63,7 +69,11 @@ async def get_conversation( messages=prompt_messages, ) - if session.using_conversation is None or session.using_conversation.pipeline_uuid != pipeline_uuid: + if ( + session.using_conversation is None + or session.using_conversation.pipeline_uuid != pipeline_uuid + or session.using_conversation.bot_uuid != bot_uuid + ): conversation = provider_session.Conversation( prompt=prompt, messages=[], diff --git a/tests/unit_tests/pipeline/test_session_manager.py b/tests/unit_tests/pipeline/test_session_manager.py new file mode 100644 index 000000000..4f0338def --- /dev/null +++ b/tests/unit_tests/pipeline/test_session_manager.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +import langbot_plugin.api.entities.builtin.provider.session as provider_session + +from langbot.pkg.provider.session.sessionmgr import SessionManager + + +class FakeApp: + def __init__(self): + self.instance_config = SimpleNamespace(data={'concurrency': {'session': 1}}) + + +@pytest.mark.asyncio +async def test_get_session_isolated_by_bot_uuid(): + mgr = SessionManager(FakeApp()) + + query_a = SimpleNamespace( + bot_uuid='bot-a', + launcher_type=provider_session.LauncherTypes.PERSON, + launcher_id='person-1', + sender_id='person-1', + ) + query_b = SimpleNamespace( + bot_uuid='bot-b', + launcher_type=provider_session.LauncherTypes.PERSON, + launcher_id='person-1', + sender_id='person-1', + ) + + session_a = await mgr.get_session(query_a) + session_b = await mgr.get_session(query_b) + + assert session_a is not session_b + + +@pytest.mark.asyncio +async def test_get_conversation_does_not_reuse_same_pipeline_across_bots(): + mgr = SessionManager(FakeApp()) + + query_a = SimpleNamespace( + bot_uuid='bot-a', + launcher_type=provider_session.LauncherTypes.PERSON, + launcher_id='person-1', + sender_id='person-1', + ) + + session = await mgr.get_session(query_a) + + conv_a = await mgr.get_conversation( + query=query_a, + session=session, + prompt_config=[], + pipeline_uuid='pipeline-1', + bot_uuid='bot-a', + ) + + conv_b = await mgr.get_conversation( + query=query_a, + session=session, + prompt_config=[], + pipeline_uuid='pipeline-1', + bot_uuid='bot-b', + ) + + assert conv_a is not conv_b + assert conv_a.bot_uuid == 'bot-a' + assert conv_b.bot_uuid == 'bot-b' diff --git a/tests/unit_tests/platform/test_wecomcs_adapter_config.py b/tests/unit_tests/platform/test_wecomcs_adapter_config.py index 263b4dad8..3810f7e50 100644 --- a/tests/unit_tests/platform/test_wecomcs_adapter_config.py +++ b/tests/unit_tests/platform/test_wecomcs_adapter_config.py @@ -77,6 +77,8 @@ async def test_wecomcs_bot_config_overrides_global_scheduler_settings(base_confi adapter.set_bot_uuid('bot-1') assert adapter.scheduler_runtime is not None + assert adapter.scheduler_runtime.scheduler_config['pull_stream_shard_count'] == 1 + assert adapter.scheduler_runtime.scheduler_config['process_stream_shard_count'] == 1 assert adapter.scheduler_runtime.scheduler_config['history_message_drop_threshold_seconds'] == 45 assert adapter.scheduler_runtime.scheduler_config['retry_max_attempts'] == 2 assert adapter.scheduler_runtime.scheduler_config['retry_backoff_seconds'] == [3, 6, 9] @@ -101,6 +103,8 @@ async def test_wecomcs_adapter_falls_back_to_global_scheduler_settings(base_conf adapter.set_bot_uuid('bot-1') assert adapter.scheduler_runtime is not None + assert adapter.scheduler_runtime.scheduler_config['pull_stream_shard_count'] == 1 + assert adapter.scheduler_runtime.scheduler_config['process_stream_shard_count'] == 1 assert adapter.scheduler_runtime.scheduler_config['history_message_drop_threshold_seconds'] == 150 assert adapter.scheduler_runtime.scheduler_config['retry_max_attempts'] == 5 assert adapter.scheduler_runtime.scheduler_config['retry_backoff_seconds'] == [10, 20] @@ -116,6 +120,8 @@ async def test_wecomcs_adapter_falls_back_to_code_defaults_when_configs_missing( adapter.set_bot_uuid('bot-1') assert adapter.scheduler_runtime is not None + assert adapter.scheduler_runtime.scheduler_config['pull_stream_shard_count'] == 1 + assert adapter.scheduler_runtime.scheduler_config['process_stream_shard_count'] == 1 assert adapter.scheduler_runtime.scheduler_config['history_message_drop_threshold_seconds'] == 90 assert adapter.scheduler_runtime.scheduler_config['retry_max_attempts'] == 3 assert adapter.scheduler_runtime.scheduler_config['retry_backoff_seconds'] == [15, 30, 45] diff --git a/tests/unit_tests/platform/test_wecomcs_message_layer.py b/tests/unit_tests/platform/test_wecomcs_message_layer.py index 7d526254d..d858fa558 100644 --- a/tests/unit_tests/platform/test_wecomcs_message_layer.py +++ b/tests/unit_tests/platform/test_wecomcs_message_layer.py @@ -33,7 +33,7 @@ def test_message_publisher_routes_message_to_process_stream(): stream_name, payload = asyncio.run(publisher.publish_message('bot-1', msg_data)) - assert stream_name.startswith('wecomcs:message-process:') + assert stream_name.startswith('wecomcs:bot-1:message-process:') assert payload['job_type'] == 'message_process' assert payload['external_userid'] == 'user-1' assert redis_mgr.entries[0][0] == stream_name diff --git a/tests/unit_tests/platform/test_wecomcs_retry_scheduler.py b/tests/unit_tests/platform/test_wecomcs_retry_scheduler.py index fd9440fa2..39a1a2ba0 100644 --- a/tests/unit_tests/platform/test_wecomcs_retry_scheduler.py +++ b/tests/unit_tests/platform/test_wecomcs_retry_scheduler.py @@ -7,8 +7,10 @@ class FakeRedisManager: def __init__(self): self.zset: dict[str, float] = {} self.stream_entries: list[tuple[str, dict[str, str]]] = [] + self.zadd_calls: list[str] = [] async def zadd(self, key: str, mapping: dict[str, float]): + self.zadd_calls.append(key) self.zset.update(mapping) return 1 @@ -27,12 +29,13 @@ async def xadd(self, stream: str, fields: dict[str, str], maxlen: int | None = N @pytest.mark.asyncio async def test_retry_scheduler_schedules_and_replays_job(): redis_mgr = FakeRedisManager() - scheduler = WecomCSRetryScheduler(redis_mgr, retry_backoff_seconds=[15, 30], retry_max_attempts=3) + scheduler = WecomCSRetryScheduler(redis_mgr, retry_zset_key='wecomcs:bot-1:retry', retry_backoff_seconds=[15, 30], retry_max_attempts=3) scheduled = await scheduler.schedule_retry('wecomcs:pull-trigger:0', {'bot_uuid': 'bot-1'}, retry_count=0, error='boom') assert scheduled is True assert len(redis_mgr.zset) == 1 + assert redis_mgr.zadd_calls == ['wecomcs:bot-1:retry'] replayed = await scheduler.replay_due_jobs(now_ts=9999999999) @@ -49,7 +52,7 @@ async def test_retry_scheduler_schedules_and_replays_job(): @pytest.mark.asyncio async def test_retry_scheduler_stops_after_max_attempts(): redis_mgr = FakeRedisManager() - scheduler = WecomCSRetryScheduler(redis_mgr, retry_backoff_seconds=[15], retry_max_attempts=2) + scheduler = WecomCSRetryScheduler(redis_mgr, retry_zset_key='wecomcs:bot-1:retry', retry_backoff_seconds=[15], retry_max_attempts=2) assert await scheduler.schedule_retry('stream-a', {'foo': 'bar'}, retry_count=0) is True assert await scheduler.schedule_retry('stream-a', {'foo': 'bar'}, retry_count=1) is True diff --git a/tests/unit_tests/platform/test_wecomcs_runtime.py b/tests/unit_tests/platform/test_wecomcs_runtime.py index 5b32d3db8..73288b542 100644 --- a/tests/unit_tests/platform/test_wecomcs_runtime.py +++ b/tests/unit_tests/platform/test_wecomcs_runtime.py @@ -12,7 +12,7 @@ def __init__(self): self.acks: list[tuple[str, str, str]] = [] self.pull_messages = [ ( - 'langbot:wecomcs:pull-trigger:0', + 'langbot:wecomcs:bot-1:pull-trigger:0', [ ('1-0', {'bot_uuid': 'bot-1', 'open_kfid': 'kf-1', 'callback_token': 'token-1'}), ], @@ -20,7 +20,7 @@ def __init__(self): ] self.process_messages = [ ( - 'langbot:wecomcs:message-process:0', + 'langbot:wecomcs:bot-1:message-process:0', [ ('2-0', {'payload': '{"msgtype":"text","external_userid":"user-1","open_kfid":"kf-1","msgid":"msg-1","send_time":111,"text":{"content":"hello"}}'}), ], @@ -35,9 +35,9 @@ async def xgroup_create(self, stream: str, group: str, id: str = '0', mkstream: async def xreadgroup(self, group: str, consumer: str, streams: dict[str, str], count=None, block_ms=None): self.xreadgroup_calls.append((group, consumer, streams)) - if group == 'pull-group' and self.pull_messages: + if group == 'pull-group:bot-1' and self.pull_messages: return [self.pull_messages.pop(0)] - if group == 'process-group' and self.process_messages: + if group == 'process-group:bot-1' and self.process_messages: return [self.process_messages.pop(0)] return [] @@ -61,9 +61,9 @@ async def test_runtime_initializes_consumer_groups(): await runtime.initialize() - assert ('wecomcs:pull-trigger:0', 'pull-group') in runtime.redis_mgr.groups - assert ('wecomcs:pull-trigger:1', 'pull-group') in runtime.redis_mgr.groups - assert ('wecomcs:message-process:2', 'process-group') in runtime.redis_mgr.groups + assert ('wecomcs:bot-1:pull-trigger:0', 'pull-group:bot-1') in runtime.redis_mgr.groups + assert ('wecomcs:bot-1:pull-trigger:1', 'pull-group:bot-1') in runtime.redis_mgr.groups + assert ('wecomcs:bot-1:message-process:2', 'process-group:bot-1') in runtime.redis_mgr.groups @pytest.mark.asyncio @@ -99,7 +99,7 @@ async def fake_handle_trigger(payload): await runtime._run_pull_loop() assert handled[0]['open_kfid'] == 'kf-1' - assert ('wecomcs:pull-trigger:0', 'pull-group', '1-0') in redis_mgr.acks + assert ('wecomcs:bot-1:pull-trigger:0', 'pull-group:bot-1', '1-0') in redis_mgr.acks @pytest.mark.asyncio @@ -131,7 +131,7 @@ async def fake_handle_stream_entry(fields): runtime.running = True await runtime._run_process_loop() - assert ('wecomcs:message-process:0', 'process-group', '2-0') in redis_mgr.acks + assert ('wecomcs:bot-1:message-process:0', 'process-group:bot-1', '2-0') in redis_mgr.acks @pytest.mark.asyncio @@ -172,8 +172,8 @@ async def fake_handle_trigger(payload): runtime.running = True await runtime._run_pull_loop() - assert scheduled[0][0] == 'wecomcs:pull-trigger:0' - assert ('wecomcs:pull-trigger:0', 'pull-group', '1-0') in redis_mgr.acks + assert scheduled[0][0] == 'wecomcs:bot-1:pull-trigger:0' + assert ('wecomcs:bot-1:pull-trigger:0', 'pull-group:bot-1', '1-0') in redis_mgr.acks @pytest.mark.asyncio @@ -181,7 +181,7 @@ async def test_runtime_pull_loop_uses_retry_count_from_stream_fields_on_failure( redis_mgr = FakeRedisManager() redis_mgr.pull_messages = [ ( - 'langbot:wecomcs:pull-trigger:0', + 'langbot:wecomcs:bot-1:pull-trigger:0', [ ('1-0', {'bot_uuid': 'bot-1', 'open_kfid': 'kf-1', 'callback_token': 'token-1', 'retry_count': '2'}), ], @@ -222,9 +222,9 @@ async def fake_handle_trigger(payload): runtime.running = True await runtime._run_pull_loop() - assert scheduled[0][0] == 'wecomcs:pull-trigger:0' + assert scheduled[0][0] == 'wecomcs:bot-1:pull-trigger:0' assert scheduled[0][2] == 2 - assert ('wecomcs:pull-trigger:0', 'pull-group', '1-0') in redis_mgr.acks + assert ('wecomcs:bot-1:pull-trigger:0', 'pull-group:bot-1', '1-0') in redis_mgr.acks @pytest.mark.asyncio @@ -300,4 +300,53 @@ async def fake_handle_trigger(payload): await runtime._run_pull_loop() assert scheduled == [] - assert ('wecomcs:pull-trigger:0', 'pull-group', '1-0') in redis_mgr.acks + assert ('wecomcs:bot-1:pull-trigger:0', 'pull-group:bot-1', '1-0') in redis_mgr.acks + + +@pytest.mark.asyncio +async def test_runtime_pull_loop_skips_foreign_bot_message(): + redis_mgr = FakeRedisManager() + redis_mgr.pull_messages = [ + ( + 'langbot:wecomcs:bot-1:pull-trigger:0', + [ + ('1-0', {'bot_uuid': 'bot-2', 'open_kfid': 'kf-1', 'callback_token': 'token-1'}), + ], + ) + ] + + runtime = WecomCSSchedulerRuntime( + 'bot-1', + client=object(), + redis_mgr=redis_mgr, + scheduler_config={ + 'pull_stream_shard_count': 1, + 'process_stream_shard_count': 1, + 'pull_consumer_group': 'pull-group', + 'process_consumer_group': 'process-group', + 'stream_batch_size': 1, + 'stream_block_ms': 1, + }, + ) + + called = False + + async def fake_handle_trigger(payload): + nonlocal called + called = True + runtime.running = False + return 1 + + runtime.pull_worker.handle_trigger = fake_handle_trigger + + async def fake_xreadgroup(group, consumer, streams, count=None, block_ms=None): + result = await FakeRedisManager.xreadgroup(redis_mgr, group, consumer, streams, count=count, block_ms=block_ms) + runtime.running = False + return result + + redis_mgr.xreadgroup = fake_xreadgroup + runtime.running = True + await runtime._run_pull_loop() + + assert called is False + assert ('wecomcs:bot-1:pull-trigger:0', 'pull-group:bot-1', '1-0') in redis_mgr.acks diff --git a/tests/unit_tests/test_wecom_customer_service.py b/tests/unit_tests/test_wecom_customer_service.py index e510e545a..06682c377 100644 --- a/tests/unit_tests/test_wecom_customer_service.py +++ b/tests/unit_tests/test_wecom_customer_service.py @@ -241,7 +241,7 @@ async def test_callback_publishes_pull_trigger_when_scheduler_enabled(monkeypatc class FakePublisher: async def publish_from_xml(self, bot_uuid: str, xml_msg: str, webhook_received_at: int | None = None): published.append((bot_uuid, xml_msg, webhook_received_at)) - return 'wecomcs:pull-trigger:0', {'open_kfid': 'kf-1', 'webhook_received_at': str(webhook_received_at or '')} + return 'wecomcs:bot-1:pull-trigger:0', {'open_kfid': 'kf-1', 'webhook_received_at': str(webhook_received_at or '')} monkeypatch.setattr(wecomcs_api, 'WXBizMsgCrypt', FakeWXBizMsgCrypt) client.scheduler_enabled = True @@ -413,3 +413,111 @@ async def post(self, url, json=None, headers=None, content=None): assert 'token_elapsed_ms=200.00' in caplog.text assert 'request_elapsed_ms=250.00' in caplog.text assert 'total_elapsed_ms=450.00' in caplog.text + + +@pytest.mark.asyncio +async def test_get_customer_info_uses_sender_cache_within_ten_minutes(monkeypatch, client): + client.access_token = 'token-1' + + async def fake_ensure_access_token(): + return client.access_token + + request_count = 0 + + class FakeResponse: + def json(self): + return { + 'errcode': 0, + 'errmsg': 'ok', + 'customer_list': [ + { + 'external_userid': 'user-1', + 'nickname': 'Tester', + } + ], + 'invalid_external_userid': [], + } + + class FakeAsyncClient: + def __init__(self, *args, **kwargs): + return None + + async def post(self, url, json=None, headers=None, content=None): + nonlocal request_count + request_count += 1 + return FakeResponse() + + current_time = {'value': 1000.0} + + monkeypatch.setattr(client, 'ensure_access_token', fake_ensure_access_token) + monkeypatch.setattr(wecomcs_api.httpx, 'AsyncClient', FakeAsyncClient) + monkeypatch.setattr(wecomcs_api.time, 'time', lambda: current_time['value']) + + first = await client.get_customer_info('user-1') + current_time['value'] = 1000.0 + 599 + second = await client.get_customer_info('user-1') + + assert first == {'external_userid': 'user-1', 'nickname': 'Tester'} + assert second == first + assert request_count == 1 + + +@pytest.mark.asyncio +async def test_wecomcs_http_client_reused_within_same_loop(monkeypatch, client): + client.access_token = 'token-1' + + async def fake_ensure_access_token(): + return client.access_token + + create_count = 0 + + class FakeResponse: + def __init__(self, payload=None, headers=None, content=b''): + self._payload = payload or {'errcode': 0, 'errmsg': 'ok'} + self.headers = headers or {'Content-Type': 'application/json'} + self.content = content + self.status_code = 200 + + def json(self): + return self._payload + + class FakeAsyncClient: + def __init__(self, *args, **kwargs): + nonlocal create_count + create_count += 1 + self.is_closed = False + + async def post(self, url, json=None, headers=None, content=None): + if '/kf/sync_msg' in url: + return FakeResponse({'errcode': 0, 'errmsg': 'ok', 'msg_list': [], 'next_cursor': 'cursor-2', 'has_more': False}) + if '/kf/customer/batchget' in url: + return FakeResponse({'errcode': 0, 'errmsg': 'ok', 'customer_list': [{'external_userid': 'user-1', 'nickname': 'Tester'}], 'invalid_external_userid': []}) + if '/kf/send_msg' in url: + return FakeResponse({'errcode': 0, 'errmsg': 'ok'}) + return FakeResponse() + + async def get(self, url): + return FakeResponse({'errcode': 0, 'errmsg': 'ok'}) + + async def aclose(self): + self.is_closed = True + + monkeypatch.setattr(client, 'ensure_access_token', fake_ensure_access_token) + monkeypatch.setattr(wecomcs_api.httpx, 'AsyncClient', FakeAsyncClient) + + await client.fetch_sync_msg_page('callback-token', 'kf-1', 'cursor-1') + await client.get_customer_info('user-1') + await client.send_text_msg('kf-1', 'user-1', 'msg-1', 'reply') + + assert create_count == 1 + + + +def test_wecomcs_diag_context_contains_bot_and_secret_fingerprint(client): + client.bot_uuid = 'bot-123' + diag = client._diag_context(open_kfid='kf-123') + + assert 'bot_uuid=bot-123' in diag + assert 'open_kfid=kf-123' in diag + assert 'corpid=corp-id' in diag + assert 'secret_fp=' in diag From ed51cce3ab42bd5ad274948a63204285b0fc7610 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=9C=E9=9B=A8?= <58286951@qq.com> Date: Fri, 27 Mar 2026 16:02:25 +0800 Subject: [PATCH 27/36] =?UTF-8?q?feat(dify):=20=E6=8C=81=E4=B9=85=E5=8C=96?= =?UTF-8?q?=20Redis=20=E4=BC=9A=E8=AF=9D=E7=BB=91=E5=AE=9A=E5=B9=B6?= =?UTF-8?q?=E8=A1=A5=E5=85=85=20README=20=E5=A2=9E=E5=BC=BA=E8=AF=B4?= =?UTF-8?q?=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 14 + src/langbot/pkg/api/http/service/bot.py | 71 +- src/langbot/pkg/api/http/service/pipeline.py | 72 +- src/langbot/pkg/plugin/handler.py | 62 + .../pkg/provider/conversation/dify_store.py | 175 +++ src/langbot/pkg/provider/runners/difysvapi.py | 1079 ++++++++++------- src/langbot/templates/config.yaml | 6 + .../api/test_dify_conversation_resets.py | 421 +++++++ .../pipeline/test_wecombot_dify_minfix.py | 608 ++++++++++ .../provider/test_dify_conversation_store.py | 224 ++++ 10 files changed, 2320 insertions(+), 412 deletions(-) create mode 100644 src/langbot/pkg/provider/conversation/dify_store.py create mode 100644 tests/unit_tests/api/test_dify_conversation_resets.py create mode 100644 tests/unit_tests/provider/test_dify_conversation_store.py diff --git a/README.md b/README.md index dad5c6249..69d1f6ad3 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,20 @@ English / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / [日本 LangBot is an **open-source, production-grade platform** for building AI-powered instant messaging bots. It connects Large Language Models (LLMs) to any chat platform, enabling you to create intelligent agents that can converse, execute tasks, and integrate with your existing workflows. +## Enhancements in This Branch + +Compared with the upstream official branch, this maintained branch includes several practical enhancements focused on WeCom / WeChat operations and long-running session reliability: + +- **WeCom Customer Service runtime enhancements** — Redis-backed scheduling, state persistence, retry handling, token cache, and sharded processing for more stable large-volume customer service workloads. +- **WeCom app managed customer service adapter** — Added `wecom_kf_app` support for scenarios where customer service is managed through a WeCom application authorization model. +- **Dify conversation persistence** — Persist Dify `conversation_id` bindings in Redis and clear them on explicit session resets, reducing context loss after runtime restarts. +- **OpenClaw WeChat adapter support** — Added OpenClaw-based WeChat integration for additional WeChat connectivity scenarios. +- **WeCom / WeChat integration hardening** — Additional fixes and runtime improvements around session isolation, callback handling, and customer-service message processing. + +These enhancements are especially useful for teams running LangBot in private deployments with heavier WeCom / WeChat customer-service traffic and stronger context-continuity requirements. + +--- + ### Key Capabilities - **AI Conversations & Agents** — Multi-turn dialogues, tool calling, multi-modal support, streaming output. Built-in RAG (knowledge base) with deep integration to [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org). diff --git a/src/langbot/pkg/api/http/service/bot.py b/src/langbot/pkg/api/http/service/bot.py index 145ac8275..3199b4b20 100644 --- a/src/langbot/pkg/api/http/service/bot.py +++ b/src/langbot/pkg/api/http/service/bot.py @@ -7,6 +7,48 @@ from ....core import app from ....entity.persistence import bot as persistence_bot from ....entity.persistence import pipeline as persistence_pipeline +from ....provider.conversation.dify_store import DifyConversationStore + + +def _normalize_launcher_type(launcher_type: typing.Any) -> str: + if hasattr(launcher_type, 'value'): + return str(launcher_type.value) + return str(launcher_type) + + +def _get_dify_conversation_store(ap: app.Application) -> DifyConversationStore | None: + cfg = getattr(getattr(ap, 'instance_config', None), 'data', {}) or {} + store_cfg = cfg.get('dify_conversation_store', {}) or {} + + try: + ttl_seconds = int(store_cfg.get('ttl_seconds', 86400)) + except (TypeError, ValueError): + ttl_seconds = 86400 + + try: + lock_ttl_seconds = int(store_cfg.get('lock_ttl_seconds', 10)) + except (TypeError, ValueError): + lock_ttl_seconds = 10 + + redis_mgr = getattr(ap, 'redis_mgr', None) + if redis_mgr is None: + return None + + return DifyConversationStore( + redis_mgr=redis_mgr, + ttl_seconds=max(1, ttl_seconds), + lock_ttl_seconds=max(1, lock_ttl_seconds), + enabled=bool(store_cfg.get('enabled', True)), + ) + + +def _get_session_scope(session: typing.Any) -> tuple[str, str] | None: + launcher_type = getattr(session, 'launcher_type', None) + launcher_id = getattr(session, 'launcher_id', None) + if launcher_type is None or launcher_id is None: + return None + + return _normalize_launcher_type(launcher_type), str(launcher_id) class BotService: @@ -151,9 +193,34 @@ async def update_bot(self, bot_uuid: str, bot_data: dict) -> None: await runtime_bot.run() # update all conversation that use this bot + store = _get_dify_conversation_store(self.ap) for session in self.ap.sess_mgr.session_list: - if session.using_conversation is not None and session.using_conversation.bot_uuid == bot_uuid: - session.using_conversation = None + using_conversation = getattr(session, 'using_conversation', None) + if using_conversation is None or using_conversation.bot_uuid != bot_uuid: + continue + + if store is not None: + scope = _get_session_scope(session) + pipeline_uuid = getattr(using_conversation, 'pipeline_uuid', None) + if scope is not None and pipeline_uuid: + launcher_type, launcher_id = scope + scope_bot_uuid = str(bot_uuid) + scope_pipeline_uuid = str(pipeline_uuid) + try: + await store.delete_conversation_id( + scope_bot_uuid, + scope_pipeline_uuid, + launcher_type, + launcher_id, + ) + except Exception as exc: + self.ap.logger.warning( + 'dify conversation reset delete failed ' + f'bot_uuid={scope_bot_uuid} pipeline_uuid={scope_pipeline_uuid} ' + f'launcher_type={launcher_type} launcher_id={launcher_id}: {exc}' + ) + + session.using_conversation = None async def delete_bot(self, bot_uuid: str) -> None: """Delete bot""" diff --git a/src/langbot/pkg/api/http/service/pipeline.py b/src/langbot/pkg/api/http/service/pipeline.py index ad75ffe70..74b4989e3 100644 --- a/src/langbot/pkg/api/http/service/pipeline.py +++ b/src/langbot/pkg/api/http/service/pipeline.py @@ -3,9 +3,52 @@ import uuid import json import sqlalchemy +import typing from ....core import app from ....entity.persistence import pipeline as persistence_pipeline +from ....provider.conversation.dify_store import DifyConversationStore + + +def _normalize_launcher_type(launcher_type: typing.Any) -> str: + if hasattr(launcher_type, 'value'): + return str(launcher_type.value) + return str(launcher_type) + + +def _get_dify_conversation_store(ap: app.Application) -> DifyConversationStore | None: + cfg = getattr(getattr(ap, 'instance_config', None), 'data', {}) or {} + store_cfg = cfg.get('dify_conversation_store', {}) or {} + + try: + ttl_seconds = int(store_cfg.get('ttl_seconds', 86400)) + except (TypeError, ValueError): + ttl_seconds = 86400 + + try: + lock_ttl_seconds = int(store_cfg.get('lock_ttl_seconds', 10)) + except (TypeError, ValueError): + lock_ttl_seconds = 10 + + redis_mgr = getattr(ap, 'redis_mgr', None) + if redis_mgr is None: + return None + + return DifyConversationStore( + redis_mgr=redis_mgr, + ttl_seconds=max(1, ttl_seconds), + lock_ttl_seconds=max(1, lock_ttl_seconds), + enabled=bool(store_cfg.get('enabled', True)), + ) + + +def _get_session_scope(session: typing.Any) -> tuple[str, str] | None: + launcher_type = getattr(session, 'launcher_type', None) + launcher_id = getattr(session, 'launcher_id', None) + if launcher_type is None or launcher_id is None: + return None + + return _normalize_launcher_type(launcher_type), str(launcher_id) default_stage_order = [ @@ -147,9 +190,34 @@ async def update_pipeline(self, pipeline_uuid: str, pipeline_data: dict) -> None await self.ap.pipeline_mgr.load_pipeline(pipeline) # update all conversation that use this pipeline + store = _get_dify_conversation_store(self.ap) for session in self.ap.sess_mgr.session_list: - if session.using_conversation is not None and session.using_conversation.pipeline_uuid == pipeline_uuid: - session.using_conversation = None + using_conversation = getattr(session, 'using_conversation', None) + if using_conversation is None or using_conversation.pipeline_uuid != pipeline_uuid: + continue + + if store is not None: + scope = _get_session_scope(session) + bot_uuid = getattr(using_conversation, 'bot_uuid', None) + if scope is not None and bot_uuid: + launcher_type, launcher_id = scope + scope_bot_uuid = str(bot_uuid) + scope_pipeline_uuid = str(pipeline_uuid) + try: + await store.delete_conversation_id( + scope_bot_uuid, + scope_pipeline_uuid, + launcher_type, + launcher_id, + ) + except Exception as exc: + self.ap.logger.warning( + 'dify conversation reset delete failed ' + f'bot_uuid={scope_bot_uuid} pipeline_uuid={scope_pipeline_uuid} ' + f'launcher_type={launcher_type} launcher_id={launcher_id}: {exc}' + ) + + session.using_conversation = None async def delete_pipeline(self, pipeline_uuid: str) -> None: await self.ap.persistence_mgr.execute_async( diff --git a/src/langbot/pkg/plugin/handler.py b/src/langbot/pkg/plugin/handler.py index b957afb18..6e7494647 100644 --- a/src/langbot/pkg/plugin/handler.py +++ b/src/langbot/pkg/plugin/handler.py @@ -23,6 +23,7 @@ from ..entity.persistence import bstorage as persistence_bstorage from ..core import app +from ..provider.conversation.dify_store import DifyConversationStore from ..utils import constants @@ -40,6 +41,53 @@ def _make_rag_error_response(error: Exception, error_type: str, **extra_context) return handler.ActionResponse.error(message=message) +def _normalize_launcher_type(launcher_type: Any) -> str: + if hasattr(launcher_type, 'value'): + return str(launcher_type.value) + return str(launcher_type) + + +def _get_dify_conversation_store(ap: app.Application) -> DifyConversationStore | None: + cfg = getattr(getattr(ap, 'instance_config', None), 'data', {}) or {} + store_cfg = cfg.get('dify_conversation_store', {}) or {} + + try: + ttl_seconds = int(store_cfg.get('ttl_seconds', 86400)) + except (TypeError, ValueError): + ttl_seconds = 86400 + + try: + lock_ttl_seconds = int(store_cfg.get('lock_ttl_seconds', 10)) + except (TypeError, ValueError): + lock_ttl_seconds = 10 + + redis_mgr = getattr(ap, 'redis_mgr', None) + if redis_mgr is None: + return None + + return DifyConversationStore( + redis_mgr=redis_mgr, + ttl_seconds=max(1, ttl_seconds), + lock_ttl_seconds=max(1, lock_ttl_seconds), + enabled=bool(store_cfg.get('enabled', True)), + ) + + +def _get_dify_scope_from_query(query: Any) -> tuple[str, str, str, str] | None: + session = getattr(query, 'session', None) + using_conversation = getattr(session, 'using_conversation', None) + + bot_uuid = getattr(query, 'bot_uuid', None) or getattr(using_conversation, 'bot_uuid', None) + pipeline_uuid = getattr(query, 'pipeline_uuid', None) or getattr(using_conversation, 'pipeline_uuid', None) + launcher_type = getattr(session, 'launcher_type', None) or getattr(query, 'launcher_type', None) + launcher_id = getattr(session, 'launcher_id', None) or getattr(query, 'launcher_id', None) + + if not bot_uuid or not pipeline_uuid or launcher_type is None or launcher_id is None: + return None + + return str(bot_uuid), str(pipeline_uuid), _normalize_launcher_type(launcher_type), str(launcher_id) + + class RuntimeConnectionHandler(handler.Handler): """Runtime connection handler""" @@ -248,9 +296,23 @@ async def create_new_conversation(data: dict[str, Any]) -> handler.ActionRespons ) query = self.ap.query_pool.cached_queries[query_id] + scope = _get_dify_scope_from_query(query) query.session.using_conversation = None + if scope is not None: + store = _get_dify_conversation_store(self.ap) + if store is not None: + bot_uuid, pipeline_uuid, launcher_type, launcher_id = scope + try: + await store.delete_conversation_id(bot_uuid, pipeline_uuid, launcher_type, launcher_id) + except Exception as exc: + self.ap.logger.warning( + 'dify conversation reset delete failed ' + f'bot_uuid={bot_uuid} pipeline_uuid={pipeline_uuid} ' + f'launcher_type={launcher_type} launcher_id={launcher_id}: {exc}' + ) + return handler.ActionResponse.success( data={}, ) diff --git a/src/langbot/pkg/provider/conversation/dify_store.py b/src/langbot/pkg/provider/conversation/dify_store.py new file mode 100644 index 000000000..59b99a62f --- /dev/null +++ b/src/langbot/pkg/provider/conversation/dify_store.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +import json +import logging +import time +import uuid + + +_logger = logging.getLogger("langbot") + + +class DifyConversationStore: + """Redis-backed store for Dify conversation bindings.""" + + def __init__( + self, + redis_mgr, + ttl_seconds: int = 86400, + lock_ttl_seconds: int = 10, + enabled: bool = True, + ): + self.redis_mgr = redis_mgr + self.ttl_seconds = ttl_seconds + self.lock_ttl_seconds = lock_ttl_seconds + self.enabled = enabled + + def is_available(self) -> bool: + return ( + self.enabled + and self.redis_mgr is not None + and hasattr(self.redis_mgr, "is_available") + and self.redis_mgr.is_available() + ) + + def _conversation_key(self, bot_uuid: str, pipeline_uuid: str, launcher_type: str, launcher_id: str) -> str: + return f"dify:conversation:{bot_uuid}:{pipeline_uuid}:{launcher_type}:{launcher_id}" + + def _lock_key(self, bot_uuid: str, pipeline_uuid: str, launcher_type: str, launcher_id: str) -> str: + return f"dify:conversation_lock:{bot_uuid}:{pipeline_uuid}:{launcher_type}:{launcher_id}" + + @staticmethod + def _validate_payload(payload: dict | None) -> dict | None: + if not isinstance(payload, dict): + return None + + conversation_id = payload.get("conversation_id") + updated_at = payload.get("updated_at") + if not isinstance(conversation_id, str) or not conversation_id: + return None + if not isinstance(updated_at, int): + return None + + return payload + + async def get_conversation_id( + self, + bot_uuid: str, + pipeline_uuid: str, + launcher_type: str, + launcher_id: str, + ) -> str | None: + if not self.is_available(): + return None + + key = self._conversation_key(bot_uuid, pipeline_uuid, launcher_type, launcher_id) + try: + if hasattr(self.redis_mgr, "get_json"): + payload = await self.redis_mgr.get_json(key) + else: + raw_payload = await self.redis_mgr.get(key) + payload = json.loads(raw_payload) if raw_payload else None + payload = self._validate_payload(payload) + if payload is None: + return None + return payload["conversation_id"] + except Exception as exc: + _logger.warning("[dify][conversation-store] failed to get conversation id: %s", exc) + return None + + async def set_conversation_id( + self, + bot_uuid: str, + pipeline_uuid: str, + launcher_type: str, + launcher_id: str, + conversation_id: str, + ): + if not self.is_available(): + return + if not isinstance(conversation_id, str) or not conversation_id.strip(): + return + + key = self._conversation_key(bot_uuid, pipeline_uuid, launcher_type, launcher_id) + payload = { + "conversation_id": conversation_id, + "updated_at": int(time.time()), + } + try: + if hasattr(self.redis_mgr, "set_json"): + await self.redis_mgr.set_json(key, payload, ex=self.ttl_seconds) + else: + await self.redis_mgr.set(key, json.dumps(payload, ensure_ascii=False), ex=self.ttl_seconds) + except Exception as exc: + _logger.warning("[dify][conversation-store] failed to set conversation id: %s", exc) + + async def delete_conversation_id( + self, + bot_uuid: str, + pipeline_uuid: str, + launcher_type: str, + launcher_id: str, + ): + if not self.is_available(): + return + + key = self._conversation_key(bot_uuid, pipeline_uuid, launcher_type, launcher_id) + try: + await self.redis_mgr.delete(key) + except Exception as exc: + _logger.warning("[dify][conversation-store] failed to delete conversation id: %s", exc) + + async def acquire_lock( + self, + bot_uuid: str, + pipeline_uuid: str, + launcher_type: str, + launcher_id: str, + ) -> str | None: + if not self.is_available(): + return None + + owner = uuid.uuid4().hex + key = self._lock_key(bot_uuid, pipeline_uuid, launcher_type, launcher_id) + try: + acquired = await self.redis_mgr.set_if_not_exists(key, owner, ex=self.lock_ttl_seconds) + except Exception as exc: + _logger.warning("[dify][conversation-store] failed to acquire lock: %s", exc) + return None + + if acquired: + return owner + return None + + async def release_lock( + self, + bot_uuid: str, + pipeline_uuid: str, + launcher_type: str, + launcher_id: str, + owner: str, + ) -> bool: + if not self.is_available() or not owner: + return False + + key = self._lock_key(bot_uuid, pipeline_uuid, launcher_type, launcher_id) + try: + redis_client = getattr(self.redis_mgr, "client", None) + if redis_client is not None and hasattr(redis_client, "eval"): + eval_key = self.redis_mgr.build_key(key) if hasattr(self.redis_mgr, "build_key") else key + lua_script = ( + "if redis.call('get', KEYS[1]) == ARGV[1] " + "then return redis.call('del', KEYS[1]) " + "else return 0 end" + ) + deleted = await redis_client.eval(lua_script, 1, eval_key, owner) + return bool(deleted) + + current_owner = await self.redis_mgr.get(key) + if current_owner != owner: + return False + await self.redis_mgr.delete(key) + return True + except Exception as exc: + _logger.warning("[dify][conversation-store] failed to release lock: %s", exc) + return False diff --git a/src/langbot/pkg/provider/runners/difysvapi.py b/src/langbot/pkg/provider/runners/difysvapi.py index 0e13b596e..78a1ba7d4 100644 --- a/src/langbot/pkg/provider/runners/difysvapi.py +++ b/src/langbot/pkg/provider/runners/difysvapi.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import typing import json import uuid @@ -13,6 +14,7 @@ from langbot.pkg.utils import image import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query from langbot.libs.dify_service_api.v1 import client, errors +from langbot.pkg.provider.conversation.dify_store import DifyConversationStore import httpx @@ -25,6 +27,8 @@ class DifyServiceAPIRunner(runner.RequestRunner): def __init__(self, ap: app.Application, pipeline_config: dict): self.ap = ap self.pipeline_config = pipeline_config + self._conversation_store: DifyConversationStore | None = None + self._conversation_store_initialized = False valid_app_types = ['chat', 'agent', 'workflow'] if self.pipeline_config['ai']['dify-service-api']['app-type'] not in valid_app_types: @@ -39,6 +43,207 @@ def __init__(self, ap: app.Application, pipeline_config: dict): base_url=self.pipeline_config['ai']['dify-service-api']['base-url'], ) + def _resolve_conversation_store_config(self) -> dict[str, typing.Any]: + app_cfg = getattr(getattr(self.ap, 'instance_config', None), 'data', {}) or {} + store_cfg = app_cfg.get('dify_conversation_store', {}) or {} + + enabled = bool(store_cfg.get('enabled', True)) + + try: + ttl_seconds = int(store_cfg.get('ttl_seconds', 86400)) + except (TypeError, ValueError): + ttl_seconds = 86400 + ttl_seconds = max(1, ttl_seconds) + + try: + lock_ttl_seconds = int(store_cfg.get('lock_ttl_seconds', 10)) + except (TypeError, ValueError): + lock_ttl_seconds = 10 + + # Keep lock TTL longer than the Dify request window to avoid early lock expiration. + request_timeout_seconds = 120 + lock_ttl_floor = request_timeout_seconds + 10 + lock_ttl_seconds = max(1, lock_ttl_seconds, lock_ttl_floor) + + try: + contention_wait_retry_count = int(store_cfg.get('contention_wait_retry_count', 15)) + except (TypeError, ValueError): + contention_wait_retry_count = 15 + contention_wait_retry_count = max(1, min(60, contention_wait_retry_count)) + + try: + contention_wait_interval_ms = int(store_cfg.get('contention_wait_interval_ms', 500)) + except (TypeError, ValueError): + contention_wait_interval_ms = 500 + contention_wait_interval_ms = max(50, min(2000, contention_wait_interval_ms)) + + return { + 'enabled': enabled, + 'ttl_seconds': ttl_seconds, + 'lock_ttl_seconds': lock_ttl_seconds, + 'contention_wait_retry_count': contention_wait_retry_count, + 'contention_wait_interval_ms': contention_wait_interval_ms, + } + + def _get_conversation_store(self) -> DifyConversationStore | None: + if self._conversation_store_initialized: + return self._conversation_store + + self._conversation_store_initialized = True + cfg = self._resolve_conversation_store_config() + + if not cfg['enabled']: + return None + + redis_mgr = getattr(self.ap, 'redis_mgr', None) + if redis_mgr is None: + return None + + self._conversation_store = DifyConversationStore( + redis_mgr=redis_mgr, + ttl_seconds=cfg['ttl_seconds'], + lock_ttl_seconds=cfg['lock_ttl_seconds'], + enabled=cfg['enabled'], + ) + + return self._conversation_store + + @staticmethod + def _extract_conversation_id_from_chunk(chunk: typing.Any) -> str | None: + if not isinstance(chunk, dict): + return None + conversation_id = chunk.get('conversation_id') + if isinstance(conversation_id, str) and conversation_id.strip(): + return conversation_id + return None + + @staticmethod + def _normalize_launcher_type(launcher_type: typing.Any) -> str: + if hasattr(launcher_type, 'value'): + return str(launcher_type.value) + return str(launcher_type) + + def _get_conversation_scope(self, query: pipeline_query.Query) -> tuple[str, str, str, str] | None: + session = getattr(query, 'session', None) + using_conversation = getattr(session, 'using_conversation', None) + + bot_uuid = getattr(query, 'bot_uuid', None) or getattr(using_conversation, 'bot_uuid', None) + pipeline_uuid = getattr(query, 'pipeline_uuid', None) or getattr(using_conversation, 'pipeline_uuid', None) + launcher_type = getattr(session, 'launcher_type', None) or getattr(query, 'launcher_type', None) + launcher_id = getattr(session, 'launcher_id', None) or getattr(query, 'launcher_id', None) + + if not bot_uuid or not pipeline_uuid or launcher_type is None or launcher_id is None: + return None + + return ( + str(bot_uuid), + str(pipeline_uuid), + self._normalize_launcher_type(launcher_type), + str(launcher_id), + ) + + async def _restore_conversation_id_if_needed(self, query: pipeline_query.Query) -> tuple[str | None, str | None]: + session = getattr(query, 'session', None) + using_conversation = getattr(session, 'using_conversation', None) + if using_conversation is None: + return None, None + + current_conversation_id = getattr(using_conversation, 'uuid', None) + if current_conversation_id: + return current_conversation_id, None + + store = self._get_conversation_store() + scope = self._get_conversation_scope(query) + if store is None or scope is None: + return None, None + + try: + conversation_id = await store.get_conversation_id(*scope) + except Exception as exc: + self.ap.logger.warning(f'dify conversation restore failed: {exc}') + return None, None + + if conversation_id: + using_conversation.uuid = conversation_id + return conversation_id, None + + try: + lock_owner = await store.acquire_lock(*scope) + except Exception as exc: + self.ap.logger.warning(f'dify conversation lock acquire failed: {exc}') + return None, None + + if not lock_owner: + cfg = self._resolve_conversation_store_config() + retry_count = int(cfg.get('contention_wait_retry_count', 15)) + wait_interval_seconds = int(cfg.get('contention_wait_interval_ms', 500)) / 1000.0 + + for _ in range(retry_count): + await asyncio.sleep(wait_interval_seconds) + try: + conversation_id = await store.get_conversation_id(*scope) + except Exception as exc: + self.ap.logger.warning(f'dify conversation contention re-check failed: {exc}') + continue + + if conversation_id: + using_conversation.uuid = conversation_id + return conversation_id, None + + self.ap.logger.warning( + 'dify conversation lock contention unresolved after bounded wait, continue without restored conversation id' + ) + return None, None + + try: + conversation_id = await store.get_conversation_id(*scope) + except Exception as exc: + self.ap.logger.warning(f'dify conversation restore re-check failed: {exc}') + conversation_id = None + + if conversation_id: + using_conversation.uuid = conversation_id + try: + await store.release_lock(*scope, lock_owner) + except Exception as exc: + self.ap.logger.warning(f'dify conversation lock release failed: {exc}') + return conversation_id, None + + return None, lock_owner + + async def _release_conversation_lock(self, query: pipeline_query.Query, lock_owner: str | None): + if not lock_owner: + return + + store = self._get_conversation_store() + scope = self._get_conversation_scope(query) + if store is None or scope is None: + return + + try: + await store.release_lock(*scope, lock_owner) + except Exception as exc: + self.ap.logger.warning(f'dify conversation lock release failed: {exc}') + + async def _persist_conversation_id(self, query: pipeline_query.Query, conversation_id: str | None): + if not conversation_id: + return + + session = getattr(query, 'session', None) + using_conversation = getattr(session, 'using_conversation', None) + if using_conversation is not None: + using_conversation.uuid = conversation_id + + store = self._get_conversation_store() + scope = self._get_conversation_scope(query) + if store is None or scope is None: + return + + try: + await store.set_conversation_id(*scope, conversation_id) + except Exception as exc: + self.ap.logger.warning(f'dify conversation persist failed: {exc}') + def _process_thinking_content( self, content: str, @@ -237,6 +442,9 @@ async def _chat_messages( ) -> typing.AsyncGenerator[provider_message.Message, None]: """调用聊天助手""" cov_id = query.session.using_conversation.uuid or None + conversation_lock_owner = None + if not cov_id: + cov_id, conversation_lock_owner = await self._restore_conversation_id_if_needed(query) query.variables['conversation_id'] = cov_id plain_text, upload_files = await self._preprocess_user_message(query) @@ -260,50 +468,56 @@ async def _chat_messages( chunk = None # 初始化chunk变量,防止在没有响应时引用错误 - async for chunk in self.dify_client.chat_messages( - inputs=inputs, - query=plain_text, - user=f'{query.session.launcher_type.value}_{query.session.launcher_id}', - conversation_id=cov_id, - files=files, - timeout=120, - ): - self.ap.logger.debug('dify-chat-chunk: ' + str(chunk)) - - if chunk['event'] == 'workflow_started': - mode = 'workflow' - - if mode == 'workflow': - if chunk['event'] == 'node_finished': - if chunk['data']['node_type'] == 'answer': - answer = self._extract_dify_text_output(chunk['data']['outputs'].get('answer')) - content, _ = self._process_thinking_content(answer) - + try: + async for chunk in self.dify_client.chat_messages( + inputs=inputs, + query=plain_text, + user=f'{query.session.launcher_type.value}_{query.session.launcher_id}', + conversation_id=cov_id, + files=files, + timeout=120, + ): + self.ap.logger.debug('dify-chat-chunk: ' + str(chunk)) + + if chunk['event'] == 'workflow_started': + mode = 'workflow' + + if mode == 'workflow': + if chunk['event'] == 'node_finished': + if chunk['data']['node_type'] == 'answer': + answer = self._extract_dify_text_output(chunk['data']['outputs'].get('answer')) + content, _ = self._process_thinking_content(answer) + + yield provider_message.Message( + role='assistant', + content=content, + ) + elif mode == 'basic': + if chunk['event'] == 'message': + basic_mode_pending_chunk += chunk['answer'] + elif chunk['event'] == 'message_end': + content, _ = self._process_thinking_content(basic_mode_pending_chunk) yield provider_message.Message( role='assistant', content=content, ) - elif mode == 'basic': - if chunk['event'] == 'message': - basic_mode_pending_chunk += chunk['answer'] - elif chunk['event'] == 'message_end': - content, _ = self._process_thinking_content(basic_mode_pending_chunk) - yield provider_message.Message( - role='assistant', - content=content, - ) - basic_mode_pending_chunk = '' + basic_mode_pending_chunk = '' - if chunk is None: - raise errors.DifyAPIError('Dify API 没有返回任何响应,请检查网络连接和API配置') - - query.session.using_conversation.uuid = chunk['conversation_id'] + if chunk is None: + raise errors.DifyAPIError('Dify API 没有返回任何响应,请检查网络连接和API配置') + final_conversation_id = self._extract_conversation_id_from_chunk(chunk) or query.session.using_conversation.uuid + await self._persist_conversation_id(query, final_conversation_id) + finally: + await self._release_conversation_lock(query, conversation_lock_owner) async def _agent_chat_messages( self, query: pipeline_query.Query ) -> typing.AsyncGenerator[provider_message.Message, None]: """调用聊天助手""" cov_id = query.session.using_conversation.uuid or None + conversation_lock_owner = None + if not cov_id: + cov_id, conversation_lock_owner = await self._restore_conversation_id_if_needed(query) query.variables['conversation_id'] = cov_id plain_text, upload_files = await self._preprocess_user_message(query) @@ -327,81 +541,86 @@ async def _agent_chat_messages( chunk = None # 初始化chunk变量,防止在没有响应时引用错误 - async for chunk in self.dify_client.chat_messages( - inputs=inputs, - query=plain_text, - user=f'{query.session.launcher_type.value}_{query.session.launcher_id}', - response_mode='streaming', - conversation_id=cov_id, - files=files, - timeout=120, - ): - self.ap.logger.debug('dify-agent-chunk: ' + str(chunk)) - - if chunk['event'] in ignored_events: - continue - - if chunk['event'] == 'agent_message' or chunk['event'] == 'message': - pending_agent_message += chunk['answer'] - else: - if pending_agent_message.strip() != '': - pending_agent_message = pending_agent_message.replace('Action:', '') - content, _ = self._process_thinking_content(pending_agent_message) - yield provider_message.Message( - role='assistant', - content=content, - ) - pending_agent_message = '' - - if chunk['event'] == 'agent_thought': - if chunk['tool'] != '' and chunk['observation'] != '': # 工具调用结果,跳过 - continue - - if chunk['tool']: - msg = provider_message.Message( - role='assistant', - tool_calls=[ - provider_message.ToolCall( - id=chunk['id'], - type='function', - function=provider_message.FunctionCall( - name=chunk['tool'], - arguments=json.dumps({}), - ), - ) - ], - ) - yield msg - if chunk['event'] == 'message_file': - if chunk['type'] == 'image' and chunk['belongs_to'] == 'assistant': - # 检查URL是否已经是完整的连接 - if chunk['url'].startswith('http://') or chunk['url'].startswith('https://'): - image_url = chunk['url'] - else: - base_url = self.dify_client.base_url - - if base_url.endswith('/v1'): - base_url = base_url[:-3] + try: + async for chunk in self.dify_client.chat_messages( + inputs=inputs, + query=plain_text, + user=f'{query.session.launcher_type.value}_{query.session.launcher_id}', + response_mode='streaming', + conversation_id=cov_id, + files=files, + timeout=120, + ): + self.ap.logger.debug('dify-agent-chunk: ' + str(chunk)) - image_url = base_url + chunk['url'] + if chunk['event'] in ignored_events: + continue + if chunk['event'] == 'agent_message' or chunk['event'] == 'message': + pending_agent_message += chunk['answer'] + else: + if pending_agent_message.strip() != '': + pending_agent_message = pending_agent_message.replace('Action:', '') + content, _ = self._process_thinking_content(pending_agent_message) yield provider_message.Message( role='assistant', - content=[provider_message.ContentElement.from_image_url(image_url)], + content=content, ) - if chunk['event'] == 'error': - raise errors.DifyAPIError('dify 服务错误: ' + chunk['message']) + pending_agent_message = '' - if chunk is None: - raise errors.DifyAPIError('Dify API 没有返回任何响应,请检查网络连接和API配置') + if chunk['event'] == 'agent_thought': + if chunk['tool'] != '' and chunk['observation'] != '': # 工具调用结果,跳过 + continue - query.session.using_conversation.uuid = chunk['conversation_id'] + if chunk['tool']: + msg = provider_message.Message( + role='assistant', + tool_calls=[ + provider_message.ToolCall( + id=chunk['id'], + type='function', + function=provider_message.FunctionCall( + name=chunk['tool'], + arguments=json.dumps({}), + ), + ) + ], + ) + yield msg + if chunk['event'] == 'message_file': + if chunk['type'] == 'image' and chunk['belongs_to'] == 'assistant': + # 检查URL是否已经是完整的连接 + if chunk['url'].startswith('http://') or chunk['url'].startswith('https://'): + image_url = chunk['url'] + else: + base_url = self.dify_client.base_url + + if base_url.endswith('/v1'): + base_url = base_url[:-3] + + image_url = base_url + chunk['url'] + + yield provider_message.Message( + role='assistant', + content=[provider_message.ContentElement.from_image_url(image_url)], + ) + if chunk['event'] == 'error': + raise errors.DifyAPIError('dify 服务错误: ' + chunk['message']) + + if chunk is None: + raise errors.DifyAPIError('Dify API 没有返回任何响应,请检查网络连接和API配置') + final_conversation_id = self._extract_conversation_id_from_chunk(chunk) or query.session.using_conversation.uuid + await self._persist_conversation_id(query, final_conversation_id) + finally: + await self._release_conversation_lock(query, conversation_lock_owner) async def _workflow_messages( self, query: pipeline_query.Query ) -> typing.AsyncGenerator[provider_message.Message, None]: """调用工作流""" + _, conversation_lock_owner = await self._restore_conversation_id_if_needed(query) + if not query.session.using_conversation.uuid: query.session.using_conversation.uuid = str(uuid.uuid4()) @@ -429,54 +648,70 @@ async def _workflow_messages( inputs.update(query.variables) - async for chunk in self.dify_client.workflow_run( - inputs=inputs, - user=f'{query.session.launcher_type.value}_{query.session.launcher_id}', - files=files, - timeout=120, - ): - self.ap.logger.debug('dify-workflow-chunk: ' + str(chunk)) - if chunk['event'] in ignored_events: - continue - - if chunk['event'] == 'node_started': - if chunk['data']['node_type'] == 'start' or chunk['data']['node_type'] == 'end': + chunk = None + workflow_succeeded = False + + try: + async for chunk in self.dify_client.workflow_run( + inputs=inputs, + user=f'{query.session.launcher_type.value}_{query.session.launcher_id}', + files=files, + timeout=120, + ): + self.ap.logger.debug('dify-workflow-chunk: ' + str(chunk)) + if chunk['event'] in ignored_events: continue - msg = provider_message.Message( - role='assistant', - content=None, - tool_calls=[ - provider_message.ToolCall( - id=chunk['data']['node_id'], - type='function', - function=provider_message.FunctionCall( - name=chunk['data']['title'], - arguments=json.dumps({}), - ), - ) - ], - ) + if chunk['event'] == 'node_started': + if chunk['data']['node_type'] == 'start' or chunk['data']['node_type'] == 'end': + continue - yield msg + msg = provider_message.Message( + role='assistant', + content=None, + tool_calls=[ + provider_message.ToolCall( + id=chunk['data']['node_id'], + type='function', + function=provider_message.FunctionCall( + name=chunk['data']['title'], + arguments=json.dumps({}), + ), + ) + ], + ) - elif chunk['event'] == 'workflow_finished': - if chunk['data']['error']: - raise errors.DifyAPIError(chunk['data']['error']) - content, _ = self._process_thinking_content(chunk['data']['outputs']['summary']) + yield msg - msg = provider_message.Message( - role='assistant', - content=content, - ) + elif chunk['event'] == 'workflow_finished': + if chunk['data']['error']: + raise errors.DifyAPIError(chunk['data']['error']) + workflow_succeeded = True + content, _ = self._process_thinking_content(chunk['data']['outputs']['summary']) - yield msg + msg = provider_message.Message( + role='assistant', + content=content, + ) + + yield msg + + if workflow_succeeded: + final_conversation_id = ( + self._extract_conversation_id_from_chunk(chunk) or query.session.using_conversation.uuid + ) + await self._persist_conversation_id(query, final_conversation_id) + finally: + await self._release_conversation_lock(query, conversation_lock_owner) async def _chat_messages_chunk( self, query: pipeline_query.Query ) -> typing.AsyncGenerator[provider_message.MessageChunk, None]: """调用聊天助手""" cov_id = query.session.using_conversation.uuid or None + conversation_lock_owner = None + if not cov_id: + cov_id, conversation_lock_owner = await self._restore_conversation_id_if_needed(query) query.variables['conversation_id'] = cov_id plain_text, upload_files = await self._preprocess_user_message(query) @@ -514,104 +749,113 @@ async def _chat_messages_chunk( flush_window_enabled = self._is_stream_flush_window_enabled(query) flush_window_ms = self._get_stream_flush_window_ms(query) - async for chunk in self.dify_client.chat_messages( - inputs=inputs, - query=plain_text, - user=f'{query.session.launcher_type.value}_{query.session.launcher_id}', - conversation_id=cov_id, - files=files, - timeout=120, - ): - self.ap.logger.debug('dify-chat-chunk: ' + str(chunk)) - - if chunk['event'] == 'workflow_started': - mode = 'workflow' - elif chunk['event'] in ('node_started', 'node_finished', 'workflow_finished'): - # Some Dify deployments may omit workflow_started in streamed chunks. - mode = 'workflow' - - if chunk['event'] == 'message': - answer = chunk.get('answer', '') - if answer != '': - message_idx += 1 - pending_chunk_count += 1 - if remove_think: - if '' in answer and not think_start: - think_start = True - continue - if '' in answer and not think_end: - import re + try: + async for chunk in self.dify_client.chat_messages( + inputs=inputs, + query=plain_text, + user=f'{query.session.launcher_type.value}_{query.session.launcher_id}', + conversation_id=cov_id, + files=files, + timeout=120, + ): + self.ap.logger.debug('dify-chat-chunk: ' + str(chunk)) - content = re.sub(r'^\n', '', answer) - basic_mode_pending_chunk += content - think_end = True - elif think_end: + if chunk['event'] == 'workflow_started': + mode = 'workflow' + elif chunk['event'] in ('node_started', 'node_finished', 'workflow_finished'): + # Some Dify deployments may omit workflow_started in streamed chunks. + mode = 'workflow' + + if chunk['event'] == 'message': + answer = chunk.get('answer', '') + if answer != '': + message_idx += 1 + pending_chunk_count += 1 + if remove_think: + if '' in answer and not think_start: + think_start = True + continue + if '' in answer and not think_end: + import re + + content = re.sub(r'^\n', '', answer) + basic_mode_pending_chunk += content + think_end = True + elif think_end: + basic_mode_pending_chunk += answer + if think_start: + continue + + else: basic_mode_pending_chunk += answer - if think_start: - continue - else: - basic_mode_pending_chunk += answer - - if chunk['event'] == 'message_end': - is_final = True - stream_completed = True - elif chunk['event'] == 'workflow_finished': - is_final = True - stream_completed = True - if chunk['data'].get('error'): - raise errors.DifyAPIError(chunk['data']['error']) - - if mode == 'workflow' and chunk['event'] == 'node_finished': - if chunk['data'].get('node_type') == 'answer': - answer = self._extract_dify_text_output(chunk['data'].get('outputs', {}).get('answer')) - if answer: - basic_mode_pending_chunk = answer - - if final_snapshot_emitted and is_final: - continue - - if self._should_emit_stream_snapshot( - content=basic_mode_pending_chunk, - is_final=is_final, - pending_chunk_count=pending_chunk_count, - chunk_batch_size=chunk_batch_size, - flush_window_enabled=flush_window_enabled, - flush_window_ms=flush_window_ms, - last_emitted_content=last_emitted_content, - last_emit_at=last_emit_at, - ): - yield provider_message.MessageChunk( - role='assistant', + if chunk['event'] == 'message_end': + is_final = True + stream_completed = True + elif chunk['event'] == 'workflow_finished': + is_final = True + stream_completed = True + if chunk['data'].get('error'): + raise errors.DifyAPIError(chunk['data']['error']) + + if mode == 'workflow' and chunk['event'] == 'node_finished': + if chunk['data'].get('node_type') == 'answer': + answer = self._extract_dify_text_output(chunk['data'].get('outputs', {}).get('answer')) + if answer: + basic_mode_pending_chunk = answer + + if final_snapshot_emitted and is_final: + continue + + if self._should_emit_stream_snapshot( content=basic_mode_pending_chunk, is_final=is_final, - ) - if is_final: - final_snapshot_emitted = True - last_emitted_content = basic_mode_pending_chunk - pending_chunk_count = 0 - last_emit_at = time.monotonic() - - if chunk is None: - raise errors.DifyAPIError('Dify API 没有返回任何响应,请检查网络连接和API配置') - - if stream_completed and not final_snapshot_emitted: - final_content = basic_mode_pending_chunk or last_emitted_content - if final_content: - self.ap.logger.debug('dify-chat-stream: emit final snapshot fallback') - yield provider_message.MessageChunk( - role='assistant', - content=final_content, - is_final=True, - ) + pending_chunk_count=pending_chunk_count, + chunk_batch_size=chunk_batch_size, + flush_window_enabled=flush_window_enabled, + flush_window_ms=flush_window_ms, + last_emitted_content=last_emitted_content, + last_emit_at=last_emit_at, + ): + yield provider_message.MessageChunk( + role='assistant', + content=basic_mode_pending_chunk, + is_final=is_final, + ) + if is_final: + final_snapshot_emitted = True + last_emitted_content = basic_mode_pending_chunk + pending_chunk_count = 0 + last_emit_at = time.monotonic() + + if chunk is None: + raise errors.DifyAPIError('Dify API 没有返回任何响应,请检查网络连接和API配置') + + if stream_completed and not final_snapshot_emitted: + final_content = basic_mode_pending_chunk or last_emitted_content + if final_content: + self.ap.logger.debug('dify-chat-stream: emit final snapshot fallback') + yield provider_message.MessageChunk( + role='assistant', + content=final_content, + is_final=True, + ) - query.session.using_conversation.uuid = chunk['conversation_id'] + final_conversation_id = ( + self._extract_conversation_id_from_chunk(chunk) or query.session.using_conversation.uuid + ) + await self._persist_conversation_id(query, final_conversation_id) + finally: + await self._release_conversation_lock(query, conversation_lock_owner) async def _agent_chat_messages_chunk( self, query: pipeline_query.Query ) -> typing.AsyncGenerator[provider_message.MessageChunk, None]: """调用聊天助手""" cov_id = query.session.using_conversation.uuid or None + conversation_lock_owner = None + if not cov_id: + cov_id, conversation_lock_owner = await self._restore_conversation_id_if_needed(query) query.variables['conversation_id'] = cov_id plain_text, upload_files = await self._preprocess_user_message(query) @@ -649,128 +893,136 @@ async def _agent_chat_messages_chunk( flush_window_enabled = self._is_stream_flush_window_enabled(query) flush_window_ms = self._get_stream_flush_window_ms(query) - async for chunk in self.dify_client.chat_messages( - inputs=inputs, - query=plain_text, - user=f'{query.session.launcher_type.value}_{query.session.launcher_id}', - response_mode='streaming', - conversation_id=cov_id, - files=files, - timeout=120, - ): - self.ap.logger.debug('dify-agent-chunk: ' + str(chunk)) - - if chunk['event'] in ignored_events: - continue - - if chunk['event'] == 'agent_message': - answer = chunk.get('answer', '') - if answer != '': - message_idx += 1 - pending_chunk_count += 1 - if remove_think: - if '' in answer and not think_start: - think_start = True - continue - if '' in answer and not think_end: - import re - - content = re.sub(r'^\n', '', answer) - pending_agent_message += content - think_end = True - elif think_end or not think_start: - pending_agent_message += answer - if think_start and not think_end: - continue - - else: - pending_agent_message += answer - elif chunk['event'] in ('message_end', 'workflow_finished'): - is_final = True - stream_completed = True - else: - if chunk['event'] == 'agent_thought': - if chunk['tool'] != '' and chunk['observation'] != '': # 工具调用结果,跳过 - continue - message_idx += 1 - if chunk['tool']: - msg = provider_message.MessageChunk( - role='assistant', - tool_calls=[ - provider_message.ToolCall( - id=chunk['id'], - type='function', - function=provider_message.FunctionCall( - name=chunk['tool'], - arguments=json.dumps({}), - ), - ) - ], - ) - yield msg - if chunk['event'] == 'message_file': - message_idx += 1 - if chunk['type'] == 'image' and chunk['belongs_to'] == 'assistant': - # 检查URL是否已经是完整的连接 - if chunk['url'].startswith('http://') or chunk['url'].startswith('https://'): - image_url = chunk['url'] - else: - base_url = self.dify_client.base_url - - if base_url.endswith('/v1'): - base_url = base_url[:-3] + try: + async for chunk in self.dify_client.chat_messages( + inputs=inputs, + query=plain_text, + user=f'{query.session.launcher_type.value}_{query.session.launcher_id}', + response_mode='streaming', + conversation_id=cov_id, + files=files, + timeout=120, + ): + self.ap.logger.debug('dify-agent-chunk: ' + str(chunk)) - image_url = base_url + chunk['url'] + if chunk['event'] in ignored_events: + continue - yield provider_message.MessageChunk( - role='assistant', - content=[provider_message.ContentElement.from_image_url(image_url)], - is_final=is_final, - ) + if chunk['event'] == 'agent_message': + answer = chunk.get('answer', '') + if answer != '': + message_idx += 1 + pending_chunk_count += 1 + if remove_think: + if '' in answer and not think_start: + think_start = True + continue + if '' in answer and not think_end: + import re + + content = re.sub(r'^\n', '', answer) + pending_agent_message += content + think_end = True + elif think_end or not think_start: + pending_agent_message += answer + if think_start and not think_end: + continue - if chunk['event'] == 'error': - raise errors.DifyAPIError('dify 服务错误: ' + chunk['message']) - if self._should_emit_stream_snapshot( - content=pending_agent_message, - is_final=is_final, - pending_chunk_count=pending_chunk_count, - chunk_batch_size=chunk_batch_size, - flush_window_enabled=flush_window_enabled, - flush_window_ms=flush_window_ms, - last_emitted_content=last_emitted_content, - last_emit_at=last_emit_at, - ): - yield provider_message.MessageChunk( - role='assistant', + else: + pending_agent_message += answer + elif chunk['event'] in ('message_end', 'workflow_finished'): + is_final = True + stream_completed = True + else: + if chunk['event'] == 'agent_thought': + if chunk['tool'] != '' and chunk['observation'] != '': # 工具调用结果,跳过 + continue + message_idx += 1 + if chunk['tool']: + msg = provider_message.MessageChunk( + role='assistant', + tool_calls=[ + provider_message.ToolCall( + id=chunk['id'], + type='function', + function=provider_message.FunctionCall( + name=chunk['tool'], + arguments=json.dumps({}), + ), + ) + ], + ) + yield msg + if chunk['event'] == 'message_file': + message_idx += 1 + if chunk['type'] == 'image' and chunk['belongs_to'] == 'assistant': + # 检查URL是否已经是完整的连接 + if chunk['url'].startswith('http://') or chunk['url'].startswith('https://'): + image_url = chunk['url'] + else: + base_url = self.dify_client.base_url + + if base_url.endswith('/v1'): + base_url = base_url[:-3] + + image_url = base_url + chunk['url'] + + yield provider_message.MessageChunk( + role='assistant', + content=[provider_message.ContentElement.from_image_url(image_url)], + is_final=is_final, + ) + + if chunk['event'] == 'error': + raise errors.DifyAPIError('dify 服务错误: ' + chunk['message']) + if self._should_emit_stream_snapshot( content=pending_agent_message, is_final=is_final, - ) - if is_final: - final_snapshot_emitted = True - last_emitted_content = pending_agent_message - pending_chunk_count = 0 - last_emit_at = time.monotonic() - - if chunk is None: - raise errors.DifyAPIError('Dify API 没有返回任何响应,请检查网络连接和API配置') - - if stream_completed and not final_snapshot_emitted: - final_content = pending_agent_message or last_emitted_content - if final_content: - self.ap.logger.debug('dify-agent-stream: emit final snapshot fallback') - yield provider_message.MessageChunk( - role='assistant', - content=final_content, - is_final=True, - ) + pending_chunk_count=pending_chunk_count, + chunk_batch_size=chunk_batch_size, + flush_window_enabled=flush_window_enabled, + flush_window_ms=flush_window_ms, + last_emitted_content=last_emitted_content, + last_emit_at=last_emit_at, + ): + yield provider_message.MessageChunk( + role='assistant', + content=pending_agent_message, + is_final=is_final, + ) + if is_final: + final_snapshot_emitted = True + last_emitted_content = pending_agent_message + pending_chunk_count = 0 + last_emit_at = time.monotonic() + + if chunk is None: + raise errors.DifyAPIError('Dify API 没有返回任何响应,请检查网络连接和API配置') + + if stream_completed and not final_snapshot_emitted: + final_content = pending_agent_message or last_emitted_content + if final_content: + self.ap.logger.debug('dify-agent-stream: emit final snapshot fallback') + yield provider_message.MessageChunk( + role='assistant', + content=final_content, + is_final=True, + ) - query.session.using_conversation.uuid = chunk['conversation_id'] + final_conversation_id = ( + self._extract_conversation_id_from_chunk(chunk) or query.session.using_conversation.uuid + ) + await self._persist_conversation_id(query, final_conversation_id) + finally: + await self._release_conversation_lock(query, conversation_lock_owner) async def _workflow_messages_chunk( self, query: pipeline_query.Query ) -> typing.AsyncGenerator[provider_message.MessageChunk, None]: """调用工作流""" + _, conversation_lock_owner = await self._restore_conversation_id_if_needed(query) + if not query.session.using_conversation.uuid: query.session.using_conversation.uuid = str(uuid.uuid4()) @@ -812,95 +1064,106 @@ async def _workflow_messages_chunk( chunk_batch_size = self._get_stream_chunk_batch_size(query) flush_window_enabled = self._is_stream_flush_window_enabled(query) flush_window_ms = self._get_stream_flush_window_ms(query) - async for chunk in self.dify_client.workflow_run( - inputs=inputs, - user=f'{query.session.launcher_type.value}_{query.session.launcher_id}', - files=files, - timeout=120, - ): - self.ap.logger.debug('dify-workflow-chunk: ' + str(chunk)) - if chunk['event'] in ignored_events: - continue - if chunk['event'] == 'workflow_finished': - is_final = True - stream_completed = True - if chunk['data']['error']: - raise errors.DifyAPIError(chunk['data']['error']) - - if chunk['event'] == 'text_chunk': - text = chunk['data'].get('text', '') - if text != '': - messsage_idx += 1 - pending_chunk_count += 1 - if remove_think: - if '' in text and not think_start: - think_start = True - continue - if '' in text and not think_end: - import re + chunk = None - content = re.sub(r'^\n', '', text) - workflow_contents += content - think_end = True - elif think_end: + try: + async for chunk in self.dify_client.workflow_run( + inputs=inputs, + user=f'{query.session.launcher_type.value}_{query.session.launcher_id}', + files=files, + timeout=120, + ): + self.ap.logger.debug('dify-workflow-chunk: ' + str(chunk)) + if chunk['event'] in ignored_events: + continue + if chunk['event'] == 'workflow_finished': + is_final = True + stream_completed = True + if chunk['data']['error']: + raise errors.DifyAPIError(chunk['data']['error']) + + if chunk['event'] == 'text_chunk': + text = chunk['data'].get('text', '') + if text != '': + messsage_idx += 1 + pending_chunk_count += 1 + if remove_think: + if '' in text and not think_start: + think_start = True + continue + if '' in text and not think_end: + import re + + content = re.sub(r'^\n', '', text) + workflow_contents += content + think_end = True + elif think_end: + workflow_contents += text + if think_start: + continue + + else: workflow_contents += text - if think_start: - continue - else: - workflow_contents += text + if chunk['event'] == 'node_started': + if chunk['data']['node_type'] == 'start' or chunk['data']['node_type'] == 'end': + continue + messsage_idx += 1 + msg = provider_message.MessageChunk( + role='assistant', + content=None, + tool_calls=[ + provider_message.ToolCall( + id=chunk['data']['node_id'], + type='function', + function=provider_message.FunctionCall( + name=chunk['data']['title'], + arguments=json.dumps({}), + ), + ) + ], + ) - if chunk['event'] == 'node_started': - if chunk['data']['node_type'] == 'start' or chunk['data']['node_type'] == 'end': - continue - messsage_idx += 1 - msg = provider_message.MessageChunk( - role='assistant', - content=None, - tool_calls=[ - provider_message.ToolCall( - id=chunk['data']['node_id'], - type='function', - function=provider_message.FunctionCall( - name=chunk['data']['title'], - arguments=json.dumps({}), - ), - ) - ], - ) + yield msg - yield msg - - if self._should_emit_stream_snapshot( - content=workflow_contents, - is_final=is_final, - pending_chunk_count=pending_chunk_count, - chunk_batch_size=chunk_batch_size, - flush_window_enabled=flush_window_enabled, - flush_window_ms=flush_window_ms, - last_emitted_content=last_emitted_content, - last_emit_at=last_emit_at, - ): - yield provider_message.MessageChunk( - role='assistant', + if self._should_emit_stream_snapshot( content=workflow_contents, is_final=is_final, + pending_chunk_count=pending_chunk_count, + chunk_batch_size=chunk_batch_size, + flush_window_enabled=flush_window_enabled, + flush_window_ms=flush_window_ms, + last_emitted_content=last_emitted_content, + last_emit_at=last_emit_at, + ): + yield provider_message.MessageChunk( + role='assistant', + content=workflow_contents, + is_final=is_final, + ) + if is_final: + final_snapshot_emitted = True + last_emitted_content = workflow_contents + pending_chunk_count = 0 + last_emit_at = time.monotonic() + + if stream_completed and not final_snapshot_emitted: + final_content = workflow_contents or last_emitted_content + if final_content: + self.ap.logger.debug('dify-workflow-stream: emit final snapshot fallback') + yield provider_message.MessageChunk( + role='assistant', + content=final_content, + is_final=True, + ) + + if stream_completed: + final_conversation_id = ( + self._extract_conversation_id_from_chunk(chunk) or query.session.using_conversation.uuid ) - if is_final: - final_snapshot_emitted = True - last_emitted_content = workflow_contents - pending_chunk_count = 0 - last_emit_at = time.monotonic() - - if stream_completed and not final_snapshot_emitted: - final_content = workflow_contents or last_emitted_content - if final_content: - self.ap.logger.debug('dify-workflow-stream: emit final snapshot fallback') - yield provider_message.MessageChunk( - role='assistant', - content=final_content, - is_final=True, - ) + await self._persist_conversation_id(query, final_conversation_id) + finally: + await self._release_conversation_lock(query, conversation_lock_owner) async def run(self, query: pipeline_query.Query) -> typing.AsyncGenerator[provider_message.Message, None]: """运行请求""" diff --git a/src/langbot/templates/config.yaml b/src/langbot/templates/config.yaml index 23c64927c..50709f3d3 100644 --- a/src/langbot/templates/config.yaml +++ b/src/langbot/templates/config.yaml @@ -92,6 +92,12 @@ redis: enabled: false url: 'redis://127.0.0.1:6379/0' key_prefix: 'langbot' +dify_conversation_store: + enabled: true + ttl_seconds: 86400 + lock_ttl_seconds: 130 + contention_wait_retry_count: 15 + contention_wait_interval_ms: 500 wecomcs_scheduler: enabled: false token_refresh_skew_seconds: 300 diff --git a/tests/unit_tests/api/test_dify_conversation_resets.py b/tests/unit_tests/api/test_dify_conversation_resets.py new file mode 100644 index 000000000..6b0ce829f --- /dev/null +++ b/tests/unit_tests/api/test_dify_conversation_resets.py @@ -0,0 +1,421 @@ +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction + + +class FakeConversationStore: + def __init__(self, raise_on_delete: bool = False): + self.raise_on_delete = raise_on_delete + self.delete_calls: list[tuple[str, str, str, str]] = [] + + async def delete_conversation_id(self, bot_uuid: str, pipeline_uuid: str, launcher_type: str, launcher_id: str): + self.delete_calls.append((bot_uuid, pipeline_uuid, launcher_type, launcher_id)) + if self.raise_on_delete: + raise RuntimeError('forced delete failure') + + +class LauncherType: + def __init__(self, value: str): + self.value = value + + +def _make_session(bot_uuid: str, pipeline_uuid: str, launcher_type='person', launcher_id='u-1'): + return SimpleNamespace( + launcher_type=LauncherType(launcher_type) if launcher_type is not None else None, + launcher_id=launcher_id, + using_conversation=SimpleNamespace(bot_uuid=bot_uuid, pipeline_uuid=pipeline_uuid), + ) + + +def _warning_messages(mock_logger: MagicMock) -> list[str]: + return [str(call.args[0]) for call in mock_logger.warning.call_args_list if call.args] + + +@pytest.mark.asyncio +async def test_plugin_create_new_conversation_deletes_persisted_binding_when_scope_exists(monkeypatch): + import langbot.pkg.plugin.handler as plugin_handler_module + + store = FakeConversationStore() + monkeypatch.setattr(plugin_handler_module, '_get_dify_conversation_store', lambda ap: store) + + query = SimpleNamespace( + bot_uuid='bot-1', + pipeline_uuid='pipe-1', + session=SimpleNamespace( + launcher_type=LauncherType('person'), + launcher_id='user-1', + using_conversation=SimpleNamespace(bot_uuid='bot-1', pipeline_uuid='pipe-1'), + ), + ) + + mock_ap = SimpleNamespace( + query_pool=SimpleNamespace(cached_queries={'q-1': query}), + logger=MagicMock(), + ) + + from langbot.pkg.plugin.handler import RuntimeConnectionHandler + + handler = RuntimeConnectionHandler( + connection=MagicMock(), + disconnect_callback=AsyncMock(return_value=False), + ap=mock_ap, + ) + + action = handler.actions[PluginToRuntimeAction.CREATE_NEW_CONVERSATION.value] + response = await action({'query_id': 'q-1'}) + + assert response.code == 0 + assert query.session.using_conversation is None + assert store.delete_calls == [('bot-1', 'pipe-1', 'person', 'user-1')] + + +@pytest.mark.asyncio +async def test_plugin_create_new_conversation_store_getter_none_still_resets_in_memory(monkeypatch): + import langbot.pkg.plugin.handler as plugin_handler_module + + monkeypatch.setattr(plugin_handler_module, '_get_dify_conversation_store', lambda ap: None) + + query = SimpleNamespace( + bot_uuid='bot-1', + pipeline_uuid='pipe-1', + session=SimpleNamespace( + launcher_type=LauncherType('person'), + launcher_id='user-1', + using_conversation=SimpleNamespace(bot_uuid='bot-1', pipeline_uuid='pipe-1'), + ), + ) + + mock_ap = SimpleNamespace( + query_pool=SimpleNamespace(cached_queries={'q-1': query}), + logger=MagicMock(), + ) + + from langbot.pkg.plugin.handler import RuntimeConnectionHandler + + handler = RuntimeConnectionHandler( + connection=MagicMock(), + disconnect_callback=AsyncMock(return_value=False), + ap=mock_ap, + ) + + action = handler.actions[PluginToRuntimeAction.CREATE_NEW_CONVERSATION.value] + response = await action({'query_id': 'q-1'}) + + assert response.code == 0 + assert query.session.using_conversation is None + + +@pytest.mark.asyncio +async def test_plugin_create_new_conversation_incomplete_scope_skips_delete_but_resets(monkeypatch): + import langbot.pkg.plugin.handler as plugin_handler_module + + store = FakeConversationStore() + monkeypatch.setattr(plugin_handler_module, '_get_dify_conversation_store', lambda ap: store) + + query = SimpleNamespace( + bot_uuid='bot-1', + pipeline_uuid='pipe-1', + session=SimpleNamespace( + launcher_type=None, + launcher_id='user-1', + using_conversation=SimpleNamespace(bot_uuid='bot-1', pipeline_uuid='pipe-1'), + ), + ) + + mock_ap = SimpleNamespace( + query_pool=SimpleNamespace(cached_queries={'q-1': query}), + logger=MagicMock(), + ) + + from langbot.pkg.plugin.handler import RuntimeConnectionHandler + + handler = RuntimeConnectionHandler( + connection=MagicMock(), + disconnect_callback=AsyncMock(return_value=False), + ap=mock_ap, + ) + + action = handler.actions[PluginToRuntimeAction.CREATE_NEW_CONVERSATION.value] + response = await action({'query_id': 'q-1'}) + + assert response.code == 0 + assert query.session.using_conversation is None + assert store.delete_calls == [] + + +@pytest.mark.asyncio +async def test_plugin_create_new_conversation_delete_failure_logs_scope_and_does_not_fail(monkeypatch): + import langbot.pkg.plugin.handler as plugin_handler_module + + store = FakeConversationStore(raise_on_delete=True) + monkeypatch.setattr(plugin_handler_module, '_get_dify_conversation_store', lambda ap: store) + + query = SimpleNamespace( + bot_uuid='bot-1', + pipeline_uuid='pipe-1', + session=SimpleNamespace( + launcher_type=LauncherType('group'), + launcher_id='group-1', + using_conversation=SimpleNamespace(bot_uuid='bot-1', pipeline_uuid='pipe-1'), + ), + ) + + mock_ap = SimpleNamespace( + query_pool=SimpleNamespace(cached_queries={'q-1': query}), + logger=MagicMock(), + ) + + from langbot.pkg.plugin.handler import RuntimeConnectionHandler + + handler = RuntimeConnectionHandler( + connection=MagicMock(), + disconnect_callback=AsyncMock(return_value=False), + ap=mock_ap, + ) + + action = handler.actions[PluginToRuntimeAction.CREATE_NEW_CONVERSATION.value] + response = await action({'query_id': 'q-1'}) + + assert response.code == 0 + assert query.session.using_conversation is None + assert store.delete_calls == [('bot-1', 'pipe-1', 'group', 'group-1')] + warnings = _warning_messages(mock_ap.logger) + assert any('bot_uuid=bot-1' in msg for msg in warnings) + assert any('pipeline_uuid=pipe-1' in msg for msg in warnings) + assert any('launcher_type=group' in msg for msg in warnings) + assert any('launcher_id=group-1' in msg for msg in warnings) + + +@pytest.mark.asyncio +async def test_bot_update_bot_deletes_persisted_bindings_for_matching_sessions(monkeypatch): + import langbot.pkg.api.http.service.bot as bot_service_module + + store = FakeConversationStore() + monkeypatch.setattr(bot_service_module, '_get_dify_conversation_store', lambda ap: store) + + runtime_bot = SimpleNamespace(enable=False, run=AsyncMock()) + + ap = SimpleNamespace( + persistence_mgr=SimpleNamespace(execute_async=AsyncMock()), + platform_mgr=SimpleNamespace(remove_bot=AsyncMock(), load_bot=AsyncMock(return_value=runtime_bot)), + sess_mgr=SimpleNamespace( + session_list=[ + _make_session('bot-1', 'pipe-1', 'person', 'user-1'), + _make_session('bot-2', 'pipe-1', 'person', 'user-2'), + _make_session('bot-1', 'pipe-2', 'group', 'group-1'), + _make_session('bot-1', 'pipe-3', 'person', None), + ] + ), + logger=MagicMock(), + ) + + from langbot.pkg.api.http.service.bot import BotService + + service = BotService(ap) + service.get_bot = AsyncMock(return_value={'uuid': 'bot-1'}) + + await service.update_bot('bot-1', {'name': 'new name'}) + + assert store.delete_calls == [ + ('bot-1', 'pipe-1', 'person', 'user-1'), + ('bot-1', 'pipe-2', 'group', 'group-1'), + ] + assert ap.sess_mgr.session_list[0].using_conversation is None + assert ap.sess_mgr.session_list[1].using_conversation is not None + assert ap.sess_mgr.session_list[2].using_conversation is None + assert ap.sess_mgr.session_list[3].using_conversation is None + + +@pytest.mark.asyncio +async def test_bot_update_bot_store_getter_none_still_clears_in_memory(monkeypatch): + import langbot.pkg.api.http.service.bot as bot_service_module + + monkeypatch.setattr(bot_service_module, '_get_dify_conversation_store', lambda ap: None) + + runtime_bot = SimpleNamespace(enable=False, run=AsyncMock()) + ap = SimpleNamespace( + persistence_mgr=SimpleNamespace(execute_async=AsyncMock()), + platform_mgr=SimpleNamespace(remove_bot=AsyncMock(), load_bot=AsyncMock(return_value=runtime_bot)), + sess_mgr=SimpleNamespace(session_list=[_make_session('bot-1', 'pipe-1', 'person', 'user-1')]), + logger=MagicMock(), + ) + + from langbot.pkg.api.http.service.bot import BotService + + service = BotService(ap) + service.get_bot = AsyncMock(return_value={'uuid': 'bot-1'}) + + await service.update_bot('bot-1', {'name': 'new'}) + + assert ap.sess_mgr.session_list[0].using_conversation is None + + +@pytest.mark.asyncio +async def test_bot_update_bot_incomplete_scope_skips_delete_but_clears_in_memory(monkeypatch): + import langbot.pkg.api.http.service.bot as bot_service_module + + store = FakeConversationStore() + monkeypatch.setattr(bot_service_module, '_get_dify_conversation_store', lambda ap: store) + + runtime_bot = SimpleNamespace(enable=False, run=AsyncMock()) + ap = SimpleNamespace( + persistence_mgr=SimpleNamespace(execute_async=AsyncMock()), + platform_mgr=SimpleNamespace(remove_bot=AsyncMock(), load_bot=AsyncMock(return_value=runtime_bot)), + sess_mgr=SimpleNamespace(session_list=[_make_session('bot-1', 'pipe-1', 'person', None)]), + logger=MagicMock(), + ) + + from langbot.pkg.api.http.service.bot import BotService + + service = BotService(ap) + service.get_bot = AsyncMock(return_value={'uuid': 'bot-1'}) + + await service.update_bot('bot-1', {'name': 'new'}) + + assert store.delete_calls == [] + assert ap.sess_mgr.session_list[0].using_conversation is None + + +@pytest.mark.asyncio +async def test_pipeline_update_pipeline_deletes_persisted_bindings_for_matching_sessions(monkeypatch): + import langbot.pkg.api.http.service.pipeline as pipeline_service_module + + store = FakeConversationStore() + monkeypatch.setattr(pipeline_service_module, '_get_dify_conversation_store', lambda ap: store) + + ap = SimpleNamespace( + persistence_mgr=SimpleNamespace(execute_async=AsyncMock()), + pipeline_mgr=SimpleNamespace(remove_pipeline=AsyncMock(), load_pipeline=AsyncMock()), + sess_mgr=SimpleNamespace( + session_list=[ + _make_session('bot-1', 'pipe-1', 'person', 'user-1'), + _make_session('bot-2', 'pipe-2', 'person', 'user-2'), + _make_session('bot-3', 'pipe-1', 'group', 'group-1'), + _make_session('bot-4', 'pipe-1', None, 'missing-type'), + ] + ), + logger=MagicMock(), + ) + + from langbot.pkg.api.http.service.pipeline import PipelineService + + service = PipelineService(ap) + service.get_pipeline = AsyncMock(return_value={'uuid': 'pipe-1'}) + + await service.update_pipeline('pipe-1', {'description': 'changed'}) + + assert store.delete_calls == [ + ('bot-1', 'pipe-1', 'person', 'user-1'), + ('bot-3', 'pipe-1', 'group', 'group-1'), + ] + assert ap.sess_mgr.session_list[0].using_conversation is None + assert ap.sess_mgr.session_list[1].using_conversation is not None + assert ap.sess_mgr.session_list[2].using_conversation is None + assert ap.sess_mgr.session_list[3].using_conversation is None + + +@pytest.mark.asyncio +async def test_pipeline_update_pipeline_store_getter_none_still_clears_in_memory(monkeypatch): + import langbot.pkg.api.http.service.pipeline as pipeline_service_module + + monkeypatch.setattr(pipeline_service_module, '_get_dify_conversation_store', lambda ap: None) + + ap = SimpleNamespace( + persistence_mgr=SimpleNamespace(execute_async=AsyncMock()), + pipeline_mgr=SimpleNamespace(remove_pipeline=AsyncMock(), load_pipeline=AsyncMock()), + sess_mgr=SimpleNamespace(session_list=[_make_session('bot-1', 'pipe-1', 'person', 'user-1')]), + logger=MagicMock(), + ) + + from langbot.pkg.api.http.service.pipeline import PipelineService + + service = PipelineService(ap) + service.get_pipeline = AsyncMock(return_value={'uuid': 'pipe-1'}) + + await service.update_pipeline('pipe-1', {'description': 'new'}) + + assert ap.sess_mgr.session_list[0].using_conversation is None + + +@pytest.mark.asyncio +async def test_pipeline_update_pipeline_incomplete_scope_skips_delete_but_clears_in_memory(monkeypatch): + import langbot.pkg.api.http.service.pipeline as pipeline_service_module + + store = FakeConversationStore() + monkeypatch.setattr(pipeline_service_module, '_get_dify_conversation_store', lambda ap: store) + + ap = SimpleNamespace( + persistence_mgr=SimpleNamespace(execute_async=AsyncMock()), + pipeline_mgr=SimpleNamespace(remove_pipeline=AsyncMock(), load_pipeline=AsyncMock()), + sess_mgr=SimpleNamespace(session_list=[_make_session('bot-1', 'pipe-1', None, 'user-1')]), + logger=MagicMock(), + ) + + from langbot.pkg.api.http.service.pipeline import PipelineService + + service = PipelineService(ap) + service.get_pipeline = AsyncMock(return_value={'uuid': 'pipe-1'}) + + await service.update_pipeline('pipe-1', {'description': 'new'}) + + assert store.delete_calls == [] + assert ap.sess_mgr.session_list[0].using_conversation is None + + +@pytest.mark.asyncio +async def test_service_reset_delete_failure_warning_contains_scope_fields(monkeypatch): + import langbot.pkg.api.http.service.bot as bot_service_module + import langbot.pkg.api.http.service.pipeline as pipeline_service_module + + bot_store = FakeConversationStore(raise_on_delete=True) + pipeline_store = FakeConversationStore(raise_on_delete=True) + monkeypatch.setattr(bot_service_module, '_get_dify_conversation_store', lambda ap: bot_store) + monkeypatch.setattr(pipeline_service_module, '_get_dify_conversation_store', lambda ap: pipeline_store) + + runtime_bot = SimpleNamespace(enable=False, run=AsyncMock()) + bot_ap = SimpleNamespace( + persistence_mgr=SimpleNamespace(execute_async=AsyncMock()), + platform_mgr=SimpleNamespace(remove_bot=AsyncMock(), load_bot=AsyncMock(return_value=runtime_bot)), + sess_mgr=SimpleNamespace(session_list=[_make_session('bot-1', 'pipe-1', 'person', 'user-1')]), + logger=MagicMock(), + ) + + pipeline_ap = SimpleNamespace( + persistence_mgr=SimpleNamespace(execute_async=AsyncMock()), + pipeline_mgr=SimpleNamespace(remove_pipeline=AsyncMock(), load_pipeline=AsyncMock()), + sess_mgr=SimpleNamespace(session_list=[_make_session('bot-1', 'pipe-1', 'person', 'user-1')]), + logger=MagicMock(), + ) + + from langbot.pkg.api.http.service.bot import BotService + from langbot.pkg.api.http.service.pipeline import PipelineService + + bot_service = BotService(bot_ap) + bot_service.get_bot = AsyncMock(return_value={'uuid': 'bot-1'}) + + pipeline_service = PipelineService(pipeline_ap) + pipeline_service.get_pipeline = AsyncMock(return_value={'uuid': 'pipe-1'}) + + await bot_service.update_bot('bot-1', {'name': 'new'}) + await pipeline_service.update_pipeline('pipe-1', {'description': 'new'}) + + assert bot_ap.sess_mgr.session_list[0].using_conversation is None + assert pipeline_ap.sess_mgr.session_list[0].using_conversation is None + + bot_warnings = _warning_messages(bot_ap.logger) + pipeline_warnings = _warning_messages(pipeline_ap.logger) + + assert any('bot_uuid=bot-1' in msg for msg in bot_warnings) + assert any('pipeline_uuid=pipe-1' in msg for msg in bot_warnings) + assert any('launcher_type=person' in msg for msg in bot_warnings) + assert any('launcher_id=user-1' in msg for msg in bot_warnings) + + assert any('bot_uuid=bot-1' in msg for msg in pipeline_warnings) + assert any('pipeline_uuid=pipe-1' in msg for msg in pipeline_warnings) + assert any('launcher_type=person' in msg for msg in pipeline_warnings) + assert any('launcher_id=user-1' in msg for msg in pipeline_warnings) diff --git a/tests/unit_tests/pipeline/test_wecombot_dify_minfix.py b/tests/unit_tests/pipeline/test_wecombot_dify_minfix.py index 94eec5139..2c53d6214 100644 --- a/tests/unit_tests/pipeline/test_wecombot_dify_minfix.py +++ b/tests/unit_tests/pipeline/test_wecombot_dify_minfix.py @@ -435,6 +435,20 @@ def test_dify_stream_uses_output_defaults_when_config_missing(): assert runner._get_stream_flush_window_ms(query) == 2000 +def test_dify_conversation_store_lock_ttl_is_not_shorter_than_request_window(): + DifyServiceAPIRunner = get_dify_runner() + app = Mock() + app.logger = Mock() + app.instance_config = SimpleNamespace(data={'dify_conversation_store': {'lock_ttl_seconds': 10}}) + runner = DifyServiceAPIRunner(app, make_dify_pipeline_config()) + + cfg = runner._resolve_conversation_store_config() + + assert cfg['lock_ttl_seconds'] >= 130 + assert cfg['contention_wait_retry_count'] >= 15 + assert cfg['contention_wait_interval_ms'] >= 500 + + def get_wecom_adapter(): return import_module('langbot.pkg.platform.sources.wecombot').WecomBotAdapter @@ -475,3 +489,597 @@ def test_wecombot_adapter_webhook_mode_normalizes_pull_config(): assert adapter.config['PullPollTimeoutMs'] == 500 assert adapter.config['PullStreamMaxLifetimeMs'] == 300000 assert adapter.config['PullPendingPlaceholderEnabled'] is False + + +@pytest.mark.asyncio +async def test_dify_chat_skips_store_restore_when_memory_conversation_exists(): + DifyServiceAPIRunner = get_dify_runner() + app = Mock() + app.logger = Mock() + runner = DifyServiceAPIRunner(app, make_dify_pipeline_config()) + + store = SimpleNamespace( + acquire_lock=AsyncMock(return_value='owner-1'), + release_lock=AsyncMock(return_value=True), + get_conversation_id=AsyncMock(return_value='conv-from-store'), + set_conversation_id=AsyncMock(), + ) + runner._get_conversation_store = Mock(return_value=store) + + observed_request = {} + + async def fake_chat_messages(**kwargs): + observed_request.update(kwargs) + yield {'event': 'message', 'answer': 'hello', 'conversation_id': 'conv-final'} + yield {'event': 'message_end', 'conversation_id': 'conv-final'} + + runner.dify_client = SimpleNamespace(chat_messages=fake_chat_messages, base_url='https://example.com/v1') + + query = SimpleNamespace( + adapter=SimpleNamespace(config={}), + bot_uuid='bot-1', + pipeline_uuid='pipe-1', + session=SimpleNamespace( + using_conversation=SimpleNamespace(uuid='conv-memory'), + launcher_type=provider_session.LauncherTypes.PERSON, + launcher_id='user-1', + ), + variables={}, + pipeline_config=runner.pipeline_config, + user_message=provider_message.Message(role='user', content='hello'), + ) + + _ = [msg async for msg in runner._chat_messages(query)] + + assert observed_request['conversation_id'] == 'conv-memory' + store.get_conversation_id.assert_not_awaited() + store.acquire_lock.assert_not_awaited() + store.release_lock.assert_not_awaited() + store.set_conversation_id.assert_awaited_once_with('bot-1', 'pipe-1', 'person', 'user-1', 'conv-final') + assert query.session.using_conversation.uuid == 'conv-final' + + +@pytest.mark.asyncio +async def test_dify_chat_restores_conversation_id_from_store_and_persists_final_id(): + DifyServiceAPIRunner = get_dify_runner() + app = Mock() + app.logger = Mock() + runner = DifyServiceAPIRunner(app, make_dify_pipeline_config()) + + store = SimpleNamespace( + acquire_lock=AsyncMock(return_value='owner-1'), + release_lock=AsyncMock(return_value=True), + get_conversation_id=AsyncMock(return_value='conv-restored'), + set_conversation_id=AsyncMock(), + ) + runner._get_conversation_store = Mock(return_value=store) + + observed_request = {} + + async def fake_chat_messages(**kwargs): + observed_request.update(kwargs) + yield {'event': 'message', 'answer': 'hello', 'conversation_id': 'conv-final-2'} + yield {'event': 'message_end', 'conversation_id': 'conv-final-2'} + + runner.dify_client = SimpleNamespace(chat_messages=fake_chat_messages, base_url='https://example.com/v1') + + query = SimpleNamespace( + adapter=SimpleNamespace(config={}), + bot_uuid='bot-2', + pipeline_uuid='pipe-2', + session=SimpleNamespace( + using_conversation=SimpleNamespace(uuid=''), + launcher_type=provider_session.LauncherTypes.PERSON, + launcher_id='user-2', + ), + variables={}, + pipeline_config=runner.pipeline_config, + user_message=provider_message.Message(role='user', content='hello'), + ) + + _ = [msg async for msg in runner._chat_messages(query)] + + assert observed_request['conversation_id'] == 'conv-restored' + store.get_conversation_id.assert_awaited_once_with('bot-2', 'pipe-2', 'person', 'user-2') + store.acquire_lock.assert_not_awaited() + store.release_lock.assert_not_awaited() + store.set_conversation_id.assert_awaited_once_with('bot-2', 'pipe-2', 'person', 'user-2', 'conv-final-2') + assert query.session.using_conversation.uuid == 'conv-final-2' + + +@pytest.mark.asyncio +async def test_dify_chat_restore_miss_triggers_lock_and_second_recheck(): + DifyServiceAPIRunner = get_dify_runner() + app = Mock() + app.logger = Mock() + runner = DifyServiceAPIRunner(app, make_dify_pipeline_config()) + + store = SimpleNamespace( + acquire_lock=AsyncMock(return_value='owner-1'), + release_lock=AsyncMock(return_value=True), + get_conversation_id=AsyncMock(side_effect=[None, 'conv-rechecked']), + set_conversation_id=AsyncMock(), + ) + runner._get_conversation_store = Mock(return_value=store) + + observed_request = {} + + async def fake_chat_messages(**kwargs): + observed_request.update(kwargs) + observed_request.update(kwargs) + yield {'event': 'message', 'answer': 'hello', 'conversation_id': 'conv-after-recheck'} + yield {'event': 'message_end', 'conversation_id': 'conv-after-recheck'} + + runner.dify_client = SimpleNamespace(chat_messages=fake_chat_messages, base_url='https://example.com/v1') + + query = SimpleNamespace( + adapter=SimpleNamespace(config={}), + bot_uuid='bot-3a', + pipeline_uuid='pipe-3a', + session=SimpleNamespace( + using_conversation=SimpleNamespace(uuid=''), + launcher_type=provider_session.LauncherTypes.PERSON, + launcher_id='user-3a', + ), + variables={}, + pipeline_config=runner.pipeline_config, + user_message=provider_message.Message(role='user', content='hello'), + ) + + _ = [msg async for msg in runner._chat_messages(query)] + + assert observed_request['conversation_id'] == 'conv-rechecked' + assert store.get_conversation_id.await_count == 2 + store.acquire_lock.assert_awaited_once_with('bot-3a', 'pipe-3a', 'person', 'user-3a') + store.release_lock.assert_awaited_once_with('bot-3a', 'pipe-3a', 'person', 'user-3a', 'owner-1') + + +@pytest.mark.asyncio +async def test_dify_chat_cold_miss_holds_same_lock_owner_until_writeback_and_release(): + DifyServiceAPIRunner = get_dify_runner() + app = Mock() + app.logger = Mock() + runner = DifyServiceAPIRunner(app, make_dify_pipeline_config()) + + events = [] + + async def acquire_lock(*args): + events.append('acquire') + return 'owner-cold' + + async def set_conversation_id(*args): + events.append('set') + + async def release_lock(*args): + events.append(f'release:{args[-1]}') + return True + + store = SimpleNamespace( + acquire_lock=AsyncMock(side_effect=acquire_lock), + release_lock=AsyncMock(side_effect=release_lock), + get_conversation_id=AsyncMock(side_effect=[None, None]), + set_conversation_id=AsyncMock(side_effect=set_conversation_id), + ) + runner._get_conversation_store = Mock(return_value=store) + + observed_request = {} + + async def fake_chat_messages(**kwargs): + observed_request.update(kwargs) + events.append('request') + yield {'event': 'message', 'answer': 'hello', 'conversation_id': 'conv-cold-final'} + yield {'event': 'message_end', 'conversation_id': 'conv-cold-final'} + + runner.dify_client = SimpleNamespace(chat_messages=fake_chat_messages, base_url='https://example.com/v1') + + query = SimpleNamespace( + adapter=SimpleNamespace(config={}), + bot_uuid='bot-4a', + pipeline_uuid='pipe-4a', + session=SimpleNamespace( + using_conversation=SimpleNamespace(uuid=''), + launcher_type=provider_session.LauncherTypes.PERSON, + launcher_id='user-4a', + ), + variables={}, + pipeline_config=runner.pipeline_config, + user_message=provider_message.Message(role='user', content='hello'), + ) + + _ = [msg async for msg in runner._chat_messages(query)] + + assert observed_request['conversation_id'] is None + assert query.session.using_conversation.uuid == 'conv-cold-final' + assert events == ['acquire', 'request', 'set', 'release:owner-cold'] + + +@pytest.mark.asyncio +async def test_dify_chat_lock_failures_do_not_break_request_path(): + DifyServiceAPIRunner = get_dify_runner() + app = Mock() + app.logger = Mock() + runner = DifyServiceAPIRunner(app, make_dify_pipeline_config()) + + acquire_fail_store = SimpleNamespace( + acquire_lock=AsyncMock(side_effect=RuntimeError('lock acquire failed')), + release_lock=AsyncMock(return_value=True), + get_conversation_id=AsyncMock(return_value=None), + set_conversation_id=AsyncMock(), + ) + runner._get_conversation_store = Mock(return_value=acquire_fail_store) + + async def fake_chat_messages_case1(**kwargs): + yield {'event': 'message', 'answer': 'ok', 'conversation_id': 'conv-acquire-failed'} + yield {'event': 'message_end', 'conversation_id': 'conv-acquire-failed'} + + runner.dify_client = SimpleNamespace(chat_messages=fake_chat_messages_case1, base_url='https://example.com/v1') + + query_case1 = SimpleNamespace( + adapter=SimpleNamespace(config={}), + bot_uuid='bot-4b', + pipeline_uuid='pipe-4b', + session=SimpleNamespace( + using_conversation=SimpleNamespace(uuid=''), + launcher_type=provider_session.LauncherTypes.PERSON, + launcher_id='user-4b', + ), + variables={}, + pipeline_config=runner.pipeline_config, + user_message=provider_message.Message(role='user', content='hello'), + ) + + msgs_case1 = [msg async for msg in runner._chat_messages(query_case1)] + assert len(msgs_case1) == 1 + assert query_case1.session.using_conversation.uuid == 'conv-acquire-failed' + + release_fail_store = SimpleNamespace( + acquire_lock=AsyncMock(return_value='owner-release-fail'), + release_lock=AsyncMock(side_effect=RuntimeError('lock release failed')), + get_conversation_id=AsyncMock(side_effect=[None, None]), + set_conversation_id=AsyncMock(), + ) + runner._get_conversation_store = Mock(return_value=release_fail_store) + + async def fake_chat_messages_case2(**kwargs): + yield {'event': 'message', 'answer': 'ok', 'conversation_id': 'conv-release-failed'} + yield {'event': 'message_end', 'conversation_id': 'conv-release-failed'} + + runner.dify_client = SimpleNamespace(chat_messages=fake_chat_messages_case2, base_url='https://example.com/v1') + + query_case2 = SimpleNamespace( + adapter=SimpleNamespace(config={}), + bot_uuid='bot-4c', + pipeline_uuid='pipe-4c', + session=SimpleNamespace( + using_conversation=SimpleNamespace(uuid=''), + launcher_type=provider_session.LauncherTypes.PERSON, + launcher_id='user-4c', + ), + variables={}, + pipeline_config=runner.pipeline_config, + user_message=provider_message.Message(role='user', content='hello'), + ) + + msgs_case2 = [msg async for msg in runner._chat_messages(query_case2)] + assert len(msgs_case2) == 1 + assert query_case2.session.using_conversation.uuid == 'conv-release-failed' + release_fail_store.release_lock.assert_awaited_once_with( + 'bot-4c', + 'pipe-4c', + 'person', + 'user-4c', + 'owner-release-fail', + ) + + +@pytest.mark.asyncio +async def test_dify_chat_store_write_failure_does_not_break_request(): + DifyServiceAPIRunner = get_dify_runner() + app = Mock() + app.logger = Mock() + runner = DifyServiceAPIRunner(app, make_dify_pipeline_config()) + + store = SimpleNamespace( + acquire_lock=AsyncMock(return_value='owner-1'), + release_lock=AsyncMock(return_value=True), + get_conversation_id=AsyncMock(return_value='conv-restored-4'), + set_conversation_id=AsyncMock(side_effect=RuntimeError('write failed')), + ) + runner._get_conversation_store = Mock(return_value=store) + + async def fake_chat_messages(**kwargs): + yield {'event': 'message', 'answer': 'hello', 'conversation_id': 'conv-after-write-fail'} + yield {'event': 'message_end', 'conversation_id': 'conv-after-write-fail'} + + runner.dify_client = SimpleNamespace(chat_messages=fake_chat_messages, base_url='https://example.com/v1') + + query = SimpleNamespace( + adapter=SimpleNamespace(config={}), + bot_uuid='bot-4', + pipeline_uuid='pipe-4', + session=SimpleNamespace( + using_conversation=SimpleNamespace(uuid=''), + launcher_type=provider_session.LauncherTypes.PERSON, + launcher_id='user-4', + ), + variables={}, + pipeline_config=runner.pipeline_config, + user_message=provider_message.Message(role='user', content='hello'), + ) + + messages = [msg async for msg in runner._chat_messages(query)] + + assert len(messages) == 1 + assert query.session.using_conversation.uuid == 'conv-after-write-fail' + + +@pytest.mark.asyncio +async def test_dify_chat_lock_contention_uses_bounded_wait_and_recheck(monkeypatch): + dify_module = import_module('langbot.pkg.provider.runners.difysvapi') + DifyServiceAPIRunner = dify_module.DifyServiceAPIRunner + app = Mock() + app.logger = Mock() + app.instance_config = SimpleNamespace( + data={ + 'dify_conversation_store': { + 'contention_wait_retry_count': 4, + 'contention_wait_interval_ms': 50, + } + } + ) + runner = DifyServiceAPIRunner(app, make_dify_pipeline_config()) + + sleep_calls = [] + + async def fake_sleep(seconds): + sleep_calls.append(seconds) + + monkeypatch.setattr(dify_module.asyncio, 'sleep', fake_sleep) + + store = SimpleNamespace( + acquire_lock=AsyncMock(return_value=None), + release_lock=AsyncMock(return_value=True), + get_conversation_id=AsyncMock(side_effect=[None, None, None, 'conv-after-wait']), + set_conversation_id=AsyncMock(), + ) + runner._get_conversation_store = Mock(return_value=store) + + observed_request = {} + + async def fake_chat_messages(**kwargs): + observed_request.update(kwargs) + yield {'event': 'message', 'answer': 'ok', 'conversation_id': 'conv-final-contention'} + yield {'event': 'message_end', 'conversation_id': 'conv-final-contention'} + + runner.dify_client = SimpleNamespace(chat_messages=fake_chat_messages, base_url='https://example.com/v1') + + query = SimpleNamespace( + adapter=SimpleNamespace(config={}), + bot_uuid='bot-contention', + pipeline_uuid='pipe-contention', + session=SimpleNamespace( + using_conversation=SimpleNamespace(uuid=''), + launcher_type=provider_session.LauncherTypes.PERSON, + launcher_id='user-contention', + ), + variables={}, + pipeline_config=runner.pipeline_config, + user_message=provider_message.Message(role='user', content='hello'), + ) + + _ = [msg async for msg in runner._chat_messages(query)] + + assert observed_request['conversation_id'] == 'conv-after-wait' + store.acquire_lock.assert_awaited_once_with('bot-contention', 'pipe-contention', 'person', 'user-contention') + assert store.get_conversation_id.await_count == 4 + assert sleep_calls == [0.05, 0.05, 0.05] + + +@pytest.mark.asyncio +async def test_dify_chat_lock_contention_unresolved_gracefully_degrades_to_new_conversation(monkeypatch): + dify_module = import_module('langbot.pkg.provider.runners.difysvapi') + DifyServiceAPIRunner = dify_module.DifyServiceAPIRunner + app = Mock() + app.logger = Mock() + app.instance_config = SimpleNamespace( + data={ + 'dify_conversation_store': { + 'contention_wait_retry_count': 2, + 'contention_wait_interval_ms': 50, + } + } + ) + runner = DifyServiceAPIRunner(app, make_dify_pipeline_config()) + + async def fake_sleep(_seconds): + return None + + monkeypatch.setattr(dify_module.asyncio, 'sleep', fake_sleep) + + store = SimpleNamespace( + acquire_lock=AsyncMock(return_value=None), + release_lock=AsyncMock(return_value=True), + get_conversation_id=AsyncMock(side_effect=[None, None, None]), + set_conversation_id=AsyncMock(), + ) + runner._get_conversation_store = Mock(return_value=store) + + observed_request = {} + + async def fake_chat_messages(**kwargs): + observed_request.update(kwargs) + yield {'event': 'message', 'answer': 'ok', 'conversation_id': 'conv-after-contention-fallback'} + yield {'event': 'message_end', 'conversation_id': 'conv-after-contention-fallback'} + + runner.dify_client = SimpleNamespace(chat_messages=fake_chat_messages, base_url='https://example.com/v1') + + query = SimpleNamespace( + adapter=SimpleNamespace(config={}), + bot_uuid='bot-contention-fail', + pipeline_uuid='pipe-contention-fail', + session=SimpleNamespace( + using_conversation=SimpleNamespace(uuid=''), + launcher_type=provider_session.LauncherTypes.PERSON, + launcher_id='user-contention-fail', + ), + variables={}, + pipeline_config=runner.pipeline_config, + user_message=provider_message.Message(role='user', content='hello'), + ) + + msgs = [msg async for msg in runner._chat_messages(query)] + + assert len(msgs) == 1 + assert observed_request['conversation_id'] is None + assert query.session.using_conversation.uuid == 'conv-after-contention-fallback' + + +@pytest.mark.asyncio +async def test_dify_agent_restore_and_persist_conversation_id(): + DifyServiceAPIRunner = get_dify_runner() + app = Mock() + app.logger = Mock() + config = make_dify_pipeline_config() + config['ai']['dify-service-api']['app-type'] = 'agent' + runner = DifyServiceAPIRunner(app, config) + + store = SimpleNamespace( + acquire_lock=AsyncMock(return_value='owner-agent'), + release_lock=AsyncMock(return_value=True), + get_conversation_id=AsyncMock(return_value='conv-agent-restored'), + set_conversation_id=AsyncMock(), + ) + runner._get_conversation_store = Mock(return_value=store) + + observed_request = {} + + async def fake_agent_chat_messages(**kwargs): + observed_request.update(kwargs) + yield {'event': 'agent_message', 'answer': 'hi', 'conversation_id': 'conv-agent-final'} + yield {'event': 'message_end', 'conversation_id': 'conv-agent-final'} + + runner.dify_client = SimpleNamespace(chat_messages=fake_agent_chat_messages, base_url='https://example.com/v1') + + query = SimpleNamespace( + adapter=SimpleNamespace(config={}), + bot_uuid='bot-agent', + pipeline_uuid='pipe-agent', + session=SimpleNamespace( + using_conversation=SimpleNamespace(uuid=''), + launcher_type=provider_session.LauncherTypes.PERSON, + launcher_id='user-agent', + ), + variables={}, + pipeline_config=runner.pipeline_config, + user_message=provider_message.Message(role='user', content='hello'), + ) + + msgs = [msg async for msg in runner._agent_chat_messages(query)] + + assert len(msgs) == 1 + assert observed_request['conversation_id'] == 'conv-agent-restored' + store.set_conversation_id.assert_awaited_once_with( + 'bot-agent', + 'pipe-agent', + 'person', + 'user-agent', + 'conv-agent-final', + ) + + +@pytest.mark.asyncio +async def test_dify_chat_stream_chunk_restore_and_persist_conversation_id(): + DifyServiceAPIRunner = get_dify_runner() + app = Mock() + app.logger = Mock() + runner = DifyServiceAPIRunner(app, make_dify_pipeline_config()) + + store = SimpleNamespace( + acquire_lock=AsyncMock(return_value='owner-stream'), + release_lock=AsyncMock(return_value=True), + get_conversation_id=AsyncMock(return_value='conv-stream-restored'), + set_conversation_id=AsyncMock(), + ) + runner._get_conversation_store = Mock(return_value=store) + + observed_request = {} + + async def fake_chat_messages(**kwargs): + observed_request.update(kwargs) + yield {'event': 'message', 'answer': '你', 'conversation_id': 'conv-stream-final'} + yield {'event': 'message_end', 'conversation_id': 'conv-stream-final'} + + runner.dify_client = SimpleNamespace(chat_messages=fake_chat_messages, base_url='https://example.com/v1') + + query = SimpleNamespace( + adapter=SimpleNamespace(config={}), + bot_uuid='bot-stream', + pipeline_uuid='pipe-stream', + session=SimpleNamespace( + using_conversation=SimpleNamespace(uuid=''), + launcher_type=provider_session.LauncherTypes.PERSON, + launcher_id='user-stream', + ), + variables={}, + pipeline_config=runner.pipeline_config, + user_message=provider_message.Message(role='user', content='hello'), + ) + + chunks = [chunk async for chunk in runner._chat_messages_chunk(query)] + + assert chunks[-1].is_final is True + assert observed_request['conversation_id'] == 'conv-stream-restored' + store.set_conversation_id.assert_awaited_once_with( + 'bot-stream', + 'pipe-stream', + 'person', + 'user-stream', + 'conv-stream-final', + ) + + +@pytest.mark.asyncio +async def test_dify_workflow_uses_restored_conversation_and_persists_on_finish(): + DifyServiceAPIRunner = get_dify_runner() + app = Mock() + app.logger = Mock() + config = make_dify_pipeline_config() + config['ai']['dify-service-api']['app-type'] = 'workflow' + runner = DifyServiceAPIRunner(app, config) + + store = SimpleNamespace( + acquire_lock=AsyncMock(return_value='owner-1'), + release_lock=AsyncMock(return_value=True), + get_conversation_id=AsyncMock(return_value='conv-workflow-restored'), + set_conversation_id=AsyncMock(), + ) + runner._get_conversation_store = Mock(return_value=store) + + observed_request = {} + + async def fake_workflow_run(**kwargs): + observed_request.update(kwargs) + yield {'event': 'workflow_started'} + yield {'event': 'workflow_finished', 'data': {'error': None, 'outputs': {'summary': 'done'}}} + + runner.dify_client = SimpleNamespace(workflow_run=fake_workflow_run, base_url='https://example.com/v1') + + query = SimpleNamespace( + adapter=SimpleNamespace(config={}), + bot_uuid='bot-5', + pipeline_uuid='pipe-5', + session=SimpleNamespace( + using_conversation=SimpleNamespace(uuid=''), + launcher_type=provider_session.LauncherTypes.PERSON, + launcher_id='user-5', + ), + variables={'session_id': 'sess-1', 'msg_create_time': 1}, + pipeline_config=runner.pipeline_config, + user_message=provider_message.Message(role='user', content='hello'), + ) + + messages = [msg async for msg in runner._workflow_messages(query)] + + assert len(messages) == 1 + assert observed_request['inputs']['conversation_id'] == 'conv-workflow-restored' + store.set_conversation_id.assert_awaited_with('bot-5', 'pipe-5', 'person', 'user-5', 'conv-workflow-restored') diff --git a/tests/unit_tests/provider/test_dify_conversation_store.py b/tests/unit_tests/provider/test_dify_conversation_store.py new file mode 100644 index 000000000..a4d2178b0 --- /dev/null +++ b/tests/unit_tests/provider/test_dify_conversation_store.py @@ -0,0 +1,224 @@ +import json + +import pytest + +from langbot.pkg.provider.conversation.dify_store import DifyConversationStore + + +class FakeRedisManager: + def __init__(self, enabled: bool = True, fail_methods: set[str] | None = None): + self.enabled = enabled + self.fail_methods = fail_methods or set() + self.values: dict[str, str] = {} + self.expirations: dict[str, int | None] = {} + self.client = _FakeRedisClient(self) + + def is_available(self) -> bool: + return self.enabled + + def _maybe_raise(self, method_name: str): + if method_name in self.fail_methods: + raise RuntimeError(f"forced redis failure in {method_name}") + + async def get(self, key: str): + self._maybe_raise("get") + return self.values.get(key) + + async def set(self, key: str, value: str, ex: int | None = None): + self._maybe_raise("set") + self.values[key] = value + self.expirations[key] = ex + + async def delete(self, key: str): + self._maybe_raise("delete") + self.values.pop(key, None) + self.expirations.pop(key, None) + + async def set_if_not_exists(self, key: str, value: str, ex: int | None = None) -> bool: + self._maybe_raise("set_if_not_exists") + if key in self.values: + return False + self.values[key] = value + self.expirations[key] = ex + return True + + async def get_json(self, key: str): + raw_value = await self.get(key) + if not raw_value: + return None + return json.loads(raw_value) + + async def set_json(self, key: str, value: dict, ex: int | None = None): + await self.set(key, json.dumps(value, ensure_ascii=False), ex=ex) + + +class _FakeRedisClient: + def __init__(self, redis_mgr: FakeRedisManager): + self.redis_mgr = redis_mgr + self.switch_owner_once: dict[str, str] = {} + + async def eval(self, script: str, numkeys: int, *keys_and_args): + del script + del numkeys + key = str(keys_and_args[0]) + owner = str(keys_and_args[1]) + + if key in self.switch_owner_once: + self.redis_mgr.values[key] = self.switch_owner_once.pop(key) + + if self.redis_mgr.values.get(key) == owner: + self.redis_mgr.values.pop(key, None) + self.redis_mgr.expirations.pop(key, None) + return 1 + return 0 + + +@pytest.mark.asyncio +async def test_conversation_id_round_trip_and_ttl(): + redis_mgr = FakeRedisManager() + store = DifyConversationStore(redis_mgr, ttl_seconds=321) + + await store.set_conversation_id("bot-1", "pipe-1", "private", "launcher-1", "conv-1") + + key = "dify:conversation:bot-1:pipe-1:private:launcher-1" + assert redis_mgr.expirations[key] == 321 + + payload = json.loads(redis_mgr.values[key]) + assert payload["conversation_id"] == "conv-1" + assert isinstance(payload["updated_at"], int) + + assert await store.get_conversation_id("bot-1", "pipe-1", "private", "launcher-1") == "conv-1" + + +@pytest.mark.asyncio +async def test_invalid_payload_is_ignored(): + redis_mgr = FakeRedisManager() + store = DifyConversationStore(redis_mgr) + + key = "dify:conversation:bot-1:pipe-1:private:launcher-1" + + redis_mgr.values[key] = "not-json" + assert await store.get_conversation_id("bot-1", "pipe-1", "private", "launcher-1") is None + + redis_mgr.values[key] = json.dumps({"conversation_id": "conv-1"}) + assert await store.get_conversation_id("bot-1", "pipe-1", "private", "launcher-1") is None + + redis_mgr.values[key] = json.dumps({"conversation_id": "conv-1", "updated_at": "123"}) + assert await store.get_conversation_id("bot-1", "pipe-1", "private", "launcher-1") is None + + redis_mgr.values[key] = json.dumps({"conversation_id": "", "updated_at": 123}) + assert await store.get_conversation_id("bot-1", "pipe-1", "private", "launcher-1") is None + + +@pytest.mark.asyncio +async def test_delete_behavior(): + redis_mgr = FakeRedisManager() + store = DifyConversationStore(redis_mgr) + + await store.set_conversation_id("bot-1", "pipe-1", "private", "launcher-1", "conv-1") + await store.delete_conversation_id("bot-1", "pipe-1", "private", "launcher-1") + + key = "dify:conversation:bot-1:pipe-1:private:launcher-1" + assert key not in redis_mgr.values + assert await store.get_conversation_id("bot-1", "pipe-1", "private", "launcher-1") is None + + +@pytest.mark.asyncio +async def test_blank_conversation_id_is_not_persisted(): + redis_mgr = FakeRedisManager() + store = DifyConversationStore(redis_mgr) + + await store.set_conversation_id("bot-1", "pipe-1", "private", "launcher-1", "") + await store.set_conversation_id("bot-1", "pipe-1", "private", "launcher-1", " ") + + key = "dify:conversation:bot-1:pipe-1:private:launcher-1" + assert key not in redis_mgr.values + assert await store.get_conversation_id("bot-1", "pipe-1", "private", "launcher-1") is None + + +@pytest.mark.asyncio +async def test_graceful_behavior_when_disabled_or_unavailable_or_redis_failures(): + redis_mgr = FakeRedisManager() + disabled_store = DifyConversationStore(redis_mgr, enabled=False) + unavailable_store = DifyConversationStore(FakeRedisManager(enabled=False)) + + assert disabled_store.is_available() is False + assert unavailable_store.is_available() is False + + assert await disabled_store.get_conversation_id("b", "p", "t", "l") is None + await disabled_store.set_conversation_id("b", "p", "t", "l", "c") + await disabled_store.delete_conversation_id("b", "p", "t", "l") + assert await disabled_store.acquire_lock("b", "p", "t", "l") is None + assert await disabled_store.release_lock("b", "p", "t", "l", "owner") is False + + failing_get_store = DifyConversationStore(FakeRedisManager(fail_methods={"get"})) + assert await failing_get_store.get_conversation_id("b", "p", "t", "l") is None + assert await failing_get_store.release_lock("b", "p", "t", "l", "owner") is False + + failing_set_store = DifyConversationStore(FakeRedisManager(fail_methods={"set"})) + await failing_set_store.set_conversation_id("b", "p", "t", "l", "c") + + failing_delete_store = DifyConversationStore(FakeRedisManager(fail_methods={"delete"})) + await failing_delete_store.delete_conversation_id("b", "p", "t", "l") + + failing_lock_store = DifyConversationStore(FakeRedisManager(fail_methods={"set_if_not_exists"})) + assert await failing_lock_store.acquire_lock("b", "p", "t", "l") is None + + +@pytest.mark.asyncio +async def test_lock_acquire_release_owner_and_lock_ttl(): + redis_mgr = FakeRedisManager() + store = DifyConversationStore(redis_mgr, lock_ttl_seconds=9) + + owner = await store.acquire_lock("bot-1", "pipe-1", "private", "launcher-1") + assert owner is not None + + lock_key = "dify:conversation_lock:bot-1:pipe-1:private:launcher-1" + assert redis_mgr.values[lock_key] == owner + assert redis_mgr.expirations[lock_key] == 9 + + second_owner = await store.acquire_lock("bot-1", "pipe-1", "private", "launcher-1") + assert second_owner is None + + released_by_other = await store.release_lock("bot-1", "pipe-1", "private", "launcher-1", "other-owner") + assert released_by_other is False + assert lock_key in redis_mgr.values + + released = await store.release_lock("bot-1", "pipe-1", "private", "launcher-1", owner) + assert released is True + assert lock_key not in redis_mgr.values + + +@pytest.mark.asyncio +async def test_release_lock_does_not_delete_new_owner_lock_on_owner_switch(): + redis_mgr = FakeRedisManager() + store = DifyConversationStore(redis_mgr) + + lock_key = "dify:conversation_lock:bot-1:pipe-1:private:launcher-1" + redis_mgr.values[lock_key] = "old-owner" + redis_mgr.client.switch_owner_once[lock_key] = "new-owner" + + released = await store.release_lock("bot-1", "pipe-1", "private", "launcher-1", "old-owner") + + assert released is False + assert redis_mgr.values.get(lock_key) == "new-owner" + + +@pytest.mark.asyncio +async def test_key_boundary_isolation_by_pipeline_and_launcher_dimensions(): + redis_mgr = FakeRedisManager() + store = DifyConversationStore(redis_mgr) + + await store.set_conversation_id("bot-1", "pipe-1", "private", "launcher-1", "conv-a") + await store.set_conversation_id("bot-2", "pipe-1", "private", "launcher-1", "conv-e") + await store.set_conversation_id("bot-1", "pipe-2", "private", "launcher-1", "conv-b") + await store.set_conversation_id("bot-1", "pipe-1", "group", "launcher-1", "conv-c") + await store.set_conversation_id("bot-1", "pipe-1", "private", "launcher-2", "conv-d") + + assert await store.get_conversation_id("bot-1", "pipe-1", "private", "launcher-1") == "conv-a" + assert await store.get_conversation_id("bot-2", "pipe-1", "private", "launcher-1") == "conv-e" + assert await store.get_conversation_id("bot-1", "pipe-2", "private", "launcher-1") == "conv-b" + assert await store.get_conversation_id("bot-1", "pipe-1", "group", "launcher-1") == "conv-c" + assert await store.get_conversation_id("bot-1", "pipe-1", "private", "launcher-2") == "conv-d" + + assert len(redis_mgr.values) == 5 From fcd036c778fb38e207c8d370bd2e96fa198d8ba9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=9C=E9=9B=A8?= <58286951@qq.com> Date: Fri, 27 Mar 2026 17:00:10 +0800 Subject: [PATCH 28/36] =?UTF-8?q?docs(readme):=20=E8=A1=A5=E5=85=85?= =?UTF-8?q?=E5=88=86=E6=94=AF=E7=9B=B8=E5=AF=B9=E5=AE=98=E6=96=B9=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E7=9A=84=E5=A2=9E=E5=BC=BA=E8=83=BD=E5=8A=9B=E8=AF=B4?= =?UTF-8?q?=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 69d1f6ad3..5f405e8ed 100644 --- a/README.md +++ b/README.md @@ -36,17 +36,35 @@ English / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / [日本 LangBot is an **open-source, production-grade platform** for building AI-powered instant messaging bots. It connects Large Language Models (LLMs) to any chat platform, enabling you to create intelligent agents that can converse, execute tasks, and integrate with your existing workflows. -## Enhancements in This Branch +## 本分支相对官方版本增加 / 增强的能力 -Compared with the upstream official branch, this maintained branch includes several practical enhancements focused on WeCom / WeChat operations and long-running session reliability: +相比官方版本,`feat/wecom` 分支围绕**企微智能机器人、企业微信客服、企微应用接管微信客服、Dify 对话连续性、以及私有化部署稳定性**做了持续增强,主要包括以下能力: -- **WeCom Customer Service runtime enhancements** — Redis-backed scheduling, state persistence, retry handling, token cache, and sharded processing for more stable large-volume customer service workloads. -- **WeCom app managed customer service adapter** — Added `wecom_kf_app` support for scenarios where customer service is managed through a WeCom application authorization model. -- **Dify conversation persistence** — Persist Dify `conversation_id` bindings in Redis and clear them on explicit session resets, reducing context loss after runtime restarts. -- **OpenClaw WeChat adapter support** — Added OpenClaw-based WeChat integration for additional WeChat connectivity scenarios. -- **WeCom / WeChat integration hardening** — Additional fixes and runtime improvements around session isolation, callback handling, and customer-service message processing. +1. **企微智能机器人增强** + 支持更细粒度的 **Pull / Webhook 流式配置**、**pending placeholder 开关与延迟控制**、以及 **Dify 流式输出节流配置**,同时补强拉流生命周期管理、websocket 兼容性与调试日志能力,适合对流式回复体验、稳定性和排障能力要求更高的企微机器人场景。 -These enhancements are especially useful for teams running LangBot in private deployments with heavier WeCom / WeChat customer-service traffic and stronger context-continuity requirements. +2. **企业微信客服运行时增强** + 在官方基础上补充了基于 **Redis Streams** 的调度机制,并增加 **游标检查点持久化、消息状态存储、失败重试、分片处理、token cache** 等能力,显著提升企业微信客服在高消息量、长时间运行、重启恢复等场景下的稳定性与可维护性。 + +3. **新增“企微应用接管微信客服”类型** + 新增 `wecom_kf_app` 适配器与对应配置,支持通过**企微应用授权方式**接管和管理微信客服能力,适配更多企业内部权限体系和组织接入模式。 + +4. **Dify 集成增强** + 修复 Dify 在 **workflow 流式结束、workflow 输出兼容、流式节流控制** 等方面的若干问题,并新增 **`conversation_id` Redis 持久化机制**,在显式 reset 场景下同步清理绑定,降低 LangBot 重启或会话重建后 Dify 上下文丢失的概率。 + +5. **企微 / 微信客服链路隔离与上下文保护增强** + 对 bot stream、pipeline session、客服消息处理链路做了进一步隔离和收口,减少多 bot、多流水线、长链路客服场景下的会话串扰与上下文错配问题。 + +6. **插件 / RAG / Pipeline 兼容增强** + 增强旧版 runtime sdk 兼容性,保留 RAG 检索链路中的 session context,并收敛部分 pipeline 异常处理策略,使复杂插件、知识库、老版本运行时共存时更稳定。 + +7. **检索能力增强** + 为 **Chroma** 增加全文检索与混合检索支持,提升向量检索之外的能力扩展空间,适合对知识检索效果有更高要求的场景。 + +8. **部署与运维增强** + 优化 Docker 镜像体积、构建缓存、Python 路径配置与 compose 镜像来源,并补充围绕 wecomcs 的 Redis 启动与运行文档,更适合长期私有化部署和运维。 + +如果你的主要使用场景是:**企微智能机器人、企业微信客服、企微应用接管微信客服、Dify 对话连续性、私有化部署稳定性**,那么这个分支相比官方版本会更贴近真实生产需求。 --- From 86ac50b5777dddd08e4680323f387a0beed95fb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=9C=E9=9B=A8?= <58286951@qq.com> Date: Fri, 27 Mar 2026 23:35:55 +0800 Subject: [PATCH 29/36] fix(wecomcs): prevent history replay and duplicate outbound msgids --- .../libs/wecom_customer_service_api/api.py | 5 ++ src/langbot/pkg/platform/sources/wecomcs.py | 20 +++++++- .../platform/wecomcs/message_state_store.py | 2 +- .../platform/test_wecomcs_adapter_config.py | 48 +++++++++++++++++++ .../platform/test_wecomcs_pull_worker.py | 26 ++++++++++ .../platform/test_wecomcs_state_store.py | 7 +-- .../unit_tests/test_wecom_customer_service.py | 37 ++++++++++++++ 7 files changed, 139 insertions(+), 6 deletions(-) diff --git a/src/langbot/libs/wecom_customer_service_api/api.py b/src/langbot/libs/wecom_customer_service_api/api.py index 71b93e741..605168623 100644 --- a/src/langbot/libs/wecom_customer_service_api/api.py +++ b/src/langbot/libs/wecom_customer_service_api/api.py @@ -361,6 +361,11 @@ async def send_text_msg(self, open_kfid: str, external_userid: str, msgid: str, if data['errcode'] == 40014 or data['errcode'] == 42001: await self.refresh_access_token() return await self.send_text_msg(open_kfid, external_userid, msgid, content) + if data['errcode'] == 95033: + _logger.warning( + f"[wecomcs] send_text_msg命中幂等重复msgid,按成功处理: external_userid={external_userid}, msgid={msgid}, {self._diag_context(open_kfid=open_kfid)}" + ) + return data if data['errcode'] != 0: await self.logger.error( f"[wecomcs] 发送消息失败: errcode={data.get('errcode')}, errmsg={data.get('errmsg')}, external_userid={external_userid}, msgid={msgid}, {self._diag_context(open_kfid=open_kfid)}" diff --git a/src/langbot/pkg/platform/sources/wecomcs.py b/src/langbot/pkg/platform/sources/wecomcs.py index 5e3881beb..7a5a0ae49 100644 --- a/src/langbot/pkg/platform/sources/wecomcs.py +++ b/src/langbot/pkg/platform/sources/wecomcs.py @@ -1,4 +1,5 @@ from __future__ import annotations +import hashlib import typing import asyncio import traceback @@ -199,6 +200,19 @@ def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventL ) self.resolved_wecomcs_runtime_settings = resolved_runtime_settings + @staticmethod + def _build_outbound_msgid(event: WecomCSEvent, content_index: int) -> str: + seed = '::'.join( + [ + 'langbot-wecomcs-reply', + str(event.receiver_id or ''), + str(event.user_id or ''), + str(event.message_id or ''), + str(content_index), + ] + ) + return hashlib.sha256(seed.encode('utf-8')).hexdigest()[:32] + async def reply_message( self, message_source: platform_events.MessageEvent, @@ -208,15 +222,17 @@ async def reply_message( Wecom_event = await WecomEventConverter.yiri2target(message_source, self.bot_account_id, self.bot) content_list = await WecomMessageConverter.yiri2target(message, self.bot) - for content in content_list: + for content_index, content in enumerate(content_list): if content['type'] != 'text': continue + outbound_msgid = self._build_outbound_msgid(Wecom_event, content_index) + try: await self.bot.send_text_msg( open_kfid=Wecom_event.receiver_id, external_userid=Wecom_event.user_id, - msgid=Wecom_event.message_id, + msgid=outbound_msgid, content=content['content'], ) state_store = getattr(self.bot, 'state_store', None) diff --git a/src/langbot/pkg/platform/wecomcs/message_state_store.py b/src/langbot/pkg/platform/wecomcs/message_state_store.py index ae1938145..0ef878c2c 100644 --- a/src/langbot/pkg/platform/wecomcs/message_state_store.py +++ b/src/langbot/pkg/platform/wecomcs/message_state_store.py @@ -68,7 +68,7 @@ async def reserve_for_queue(self, bot_uuid: str, open_kfid: str, msg_data: dict) return False current = await self.get_state(bot_uuid, open_kfid, msgid) - if current and current.get('process_status') in self.ACTIVE_STATUSES: + if current: return False now_ts = int(time.time()) diff --git a/tests/unit_tests/platform/test_wecomcs_adapter_config.py b/tests/unit_tests/platform/test_wecomcs_adapter_config.py index 3810f7e50..ec66919e6 100644 --- a/tests/unit_tests/platform/test_wecomcs_adapter_config.py +++ b/tests/unit_tests/platform/test_wecomcs_adapter_config.py @@ -180,3 +180,51 @@ class FakeEvent: assert captured['timeout'] == 1.75 assert result.sender.nickname == 'Tester' + + +@pytest.mark.asyncio +async def test_reply_message_uses_stable_outbound_msgids_instead_of_inbound_msgid(base_config, monkeypatch): + logger = FakeLogger({'enabled': False}) + adapter = WecomCSAdapter(base_config, logger) + + class FakeWecomEvent: + receiver_id = 'kf-1' + user_id = 'user-1' + message_id = 'incoming-msg-1' + + outbound_calls: list[tuple[str, str]] = [] + + async def fake_yiri2target(_message_source, _bot_account_id, _bot): + return FakeWecomEvent() + + async def fake_message_yiri2target(_message, _bot): + return [ + {'type': 'text', 'content': 'hello'}, + {'type': 'text', 'content': 'world'}, + ] + + async def fake_send_text_msg(open_kfid: str, external_userid: str, msgid: str, content: str): + outbound_calls.append((msgid, content)) + return {'errcode': 0, 'errmsg': 'ok'} + + monkeypatch.setattr(wecomcs_source.WecomEventConverter, 'yiri2target', staticmethod(fake_yiri2target)) + monkeypatch.setattr(wecomcs_source.WecomMessageConverter, 'yiri2target', staticmethod(fake_message_yiri2target)) + monkeypatch.setattr(adapter.bot, 'send_text_msg', fake_send_text_msg) + + await adapter.reply_message(object(), object()) + + assert [content for _, content in outbound_calls] == ['hello', 'world'] + assert all(msgid != 'incoming-msg-1' for msgid, _ in outbound_calls) + assert len({msgid for msgid, _ in outbound_calls}) == 2 + + repeated_calls: list[tuple[str, str]] = [] + + async def fake_send_text_msg_again(open_kfid: str, external_userid: str, msgid: str, content: str): + repeated_calls.append((msgid, content)) + return {'errcode': 0, 'errmsg': 'ok'} + + monkeypatch.setattr(adapter.bot, 'send_text_msg', fake_send_text_msg_again) + + await adapter.reply_message(object(), object()) + + assert repeated_calls == outbound_calls diff --git a/tests/unit_tests/platform/test_wecomcs_pull_worker.py b/tests/unit_tests/platform/test_wecomcs_pull_worker.py index 29924915a..4f5460c2b 100644 --- a/tests/unit_tests/platform/test_wecomcs_pull_worker.py +++ b/tests/unit_tests/platform/test_wecomcs_pull_worker.py @@ -105,6 +105,32 @@ async def on_message(message_data: dict): assert handled_messages == ['msg-2', 'msg-3'] +@pytest.mark.asyncio +async def test_pull_worker_does_not_requeue_failed_message_from_state_store(): + redis_mgr = FakeRedisManager() + store = WecomCSStateStore(redis_mgr, cursor_bootstrap_mode='replay') + client = FakeWecomClient() + handled_messages: list[str] = [] + + await store.reserve_message_for_queue('bot-1', 'kf-1', {'msgid': 'msg-1', 'external_userid': 'user-1', 'msgtype': 'text'}) + await store.mark_message_failed('bot-1', 'kf-1', 'msg-1', stage='reply_message', error='boom') + + async def on_message(message_data: dict): + handled_messages.append(message_data['msgid']) + + worker = WecomCSPullWorker(client, store, on_message, message_state_ttl_seconds=600, lock_ttl_seconds=60) + processed_count = await worker.handle_trigger( + { + 'bot_uuid': 'bot-1', + 'open_kfid': 'kf-1', + 'callback_token': 'token-1', + } + ) + + assert processed_count == 2 + assert handled_messages == ['msg-2', 'msg-3'] + + @pytest.mark.asyncio async def test_pull_worker_raises_when_lock_not_acquired(): redis_mgr = FakeRedisManager() diff --git a/tests/unit_tests/platform/test_wecomcs_state_store.py b/tests/unit_tests/platform/test_wecomcs_state_store.py index 8de5241c6..81559e7d1 100644 --- a/tests/unit_tests/platform/test_wecomcs_state_store.py +++ b/tests/unit_tests/platform/test_wecomcs_state_store.py @@ -102,7 +102,7 @@ async def test_state_store_tracks_message_status_with_ttl(): @pytest.mark.asyncio -async def test_state_store_allows_failed_message_to_requeue(): +async def test_state_store_rejects_failed_message_from_pull_requeue(): redis_mgr = FakeRedisManager() store = WecomCSStateStore(redis_mgr, message_state_ttl_seconds=123) @@ -127,6 +127,7 @@ async def test_state_store_allows_failed_message_to_requeue(): }, ) - assert reserved is True + assert reserved is False state = await store.get_message_state('bot-1', 'kf-1', 'msg-1') - assert state['process_status'] == 'queued' + assert state['process_status'] == 'failed' + assert state['last_error_stage'] == 'publish' diff --git a/tests/unit_tests/test_wecom_customer_service.py b/tests/unit_tests/test_wecom_customer_service.py index 06682c377..476cc4dd2 100644 --- a/tests/unit_tests/test_wecom_customer_service.py +++ b/tests/unit_tests/test_wecom_customer_service.py @@ -521,3 +521,40 @@ def test_wecomcs_diag_context_contains_bot_and_secret_fingerprint(client): assert 'open_kfid=kf-123' in diag assert 'corpid=corp-id' in diag assert 'secret_fp=' in diag + + +@pytest.mark.asyncio +async def test_send_text_msg_treats_repeated_msgid_as_idempotent_success(monkeypatch, client): + client.access_token = 'token-1' + client.token_cache._local_cache = { + 'access_token': 'token-1', + 'expires_at': 4102444800, + } + + class FakeResponse: + def json(self): + return {'errcode': 95033, 'errmsg': 'repeated msgid'} + + class FakeAsyncClient: + def __init__(self, *args, **kwargs): + return None + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + async def post(self, url, json=None, headers=None, content=None): + return FakeResponse() + + monkeypatch.setattr(wecomcs_api.httpx, 'AsyncClient', FakeAsyncClient) + + result = await client.send_text_msg( + open_kfid='kf-1', + external_userid='user-1', + msgid='stable-outbound-msg-1', + content='reply', + ) + + assert result == {'errcode': 95033, 'errmsg': 'repeated msgid'} From f26e6cc31f33325dd40571f4705356681143d309 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=9C=E9=9B=A8?= <58286951@qq.com> Date: Tue, 31 Mar 2026 10:33:14 +0800 Subject: [PATCH 30/36] feat(pipeline): add dify session settings and recovery flow --- src/langbot/pkg/api/http/service/bot.py | 32 +- src/langbot/pkg/api/http/service/pipeline.py | 32 +- src/langbot/pkg/platform/sources/wecombot.py | 2 + src/langbot/pkg/plugin/handler.py | 32 +- .../pkg/provider/conversation/dify_store.py | 196 +- src/langbot/pkg/provider/runners/difysvapi.py | 509 +++++- src/langbot/templates/config.yaml | 3 +- .../templates/metadata/pipeline/ai.yaml | 50 +- .../api/test_dify_conversation_resets.py | 124 ++ .../pipeline/test_wecombot_dify_minfix.py | 1571 ++++++++++++++--- .../test_dify_conversation_binding.py | 147 ++ .../provider/test_dify_conversation_store.py | 227 ++- .../dynamic-form/DynamicFormComponent.tsx | 53 +- .../home/pipelines/PipelineDetailDialog.tsx | 13 +- .../DifySessionSettingsSection.tsx | 73 + .../pipeline-form/PipelineFormComponent.tsx | 107 +- web/src/app/infra/entities/pipeline/index.ts | 7 + web/src/i18n/locales/en-US.ts | 3 + web/src/i18n/locales/ja-JP.ts | 6 +- web/src/i18n/locales/zh-Hans.ts | 3 + web/src/i18n/locales/zh-Hant.ts | 3 + 21 files changed, 2773 insertions(+), 420 deletions(-) create mode 100644 tests/unit_tests/provider/test_dify_conversation_binding.py create mode 100644 web/src/app/home/pipelines/components/pipeline-form/DifySessionSettingsSection.tsx diff --git a/src/langbot/pkg/api/http/service/bot.py b/src/langbot/pkg/api/http/service/bot.py index 3199b4b20..b4c41f0af 100644 --- a/src/langbot/pkg/api/http/service/bot.py +++ b/src/langbot/pkg/api/http/service/bot.py @@ -15,15 +15,37 @@ def _normalize_launcher_type(launcher_type: typing.Any) -> str: return str(launcher_type.value) return str(launcher_type) +def _parse_bool_config(value: typing.Any, default: bool) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {'true', '1', 'yes', 'on'}: + return True + if normalized in {'false', '0', 'no', 'off'}: + return False + return default + if isinstance(value, (int, float)): + return bool(value) + if value is None: + return default + return bool(value) + + def _get_dify_conversation_store(ap: app.Application) -> DifyConversationStore | None: - cfg = getattr(getattr(ap, 'instance_config', None), 'data', {}) or {} - store_cfg = cfg.get('dify_conversation_store', {}) or {} + raw_cfg = getattr(getattr(ap, 'instance_config', None), 'data', {}) or {} + cfg = raw_cfg if isinstance(raw_cfg, dict) else {} + raw_store_cfg = cfg.get('dify_conversation_store', {}) or {} + store_cfg = raw_store_cfg if isinstance(raw_store_cfg, dict) else {} try: - ttl_seconds = int(store_cfg.get('ttl_seconds', 86400)) + if 'idle_timeout_seconds' in store_cfg: + ttl_seconds = int(store_cfg.get('idle_timeout_seconds')) + else: + ttl_seconds = int(store_cfg.get('ttl_seconds', 43200)) except (TypeError, ValueError): - ttl_seconds = 86400 + ttl_seconds = 43200 try: lock_ttl_seconds = int(store_cfg.get('lock_ttl_seconds', 10)) @@ -38,7 +60,7 @@ def _get_dify_conversation_store(ap: app.Application) -> DifyConversationStore | redis_mgr=redis_mgr, ttl_seconds=max(1, ttl_seconds), lock_ttl_seconds=max(1, lock_ttl_seconds), - enabled=bool(store_cfg.get('enabled', True)), + enabled=_parse_bool_config(store_cfg.get('enabled', True), True), ) diff --git a/src/langbot/pkg/api/http/service/pipeline.py b/src/langbot/pkg/api/http/service/pipeline.py index 74b4989e3..0a5a26098 100644 --- a/src/langbot/pkg/api/http/service/pipeline.py +++ b/src/langbot/pkg/api/http/service/pipeline.py @@ -15,15 +15,37 @@ def _normalize_launcher_type(launcher_type: typing.Any) -> str: return str(launcher_type.value) return str(launcher_type) +def _parse_bool_config(value: typing.Any, default: bool) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {'true', '1', 'yes', 'on'}: + return True + if normalized in {'false', '0', 'no', 'off'}: + return False + return default + if isinstance(value, (int, float)): + return bool(value) + if value is None: + return default + return bool(value) + + def _get_dify_conversation_store(ap: app.Application) -> DifyConversationStore | None: - cfg = getattr(getattr(ap, 'instance_config', None), 'data', {}) or {} - store_cfg = cfg.get('dify_conversation_store', {}) or {} + raw_cfg = getattr(getattr(ap, 'instance_config', None), 'data', {}) or {} + cfg = raw_cfg if isinstance(raw_cfg, dict) else {} + raw_store_cfg = cfg.get('dify_conversation_store', {}) or {} + store_cfg = raw_store_cfg if isinstance(raw_store_cfg, dict) else {} try: - ttl_seconds = int(store_cfg.get('ttl_seconds', 86400)) + if 'idle_timeout_seconds' in store_cfg: + ttl_seconds = int(store_cfg.get('idle_timeout_seconds')) + else: + ttl_seconds = int(store_cfg.get('ttl_seconds', 43200)) except (TypeError, ValueError): - ttl_seconds = 86400 + ttl_seconds = 43200 try: lock_ttl_seconds = int(store_cfg.get('lock_ttl_seconds', 10)) @@ -38,7 +60,7 @@ def _get_dify_conversation_store(ap: app.Application) -> DifyConversationStore | redis_mgr=redis_mgr, ttl_seconds=max(1, ttl_seconds), lock_ttl_seconds=max(1, lock_ttl_seconds), - enabled=bool(store_cfg.get('enabled', True)), + enabled=_parse_bool_config(store_cfg.get('enabled', True), True), ) diff --git a/src/langbot/pkg/platform/sources/wecombot.py b/src/langbot/pkg/platform/sources/wecombot.py index 63b352d58..c908854be 100644 --- a/src/langbot/pkg/platform/sources/wecombot.py +++ b/src/langbot/pkg/platform/sources/wecombot.py @@ -275,6 +275,8 @@ def __init__(self, config: dict, logger: EventLogger): bot_name=bot_name, event_converter=event_converter, ) + # Keep websocket mode as an instance state for runtime checks and tests. + self._ws_mode = not enable_webhook self.listeners = {} async def reply_message( diff --git a/src/langbot/pkg/plugin/handler.py b/src/langbot/pkg/plugin/handler.py index 6e7494647..6f74ff530 100644 --- a/src/langbot/pkg/plugin/handler.py +++ b/src/langbot/pkg/plugin/handler.py @@ -46,15 +46,37 @@ def _normalize_launcher_type(launcher_type: Any) -> str: return str(launcher_type.value) return str(launcher_type) +def _parse_bool_config(value: Any, default: bool) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {'true', '1', 'yes', 'on'}: + return True + if normalized in {'false', '0', 'no', 'off'}: + return False + return default + if isinstance(value, (int, float)): + return bool(value) + if value is None: + return default + return bool(value) + + def _get_dify_conversation_store(ap: app.Application) -> DifyConversationStore | None: - cfg = getattr(getattr(ap, 'instance_config', None), 'data', {}) or {} - store_cfg = cfg.get('dify_conversation_store', {}) or {} + raw_cfg = getattr(getattr(ap, 'instance_config', None), 'data', {}) or {} + cfg = raw_cfg if isinstance(raw_cfg, dict) else {} + raw_store_cfg = cfg.get('dify_conversation_store', {}) or {} + store_cfg = raw_store_cfg if isinstance(raw_store_cfg, dict) else {} try: - ttl_seconds = int(store_cfg.get('ttl_seconds', 86400)) + if 'idle_timeout_seconds' in store_cfg: + ttl_seconds = int(store_cfg.get('idle_timeout_seconds')) + else: + ttl_seconds = int(store_cfg.get('ttl_seconds', 43200)) except (TypeError, ValueError): - ttl_seconds = 86400 + ttl_seconds = 43200 try: lock_ttl_seconds = int(store_cfg.get('lock_ttl_seconds', 10)) @@ -69,7 +91,7 @@ def _get_dify_conversation_store(ap: app.Application) -> DifyConversationStore | redis_mgr=redis_mgr, ttl_seconds=max(1, ttl_seconds), lock_ttl_seconds=max(1, lock_ttl_seconds), - enabled=bool(store_cfg.get('enabled', True)), + enabled=_parse_bool_config(store_cfg.get('enabled', True), True), ) diff --git a/src/langbot/pkg/provider/conversation/dify_store.py b/src/langbot/pkg/provider/conversation/dify_store.py index 59b99a62f..249054931 100644 --- a/src/langbot/pkg/provider/conversation/dify_store.py +++ b/src/langbot/pkg/provider/conversation/dify_store.py @@ -9,19 +9,95 @@ _logger = logging.getLogger("langbot") +def _is_valid_timestamp(value) -> bool: + # Fail closed on coercion ambiguity: only native int is accepted. + return type(value) is int + + +def normalize_binding_payload(payload: dict | None) -> dict | None: + """Normalize conversation binding payload from legacy/new schema to canonical schema.""" + if not isinstance(payload, dict): + return None + + conversation_id = payload.get("conversation_id") + if not isinstance(conversation_id, str): + return None + normalized_conversation_id = conversation_id.strip() + if not normalized_conversation_id: + return None + + created_at = payload.get("created_at") + last_active_at = payload.get("last_active_at") + policy_version = payload.get("policy_version") + if _is_valid_timestamp(created_at) and _is_valid_timestamp(last_active_at) and type(policy_version) is int: + return { + "conversation_id": normalized_conversation_id, + "created_at": created_at, + "last_active_at": last_active_at, + "policy_version": policy_version, + } + + updated_at = payload.get("updated_at") + if _is_valid_timestamp(updated_at): + return { + "conversation_id": normalized_conversation_id, + "created_at": updated_at, + "last_active_at": updated_at, + "policy_version": 1, + } + return None + + +def build_binding_payload(conversation_id: str, now_ts: int, existing_created_at: int | None = None) -> dict: + """Build canonical binding payload in latest schema.""" + created_at = existing_created_at if _is_valid_timestamp(existing_created_at) else now_ts + return { + "conversation_id": conversation_id, + "created_at": created_at, + "last_active_at": now_ts, + "policy_version": 2, + } + + +def is_binding_expired(binding: dict, *, now_ts: int, idle_timeout_seconds: int) -> bool: + """Check whether a normalized binding has exceeded idle timeout.""" + if idle_timeout_seconds <= 0: + return True + + last_active_at = binding.get("last_active_at") if isinstance(binding, dict) else None + if not _is_valid_timestamp(last_active_at): + return True + return now_ts - last_active_at >= idle_timeout_seconds + + class DifyConversationStore: """Redis-backed store for Dify conversation bindings.""" def __init__( self, redis_mgr, - ttl_seconds: int = 86400, + idle_timeout_seconds: int = 86400, lock_ttl_seconds: int = 10, enabled: bool = True, + ttl_seconds: int | None = None, ): self.redis_mgr = redis_mgr - self.ttl_seconds = ttl_seconds - self.lock_ttl_seconds = lock_ttl_seconds + # When both are provided, keep ttl_seconds as compatibility-first alias. + if ttl_seconds is not None: + idle_timeout_seconds = ttl_seconds + + self.idle_timeout_seconds = ( + idle_timeout_seconds + if type(idle_timeout_seconds) is int and idle_timeout_seconds > 0 + else 1 + ) + # Backward-compatible alias for older callsites. + self.ttl_seconds = self.idle_timeout_seconds + self.lock_ttl_seconds = ( + lock_ttl_seconds + if type(lock_ttl_seconds) is int and lock_ttl_seconds > 0 + else 1 + ) self.enabled = enabled def is_available(self) -> bool: @@ -40,68 +116,116 @@ def _lock_key(self, bot_uuid: str, pipeline_uuid: str, launcher_type: str, launc @staticmethod def _validate_payload(payload: dict | None) -> dict | None: - if not isinstance(payload, dict): - return None + return normalize_binding_payload(payload) - conversation_id = payload.get("conversation_id") - updated_at = payload.get("updated_at") - if not isinstance(conversation_id, str) or not conversation_id: - return None - if not isinstance(updated_at, int): - return None + async def _load_binding_payload(self, key: str) -> dict | None: + if hasattr(self.redis_mgr, "get_json"): + payload = await self.redis_mgr.get_json(key) + else: + raw_payload = await self.redis_mgr.get(key) + payload = json.loads(raw_payload) if raw_payload else None + return self._validate_payload(payload) - return payload - - async def get_conversation_id( + async def get_conversation_binding( self, bot_uuid: str, pipeline_uuid: str, launcher_type: str, launcher_id: str, - ) -> str | None: + ) -> dict | None: if not self.is_available(): return None key = self._conversation_key(bot_uuid, pipeline_uuid, launcher_type, launcher_id) try: - if hasattr(self.redis_mgr, "get_json"): - payload = await self.redis_mgr.get_json(key) - else: - raw_payload = await self.redis_mgr.get(key) - payload = json.loads(raw_payload) if raw_payload else None - payload = self._validate_payload(payload) - if payload is None: - return None - return payload["conversation_id"] + return await self._load_binding_payload(key) except Exception as exc: - _logger.warning("[dify][conversation-store] failed to get conversation id: %s", exc) + _logger.warning("[dify][conversation-store] failed to get conversation binding: %s", exc) return None - async def set_conversation_id( + async def set_conversation_binding( self, bot_uuid: str, pipeline_uuid: str, launcher_type: str, launcher_id: str, conversation_id: str, - ): + now_ts: int | None = None, + created_at: int | None = None, + ) -> None: if not self.is_available(): return - if not isinstance(conversation_id, str) or not conversation_id.strip(): + if not isinstance(conversation_id, str): + return + + normalized_conversation_id = conversation_id.strip() + if not normalized_conversation_id: return key = self._conversation_key(bot_uuid, pipeline_uuid, launcher_type, launcher_id) - payload = { - "conversation_id": conversation_id, - "updated_at": int(time.time()), - } + current_ts = now_ts if _is_valid_timestamp(now_ts) else int(time.time()) + existing_created_at = created_at if _is_valid_timestamp(created_at) else None + + if existing_created_at is None: + try: + existing_payload = await self._load_binding_payload(key) + if ( + existing_payload is not None + and existing_payload["conversation_id"] == normalized_conversation_id + ): + existing_created_at = existing_payload["created_at"] + except Exception as exc: + _logger.warning("[dify][conversation-store] failed to load existing conversation binding: %s", exc) + + payload = build_binding_payload( + conversation_id=normalized_conversation_id, + now_ts=current_ts, + existing_created_at=existing_created_at, + ) try: if hasattr(self.redis_mgr, "set_json"): - await self.redis_mgr.set_json(key, payload, ex=self.ttl_seconds) + await self.redis_mgr.set_json(key, payload, ex=self.idle_timeout_seconds) else: - await self.redis_mgr.set(key, json.dumps(payload, ensure_ascii=False), ex=self.ttl_seconds) + await self.redis_mgr.set( + key, + json.dumps(payload, ensure_ascii=False), + ex=self.idle_timeout_seconds, + ) except Exception as exc: - _logger.warning("[dify][conversation-store] failed to set conversation id: %s", exc) + _logger.warning("[dify][conversation-store] failed to set conversation binding: %s", exc) + + async def get_conversation_id( + self, + bot_uuid: str, + pipeline_uuid: str, + launcher_type: str, + launcher_id: str, + ) -> str | None: + binding = await self.get_conversation_binding( + bot_uuid, + pipeline_uuid, + launcher_type, + launcher_id, + ) + if binding is None: + return None + return binding["conversation_id"] + + async def set_conversation_id( + self, + bot_uuid: str, + pipeline_uuid: str, + launcher_type: str, + launcher_id: str, + conversation_id: str, + ): + await self.set_conversation_binding( + bot_uuid, + pipeline_uuid, + launcher_type, + launcher_id, + conversation_id, + ) async def delete_conversation_id( self, @@ -166,6 +290,8 @@ async def release_lock( return bool(deleted) current_owner = await self.redis_mgr.get(key) + if isinstance(current_owner, bytes): + current_owner = current_owner.decode("utf-8", errors="ignore") if current_owner != owner: return False await self.redis_mgr.delete(key) diff --git a/src/langbot/pkg/provider/runners/difysvapi.py b/src/langbot/pkg/provider/runners/difysvapi.py index 78a1ba7d4..204d41c7a 100644 --- a/src/langbot/pkg/provider/runners/difysvapi.py +++ b/src/langbot/pkg/provider/runners/difysvapi.py @@ -14,7 +14,11 @@ from langbot.pkg.utils import image import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query from langbot.libs.dify_service_api.v1 import client, errors -from langbot.pkg.provider.conversation.dify_store import DifyConversationStore +from langbot.pkg.provider.conversation.dify_store import ( + DifyConversationStore, + is_binding_expired, + normalize_binding_payload, +) import httpx @@ -22,6 +26,9 @@ class DifyServiceAPIRunner(runner.RequestRunner): """Dify Service API 对话请求器""" + _RUNTIME_BINDING_ATTR = 'dify_conversation_binding' + _RUNTIME_POLICY_VERSION = 2 + dify_client: client.AsyncDifyServiceClient def __init__(self, ap: app.Application, pipeline_config: dict): @@ -43,17 +50,61 @@ def __init__(self, ap: app.Application, pipeline_config: dict): base_url=self.pipeline_config['ai']['dify-service-api']['base-url'], ) + @staticmethod + def _parse_bool_config(value: typing.Any, default: bool) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {'true', '1', 'yes', 'on'}: + return True + if normalized in {'false', '0', 'no', 'off'}: + return False + return default + if isinstance(value, (int, float)): + return bool(value) + if value is None: + return default + return bool(value) + + def _get_output_misc_config(self) -> dict[str, typing.Any]: + output_config = self.pipeline_config.get('output') + if not isinstance(output_config, dict): + return {} + + misc_config = output_config.get('misc') + if not isinstance(misc_config, dict): + return {} + return misc_config + + def _is_remove_think_enabled(self) -> bool: + misc_config = self._get_output_misc_config() + return self._parse_bool_config(misc_config.get('remove-think', False), False) + def _resolve_conversation_store_config(self) -> dict[str, typing.Any]: - app_cfg = getattr(getattr(self.ap, 'instance_config', None), 'data', {}) or {} - store_cfg = app_cfg.get('dify_conversation_store', {}) or {} + raw_app_cfg = getattr(getattr(self.ap, 'instance_config', None), 'data', {}) or {} + app_cfg = raw_app_cfg if isinstance(raw_app_cfg, dict) else {} + raw_instance_store_cfg = app_cfg.get('dify_conversation_store', {}) or {} + instance_store_cfg = raw_instance_store_cfg if isinstance(raw_instance_store_cfg, dict) else {} - enabled = bool(store_cfg.get('enabled', True)) + pipeline_ai_cfg = self.pipeline_config.get('ai', {}) if isinstance(self.pipeline_config, dict) else {} + pipeline_ai_cfg = pipeline_ai_cfg if isinstance(pipeline_ai_cfg, dict) else {} + raw_pipeline_store_cfg = pipeline_ai_cfg.get('dify_conversation_store', {}) or {} + pipeline_store_cfg = raw_pipeline_store_cfg if isinstance(raw_pipeline_store_cfg, dict) else {} + + # Precedence: defaults -> instance config -> pipeline config. + store_cfg = {**instance_store_cfg, **pipeline_store_cfg} + + enabled = self._parse_bool_config(store_cfg.get('enabled', True), True) try: - ttl_seconds = int(store_cfg.get('ttl_seconds', 86400)) + if 'idle_timeout_seconds' in store_cfg: + idle_timeout_seconds = int(store_cfg.get('idle_timeout_seconds')) + else: + idle_timeout_seconds = int(store_cfg.get('ttl_seconds', 43200)) except (TypeError, ValueError): - ttl_seconds = 86400 - ttl_seconds = max(1, ttl_seconds) + idle_timeout_seconds = 43200 + idle_timeout_seconds = max(1, idle_timeout_seconds) try: lock_ttl_seconds = int(store_cfg.get('lock_ttl_seconds', 10)) @@ -79,7 +130,9 @@ def _resolve_conversation_store_config(self) -> dict[str, typing.Any]: return { 'enabled': enabled, - 'ttl_seconds': ttl_seconds, + 'idle_timeout_seconds': idle_timeout_seconds, + # Keep compatibility for legacy call sites and diagnostics. + 'ttl_seconds': idle_timeout_seconds, 'lock_ttl_seconds': lock_ttl_seconds, 'contention_wait_retry_count': contention_wait_retry_count, 'contention_wait_interval_ms': contention_wait_interval_ms, @@ -89,10 +142,10 @@ def _get_conversation_store(self) -> DifyConversationStore | None: if self._conversation_store_initialized: return self._conversation_store - self._conversation_store_initialized = True cfg = self._resolve_conversation_store_config() if not cfg['enabled']: + self._conversation_store_initialized = True return None redis_mgr = getattr(self.ap, 'redis_mgr', None) @@ -101,10 +154,11 @@ def _get_conversation_store(self) -> DifyConversationStore | None: self._conversation_store = DifyConversationStore( redis_mgr=redis_mgr, - ttl_seconds=cfg['ttl_seconds'], + idle_timeout_seconds=cfg['idle_timeout_seconds'], lock_ttl_seconds=cfg['lock_ttl_seconds'], enabled=cfg['enabled'], ) + self._conversation_store_initialized = True return self._conversation_store @@ -142,30 +196,323 @@ def _get_conversation_scope(self, query: pipeline_query.Query) -> tuple[str, str str(launcher_id), ) + @staticmethod + def _current_unix_timestamp() -> int: + return int(time.time()) + + @classmethod + def _get_runtime_binding_metadata(cls, using_conversation: typing.Any) -> typing.Any: + if using_conversation is None: + return None + metadata = getattr(using_conversation, cls._RUNTIME_BINDING_ATTR, None) + if metadata is not None: + return metadata + + conversation_dict = getattr(using_conversation, '__dict__', None) + if isinstance(conversation_dict, dict): + return conversation_dict.get(cls._RUNTIME_BINDING_ATTR) + return None + + @classmethod + def _set_runtime_binding_metadata(cls, using_conversation: typing.Any, metadata: dict | None): + if using_conversation is None: + return + + try: + setattr(using_conversation, cls._RUNTIME_BINDING_ATTR, metadata) + return + except Exception: + pass + + conversation_dict = getattr(using_conversation, '__dict__', None) + if isinstance(conversation_dict, dict): + if metadata is None: + conversation_dict.pop(cls._RUNTIME_BINDING_ATTR, None) + else: + conversation_dict[cls._RUNTIME_BINDING_ATTR] = metadata + + def _normalize_runtime_conversation_binding(self, query: pipeline_query.Query) -> dict | None: + session = getattr(query, 'session', None) + using_conversation = getattr(session, 'using_conversation', None) + if using_conversation is None: + return None + + runtime_uuid = getattr(using_conversation, 'uuid', None) + if not isinstance(runtime_uuid, str) or not runtime_uuid.strip(): + return None + runtime_uuid = runtime_uuid.strip() + + metadata = self._get_runtime_binding_metadata(using_conversation) + if not isinstance(metadata, dict): + return None + + metadata_uuid = metadata.get('uuid') + if not isinstance(metadata_uuid, str) or not metadata_uuid.strip(): + return None + + payload = { + 'conversation_id': metadata_uuid, + 'created_at': metadata.get('created_at'), + 'last_active_at': metadata.get('last_active_at'), + 'policy_version': metadata.get('policy_version'), + } + normalized_binding = normalize_binding_payload(payload) + if normalized_binding is None: + return None + if normalized_binding['conversation_id'] != runtime_uuid: + return None + return normalized_binding + + @staticmethod + def _normalize_store_binding_payload(payload: typing.Any) -> dict | None: + if not isinstance(payload, dict): + return None + + if 'conversation_id' not in payload and isinstance(payload.get('uuid'), str): + payload = { + 'conversation_id': payload.get('uuid'), + 'created_at': payload.get('created_at'), + 'last_active_at': payload.get('last_active_at'), + 'policy_version': payload.get('policy_version'), + } + + return normalize_binding_payload(payload) + + def _is_binding_expired(self, binding: dict | None) -> bool: + if not isinstance(binding, dict): + return True + + cfg = self._resolve_conversation_store_config() + return is_binding_expired( + binding, + now_ts=self._current_unix_timestamp(), + idle_timeout_seconds=int(cfg.get('idle_timeout_seconds', 43200)), + ) + + def _set_runtime_conversation_binding(self, query: pipeline_query.Query, binding: dict | None): + session = getattr(query, 'session', None) + using_conversation = getattr(session, 'using_conversation', None) + if using_conversation is None: + return + + if binding is None: + using_conversation.uuid = None + self._set_runtime_binding_metadata(using_conversation, None) + return + + using_conversation.uuid = binding['conversation_id'] + self._set_runtime_binding_metadata( + using_conversation, + { + 'uuid': binding['conversation_id'], + 'created_at': binding['created_at'], + 'last_active_at': binding['last_active_at'], + 'policy_version': binding['policy_version'], + }, + ) + + async def _get_binding_from_store( + self, + store: DifyConversationStore, + scope: tuple[str, str, str, str], + ) -> dict | None: + if hasattr(store, 'get_conversation_binding'): + binding_payload = await store.get_conversation_binding(*scope) + normalized_binding = self._normalize_store_binding_payload(binding_payload) + if binding_payload is not None and normalized_binding is None: + self.ap.logger.warning('dify conversation restore skipped malformed store binding payload') + return normalized_binding + + # Compatibility fallback for tests/legacy mocks that still expose id-only APIs. + if hasattr(store, 'get_conversation_id'): + conversation_id = await store.get_conversation_id(*scope) + if not isinstance(conversation_id, str) or not conversation_id.strip(): + return None + now_ts = self._current_unix_timestamp() + return { + 'conversation_id': conversation_id.strip(), + 'created_at': now_ts, + 'last_active_at': now_ts, + 'policy_version': 1, + } + + return None + + async def _set_binding_to_store( + self, + store: DifyConversationStore, + scope: tuple[str, str, str, str], + conversation_id: str, + now_ts: int, + created_at: int, + ): + if hasattr(store, 'set_conversation_binding'): + await store.set_conversation_binding( + *scope, + conversation_id, + now_ts=now_ts, + created_at=created_at, + ) + return + + # Compatibility fallback for tests/legacy mocks that still expose id-only APIs. + if hasattr(store, 'set_conversation_id'): + await store.set_conversation_id(*scope, conversation_id) + + async def _delete_binding_from_store( + self, + store: DifyConversationStore, + scope: tuple[str, str, str, str], + ): + if hasattr(store, 'delete_conversation_binding'): + await store.delete_conversation_binding(*scope) + return + + # Compatibility fallback for tests/legacy mocks that still expose id-only APIs. + if hasattr(store, 'delete_conversation_id'): + await store.delete_conversation_id(*scope) + + @staticmethod + def _is_invalid_conversation_error(exc: Exception) -> bool: + text_candidates: list[str] = [] + message_attr = getattr(exc, 'message', None) + if isinstance(message_attr, str): + text_candidates.append(message_attr) + + exc_text = str(exc) + if isinstance(exc_text, str) and exc_text: + text_candidates.append(exc_text) + + if not text_candidates: + return False + + normalized_text = ' '.join(text_candidates).lower() + if 'conversation' not in normalized_text: + return False + + invalid_markers = ( + 'conversation_not_found', + 'invalid_conversation', + 'invalid conversation', + 'conversation not found', + 'conversation does not exist', + 'conversation not exist', + ) + return any(marker in normalized_text for marker in invalid_markers) + + async def _clear_conversation_binding_after_invalid_conversation_error(self, query: pipeline_query.Query): + # Fail closed: runtime binding must be cleared before retrying without stale conversation_id. + self._set_runtime_conversation_binding(query, None) + + store = self._get_conversation_store() + scope = self._get_conversation_scope(query) + if store is None or scope is None: + return + + try: + await self._delete_binding_from_store(store, scope) + except Exception as exc: + self.ap.logger.warning(f'dify conversation clear persisted binding failed: {exc}') + + async def _chat_messages_with_invalid_conversation_retry( + self, + query: pipeline_query.Query, + *, + inputs: dict[str, typing.Any], + plain_text: str, + files: list[dict[str, typing.Any]], + conversation_id: str | None, + response_mode: str | None = None, + timeout: int = 120, + retry_lock_owner_holder: list[str | None] | None = None, + ) -> typing.AsyncGenerator[dict[str, typing.Any], None]: + active_conversation_id = conversation_id + retried = False + + while True: + query.variables['conversation_id'] = active_conversation_id + inputs['conversation_id'] = active_conversation_id + request_kwargs: dict[str, typing.Any] = { + 'inputs': inputs, + 'query': plain_text, + 'user': f'{query.session.launcher_type.value}_{query.session.launcher_id}', + 'conversation_id': active_conversation_id, + 'files': files, + 'timeout': timeout, + } + if response_mode is not None: + request_kwargs['response_mode'] = response_mode + + emitted_any_chunk = False + try: + async for chunk in self.dify_client.chat_messages(**request_kwargs): + emitted_any_chunk = True + yield chunk + return + except Exception as exc: + should_retry = ( + not retried + and not emitted_any_chunk + and isinstance(active_conversation_id, str) + and active_conversation_id.strip() != '' + and self._is_invalid_conversation_error(exc) + ) + if not should_retry: + raise + + self.ap.logger.warning( + 'dify conversation rejected by upstream, clear stale binding and retry once after re-resolving scope lock' + ) + await self._clear_conversation_binding_after_invalid_conversation_error(query) + restored_conversation_id, retry_lock_owner = await self._restore_conversation_id_if_needed(query) + if retry_lock_owner_holder is not None: + retry_lock_owner_holder[0] = retry_lock_owner + active_conversation_id = restored_conversation_id or '' + retried = True + async def _restore_conversation_id_if_needed(self, query: pipeline_query.Query) -> tuple[str | None, str | None]: session = getattr(query, 'session', None) using_conversation = getattr(session, 'using_conversation', None) if using_conversation is None: return None, None - current_conversation_id = getattr(using_conversation, 'uuid', None) - if current_conversation_id: - return current_conversation_id, None + current_runtime_uuid = getattr(using_conversation, 'uuid', None) + if not isinstance(current_runtime_uuid, str) or not current_runtime_uuid.strip(): + current_runtime_uuid = None + else: + current_runtime_uuid = current_runtime_uuid.strip() + + runtime_binding = self._normalize_runtime_conversation_binding(query) + if runtime_binding and not self._is_binding_expired(runtime_binding): + self._set_runtime_conversation_binding(query, runtime_binding) + return runtime_binding['conversation_id'], None + + # Expired/invalid runtime metadata should not remain authoritative. + if runtime_binding is not None: + self._set_runtime_conversation_binding(query, None) + + # Runtime UUID without metadata is treated as legacy authority until trusted replacement is found. + legacy_runtime_uuid = current_runtime_uuid if runtime_binding is None else None store = self._get_conversation_store() scope = self._get_conversation_scope(query) if store is None or scope is None: - return None, None + return legacy_runtime_uuid, None try: - conversation_id = await store.get_conversation_id(*scope) + restored_binding = await self._get_binding_from_store(store, scope) except Exception as exc: self.ap.logger.warning(f'dify conversation restore failed: {exc}') - return None, None + return legacy_runtime_uuid, None - if conversation_id: - using_conversation.uuid = conversation_id - return conversation_id, None + if restored_binding and not self._is_binding_expired(restored_binding): + if legacy_runtime_uuid and restored_binding['conversation_id'] != legacy_runtime_uuid: + return legacy_runtime_uuid, None + self._set_runtime_conversation_binding(query, restored_binding) + return restored_binding['conversation_id'], None + + if legacy_runtime_uuid: + return legacy_runtime_uuid, None try: lock_owner = await store.acquire_lock(*scope) @@ -181,14 +528,14 @@ async def _restore_conversation_id_if_needed(self, query: pipeline_query.Query) for _ in range(retry_count): await asyncio.sleep(wait_interval_seconds) try: - conversation_id = await store.get_conversation_id(*scope) + restored_binding = await self._get_binding_from_store(store, scope) except Exception as exc: self.ap.logger.warning(f'dify conversation contention re-check failed: {exc}') continue - if conversation_id: - using_conversation.uuid = conversation_id - return conversation_id, None + if restored_binding and not self._is_binding_expired(restored_binding): + self._set_runtime_conversation_binding(query, restored_binding) + return restored_binding['conversation_id'], None self.ap.logger.warning( 'dify conversation lock contention unresolved after bounded wait, continue without restored conversation id' @@ -196,18 +543,18 @@ async def _restore_conversation_id_if_needed(self, query: pipeline_query.Query) return None, None try: - conversation_id = await store.get_conversation_id(*scope) + restored_binding = await self._get_binding_from_store(store, scope) except Exception as exc: self.ap.logger.warning(f'dify conversation restore re-check failed: {exc}') - conversation_id = None + restored_binding = None - if conversation_id: - using_conversation.uuid = conversation_id + if restored_binding and not self._is_binding_expired(restored_binding): + self._set_runtime_conversation_binding(query, restored_binding) try: await store.release_lock(*scope, lock_owner) except Exception as exc: self.ap.logger.warning(f'dify conversation lock release failed: {exc}') - return conversation_id, None + return restored_binding['conversation_id'], None return None, lock_owner @@ -229,10 +576,22 @@ async def _persist_conversation_id(self, query: pipeline_query.Query, conversati if not conversation_id: return - session = getattr(query, 'session', None) - using_conversation = getattr(session, 'using_conversation', None) - if using_conversation is not None: - using_conversation.uuid = conversation_id + now_ts = self._current_unix_timestamp() + existing_runtime_binding = self._normalize_runtime_conversation_binding(query) + created_at = now_ts + if ( + existing_runtime_binding is not None + and existing_runtime_binding['conversation_id'] == conversation_id + ): + created_at = existing_runtime_binding['created_at'] + + runtime_binding = { + 'conversation_id': conversation_id, + 'created_at': created_at, + 'last_active_at': now_ts, + 'policy_version': self._RUNTIME_POLICY_VERSION, + } + self._set_runtime_conversation_binding(query, runtime_binding) store = self._get_conversation_store() scope = self._get_conversation_scope(query) @@ -240,7 +599,13 @@ async def _persist_conversation_id(self, query: pipeline_query.Query, conversati return try: - await store.set_conversation_id(*scope, conversation_id) + await self._set_binding_to_store( + store, + scope, + conversation_id=conversation_id, + now_ts=now_ts, + created_at=created_at, + ) except Exception as exc: self.ap.logger.warning(f'dify conversation persist failed: {exc}') @@ -255,7 +620,7 @@ def _process_thinking_content( Returns: (处理后的内容, 提取的思维链内容) """ - remove_think = self.pipeline_config['output'].get('misc', '').get('remove-think') + remove_think = self._is_remove_think_enabled() thinking_content = '' # 从 content 中提取 标签内容 if content and '' in content and '' in content: @@ -330,7 +695,7 @@ def _is_stream_flush_window_enabled(query: pipeline_query.Query) -> bool: pipeline_config = getattr(query, 'pipeline_config', {}) or {} output_config = pipeline_config.get('output', {}) or {} dify_stream_config = output_config.get('dify-stream', {}) or {} - return bool(dify_stream_config.get('flush-window-enabled', False)) + return DifyServiceAPIRunner._parse_bool_config(dify_stream_config.get('flush-window-enabled', False), False) @staticmethod def _should_emit_stream_snapshot( @@ -441,10 +806,8 @@ async def _chat_messages( self, query: pipeline_query.Query ) -> typing.AsyncGenerator[provider_message.Message, None]: """调用聊天助手""" - cov_id = query.session.using_conversation.uuid or None - conversation_lock_owner = None - if not cov_id: - cov_id, conversation_lock_owner = await self._restore_conversation_id_if_needed(query) + cov_id, conversation_lock_owner = await self._restore_conversation_id_if_needed(query) + retry_lock_owner_holder = [None] query.variables['conversation_id'] = cov_id plain_text, upload_files = await self._preprocess_user_message(query) @@ -469,13 +832,14 @@ async def _chat_messages( chunk = None # 初始化chunk变量,防止在没有响应时引用错误 try: - async for chunk in self.dify_client.chat_messages( + async for chunk in self._chat_messages_with_invalid_conversation_retry( + query, inputs=inputs, - query=plain_text, - user=f'{query.session.launcher_type.value}_{query.session.launcher_id}', - conversation_id=cov_id, + plain_text=plain_text, files=files, + conversation_id=cov_id, timeout=120, + retry_lock_owner_holder=retry_lock_owner_holder, ): self.ap.logger.debug('dify-chat-chunk: ' + str(chunk)) @@ -509,15 +873,14 @@ async def _chat_messages( await self._persist_conversation_id(query, final_conversation_id) finally: await self._release_conversation_lock(query, conversation_lock_owner) + await self._release_conversation_lock(query, retry_lock_owner_holder[0]) async def _agent_chat_messages( self, query: pipeline_query.Query ) -> typing.AsyncGenerator[provider_message.Message, None]: """调用聊天助手""" - cov_id = query.session.using_conversation.uuid or None - conversation_lock_owner = None - if not cov_id: - cov_id, conversation_lock_owner = await self._restore_conversation_id_if_needed(query) + cov_id, conversation_lock_owner = await self._restore_conversation_id_if_needed(query) + retry_lock_owner_holder = [None] query.variables['conversation_id'] = cov_id plain_text, upload_files = await self._preprocess_user_message(query) @@ -542,14 +905,15 @@ async def _agent_chat_messages( chunk = None # 初始化chunk变量,防止在没有响应时引用错误 try: - async for chunk in self.dify_client.chat_messages( + async for chunk in self._chat_messages_with_invalid_conversation_retry( + query, inputs=inputs, - query=plain_text, - user=f'{query.session.launcher_type.value}_{query.session.launcher_id}', - response_mode='streaming', - conversation_id=cov_id, + plain_text=plain_text, files=files, + conversation_id=cov_id, + response_mode='streaming', timeout=120, + retry_lock_owner_holder=retry_lock_owner_holder, ): self.ap.logger.debug('dify-agent-chunk: ' + str(chunk)) @@ -613,6 +977,7 @@ async def _agent_chat_messages( await self._persist_conversation_id(query, final_conversation_id) finally: await self._release_conversation_lock(query, conversation_lock_owner) + await self._release_conversation_lock(query, retry_lock_owner_holder[0]) async def _workflow_messages( self, query: pipeline_query.Query @@ -708,10 +1073,8 @@ async def _chat_messages_chunk( self, query: pipeline_query.Query ) -> typing.AsyncGenerator[provider_message.MessageChunk, None]: """调用聊天助手""" - cov_id = query.session.using_conversation.uuid or None - conversation_lock_owner = None - if not cov_id: - cov_id, conversation_lock_owner = await self._restore_conversation_id_if_needed(query) + cov_id, conversation_lock_owner = await self._restore_conversation_id_if_needed(query) + retry_lock_owner_holder = [None] query.variables['conversation_id'] = cov_id plain_text, upload_files = await self._preprocess_user_message(query) @@ -744,19 +1107,20 @@ async def _chat_messages_chunk( pending_chunk_count = 0 last_emit_at = time.monotonic() - remove_think = self.pipeline_config['output'].get('misc', '').get('remove-think') + remove_think = self._is_remove_think_enabled() chunk_batch_size = self._get_stream_chunk_batch_size(query) flush_window_enabled = self._is_stream_flush_window_enabled(query) flush_window_ms = self._get_stream_flush_window_ms(query) try: - async for chunk in self.dify_client.chat_messages( + async for chunk in self._chat_messages_with_invalid_conversation_retry( + query, inputs=inputs, - query=plain_text, - user=f'{query.session.launcher_type.value}_{query.session.launcher_id}', - conversation_id=cov_id, + plain_text=plain_text, files=files, + conversation_id=cov_id, timeout=120, + retry_lock_owner_holder=retry_lock_owner_holder, ): self.ap.logger.debug('dify-chat-chunk: ' + str(chunk)) @@ -847,15 +1211,14 @@ async def _chat_messages_chunk( await self._persist_conversation_id(query, final_conversation_id) finally: await self._release_conversation_lock(query, conversation_lock_owner) + await self._release_conversation_lock(query, retry_lock_owner_holder[0]) async def _agent_chat_messages_chunk( self, query: pipeline_query.Query ) -> typing.AsyncGenerator[provider_message.MessageChunk, None]: """调用聊天助手""" - cov_id = query.session.using_conversation.uuid or None - conversation_lock_owner = None - if not cov_id: - cov_id, conversation_lock_owner = await self._restore_conversation_id_if_needed(query) + cov_id, conversation_lock_owner = await self._restore_conversation_id_if_needed(query) + retry_lock_owner_holder = [None] query.variables['conversation_id'] = cov_id plain_text, upload_files = await self._preprocess_user_message(query) @@ -888,20 +1251,21 @@ async def _agent_chat_messages_chunk( pending_chunk_count = 0 last_emit_at = time.monotonic() - remove_think = self.pipeline_config['output'].get('misc', '').get('remove-think') + remove_think = self._is_remove_think_enabled() chunk_batch_size = self._get_stream_chunk_batch_size(query) flush_window_enabled = self._is_stream_flush_window_enabled(query) flush_window_ms = self._get_stream_flush_window_ms(query) try: - async for chunk in self.dify_client.chat_messages( + async for chunk in self._chat_messages_with_invalid_conversation_retry( + query, inputs=inputs, - query=plain_text, - user=f'{query.session.launcher_type.value}_{query.session.launcher_id}', - response_mode='streaming', - conversation_id=cov_id, + plain_text=plain_text, files=files, + conversation_id=cov_id, + response_mode='streaming', timeout=120, + retry_lock_owner_holder=retry_lock_owner_holder, ): self.ap.logger.debug('dify-agent-chunk: ' + str(chunk)) @@ -1015,6 +1379,7 @@ async def _agent_chat_messages_chunk( await self._persist_conversation_id(query, final_conversation_id) finally: await self._release_conversation_lock(query, conversation_lock_owner) + await self._release_conversation_lock(query, retry_lock_owner_holder[0]) async def _workflow_messages_chunk( self, query: pipeline_query.Query @@ -1060,7 +1425,7 @@ async def _workflow_messages_chunk( pending_chunk_count = 0 last_emit_at = time.monotonic() - remove_think = self.pipeline_config['output'].get('misc', '').get('remove-think') + remove_think = self._is_remove_think_enabled() chunk_batch_size = self._get_stream_chunk_batch_size(query) flush_window_enabled = self._is_stream_flush_window_enabled(query) flush_window_ms = self._get_stream_flush_window_ms(query) diff --git a/src/langbot/templates/config.yaml b/src/langbot/templates/config.yaml index 50709f3d3..907f4d332 100644 --- a/src/langbot/templates/config.yaml +++ b/src/langbot/templates/config.yaml @@ -94,7 +94,8 @@ redis: key_prefix: 'langbot' dify_conversation_store: enabled: true - ttl_seconds: 86400 + # same-scope conversation is reusable only while active within this timeout + idle_timeout_seconds: 43200 lock_ttl_seconds: 130 contention_wait_retry_count: 15 contention_wait_interval_ms: 500 diff --git a/src/langbot/templates/metadata/pipeline/ai.yaml b/src/langbot/templates/metadata/pipeline/ai.yaml index 46f5d4635..5d125760e 100644 --- a/src/langbot/templates/metadata/pipeline/ai.yaml +++ b/src/langbot/templates/metadata/pipeline/ai.yaml @@ -163,6 +163,54 @@ stages: zh_Hans: API 密钥 type: string required: true + - name: dify_conversation_store + label: + en_US: Dify Session Settings (Advanced) + zh_Hans: Dify 会话设置(高级) + description: + en_US: Advanced Dify conversation store settings. Only effective when the runner is Dify Service API, and controls idle-based session rotation plus lock contention behavior. + zh_Hans: Dify 会话存储高级设置。仅在运行器为 Dify 服务 API 时生效,用于控制基于空闲时间的会话切换与锁竞争行为。 + config: + - name: idle_timeout_seconds + label: + en_US: Session Idle Timeout (Seconds) + zh_Hans: 会话空闲超时(秒) + description: + en_US: Max idle time before an inactive Dify session is considered expired. + zh_Hans: Dify 会话无活动后判定过期的最大空闲时长。 + type: integer + required: true + default: 43200 + - name: lock_ttl_seconds + label: + en_US: Conversation Lock TTL (Seconds) + zh_Hans: 会话锁 TTL(秒) + description: + en_US: Lifetime of the distributed lock used for Dify conversation state updates. + zh_Hans: Dify 会话状态更新时分布式锁的存活时间。 + type: integer + required: true + default: 130 + - name: contention_wait_retry_count + label: + en_US: Lock Contention Retry Count + zh_Hans: 锁竞争重试次数 + description: + en_US: Number of retries when the conversation lock is currently occupied. + zh_Hans: 会话锁被占用时的重试次数。 + type: integer + required: true + default: 15 + - name: contention_wait_interval_ms + label: + en_US: Lock Contention Retry Interval (Milliseconds) + zh_Hans: 锁竞争重试间隔(毫秒) + description: + en_US: Wait interval between lock contention retries. + zh_Hans: 锁竞争重试之间的等待间隔。 + type: integer + required: true + default: 500 - name: dashscope-app-api label: en_US: Aliyun Dashscope App API @@ -450,4 +498,4 @@ stages: en_US: Timeout in seconds for API requests zh_Hans: API 请求超时时间(秒) type: number - default: 120 \ No newline at end of file + default: 120 diff --git a/tests/unit_tests/api/test_dify_conversation_resets.py b/tests/unit_tests/api/test_dify_conversation_resets.py index 6b0ce829f..4f3f957f7 100644 --- a/tests/unit_tests/api/test_dify_conversation_resets.py +++ b/tests/unit_tests/api/test_dify_conversation_resets.py @@ -419,3 +419,127 @@ async def test_service_reset_delete_failure_warning_contains_scope_fields(monkey assert any('pipeline_uuid=pipe-1' in msg for msg in pipeline_warnings) assert any('launcher_type=person' in msg for msg in pipeline_warnings) assert any('launcher_id=user-1' in msg for msg in pipeline_warnings) + + +@pytest.mark.parametrize( + 'module_name', + [ + 'langbot.pkg.plugin.handler', + 'langbot.pkg.api.http.service.bot', + 'langbot.pkg.api.http.service.pipeline', + ], +) +def test_get_dify_conversation_store_prefers_idle_timeout_seconds(module_name): + import importlib + + target_module = importlib.import_module(module_name) + ap = SimpleNamespace( + instance_config=SimpleNamespace( + data={ + 'dify_conversation_store': { + 'idle_timeout_seconds': 321, + 'ttl_seconds': 999, + 'lock_ttl_seconds': 9, + } + } + ), + redis_mgr=object(), + ) + + store = target_module._get_dify_conversation_store(ap) + + assert store is not None + assert store.ttl_seconds == 321 + assert store.lock_ttl_seconds == 9 + + +@pytest.mark.parametrize( + 'module_name', + [ + 'langbot.pkg.plugin.handler', + 'langbot.pkg.api.http.service.bot', + 'langbot.pkg.api.http.service.pipeline', + ], +) +def test_get_dify_conversation_store_uses_legacy_ttl_seconds_when_idle_timeout_missing(module_name): + import importlib + + target_module = importlib.import_module(module_name) + ap = SimpleNamespace( + instance_config=SimpleNamespace(data={'dify_conversation_store': {'ttl_seconds': 654}}), + redis_mgr=object(), + ) + + store = target_module._get_dify_conversation_store(ap) + + assert store is not None + assert store.ttl_seconds == 654 + + +@pytest.mark.parametrize( + 'module_name', + [ + 'langbot.pkg.plugin.handler', + 'langbot.pkg.api.http.service.bot', + 'langbot.pkg.api.http.service.pipeline', + ], +) +def test_get_dify_conversation_store_uses_12h_default_timeout(module_name): + import importlib + + target_module = importlib.import_module(module_name) + ap = SimpleNamespace(instance_config=SimpleNamespace(data={}), redis_mgr=object()) + + store = target_module._get_dify_conversation_store(ap) + + assert store is not None + assert store.ttl_seconds == 43200 + + +@pytest.mark.parametrize( + 'module_name', + [ + 'langbot.pkg.plugin.handler', + 'langbot.pkg.api.http.service.bot', + 'langbot.pkg.api.http.service.pipeline', + ], +) +def test_get_dify_conversation_store_falls_back_when_store_config_is_not_dict(module_name): + import importlib + + target_module = importlib.import_module(module_name) + ap = SimpleNamespace( + instance_config=SimpleNamespace(data={'dify_conversation_store': 'invalid-config'}), + redis_mgr=object(), + ) + + store = target_module._get_dify_conversation_store(ap) + + assert store is not None + assert store.ttl_seconds == 43200 + assert store.lock_ttl_seconds == 10 + assert store.enabled is True + + +@pytest.mark.parametrize( + ('module_name', 'enabled_value', 'expected_enabled'), + [ + ('langbot.pkg.plugin.handler', 'false', False), + ('langbot.pkg.api.http.service.bot', '0', False), + ('langbot.pkg.api.http.service.pipeline', 'no', False), + ('langbot.pkg.plugin.handler', 'true', True), + ], +) +def test_get_dify_conversation_store_parses_enabled_string_values(module_name, enabled_value, expected_enabled): + import importlib + + target_module = importlib.import_module(module_name) + ap = SimpleNamespace( + instance_config=SimpleNamespace(data={'dify_conversation_store': {'enabled': enabled_value}}), + redis_mgr=object(), + ) + + store = target_module._get_dify_conversation_store(ap) + + assert store is not None + assert store.enabled is expected_enabled diff --git a/tests/unit_tests/pipeline/test_wecombot_dify_minfix.py b/tests/unit_tests/pipeline/test_wecombot_dify_minfix.py index 2c53d6214..2d568fb7a 100644 --- a/tests/unit_tests/pipeline/test_wecombot_dify_minfix.py +++ b/tests/unit_tests/pipeline/test_wecombot_dify_minfix.py @@ -1,12 +1,15 @@ from __future__ import annotations import sys +import time import types from importlib import import_module +from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock, Mock import pytest +import yaml import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.provider.message as provider_message @@ -28,6 +31,8 @@ def get_wecom_converter(): def get_dify_runner(): + # Keep import order aligned with runtime startup to avoid partial runner module initialization. + import_module('langbot.pkg.pipeline.pipelinemgr') return import_module('langbot.pkg.provider.runners.difysvapi').DifyServiceAPIRunner @@ -93,6 +98,15 @@ def make_dify_pipeline_config( } +def make_runtime_binding(uuid: str, created_at: int, last_active_at: int, policy_version: int = 2) -> dict: + return { + 'uuid': uuid, + 'created_at': created_at, + 'last_active_at': last_active_at, + 'policy_version': policy_version, + } + + @pytest.mark.asyncio async def test_respback_uses_latest_chunk_final_flag(mock_app, sample_query): SendResponseBackStage = get_respback_stage() @@ -435,6 +449,86 @@ def test_dify_stream_uses_output_defaults_when_config_missing(): assert runner._get_stream_flush_window_ms(query) == 2000 +@pytest.mark.parametrize( + ('enabled_value', 'expected'), + [ + ('false', False), + ('0', False), + ('no', False), + ('off', False), + ('true', True), + ('1', True), + ('yes', True), + ('on', True), + ], +) +def test_dify_stream_flush_window_enabled_parses_common_boolean_strings(enabled_value, expected): + DifyServiceAPIRunner = get_dify_runner() + app = Mock() + app.logger = Mock() + runner = DifyServiceAPIRunner(app, make_dify_pipeline_config()) + query = SimpleNamespace( + pipeline_config={ + 'output': { + 'dify-stream': { + 'flush-window-enabled': enabled_value, + } + } + } + ) + + assert runner._is_stream_flush_window_enabled(query) is expected + + + + +@pytest.mark.asyncio +async def test_dify_chat_stream_does_not_crash_when_output_misc_is_missing(): + DifyServiceAPIRunner = get_dify_runner() + app = Mock() + app.logger = Mock() + pipeline_config = make_dify_pipeline_config() + pipeline_config['output'] = {} + runner = DifyServiceAPIRunner(app, pipeline_config) + + async def fake_chat_messages(**kwargs): + yield {'event': 'message', 'answer': '你好', 'conversation_id': 'conv-misc-missing'} + yield {'event': 'message_end', 'conversation_id': 'conv-misc-missing'} + + runner.dify_client = SimpleNamespace(chat_messages=fake_chat_messages, base_url='https://example.com/v1') + + query = SimpleNamespace( + adapter=SimpleNamespace(config={}), + session=SimpleNamespace( + using_conversation=SimpleNamespace(uuid='conv-1'), + launcher_type=provider_session.LauncherTypes.PERSON, + launcher_id='user-1', + ), + variables={}, + pipeline_config=runner.pipeline_config, + user_message=provider_message.Message(role='user', content='hello'), + ) + + chunks = [chunk async for chunk in runner._chat_messages_chunk(query)] + + assert chunks + assert chunks[-1].is_final is True + + +@pytest.mark.asyncio +async def test_dify_process_thinking_content_does_not_crash_when_output_misc_is_not_dict(): + DifyServiceAPIRunner = get_dify_runner() + app = Mock() + app.logger = Mock() + pipeline_config = make_dify_pipeline_config() + pipeline_config['output'] = {'misc': ''} + runner = DifyServiceAPIRunner(app, pipeline_config) + + content, thinking = runner._process_thinking_content('abchello') + + assert '' in content + assert thinking == 'abc' + def test_dify_conversation_store_lock_ttl_is_not_shorter_than_request_window(): DifyServiceAPIRunner = get_dify_runner() app = Mock() @@ -449,6 +543,134 @@ def test_dify_conversation_store_lock_ttl_is_not_shorter_than_request_window(): assert cfg['contention_wait_interval_ms'] >= 500 +def test_dify_conversation_store_config_prefers_idle_timeout_seconds_over_ttl_seconds(): + DifyServiceAPIRunner = get_dify_runner() + app = Mock() + app.logger = Mock() + app.instance_config = SimpleNamespace( + data={'dify_conversation_store': {'idle_timeout_seconds': 321, 'ttl_seconds': 999}} + ) + runner = DifyServiceAPIRunner(app, make_dify_pipeline_config()) + + cfg = runner._resolve_conversation_store_config() + + assert cfg['idle_timeout_seconds'] == 321 + assert cfg['ttl_seconds'] == 321 + + +@pytest.mark.parametrize('store_value', ['', [], 123, 0.1, True]) +def test_dify_conversation_store_config_falls_back_to_defaults_when_store_config_is_not_dict(store_value): + DifyServiceAPIRunner = get_dify_runner() + app = Mock() + app.logger = Mock() + app.instance_config = SimpleNamespace(data={'dify_conversation_store': store_value}) + runner = DifyServiceAPIRunner(app, make_dify_pipeline_config()) + + cfg = runner._resolve_conversation_store_config() + + assert cfg['enabled'] is True + assert cfg['idle_timeout_seconds'] == 43200 + assert cfg['ttl_seconds'] == 43200 + assert cfg['lock_ttl_seconds'] == 130 + assert cfg['contention_wait_retry_count'] == 15 + assert cfg['contention_wait_interval_ms'] == 500 + + +def test_dify_conversation_store_pipeline_idle_timeout_overrides_instance_default(): + DifyServiceAPIRunner = get_dify_runner() + app = Mock() + app.logger = Mock() + app.instance_config = SimpleNamespace(data={'dify_conversation_store': {'idle_timeout_seconds': 111}}) + pipeline_config = make_dify_pipeline_config() + pipeline_config['ai']['dify_conversation_store'] = {'idle_timeout_seconds': 222} + runner = DifyServiceAPIRunner(app, pipeline_config) + + cfg = runner._resolve_conversation_store_config() + + assert cfg['idle_timeout_seconds'] == 222 + assert cfg['ttl_seconds'] == 222 + + +def test_dify_conversation_store_pipeline_lock_ttl_overrides_instance_and_still_applies_floor(): + DifyServiceAPIRunner = get_dify_runner() + app = Mock() + app.logger = Mock() + app.instance_config = SimpleNamespace(data={'dify_conversation_store': {'lock_ttl_seconds': 200}}) + pipeline_config = make_dify_pipeline_config() + pipeline_config['ai']['dify_conversation_store'] = {'lock_ttl_seconds': 1} + runner = DifyServiceAPIRunner(app, pipeline_config) + + cfg = runner._resolve_conversation_store_config() + + assert cfg['lock_ttl_seconds'] == 130 + + +def test_dify_conversation_store_pipeline_idle_timeout_overrides_instance_legacy_ttl_seconds(): + DifyServiceAPIRunner = get_dify_runner() + app = Mock() + app.logger = Mock() + app.instance_config = SimpleNamespace(data={'dify_conversation_store': {'ttl_seconds': 333}}) + pipeline_config = make_dify_pipeline_config() + pipeline_config['ai']['dify_conversation_store'] = {'idle_timeout_seconds': 444} + runner = DifyServiceAPIRunner(app, pipeline_config) + + cfg = runner._resolve_conversation_store_config() + + assert cfg['idle_timeout_seconds'] == 444 + assert cfg['ttl_seconds'] == 444 + + +@pytest.mark.parametrize( + ('enabled_value', 'expected'), + [ + ('false', False), + ('0', False), + ('no', False), + ('off', False), + ('true', True), + ('1', True), + ('yes', True), + ('on', True), + ], +) +def test_dify_conversation_store_enabled_parses_common_boolean_strings(enabled_value, expected): + DifyServiceAPIRunner = get_dify_runner() + app = Mock() + app.logger = Mock() + app.instance_config = SimpleNamespace(data={'dify_conversation_store': {'enabled': enabled_value}}) + runner = DifyServiceAPIRunner(app, make_dify_pipeline_config()) + + cfg = runner._resolve_conversation_store_config() + + assert cfg['enabled'] is expected + + +def test_dify_conversation_store_can_initialize_after_redis_manager_becomes_available(): + DifyServiceAPIRunner = get_dify_runner() + app = Mock() + app.logger = Mock() + app.redis_mgr = None + runner = DifyServiceAPIRunner(app, make_dify_pipeline_config()) + + assert runner._get_conversation_store() is None + + app.redis_mgr = Mock() + store = runner._get_conversation_store() + + assert store is not None + assert store.redis_mgr is app.redis_mgr + assert runner._get_conversation_store() is store + + +def test_template_default_dify_idle_timeout_seconds_is_12_hours(): + config_path = Path(__file__).resolve().parents[3] / 'src' / 'langbot' / 'templates' / 'config.yaml' + + with config_path.open('r', encoding='utf-8') as f: + config = yaml.safe_load(f) + + assert config['dify_conversation_store']['idle_timeout_seconds'] == 43200 + + def get_wecom_adapter(): return import_module('langbot.pkg.platform.sources.wecombot').WecomBotAdapter @@ -485,6 +707,7 @@ def test_wecombot_adapter_webhook_mode_normalizes_pull_config(): make_valid_event_logger(), ) + assert adapter._ws_mode is False assert adapter.config['enable-webhook'] is True assert adapter.config['PullPollTimeoutMs'] == 500 assert adapter.config['PullStreamMaxLifetimeMs'] == 300000 @@ -492,17 +715,21 @@ def test_wecombot_adapter_webhook_mode_normalizes_pull_config(): @pytest.mark.asyncio -async def test_dify_chat_skips_store_restore_when_memory_conversation_exists(): +async def test_dify_chat_reuses_runtime_binding_when_not_expired_without_store_read(monkeypatch): DifyServiceAPIRunner = get_dify_runner() app = Mock() app.logger = Mock() + app.instance_config = SimpleNamespace(data={'dify_conversation_store': {'idle_timeout_seconds': 300}}) runner = DifyServiceAPIRunner(app, make_dify_pipeline_config()) + dify_module = import_module('langbot.pkg.provider.runners.difysvapi') + monkeypatch.setattr(dify_module.time, 'time', lambda: 1000) + store = SimpleNamespace( acquire_lock=AsyncMock(return_value='owner-1'), release_lock=AsyncMock(return_value=True), - get_conversation_id=AsyncMock(return_value='conv-from-store'), - set_conversation_id=AsyncMock(), + get_conversation_binding=AsyncMock(return_value=make_runtime_binding('conv-from-store', 900, 995)), + set_conversation_binding=AsyncMock(), ) runner._get_conversation_store = Mock(return_value=store) @@ -520,7 +747,10 @@ async def fake_chat_messages(**kwargs): bot_uuid='bot-1', pipeline_uuid='pipe-1', session=SimpleNamespace( - using_conversation=SimpleNamespace(uuid='conv-memory'), + using_conversation=SimpleNamespace( + uuid='conv-memory', + dify_conversation_binding=make_runtime_binding('conv-memory', 900, 990), + ), launcher_type=provider_session.LauncherTypes.PERSON, launcher_id='user-1', ), @@ -532,25 +762,38 @@ async def fake_chat_messages(**kwargs): _ = [msg async for msg in runner._chat_messages(query)] assert observed_request['conversation_id'] == 'conv-memory' - store.get_conversation_id.assert_not_awaited() + store.get_conversation_binding.assert_not_awaited() store.acquire_lock.assert_not_awaited() store.release_lock.assert_not_awaited() - store.set_conversation_id.assert_awaited_once_with('bot-1', 'pipe-1', 'person', 'user-1', 'conv-final') + store.set_conversation_binding.assert_awaited_once_with( + 'bot-1', + 'pipe-1', + 'person', + 'user-1', + 'conv-final', + now_ts=1000, + created_at=1000, + ) assert query.session.using_conversation.uuid == 'conv-final' + assert query.session.using_conversation.dify_conversation_binding == make_runtime_binding('conv-final', 1000, 1000) @pytest.mark.asyncio -async def test_dify_chat_restores_conversation_id_from_store_and_persists_final_id(): +async def test_dify_chat_runtime_binding_expired_clears_and_restores_from_store(monkeypatch): DifyServiceAPIRunner = get_dify_runner() app = Mock() app.logger = Mock() + app.instance_config = SimpleNamespace(data={'dify_conversation_store': {'idle_timeout_seconds': 60}}) runner = DifyServiceAPIRunner(app, make_dify_pipeline_config()) + dify_module = import_module('langbot.pkg.provider.runners.difysvapi') + monkeypatch.setattr(dify_module.time, 'time', lambda: 1000) + store = SimpleNamespace( acquire_lock=AsyncMock(return_value='owner-1'), release_lock=AsyncMock(return_value=True), - get_conversation_id=AsyncMock(return_value='conv-restored'), - set_conversation_id=AsyncMock(), + get_conversation_binding=AsyncMock(return_value=make_runtime_binding('conv-restored', 900, 980)), + set_conversation_binding=AsyncMock(), ) runner._get_conversation_store = Mock(return_value=store) @@ -568,7 +811,10 @@ async def fake_chat_messages(**kwargs): bot_uuid='bot-2', pipeline_uuid='pipe-2', session=SimpleNamespace( - using_conversation=SimpleNamespace(uuid=''), + using_conversation=SimpleNamespace( + uuid='conv-expired', + dify_conversation_binding=make_runtime_binding('conv-expired', 500, 900), + ), launcher_type=provider_session.LauncherTypes.PERSON, launcher_id='user-2', ), @@ -580,25 +826,42 @@ async def fake_chat_messages(**kwargs): _ = [msg async for msg in runner._chat_messages(query)] assert observed_request['conversation_id'] == 'conv-restored' - store.get_conversation_id.assert_awaited_once_with('bot-2', 'pipe-2', 'person', 'user-2') + store.get_conversation_binding.assert_awaited_once_with('bot-2', 'pipe-2', 'person', 'user-2') store.acquire_lock.assert_not_awaited() store.release_lock.assert_not_awaited() - store.set_conversation_id.assert_awaited_once_with('bot-2', 'pipe-2', 'person', 'user-2', 'conv-final-2') + store.set_conversation_binding.assert_awaited_once_with( + 'bot-2', + 'pipe-2', + 'person', + 'user-2', + 'conv-final-2', + now_ts=1000, + created_at=1000, + ) assert query.session.using_conversation.uuid == 'conv-final-2' + assert query.session.using_conversation.dify_conversation_binding == make_runtime_binding( + 'conv-final-2', + 1000, + 1000, + ) @pytest.mark.asyncio -async def test_dify_chat_restore_miss_triggers_lock_and_second_recheck(): +async def test_dify_chat_runtime_uuid_without_metadata_keeps_runtime_when_store_binding_differs(monkeypatch): DifyServiceAPIRunner = get_dify_runner() app = Mock() app.logger = Mock() + app.instance_config = SimpleNamespace(data={'dify_conversation_store': {'idle_timeout_seconds': 60}}) runner = DifyServiceAPIRunner(app, make_dify_pipeline_config()) + dify_module = import_module('langbot.pkg.provider.runners.difysvapi') + monkeypatch.setattr(dify_module.time, 'time', lambda: 1000) + store = SimpleNamespace( - acquire_lock=AsyncMock(return_value='owner-1'), + acquire_lock=AsyncMock(return_value='owner-3'), release_lock=AsyncMock(return_value=True), - get_conversation_id=AsyncMock(side_effect=[None, 'conv-rechecked']), - set_conversation_id=AsyncMock(), + get_conversation_binding=AsyncMock(return_value=make_runtime_binding('conv-restored-3', 900, 980)), + set_conversation_binding=AsyncMock(), ) runner._get_conversation_store = Mock(return_value=store) @@ -606,20 +869,19 @@ async def test_dify_chat_restore_miss_triggers_lock_and_second_recheck(): async def fake_chat_messages(**kwargs): observed_request.update(kwargs) - observed_request.update(kwargs) - yield {'event': 'message', 'answer': 'hello', 'conversation_id': 'conv-after-recheck'} - yield {'event': 'message_end', 'conversation_id': 'conv-after-recheck'} + yield {'event': 'message', 'answer': 'hello', 'conversation_id': 'conv-memory-without-meta'} + yield {'event': 'message_end', 'conversation_id': 'conv-memory-without-meta'} runner.dify_client = SimpleNamespace(chat_messages=fake_chat_messages, base_url='https://example.com/v1') query = SimpleNamespace( adapter=SimpleNamespace(config={}), - bot_uuid='bot-3a', - pipeline_uuid='pipe-3a', + bot_uuid='bot-3', + pipeline_uuid='pipe-3', session=SimpleNamespace( - using_conversation=SimpleNamespace(uuid=''), + using_conversation=SimpleNamespace(uuid='conv-memory-without-meta'), launcher_type=provider_session.LauncherTypes.PERSON, - launcher_id='user-3a', + launcher_id='user-3', ), variables={}, pipeline_config=runner.pipeline_config, @@ -628,179 +890,784 @@ async def fake_chat_messages(**kwargs): _ = [msg async for msg in runner._chat_messages(query)] - assert observed_request['conversation_id'] == 'conv-rechecked' - assert store.get_conversation_id.await_count == 2 - store.acquire_lock.assert_awaited_once_with('bot-3a', 'pipe-3a', 'person', 'user-3a') - store.release_lock.assert_awaited_once_with('bot-3a', 'pipe-3a', 'person', 'user-3a', 'owner-1') + assert observed_request['conversation_id'] == 'conv-memory-without-meta' + store.get_conversation_binding.assert_awaited_once_with('bot-3', 'pipe-3', 'person', 'user-3') + store.acquire_lock.assert_not_awaited() @pytest.mark.asyncio -async def test_dify_chat_cold_miss_holds_same_lock_owner_until_writeback_and_release(): +async def test_dify_restore_from_store_binding_writes_runtime_metadata(monkeypatch): DifyServiceAPIRunner = get_dify_runner() app = Mock() app.logger = Mock() + app.instance_config = SimpleNamespace(data={'dify_conversation_store': {'idle_timeout_seconds': 60}}) runner = DifyServiceAPIRunner(app, make_dify_pipeline_config()) - events = [] - - async def acquire_lock(*args): - events.append('acquire') - return 'owner-cold' - - async def set_conversation_id(*args): - events.append('set') - - async def release_lock(*args): - events.append(f'release:{args[-1]}') - return True + dify_module = import_module('langbot.pkg.provider.runners.difysvapi') + monkeypatch.setattr(dify_module.time, 'time', lambda: 1000) store = SimpleNamespace( - acquire_lock=AsyncMock(side_effect=acquire_lock), - release_lock=AsyncMock(side_effect=release_lock), - get_conversation_id=AsyncMock(side_effect=[None, None]), - set_conversation_id=AsyncMock(side_effect=set_conversation_id), + acquire_lock=AsyncMock(return_value='owner-restore'), + release_lock=AsyncMock(return_value=True), + get_conversation_binding=AsyncMock(return_value=make_runtime_binding('conv-restored-meta', 700, 980)), ) runner._get_conversation_store = Mock(return_value=store) - observed_request = {} - - async def fake_chat_messages(**kwargs): - observed_request.update(kwargs) - events.append('request') - yield {'event': 'message', 'answer': 'hello', 'conversation_id': 'conv-cold-final'} - yield {'event': 'message_end', 'conversation_id': 'conv-cold-final'} - - runner.dify_client = SimpleNamespace(chat_messages=fake_chat_messages, base_url='https://example.com/v1') - query = SimpleNamespace( - adapter=SimpleNamespace(config={}), - bot_uuid='bot-4a', - pipeline_uuid='pipe-4a', + bot_uuid='bot-restore', + pipeline_uuid='pipe-restore', session=SimpleNamespace( using_conversation=SimpleNamespace(uuid=''), launcher_type=provider_session.LauncherTypes.PERSON, - launcher_id='user-4a', + launcher_id='user-restore', ), - variables={}, - pipeline_config=runner.pipeline_config, - user_message=provider_message.Message(role='user', content='hello'), ) - _ = [msg async for msg in runner._chat_messages(query)] + conversation_id, lock_owner = await runner._restore_conversation_id_if_needed(query) - assert observed_request['conversation_id'] is None - assert query.session.using_conversation.uuid == 'conv-cold-final' - assert events == ['acquire', 'request', 'set', 'release:owner-cold'] + assert lock_owner is None + assert conversation_id == 'conv-restored-meta' + assert query.session.using_conversation.uuid == 'conv-restored-meta' + assert query.session.using_conversation.dify_conversation_binding == make_runtime_binding( + 'conv-restored-meta', + 700, + 980, + ) @pytest.mark.asyncio -async def test_dify_chat_lock_failures_do_not_break_request_path(): +async def test_dify_restore_from_store_binding_ignores_expired_payload(monkeypatch): DifyServiceAPIRunner = get_dify_runner() app = Mock() app.logger = Mock() + app.instance_config = SimpleNamespace(data={'dify_conversation_store': {'idle_timeout_seconds': 60}}) runner = DifyServiceAPIRunner(app, make_dify_pipeline_config()) - acquire_fail_store = SimpleNamespace( - acquire_lock=AsyncMock(side_effect=RuntimeError('lock acquire failed')), - release_lock=AsyncMock(return_value=True), - get_conversation_id=AsyncMock(return_value=None), - set_conversation_id=AsyncMock(), - ) - runner._get_conversation_store = Mock(return_value=acquire_fail_store) - - async def fake_chat_messages_case1(**kwargs): - yield {'event': 'message', 'answer': 'ok', 'conversation_id': 'conv-acquire-failed'} - yield {'event': 'message_end', 'conversation_id': 'conv-acquire-failed'} - - runner.dify_client = SimpleNamespace(chat_messages=fake_chat_messages_case1, base_url='https://example.com/v1') + dify_module = import_module('langbot.pkg.provider.runners.difysvapi') + monkeypatch.setattr(dify_module.time, 'time', lambda: 1000) - query_case1 = SimpleNamespace( - adapter=SimpleNamespace(config={}), - bot_uuid='bot-4b', - pipeline_uuid='pipe-4b', - session=SimpleNamespace( - using_conversation=SimpleNamespace(uuid=''), - launcher_type=provider_session.LauncherTypes.PERSON, - launcher_id='user-4b', + store = SimpleNamespace( + acquire_lock=AsyncMock(return_value='owner-expired'), + release_lock=AsyncMock(return_value=True), + get_conversation_binding=AsyncMock( + side_effect=[ + make_runtime_binding('conv-expired-store', 100, 700), + make_runtime_binding('conv-expired-store', 100, 700), + ] ), - variables={}, - pipeline_config=runner.pipeline_config, - user_message=provider_message.Message(role='user', content='hello'), - ) - - msgs_case1 = [msg async for msg in runner._chat_messages(query_case1)] - assert len(msgs_case1) == 1 - assert query_case1.session.using_conversation.uuid == 'conv-acquire-failed' - - release_fail_store = SimpleNamespace( - acquire_lock=AsyncMock(return_value='owner-release-fail'), - release_lock=AsyncMock(side_effect=RuntimeError('lock release failed')), - get_conversation_id=AsyncMock(side_effect=[None, None]), - set_conversation_id=AsyncMock(), ) - runner._get_conversation_store = Mock(return_value=release_fail_store) - - async def fake_chat_messages_case2(**kwargs): - yield {'event': 'message', 'answer': 'ok', 'conversation_id': 'conv-release-failed'} - yield {'event': 'message_end', 'conversation_id': 'conv-release-failed'} - - runner.dify_client = SimpleNamespace(chat_messages=fake_chat_messages_case2, base_url='https://example.com/v1') + runner._get_conversation_store = Mock(return_value=store) - query_case2 = SimpleNamespace( - adapter=SimpleNamespace(config={}), - bot_uuid='bot-4c', - pipeline_uuid='pipe-4c', + query = SimpleNamespace( + bot_uuid='bot-expired', + pipeline_uuid='pipe-expired', session=SimpleNamespace( using_conversation=SimpleNamespace(uuid=''), launcher_type=provider_session.LauncherTypes.PERSON, - launcher_id='user-4c', + launcher_id='user-expired', ), - variables={}, - pipeline_config=runner.pipeline_config, - user_message=provider_message.Message(role='user', content='hello'), ) - msgs_case2 = [msg async for msg in runner._chat_messages(query_case2)] - assert len(msgs_case2) == 1 - assert query_case2.session.using_conversation.uuid == 'conv-release-failed' - release_fail_store.release_lock.assert_awaited_once_with( - 'bot-4c', - 'pipe-4c', - 'person', - 'user-4c', - 'owner-release-fail', - ) + conversation_id, lock_owner = await runner._restore_conversation_id_if_needed(query) + + assert conversation_id is None + assert lock_owner == 'owner-expired' + assert query.session.using_conversation.uuid == '' @pytest.mark.asyncio -async def test_dify_chat_store_write_failure_does_not_break_request(): +async def test_dify_restore_uses_valid_runtime_binding_when_store_unavailable(monkeypatch): DifyServiceAPIRunner = get_dify_runner() app = Mock() app.logger = Mock() + app.instance_config = SimpleNamespace(data={'dify_conversation_store': {'idle_timeout_seconds': 300}}) runner = DifyServiceAPIRunner(app, make_dify_pipeline_config()) + dify_module = import_module('langbot.pkg.provider.runners.difysvapi') + monkeypatch.setattr(dify_module.time, 'time', lambda: 1000) + + runner._get_conversation_store = Mock(return_value=None) + + query = SimpleNamespace( + bot_uuid='bot-runtime-none-store', + pipeline_uuid='pipe-runtime-none-store', + session=SimpleNamespace( + using_conversation=SimpleNamespace( + uuid='conv-runtime-valid', + dify_conversation_binding=make_runtime_binding('conv-runtime-valid', 900, 990), + ), + launcher_type=provider_session.LauncherTypes.PERSON, + launcher_id='user-runtime-none-store', + ), + ) + + conversation_id, lock_owner = await runner._restore_conversation_id_if_needed(query) + + assert conversation_id == 'conv-runtime-valid' + assert lock_owner is None + assert query.session.using_conversation.uuid == 'conv-runtime-valid' + assert query.session.using_conversation.dify_conversation_binding == make_runtime_binding( + 'conv-runtime-valid', + 900, + 990, + ) + + +@pytest.mark.asyncio +async def test_dify_restore_store_unavailable_keeps_runtime_uuid_without_metadata(monkeypatch): + DifyServiceAPIRunner = get_dify_runner() + app = Mock() + app.logger = Mock() + app.instance_config = SimpleNamespace(data={'dify_conversation_store': {'idle_timeout_seconds': 60}}) + runner = DifyServiceAPIRunner(app, make_dify_pipeline_config()) + + dify_module = import_module('langbot.pkg.provider.runners.difysvapi') + monkeypatch.setattr(dify_module.time, 'time', lambda: 1000) + + runner._get_conversation_store = Mock(return_value=None) + + query = SimpleNamespace( + bot_uuid='bot-runtime-without-meta-none-store', + pipeline_uuid='pipe-runtime-without-meta-none-store', + session=SimpleNamespace( + using_conversation=SimpleNamespace(uuid='conv-runtime-without-meta'), + launcher_type=provider_session.LauncherTypes.PERSON, + launcher_id='user-runtime-without-meta-none-store', + ), + ) + + conversation_id, lock_owner = await runner._restore_conversation_id_if_needed(query) + + assert conversation_id == 'conv-runtime-without-meta' + assert lock_owner is None + assert query.session.using_conversation.uuid == 'conv-runtime-without-meta' + assert getattr(query.session.using_conversation, 'dify_conversation_binding', None) is None + + +@pytest.mark.asyncio +async def test_dify_restore_store_unavailable_fail_closed_for_expired_runtime_binding(monkeypatch): + DifyServiceAPIRunner = get_dify_runner() + app = Mock() + app.logger = Mock() + app.instance_config = SimpleNamespace(data={'dify_conversation_store': {'idle_timeout_seconds': 60}}) + runner = DifyServiceAPIRunner(app, make_dify_pipeline_config()) + + dify_module = import_module('langbot.pkg.provider.runners.difysvapi') + monkeypatch.setattr(dify_module.time, 'time', lambda: 1000) + + runner._get_conversation_store = Mock(return_value=None) + + query = SimpleNamespace( + bot_uuid='bot-runtime-expired-none-store', + pipeline_uuid='pipe-runtime-expired-none-store', + session=SimpleNamespace( + using_conversation=SimpleNamespace( + uuid='conv-runtime-expired', + dify_conversation_binding=make_runtime_binding('conv-runtime-expired', 100, 700), + ), + launcher_type=provider_session.LauncherTypes.PERSON, + launcher_id='user-runtime-expired-none-store', + ), + ) + + conversation_id, lock_owner = await runner._restore_conversation_id_if_needed(query) + + assert conversation_id is None + assert lock_owner is None + assert query.session.using_conversation.uuid is None + assert getattr(query.session.using_conversation, 'dify_conversation_binding', None) is None + + +@pytest.mark.asyncio +async def test_dify_restore_malformed_store_binding_keeps_runtime_uuid_without_metadata(monkeypatch): + DifyServiceAPIRunner = get_dify_runner() + app = Mock() + app.logger = Mock() + app.instance_config = SimpleNamespace(data={'dify_conversation_store': {'idle_timeout_seconds': 60}}) + runner = DifyServiceAPIRunner(app, make_dify_pipeline_config()) + + dify_module = import_module('langbot.pkg.provider.runners.difysvapi') + monkeypatch.setattr(dify_module.time, 'time', lambda: 1000) + + malformed_payload = { + 'uuid': 'conv-malformed', + 'created_at': 'oops', + 'last_active_at': 900, + 'policy_version': 2, + } + store = SimpleNamespace( + acquire_lock=AsyncMock(return_value='owner-malformed'), + release_lock=AsyncMock(return_value=True), + get_conversation_binding=AsyncMock(side_effect=[malformed_payload, malformed_payload]), + ) + runner._get_conversation_store = Mock(return_value=store) + + query = SimpleNamespace( + bot_uuid='bot-malformed', + pipeline_uuid='pipe-malformed', + session=SimpleNamespace( + using_conversation=SimpleNamespace(uuid='conv-orphan-runtime'), + launcher_type=provider_session.LauncherTypes.PERSON, + launcher_id='user-malformed', + ), + ) + + conversation_id, lock_owner = await runner._restore_conversation_id_if_needed(query) + + assert conversation_id == 'conv-orphan-runtime' + assert lock_owner is None + assert query.session.using_conversation.uuid == 'conv-orphan-runtime' + assert getattr(query.session.using_conversation, 'dify_conversation_binding', None) is None + assert store.get_conversation_binding.await_count == 1 + store.acquire_lock.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_dify_restore_id_only_store_fallback_marks_binding_as_policy_v1(monkeypatch): + DifyServiceAPIRunner = get_dify_runner() + app = Mock() + app.logger = Mock() + app.instance_config = SimpleNamespace(data={'dify_conversation_store': {'idle_timeout_seconds': 60}}) + runner = DifyServiceAPIRunner(app, make_dify_pipeline_config()) + + dify_module = import_module('langbot.pkg.provider.runners.difysvapi') + monkeypatch.setattr(dify_module.time, 'time', lambda: 1000) + + store = SimpleNamespace( + acquire_lock=AsyncMock(return_value='owner-legacy'), + release_lock=AsyncMock(return_value=True), + get_conversation_id=AsyncMock(return_value='conv-legacy-only-id'), + ) + runner._get_conversation_store = Mock(return_value=store) + + query = SimpleNamespace( + bot_uuid='bot-legacy', + pipeline_uuid='pipe-legacy', + session=SimpleNamespace( + using_conversation=SimpleNamespace(uuid=''), + launcher_type=provider_session.LauncherTypes.PERSON, + launcher_id='user-legacy', + ), + ) + + conversation_id, lock_owner = await runner._restore_conversation_id_if_needed(query) + + assert conversation_id == 'conv-legacy-only-id' + assert lock_owner is None + assert query.session.using_conversation.uuid == 'conv-legacy-only-id' + assert query.session.using_conversation.dify_conversation_binding == { + 'uuid': 'conv-legacy-only-id', + 'created_at': 1000, + 'last_active_at': 1000, + 'policy_version': 1, + } + + +@pytest.mark.asyncio +async def test_dify_chat_restore_miss_triggers_lock_and_second_recheck(): + DifyServiceAPIRunner = get_dify_runner() + app = Mock() + app.logger = Mock() + runner = DifyServiceAPIRunner(app, make_dify_pipeline_config()) + + store = SimpleNamespace( + acquire_lock=AsyncMock(return_value='owner-1'), + release_lock=AsyncMock(return_value=True), + get_conversation_id=AsyncMock(side_effect=[None, 'conv-rechecked']), + set_conversation_id=AsyncMock(), + ) + runner._get_conversation_store = Mock(return_value=store) + + observed_request = {} + + async def fake_chat_messages(**kwargs): + observed_request.update(kwargs) + observed_request.update(kwargs) + yield {'event': 'message', 'answer': 'hello', 'conversation_id': 'conv-after-recheck'} + yield {'event': 'message_end', 'conversation_id': 'conv-after-recheck'} + + runner.dify_client = SimpleNamespace(chat_messages=fake_chat_messages, base_url='https://example.com/v1') + + query = SimpleNamespace( + adapter=SimpleNamespace(config={}), + bot_uuid='bot-3a', + pipeline_uuid='pipe-3a', + session=SimpleNamespace( + using_conversation=SimpleNamespace(uuid=''), + launcher_type=provider_session.LauncherTypes.PERSON, + launcher_id='user-3a', + ), + variables={}, + pipeline_config=runner.pipeline_config, + user_message=provider_message.Message(role='user', content='hello'), + ) + + _ = [msg async for msg in runner._chat_messages(query)] + + assert observed_request['conversation_id'] == 'conv-rechecked' + assert store.get_conversation_id.await_count == 2 + store.acquire_lock.assert_awaited_once_with('bot-3a', 'pipe-3a', 'person', 'user-3a') + store.release_lock.assert_awaited_once_with('bot-3a', 'pipe-3a', 'person', 'user-3a', 'owner-1') + + +@pytest.mark.asyncio +async def test_dify_chat_cold_miss_holds_same_lock_owner_until_writeback_and_release(): + DifyServiceAPIRunner = get_dify_runner() + app = Mock() + app.logger = Mock() + runner = DifyServiceAPIRunner(app, make_dify_pipeline_config()) + + events = [] + + async def acquire_lock(*args): + events.append('acquire') + return 'owner-cold' + + async def set_conversation_id(*args): + events.append('set') + + async def release_lock(*args): + events.append(f'release:{args[-1]}') + return True + + store = SimpleNamespace( + acquire_lock=AsyncMock(side_effect=acquire_lock), + release_lock=AsyncMock(side_effect=release_lock), + get_conversation_id=AsyncMock(side_effect=[None, None]), + set_conversation_id=AsyncMock(side_effect=set_conversation_id), + ) + runner._get_conversation_store = Mock(return_value=store) + + observed_request = {} + + async def fake_chat_messages(**kwargs): + observed_request.update(kwargs) + events.append('request') + yield {'event': 'message', 'answer': 'hello', 'conversation_id': 'conv-cold-final'} + yield {'event': 'message_end', 'conversation_id': 'conv-cold-final'} + + runner.dify_client = SimpleNamespace(chat_messages=fake_chat_messages, base_url='https://example.com/v1') + + query = SimpleNamespace( + adapter=SimpleNamespace(config={}), + bot_uuid='bot-4a', + pipeline_uuid='pipe-4a', + session=SimpleNamespace( + using_conversation=SimpleNamespace(uuid=''), + launcher_type=provider_session.LauncherTypes.PERSON, + launcher_id='user-4a', + ), + variables={}, + pipeline_config=runner.pipeline_config, + user_message=provider_message.Message(role='user', content='hello'), + ) + + _ = [msg async for msg in runner._chat_messages(query)] + + assert observed_request['conversation_id'] is None + assert query.session.using_conversation.uuid == 'conv-cold-final' + assert events == ['acquire', 'request', 'set', 'release:owner-cold'] + + +@pytest.mark.asyncio +async def test_dify_chat_lock_failures_do_not_break_request_path(): + DifyServiceAPIRunner = get_dify_runner() + app = Mock() + app.logger = Mock() + runner = DifyServiceAPIRunner(app, make_dify_pipeline_config()) + + acquire_fail_store = SimpleNamespace( + acquire_lock=AsyncMock(side_effect=RuntimeError('lock acquire failed')), + release_lock=AsyncMock(return_value=True), + get_conversation_id=AsyncMock(return_value=None), + set_conversation_id=AsyncMock(), + ) + runner._get_conversation_store = Mock(return_value=acquire_fail_store) + + async def fake_chat_messages_case1(**kwargs): + yield {'event': 'message', 'answer': 'ok', 'conversation_id': 'conv-acquire-failed'} + yield {'event': 'message_end', 'conversation_id': 'conv-acquire-failed'} + + runner.dify_client = SimpleNamespace(chat_messages=fake_chat_messages_case1, base_url='https://example.com/v1') + + query_case1 = SimpleNamespace( + adapter=SimpleNamespace(config={}), + bot_uuid='bot-4b', + pipeline_uuid='pipe-4b', + session=SimpleNamespace( + using_conversation=SimpleNamespace(uuid=''), + launcher_type=provider_session.LauncherTypes.PERSON, + launcher_id='user-4b', + ), + variables={}, + pipeline_config=runner.pipeline_config, + user_message=provider_message.Message(role='user', content='hello'), + ) + + msgs_case1 = [msg async for msg in runner._chat_messages(query_case1)] + assert len(msgs_case1) == 1 + assert query_case1.session.using_conversation.uuid == 'conv-acquire-failed' + + release_fail_store = SimpleNamespace( + acquire_lock=AsyncMock(return_value='owner-release-fail'), + release_lock=AsyncMock(side_effect=RuntimeError('lock release failed')), + get_conversation_id=AsyncMock(side_effect=[None, None]), + set_conversation_id=AsyncMock(), + ) + runner._get_conversation_store = Mock(return_value=release_fail_store) + + async def fake_chat_messages_case2(**kwargs): + yield {'event': 'message', 'answer': 'ok', 'conversation_id': 'conv-release-failed'} + yield {'event': 'message_end', 'conversation_id': 'conv-release-failed'} + + runner.dify_client = SimpleNamespace(chat_messages=fake_chat_messages_case2, base_url='https://example.com/v1') + + query_case2 = SimpleNamespace( + adapter=SimpleNamespace(config={}), + bot_uuid='bot-4c', + pipeline_uuid='pipe-4c', + session=SimpleNamespace( + using_conversation=SimpleNamespace(uuid=''), + launcher_type=provider_session.LauncherTypes.PERSON, + launcher_id='user-4c', + ), + variables={}, + pipeline_config=runner.pipeline_config, + user_message=provider_message.Message(role='user', content='hello'), + ) + + msgs_case2 = [msg async for msg in runner._chat_messages(query_case2)] + assert len(msgs_case2) == 1 + assert query_case2.session.using_conversation.uuid == 'conv-release-failed' + release_fail_store.release_lock.assert_awaited_once_with( + 'bot-4c', + 'pipe-4c', + 'person', + 'user-4c', + 'owner-release-fail', + ) + + +@pytest.mark.asyncio +async def test_dify_chat_store_write_failure_does_not_break_request(): + DifyServiceAPIRunner = get_dify_runner() + app = Mock() + app.logger = Mock() + runner = DifyServiceAPIRunner(app, make_dify_pipeline_config()) + + store = SimpleNamespace( + acquire_lock=AsyncMock(return_value='owner-1'), + release_lock=AsyncMock(return_value=True), + get_conversation_id=AsyncMock(return_value='conv-restored-4'), + set_conversation_id=AsyncMock(side_effect=RuntimeError('write failed')), + ) + runner._get_conversation_store = Mock(return_value=store) + + async def fake_chat_messages(**kwargs): + yield {'event': 'message', 'answer': 'hello', 'conversation_id': 'conv-after-write-fail'} + yield {'event': 'message_end', 'conversation_id': 'conv-after-write-fail'} + + runner.dify_client = SimpleNamespace(chat_messages=fake_chat_messages, base_url='https://example.com/v1') + + query = SimpleNamespace( + adapter=SimpleNamespace(config={}), + bot_uuid='bot-4', + pipeline_uuid='pipe-4', + session=SimpleNamespace( + using_conversation=SimpleNamespace(uuid=''), + launcher_type=provider_session.LauncherTypes.PERSON, + launcher_id='user-4', + ), + variables={}, + pipeline_config=runner.pipeline_config, + user_message=provider_message.Message(role='user', content='hello'), + ) + + messages = [msg async for msg in runner._chat_messages(query)] + + assert len(messages) == 1 + assert query.session.using_conversation.uuid == 'conv-after-write-fail' + + +@pytest.mark.asyncio +async def test_dify_chat_lock_contention_uses_bounded_wait_and_recheck(monkeypatch): + dify_module = import_module('langbot.pkg.provider.runners.difysvapi') + DifyServiceAPIRunner = dify_module.DifyServiceAPIRunner + app = Mock() + app.logger = Mock() + app.instance_config = SimpleNamespace( + data={ + 'dify_conversation_store': { + 'contention_wait_retry_count': 4, + 'contention_wait_interval_ms': 50, + } + } + ) + runner = DifyServiceAPIRunner(app, make_dify_pipeline_config()) + + sleep_calls = [] + + async def fake_sleep(seconds): + sleep_calls.append(seconds) + + monkeypatch.setattr(dify_module.asyncio, 'sleep', fake_sleep) + + store = SimpleNamespace( + acquire_lock=AsyncMock(return_value=None), + release_lock=AsyncMock(return_value=True), + get_conversation_id=AsyncMock(side_effect=[None, None, None, 'conv-after-wait']), + set_conversation_id=AsyncMock(), + ) + runner._get_conversation_store = Mock(return_value=store) + + observed_request = {} + + async def fake_chat_messages(**kwargs): + observed_request.update(kwargs) + yield {'event': 'message', 'answer': 'ok', 'conversation_id': 'conv-final-contention'} + yield {'event': 'message_end', 'conversation_id': 'conv-final-contention'} + + runner.dify_client = SimpleNamespace(chat_messages=fake_chat_messages, base_url='https://example.com/v1') + + query = SimpleNamespace( + adapter=SimpleNamespace(config={}), + bot_uuid='bot-contention', + pipeline_uuid='pipe-contention', + session=SimpleNamespace( + using_conversation=SimpleNamespace(uuid=''), + launcher_type=provider_session.LauncherTypes.PERSON, + launcher_id='user-contention', + ), + variables={}, + pipeline_config=runner.pipeline_config, + user_message=provider_message.Message(role='user', content='hello'), + ) + + _ = [msg async for msg in runner._chat_messages(query)] + + assert observed_request['conversation_id'] == 'conv-after-wait' + store.acquire_lock.assert_awaited_once_with('bot-contention', 'pipe-contention', 'person', 'user-contention') + assert store.get_conversation_id.await_count == 4 + assert sleep_calls == [0.05, 0.05, 0.05] + + +@pytest.mark.asyncio +async def test_dify_chat_lock_contention_unresolved_gracefully_degrades_to_new_conversation(monkeypatch): + dify_module = import_module('langbot.pkg.provider.runners.difysvapi') + DifyServiceAPIRunner = dify_module.DifyServiceAPIRunner + app = Mock() + app.logger = Mock() + app.instance_config = SimpleNamespace( + data={ + 'dify_conversation_store': { + 'contention_wait_retry_count': 2, + 'contention_wait_interval_ms': 50, + } + } + ) + runner = DifyServiceAPIRunner(app, make_dify_pipeline_config()) + + async def fake_sleep(_seconds): + return None + + monkeypatch.setattr(dify_module.asyncio, 'sleep', fake_sleep) + + store = SimpleNamespace( + acquire_lock=AsyncMock(return_value=None), + release_lock=AsyncMock(return_value=True), + get_conversation_id=AsyncMock(side_effect=[None, None, None]), + set_conversation_id=AsyncMock(), + ) + runner._get_conversation_store = Mock(return_value=store) + + observed_request = {} + + async def fake_chat_messages(**kwargs): + observed_request.update(kwargs) + yield {'event': 'message', 'answer': 'ok', 'conversation_id': 'conv-after-contention-fallback'} + yield {'event': 'message_end', 'conversation_id': 'conv-after-contention-fallback'} + + runner.dify_client = SimpleNamespace(chat_messages=fake_chat_messages, base_url='https://example.com/v1') + + query = SimpleNamespace( + adapter=SimpleNamespace(config={}), + bot_uuid='bot-contention-fail', + pipeline_uuid='pipe-contention-fail', + session=SimpleNamespace( + using_conversation=SimpleNamespace(uuid=''), + launcher_type=provider_session.LauncherTypes.PERSON, + launcher_id='user-contention-fail', + ), + variables={}, + pipeline_config=runner.pipeline_config, + user_message=provider_message.Message(role='user', content='hello'), + ) + + msgs = [msg async for msg in runner._chat_messages(query)] + + assert len(msgs) == 1 + assert observed_request['conversation_id'] is None + assert query.session.using_conversation.uuid == 'conv-after-contention-fallback' + + +@pytest.mark.asyncio +async def test_dify_agent_restore_and_persist_conversation_id(): + DifyServiceAPIRunner = get_dify_runner() + app = Mock() + app.logger = Mock() + config = make_dify_pipeline_config() + config['ai']['dify-service-api']['app-type'] = 'agent' + runner = DifyServiceAPIRunner(app, config) + + store = SimpleNamespace( + acquire_lock=AsyncMock(return_value='owner-agent'), + release_lock=AsyncMock(return_value=True), + get_conversation_id=AsyncMock(return_value='conv-agent-restored'), + set_conversation_id=AsyncMock(), + ) + runner._get_conversation_store = Mock(return_value=store) + + observed_request = {} + + async def fake_agent_chat_messages(**kwargs): + observed_request.update(kwargs) + yield {'event': 'agent_message', 'answer': 'hi', 'conversation_id': 'conv-agent-final'} + yield {'event': 'message_end', 'conversation_id': 'conv-agent-final'} + + runner.dify_client = SimpleNamespace(chat_messages=fake_agent_chat_messages, base_url='https://example.com/v1') + + query = SimpleNamespace( + adapter=SimpleNamespace(config={}), + bot_uuid='bot-agent', + pipeline_uuid='pipe-agent', + session=SimpleNamespace( + using_conversation=SimpleNamespace(uuid=''), + launcher_type=provider_session.LauncherTypes.PERSON, + launcher_id='user-agent', + ), + variables={}, + pipeline_config=runner.pipeline_config, + user_message=provider_message.Message(role='user', content='hello'), + ) + + msgs = [msg async for msg in runner._agent_chat_messages(query)] + + assert len(msgs) == 1 + assert observed_request['conversation_id'] == 'conv-agent-restored' + store.set_conversation_id.assert_awaited_once_with( + 'bot-agent', + 'pipe-agent', + 'person', + 'user-agent', + 'conv-agent-final', + ) + + +@pytest.mark.asyncio +async def test_dify_chat_stream_chunk_restore_and_persist_conversation_id(): + DifyServiceAPIRunner = get_dify_runner() + app = Mock() + app.logger = Mock() + runner = DifyServiceAPIRunner(app, make_dify_pipeline_config()) + + store = SimpleNamespace( + acquire_lock=AsyncMock(return_value='owner-stream'), + release_lock=AsyncMock(return_value=True), + get_conversation_id=AsyncMock(return_value='conv-stream-restored'), + set_conversation_id=AsyncMock(), + ) + runner._get_conversation_store = Mock(return_value=store) + + observed_request = {} + + async def fake_chat_messages(**kwargs): + observed_request.update(kwargs) + yield {'event': 'message', 'answer': '你', 'conversation_id': 'conv-stream-final'} + yield {'event': 'message_end', 'conversation_id': 'conv-stream-final'} + + runner.dify_client = SimpleNamespace(chat_messages=fake_chat_messages, base_url='https://example.com/v1') + + query = SimpleNamespace( + adapter=SimpleNamespace(config={}), + bot_uuid='bot-stream', + pipeline_uuid='pipe-stream', + session=SimpleNamespace( + using_conversation=SimpleNamespace(uuid=''), + launcher_type=provider_session.LauncherTypes.PERSON, + launcher_id='user-stream', + ), + variables={}, + pipeline_config=runner.pipeline_config, + user_message=provider_message.Message(role='user', content='hello'), + ) + + chunks = [chunk async for chunk in runner._chat_messages_chunk(query)] + + assert chunks[-1].is_final is True + assert observed_request['conversation_id'] == 'conv-stream-restored' + store.set_conversation_id.assert_awaited_once_with( + 'bot-stream', + 'pipe-stream', + 'person', + 'user-stream', + 'conv-stream-final', + ) + + +@pytest.mark.asyncio +async def test_dify_chat_invalid_conversation_retries_once_with_restored_binding_from_contention_wait(monkeypatch): + dify_module = import_module('langbot.pkg.provider.runners.difysvapi') + DifyServiceAPIRunner = dify_module.DifyServiceAPIRunner + dify_errors = import_module('langbot.libs.dify_service_api.v1.errors') + app = Mock() + app.logger = Mock() + app.instance_config = SimpleNamespace( + data={ + 'dify_conversation_store': { + 'contention_wait_retry_count': 3, + 'contention_wait_interval_ms': 50, + } + } + ) + runner = DifyServiceAPIRunner(app, make_dify_pipeline_config()) + + async def fake_sleep(_seconds): + return None + + monkeypatch.setattr(dify_module.asyncio, 'sleep', fake_sleep) + store = SimpleNamespace( - acquire_lock=AsyncMock(return_value='owner-1'), + acquire_lock=AsyncMock(return_value=None), release_lock=AsyncMock(return_value=True), - get_conversation_id=AsyncMock(return_value='conv-restored-4'), - set_conversation_id=AsyncMock(side_effect=RuntimeError('write failed')), + get_conversation_id=AsyncMock(side_effect=[None, None, 'conv-recovered-after-invalid']), + set_conversation_id=AsyncMock(), + delete_conversation_id=AsyncMock(), ) runner._get_conversation_store = Mock(return_value=store) + now_ts = int(time.time()) + + request_args = [] async def fake_chat_messages(**kwargs): - yield {'event': 'message', 'answer': 'hello', 'conversation_id': 'conv-after-write-fail'} - yield {'event': 'message_end', 'conversation_id': 'conv-after-write-fail'} + request_args.append(kwargs) + if len(request_args) == 1: + raise dify_errors.DifyAPIError('400 {"code":"conversation_not_found","message":"Conversation not found"}') + yield {'event': 'message', 'answer': 'retry-ok', 'conversation_id': 'conv-after-retry'} + yield {'event': 'message_end', 'conversation_id': 'conv-after-retry'} runner.dify_client = SimpleNamespace(chat_messages=fake_chat_messages, base_url='https://example.com/v1') query = SimpleNamespace( adapter=SimpleNamespace(config={}), - bot_uuid='bot-4', - pipeline_uuid='pipe-4', + bot_uuid='bot-invalid-retry', + pipeline_uuid='pipe-invalid-retry', session=SimpleNamespace( - using_conversation=SimpleNamespace(uuid=''), + using_conversation=SimpleNamespace( + uuid='conv-stale', + dify_conversation_binding=make_runtime_binding('conv-stale', now_ts - 10, now_ts - 5), + ), launcher_type=provider_session.LauncherTypes.PERSON, - launcher_id='user-4', + launcher_id='user-invalid-retry', ), variables={}, pipeline_config=runner.pipeline_config, @@ -810,81 +1677,240 @@ async def fake_chat_messages(**kwargs): messages = [msg async for msg in runner._chat_messages(query)] assert len(messages) == 1 - assert query.session.using_conversation.uuid == 'conv-after-write-fail' + assert messages[0].content == 'retry-ok' + assert len(request_args) == 2 + assert request_args[0]['conversation_id'] == 'conv-stale' + assert request_args[1]['conversation_id'] == 'conv-recovered-after-invalid' + assert request_args[1]['inputs']['conversation_id'] == 'conv-recovered-after-invalid' + store.acquire_lock.assert_awaited_once_with('bot-invalid-retry', 'pipe-invalid-retry', 'person', 'user-invalid-retry') + store.delete_conversation_id.assert_awaited_once_with( + 'bot-invalid-retry', + 'pipe-invalid-retry', + 'person', + 'user-invalid-retry', + ) + store.set_conversation_id.assert_awaited_once_with( + 'bot-invalid-retry', + 'pipe-invalid-retry', + 'person', + 'user-invalid-retry', + 'conv-after-retry', + ) @pytest.mark.asyncio -async def test_dify_chat_lock_contention_uses_bounded_wait_and_recheck(monkeypatch): - dify_module = import_module('langbot.pkg.provider.runners.difysvapi') - DifyServiceAPIRunner = dify_module.DifyServiceAPIRunner +async def test_dify_chat_invalid_conversation_retry_reacquires_lock_before_empty_retry(): + DifyServiceAPIRunner = get_dify_runner() + dify_errors = import_module('langbot.libs.dify_service_api.v1.errors') app = Mock() app.logger = Mock() - app.instance_config = SimpleNamespace( - data={ - 'dify_conversation_store': { - 'contention_wait_retry_count': 4, - 'contention_wait_interval_ms': 50, - } - } + runner = DifyServiceAPIRunner(app, make_dify_pipeline_config()) + + store = SimpleNamespace( + acquire_lock=AsyncMock(return_value='owner-invalid-retry-lock-holder'), + release_lock=AsyncMock(return_value=True), + get_conversation_id=AsyncMock(side_effect=[None, None]), + set_conversation_id=AsyncMock(), + delete_conversation_id=AsyncMock(), + ) + runner._get_conversation_store = Mock(return_value=store) + now_ts = int(time.time()) + + request_args = [] + + async def fake_chat_messages(**kwargs): + request_args.append(kwargs) + if len(request_args) == 1: + raise dify_errors.DifyAPIError('400 {"code":"conversation_not_found","message":"Conversation not found"}') + yield {'event': 'message', 'answer': 'retry-ok-lock-holder', 'conversation_id': 'conv-after-lock-holder-retry'} + yield {'event': 'message_end', 'conversation_id': 'conv-after-lock-holder-retry'} + + runner.dify_client = SimpleNamespace(chat_messages=fake_chat_messages, base_url='https://example.com/v1') + + query = SimpleNamespace( + adapter=SimpleNamespace(config={}), + bot_uuid='bot-invalid-retry-lock-holder', + pipeline_uuid='pipe-invalid-retry-lock-holder', + session=SimpleNamespace( + using_conversation=SimpleNamespace( + uuid='conv-stale-lock-holder', + dify_conversation_binding=make_runtime_binding('conv-stale-lock-holder', now_ts - 10, now_ts - 5), + ), + launcher_type=provider_session.LauncherTypes.PERSON, + launcher_id='user-invalid-retry-lock-holder', + ), + variables={}, + pipeline_config=runner.pipeline_config, + user_message=provider_message.Message(role='user', content='hello'), + ) + + messages = [msg async for msg in runner._chat_messages(query)] + + assert len(messages) == 1 + assert messages[0].content == 'retry-ok-lock-holder' + assert len(request_args) == 2 + assert request_args[0]['conversation_id'] == 'conv-stale-lock-holder' + assert request_args[1]['conversation_id'] == '' + assert request_args[1]['inputs']['conversation_id'] == '' + store.acquire_lock.assert_awaited_once_with( + 'bot-invalid-retry-lock-holder', + 'pipe-invalid-retry-lock-holder', + 'person', + 'user-invalid-retry-lock-holder', + ) + store.release_lock.assert_awaited_once_with( + 'bot-invalid-retry-lock-holder', + 'pipe-invalid-retry-lock-holder', + 'person', + 'user-invalid-retry-lock-holder', + 'owner-invalid-retry-lock-holder', ) + + +@pytest.mark.asyncio +async def test_dify_chat_invalid_conversation_second_failure_raises_after_single_retry(): + DifyServiceAPIRunner = get_dify_runner() + dify_errors = import_module('langbot.libs.dify_service_api.v1.errors') + app = Mock() + app.logger = Mock() runner = DifyServiceAPIRunner(app, make_dify_pipeline_config()) - sleep_calls = [] + store = SimpleNamespace( + acquire_lock=AsyncMock(return_value='owner-invalid-double-fail'), + release_lock=AsyncMock(return_value=True), + get_conversation_id=AsyncMock(side_effect=[None, None]), + set_conversation_id=AsyncMock(), + delete_conversation_id=AsyncMock(), + ) + runner._get_conversation_store = Mock(return_value=store) + now_ts = int(time.time()) - async def fake_sleep(seconds): - sleep_calls.append(seconds) + request_args = [] - monkeypatch.setattr(dify_module.asyncio, 'sleep', fake_sleep) + async def fake_chat_messages(**kwargs): + request_args.append(kwargs) + raise dify_errors.DifyAPIError('400 {"code":"conversation_not_found","message":"Conversation not found"}') + yield # pragma: no cover + + runner.dify_client = SimpleNamespace(chat_messages=fake_chat_messages, base_url='https://example.com/v1') + + query = SimpleNamespace( + adapter=SimpleNamespace(config={}), + bot_uuid='bot-invalid-double-fail', + pipeline_uuid='pipe-invalid-double-fail', + session=SimpleNamespace( + using_conversation=SimpleNamespace( + uuid='conv-stale-double-fail', + dify_conversation_binding=make_runtime_binding( + 'conv-stale-double-fail', + now_ts - 10, + now_ts - 5, + ), + ), + launcher_type=provider_session.LauncherTypes.PERSON, + launcher_id='user-invalid-double-fail', + ), + variables={}, + pipeline_config=runner.pipeline_config, + user_message=provider_message.Message(role='user', content='hello'), + ) + + with pytest.raises(dify_errors.DifyAPIError): + _ = [msg async for msg in runner._chat_messages(query)] + + assert len(request_args) == 2 + assert request_args[0]['conversation_id'] == 'conv-stale-double-fail' + assert request_args[1]['conversation_id'] == '' + assert request_args[1]['inputs']['conversation_id'] == '' + store.acquire_lock.assert_awaited_once_with( + 'bot-invalid-double-fail', + 'pipe-invalid-double-fail', + 'person', + 'user-invalid-double-fail', + ) + store.release_lock.assert_awaited_once_with( + 'bot-invalid-double-fail', + 'pipe-invalid-double-fail', + 'person', + 'user-invalid-double-fail', + 'owner-invalid-double-fail', + ) + store.acquire_lock.assert_awaited_once_with( + 'bot-invalid-double-fail', + 'pipe-invalid-double-fail', + 'person', + 'user-invalid-double-fail', + ) + store.delete_conversation_id.assert_awaited_once() + store.set_conversation_id.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_dify_chat_non_invalid_conversation_error_does_not_retry(): + DifyServiceAPIRunner = get_dify_runner() + dify_errors = import_module('langbot.libs.dify_service_api.v1.errors') + app = Mock() + app.logger = Mock() + runner = DifyServiceAPIRunner(app, make_dify_pipeline_config()) store = SimpleNamespace( - acquire_lock=AsyncMock(return_value=None), + acquire_lock=AsyncMock(return_value='owner-non-invalid'), release_lock=AsyncMock(return_value=True), - get_conversation_id=AsyncMock(side_effect=[None, None, None, 'conv-after-wait']), + get_conversation_id=AsyncMock(return_value=None), set_conversation_id=AsyncMock(), + delete_conversation_id=AsyncMock(), ) runner._get_conversation_store = Mock(return_value=store) + now_ts = int(time.time()) - observed_request = {} + request_args = [] async def fake_chat_messages(**kwargs): - observed_request.update(kwargs) - yield {'event': 'message', 'answer': 'ok', 'conversation_id': 'conv-final-contention'} - yield {'event': 'message_end', 'conversation_id': 'conv-final-contention'} + request_args.append(kwargs) + raise dify_errors.DifyAPIError('500 internal server error') + yield # pragma: no cover runner.dify_client = SimpleNamespace(chat_messages=fake_chat_messages, base_url='https://example.com/v1') query = SimpleNamespace( adapter=SimpleNamespace(config={}), - bot_uuid='bot-contention', - pipeline_uuid='pipe-contention', + bot_uuid='bot-non-invalid', + pipeline_uuid='pipe-non-invalid', session=SimpleNamespace( - using_conversation=SimpleNamespace(uuid=''), + using_conversation=SimpleNamespace( + uuid='conv-non-invalid', + dify_conversation_binding=make_runtime_binding('conv-non-invalid', now_ts - 10, now_ts - 5), + ), launcher_type=provider_session.LauncherTypes.PERSON, - launcher_id='user-contention', + launcher_id='user-non-invalid', ), variables={}, pipeline_config=runner.pipeline_config, user_message=provider_message.Message(role='user', content='hello'), ) - _ = [msg async for msg in runner._chat_messages(query)] + with pytest.raises(dify_errors.DifyAPIError): + _ = [msg async for msg in runner._chat_messages(query)] - assert observed_request['conversation_id'] == 'conv-after-wait' - store.acquire_lock.assert_awaited_once_with('bot-contention', 'pipe-contention', 'person', 'user-contention') - assert store.get_conversation_id.await_count == 4 - assert sleep_calls == [0.05, 0.05, 0.05] + assert len(request_args) == 1 + assert request_args[0]['conversation_id'] == 'conv-non-invalid' + store.delete_conversation_id.assert_not_awaited() + assert query.session.using_conversation.uuid == 'conv-non-invalid' @pytest.mark.asyncio -async def test_dify_chat_lock_contention_unresolved_gracefully_degrades_to_new_conversation(monkeypatch): +async def test_dify_chat_stream_invalid_conversation_retries_once_with_restored_binding_from_contention_wait( + monkeypatch, +): dify_module = import_module('langbot.pkg.provider.runners.difysvapi') DifyServiceAPIRunner = dify_module.DifyServiceAPIRunner + dify_errors = import_module('langbot.libs.dify_service_api.v1.errors') app = Mock() app.logger = Mock() app.instance_config = SimpleNamespace( data={ 'dify_conversation_store': { - 'contention_wait_retry_count': 2, + 'contention_wait_retry_count': 3, 'contention_wait_interval_ms': 50, } } @@ -899,142 +1925,233 @@ async def fake_sleep(_seconds): store = SimpleNamespace( acquire_lock=AsyncMock(return_value=None), release_lock=AsyncMock(return_value=True), - get_conversation_id=AsyncMock(side_effect=[None, None, None]), + get_conversation_id=AsyncMock(side_effect=[None, None, 'conv-stream-recovered-after-invalid']), set_conversation_id=AsyncMock(), + delete_conversation_id=AsyncMock(), ) runner._get_conversation_store = Mock(return_value=store) + now_ts = int(time.time()) - observed_request = {} + request_args = [] async def fake_chat_messages(**kwargs): - observed_request.update(kwargs) - yield {'event': 'message', 'answer': 'ok', 'conversation_id': 'conv-after-contention-fallback'} - yield {'event': 'message_end', 'conversation_id': 'conv-after-contention-fallback'} + request_args.append(kwargs) + if len(request_args) == 1: + raise dify_errors.DifyAPIError('400 {"code":"invalid_conversation","message":"invalid conversation"}') + yield {'event': 'message', 'answer': '你', 'conversation_id': 'conv-stream-after-retry'} + yield {'event': 'message_end', 'conversation_id': 'conv-stream-after-retry'} runner.dify_client = SimpleNamespace(chat_messages=fake_chat_messages, base_url='https://example.com/v1') query = SimpleNamespace( adapter=SimpleNamespace(config={}), - bot_uuid='bot-contention-fail', - pipeline_uuid='pipe-contention-fail', + bot_uuid='bot-stream-invalid-retry', + pipeline_uuid='pipe-stream-invalid-retry', session=SimpleNamespace( - using_conversation=SimpleNamespace(uuid=''), + using_conversation=SimpleNamespace( + uuid='conv-stream-stale', + dify_conversation_binding=make_runtime_binding('conv-stream-stale', now_ts - 10, now_ts - 5), + ), launcher_type=provider_session.LauncherTypes.PERSON, - launcher_id='user-contention-fail', + launcher_id='user-stream-invalid-retry', ), variables={}, pipeline_config=runner.pipeline_config, user_message=provider_message.Message(role='user', content='hello'), ) - msgs = [msg async for msg in runner._chat_messages(query)] + chunks = [chunk async for chunk in runner._chat_messages_chunk(query)] - assert len(msgs) == 1 - assert observed_request['conversation_id'] is None - assert query.session.using_conversation.uuid == 'conv-after-contention-fallback' + assert len(chunks) >= 1 + assert chunks[-1].is_final is True + assert len(request_args) == 2 + assert request_args[0]['conversation_id'] == 'conv-stream-stale' + assert request_args[1]['conversation_id'] == 'conv-stream-recovered-after-invalid' + assert request_args[1]['inputs']['conversation_id'] == 'conv-stream-recovered-after-invalid' + store.acquire_lock.assert_awaited_once_with( + 'bot-stream-invalid-retry', + 'pipe-stream-invalid-retry', + 'person', + 'user-stream-invalid-retry', + ) + store.acquire_lock.assert_awaited_once_with( + 'bot-stream-invalid-retry', + 'pipe-stream-invalid-retry', + 'person', + 'user-stream-invalid-retry', + ) + store.delete_conversation_id.assert_awaited_once() @pytest.mark.asyncio -async def test_dify_agent_restore_and_persist_conversation_id(): +async def test_dify_chat_invalid_conversation_waits_for_recovered_binding_before_retry(monkeypatch): DifyServiceAPIRunner = get_dify_runner() + dify_errors = import_module('langbot.libs.dify_service_api.v1.errors') + dify_module = import_module('langbot.pkg.provider.runners.difysvapi') app = Mock() app.logger = Mock() - config = make_dify_pipeline_config() - config['ai']['dify-service-api']['app-type'] = 'agent' - runner = DifyServiceAPIRunner(app, config) + app.instance_config = SimpleNamespace( + data={'dify_conversation_store': {'contention_wait_retry_count': 2, 'contention_wait_interval_ms': 50}} + ) + runner = DifyServiceAPIRunner(app, make_dify_pipeline_config()) + async def fake_sleep(_seconds): + return None + + monkeypatch.setattr(dify_module.asyncio, 'sleep', fake_sleep) + now_ts = int(time.time()) + + recovered_binding = { + 'conversation_id': 'conv-recovered-after-invalid', + 'created_at': now_ts, + 'last_active_at': now_ts, + 'policy_version': 2, + } store = SimpleNamespace( - acquire_lock=AsyncMock(return_value='owner-agent'), + acquire_lock=AsyncMock(return_value=None), release_lock=AsyncMock(return_value=True), - get_conversation_id=AsyncMock(return_value='conv-agent-restored'), - set_conversation_id=AsyncMock(), + get_conversation_binding=AsyncMock(side_effect=[None, None, recovered_binding]), + set_conversation_binding=AsyncMock(), + delete_conversation_id=AsyncMock(), ) runner._get_conversation_store = Mock(return_value=store) - observed_request = {} + request_args = [] - async def fake_agent_chat_messages(**kwargs): - observed_request.update(kwargs) - yield {'event': 'agent_message', 'answer': 'hi', 'conversation_id': 'conv-agent-final'} - yield {'event': 'message_end', 'conversation_id': 'conv-agent-final'} + async def fake_chat_messages(**kwargs): + request_args.append(kwargs) + if len(request_args) == 1: + raise dify_errors.DifyAPIError('400 {"code":"conversation_not_found","message":"Conversation not found"}') + yield {'event': 'message', 'answer': 'retry-with-restored-binding', 'conversation_id': 'conv-recovered-after-invalid'} + yield {'event': 'message_end', 'conversation_id': 'conv-recovered-after-invalid'} - runner.dify_client = SimpleNamespace(chat_messages=fake_agent_chat_messages, base_url='https://example.com/v1') + runner.dify_client = SimpleNamespace(chat_messages=fake_chat_messages, base_url='https://example.com/v1') query = SimpleNamespace( adapter=SimpleNamespace(config={}), - bot_uuid='bot-agent', - pipeline_uuid='pipe-agent', + bot_uuid='bot-invalid-contention', + pipeline_uuid='pipe-invalid-contention', session=SimpleNamespace( - using_conversation=SimpleNamespace(uuid=''), + using_conversation=SimpleNamespace( + uuid='conv-stale-contention', + dify_conversation_binding=make_runtime_binding('conv-stale-contention', now_ts - 10, now_ts - 5), + ), launcher_type=provider_session.LauncherTypes.PERSON, - launcher_id='user-agent', + launcher_id='user-invalid-contention', ), variables={}, pipeline_config=runner.pipeline_config, user_message=provider_message.Message(role='user', content='hello'), ) - msgs = [msg async for msg in runner._agent_chat_messages(query)] + messages = [msg async for msg in runner._chat_messages(query)] - assert len(msgs) == 1 - assert observed_request['conversation_id'] == 'conv-agent-restored' - store.set_conversation_id.assert_awaited_once_with( - 'bot-agent', - 'pipe-agent', + assert len(messages) == 1 + assert request_args[0]['conversation_id'] == 'conv-stale-contention' + assert request_args[1]['conversation_id'] == 'conv-recovered-after-invalid' + assert request_args[1]['inputs']['conversation_id'] == 'conv-recovered-after-invalid' + store.delete_conversation_id.assert_awaited_once_with( + 'bot-invalid-contention', + 'pipe-invalid-contention', 'person', - 'user-agent', - 'conv-agent-final', + 'user-invalid-contention', + ) + store.acquire_lock.assert_awaited_once_with( + 'bot-invalid-contention', + 'pipe-invalid-contention', + 'person', + 'user-invalid-contention', ) + assert store.get_conversation_binding.await_count == 3 @pytest.mark.asyncio -async def test_dify_chat_stream_chunk_restore_and_persist_conversation_id(): +async def test_dify_agent_invalid_conversation_retries_once_with_empty_conversation_id(): DifyServiceAPIRunner = get_dify_runner() + dify_errors = import_module('langbot.libs.dify_service_api.v1.errors') app = Mock() app.logger = Mock() - runner = DifyServiceAPIRunner(app, make_dify_pipeline_config()) + config = make_dify_pipeline_config() + config['ai']['dify-service-api']['app-type'] = 'agent' + runner = DifyServiceAPIRunner(app, config) store = SimpleNamespace( - acquire_lock=AsyncMock(return_value='owner-stream'), + acquire_lock=AsyncMock(return_value='owner-agent-invalid-retry'), release_lock=AsyncMock(return_value=True), - get_conversation_id=AsyncMock(return_value='conv-stream-restored'), + get_conversation_id=AsyncMock(side_effect=[None, None]), set_conversation_id=AsyncMock(), + delete_conversation_id=AsyncMock(), ) runner._get_conversation_store = Mock(return_value=store) + now_ts = int(time.time()) - observed_request = {} + request_args = [] - async def fake_chat_messages(**kwargs): - observed_request.update(kwargs) - yield {'event': 'message', 'answer': '你', 'conversation_id': 'conv-stream-final'} - yield {'event': 'message_end', 'conversation_id': 'conv-stream-final'} + async def fake_agent_chat_messages(**kwargs): + request_args.append(kwargs) + if len(request_args) == 1: + raise dify_errors.DifyAPIError('400 {"code":"invalid_conversation","message":"invalid conversation"}') + yield {'event': 'agent_message', 'answer': 'agent-retry-ok', 'conversation_id': 'conv-agent-after-retry'} + yield {'event': 'message_end', 'conversation_id': 'conv-agent-after-retry'} - runner.dify_client = SimpleNamespace(chat_messages=fake_chat_messages, base_url='https://example.com/v1') + runner.dify_client = SimpleNamespace(chat_messages=fake_agent_chat_messages, base_url='https://example.com/v1') query = SimpleNamespace( adapter=SimpleNamespace(config={}), - bot_uuid='bot-stream', - pipeline_uuid='pipe-stream', + bot_uuid='bot-agent-invalid-retry', + pipeline_uuid='pipe-agent-invalid-retry', session=SimpleNamespace( - using_conversation=SimpleNamespace(uuid=''), + using_conversation=SimpleNamespace( + uuid='conv-agent-stale', + dify_conversation_binding=make_runtime_binding('conv-agent-stale', now_ts - 10, now_ts - 5), + ), launcher_type=provider_session.LauncherTypes.PERSON, - launcher_id='user-stream', + launcher_id='user-agent-invalid-retry', ), variables={}, pipeline_config=runner.pipeline_config, user_message=provider_message.Message(role='user', content='hello'), ) - chunks = [chunk async for chunk in runner._chat_messages_chunk(query)] + messages = [msg async for msg in runner._agent_chat_messages(query)] - assert chunks[-1].is_final is True - assert observed_request['conversation_id'] == 'conv-stream-restored' + assert len(messages) == 1 + assert messages[0].content == 'agent-retry-ok' + assert len(request_args) == 2 + assert request_args[0]['conversation_id'] == 'conv-agent-stale' + assert request_args[1]['conversation_id'] == '' + assert request_args[1]['inputs']['conversation_id'] == '' + store.acquire_lock.assert_awaited_once_with( + 'bot-agent-invalid-retry', + 'pipe-agent-invalid-retry', + 'person', + 'user-agent-invalid-retry', + ) + store.release_lock.assert_awaited_once_with( + 'bot-agent-invalid-retry', + 'pipe-agent-invalid-retry', + 'person', + 'user-agent-invalid-retry', + 'owner-agent-invalid-retry', + ) + store.delete_conversation_id.assert_awaited_once_with( + 'bot-agent-invalid-retry', + 'pipe-agent-invalid-retry', + 'person', + 'user-agent-invalid-retry', + ) + store.acquire_lock.assert_awaited_once_with( + 'bot-agent-invalid-retry', + 'pipe-agent-invalid-retry', + 'person', + 'user-agent-invalid-retry', + ) store.set_conversation_id.assert_awaited_once_with( - 'bot-stream', - 'pipe-stream', + 'bot-agent-invalid-retry', + 'pipe-agent-invalid-retry', 'person', - 'user-stream', - 'conv-stream-final', + 'user-agent-invalid-retry', + 'conv-agent-after-retry', ) diff --git a/tests/unit_tests/provider/test_dify_conversation_binding.py b/tests/unit_tests/provider/test_dify_conversation_binding.py new file mode 100644 index 000000000..900c5fb38 --- /dev/null +++ b/tests/unit_tests/provider/test_dify_conversation_binding.py @@ -0,0 +1,147 @@ +import pytest + +from langbot.pkg.provider.conversation.dify_store import ( + build_binding_payload, + is_binding_expired, + normalize_binding_payload, +) + + +def test_normalize_binding_payload_with_new_structure(): + payload = { + "conversation_id": "conv-1", + "created_at": 100, + "last_active_at": 120, + "policy_version": 2, + } + + normalized = normalize_binding_payload(payload) + + assert normalized == payload + + +def test_normalize_binding_payload_strips_conversation_id_whitespace(): + payload = { + "conversation_id": " conv-1 ", + "created_at": 100, + "last_active_at": 120, + "policy_version": 2, + } + + normalized = normalize_binding_payload(payload) + + assert normalized == { + "conversation_id": "conv-1", + "created_at": 100, + "last_active_at": 120, + "policy_version": 2, + } + + +@pytest.mark.parametrize( + "payload", + [ + {"conversation_id": "conv-x", "created_at": 1, "last_active_at": 2}, + {"conversation_id": "conv-x", "policy_version": 2}, + ], +) +def test_normalize_binding_payload_with_half_new_structure_fails_closed(payload): + assert normalize_binding_payload(payload) is None + + +@pytest.mark.parametrize( + "payload", + [ + {"conversation_id": "conv-x", "updated_at": 100, "policy_version": 2}, + {"conversation_id": "conv-x", "created_at": 1, "updated_at": 100}, + {"conversation_id": "conv-x", "last_active_at": 2, "updated_at": 100}, + ], +) +def test_normalize_binding_payload_with_mixed_schema_falls_back_to_legacy(payload): + assert normalize_binding_payload(payload) == { + "conversation_id": "conv-x", + "created_at": 100, + "last_active_at": 100, + "policy_version": 1, + } + + +def test_normalize_binding_payload_with_legacy_structure(): + payload = {"conversation_id": "conv-legacy", "updated_at": 1680000000} + + normalized = normalize_binding_payload(payload) + + assert normalized == { + "conversation_id": "conv-legacy", + "created_at": 1680000000, + "last_active_at": 1680000000, + "policy_version": 1, + } + + +def test_build_binding_payload_keeps_existing_created_at(): + payload = build_binding_payload( + conversation_id="conv-2", + now_ts=200, + existing_created_at=100, + ) + + assert payload == { + "conversation_id": "conv-2", + "created_at": 100, + "last_active_at": 200, + "policy_version": 2, + } + + +def test_is_binding_expired_uses_last_active_at(): + binding = { + "conversation_id": "conv-3", + "created_at": 100, + "last_active_at": 150, + "policy_version": 2, + } + + assert is_binding_expired(binding, now_ts=200, idle_timeout_seconds=60) is False + assert is_binding_expired(binding, now_ts=211, idle_timeout_seconds=60) is True + + +def test_is_binding_expired_at_timeout_boundary_is_expired(): + binding = { + "conversation_id": "conv-3", + "created_at": 100, + "last_active_at": 150, + "policy_version": 2, + } + + assert is_binding_expired(binding, now_ts=210, idle_timeout_seconds=60) is True + + +@pytest.mark.parametrize("idle_timeout_seconds", [0, -1]) +def test_is_binding_expired_when_timeout_non_positive_fails_closed(idle_timeout_seconds): + binding = { + "conversation_id": "conv-3", + "created_at": 100, + "last_active_at": 150, + "policy_version": 2, + } + + assert is_binding_expired(binding, now_ts=200, idle_timeout_seconds=idle_timeout_seconds) is True + + +@pytest.mark.parametrize( + "payload", + [ + None, + {}, + {"last_active_at": 100}, + {"conversation_id": "", "last_active_at": 100}, + {"conversation_id": " ", "last_active_at": 100}, + {"conversation_id": "conv-x"}, + {"conversation_id": "conv-x", "last_active_at": "100"}, + {"conversation_id": "conv-x", "created_at": "99", "last_active_at": 100}, + {"conversation_id": "conv-x", "updated_at": "100"}, + ], +) +def test_normalize_binding_payload_with_invalid_data_returns_none(payload): + assert normalize_binding_payload(payload) is None diff --git a/tests/unit_tests/provider/test_dify_conversation_store.py b/tests/unit_tests/provider/test_dify_conversation_store.py index a4d2178b0..d68bfed7a 100644 --- a/tests/unit_tests/provider/test_dify_conversation_store.py +++ b/tests/unit_tests/provider/test_dify_conversation_store.py @@ -52,6 +52,37 @@ async def set_json(self, key: str, value: dict, ex: int | None = None): await self.set(key, json.dumps(value, ensure_ascii=False), ex=ex) +class LegacyRedisManagerNoJson: + """Redis manager compatibility stub without get_json/set_json helpers.""" + + def __init__(self, enabled: bool = True): + self.enabled = enabled + self.values: dict[str, str] = {} + self.expirations: dict[str, int | None] = {} + self.client = _FakeRedisClient(self) + + def is_available(self) -> bool: + return self.enabled + + async def get(self, key: str): + return self.values.get(key) + + async def set(self, key: str, value: str, ex: int | None = None): + self.values[key] = value + self.expirations[key] = ex + + async def delete(self, key: str): + self.values.pop(key, None) + self.expirations.pop(key, None) + + async def set_if_not_exists(self, key: str, value: str, ex: int | None = None) -> bool: + if key in self.values: + return False + self.values[key] = value + self.expirations[key] = ex + return True + + class _FakeRedisClient: def __init__(self, redis_mgr: FakeRedisManager): self.redis_mgr = redis_mgr @@ -74,22 +105,182 @@ async def eval(self, script: str, numkeys: int, *keys_and_args): @pytest.mark.asyncio -async def test_conversation_id_round_trip_and_ttl(): +async def test_conversation_binding_round_trip_and_idle_timeout_ttl(): redis_mgr = FakeRedisManager() - store = DifyConversationStore(redis_mgr, ttl_seconds=321) + store = DifyConversationStore(redis_mgr, idle_timeout_seconds=321) - await store.set_conversation_id("bot-1", "pipe-1", "private", "launcher-1", "conv-1") + await store.set_conversation_binding( + "bot-1", + "pipe-1", + "private", + "launcher-1", + "conv-1", + now_ts=111, + ) key = "dify:conversation:bot-1:pipe-1:private:launcher-1" assert redis_mgr.expirations[key] == 321 payload = json.loads(redis_mgr.values[key]) assert payload["conversation_id"] == "conv-1" - assert isinstance(payload["updated_at"], int) + assert isinstance(payload["created_at"], int) + assert isinstance(payload["last_active_at"], int) + assert payload["policy_version"] == 2 + assert payload["created_at"] == 111 + assert payload["last_active_at"] == 111 + + binding = await store.get_conversation_binding("bot-1", "pipe-1", "private", "launcher-1") + assert binding == { + "conversation_id": "conv-1", + "created_at": 111, + "last_active_at": 111, + "policy_version": 2, + } assert await store.get_conversation_id("bot-1", "pipe-1", "private", "launcher-1") == "conv-1" +@pytest.mark.asyncio +async def test_set_conversation_binding_preserves_created_at_for_same_conversation_id(): + redis_mgr = FakeRedisManager() + store = DifyConversationStore(redis_mgr, idle_timeout_seconds=600) + + await store.set_conversation_binding("bot-1", "pipe-1", "private", "launcher-1", "conv-1", now_ts=100) + await store.set_conversation_binding("bot-1", "pipe-1", "private", "launcher-1", "conv-1", now_ts=150) + + binding = await store.get_conversation_binding("bot-1", "pipe-1", "private", "launcher-1") + assert binding == { + "conversation_id": "conv-1", + "created_at": 100, + "last_active_at": 150, + "policy_version": 2, + } + + +@pytest.mark.asyncio +async def test_get_conversation_binding_supports_legacy_payload_and_returns_canonical_metadata(): + redis_mgr = FakeRedisManager() + store = DifyConversationStore(redis_mgr) + + key = "dify:conversation:bot-1:pipe-1:private:launcher-1" + redis_mgr.values[key] = json.dumps({"conversation_id": " conv-legacy ", "updated_at": 1680000000}) + + binding = await store.get_conversation_binding("bot-1", "pipe-1", "private", "launcher-1") + + assert binding == { + "conversation_id": "conv-legacy", + "created_at": 1680000000, + "last_active_at": 1680000000, + "policy_version": 1, + } + assert await store.get_conversation_id("bot-1", "pipe-1", "private", "launcher-1") == "conv-legacy" + + +@pytest.mark.asyncio +async def test_ttl_seconds_legacy_alias_is_compatible(): + redis_mgr = FakeRedisManager() + store = DifyConversationStore(redis_mgr, ttl_seconds=456) + + await store.set_conversation_binding("bot-1", "pipe-1", "private", "launcher-1", "conv-1") + + key = "dify:conversation:bot-1:pipe-1:private:launcher-1" + assert redis_mgr.expirations[key] == 456 + + + + +@pytest.mark.asyncio +async def test_mixed_schema_payload_with_updated_at_falls_back_to_legacy_canonical(): + redis_mgr = FakeRedisManager() + store = DifyConversationStore(redis_mgr) + + key = "dify:conversation:bot-1:pipe-1:private:launcher-1" + redis_mgr.values[key] = json.dumps( + { + "conversation_id": "conv-mixed", + "created_at": 1, + "policy_version": 2, + "updated_at": 1680000000, + } + ) + + binding = await store.get_conversation_binding("bot-1", "pipe-1", "private", "launcher-1") + + assert binding == { + "conversation_id": "conv-mixed", + "created_at": 1680000000, + "last_active_at": 1680000000, + "policy_version": 1, + } + + +@pytest.mark.asyncio +async def test_ttl_seconds_alias_takes_precedence_when_passed_together_with_idle_timeout_seconds(): + redis_mgr = FakeRedisManager() + store = DifyConversationStore(redis_mgr, idle_timeout_seconds=123, ttl_seconds=456) + + await store.set_conversation_binding("bot-1", "pipe-1", "private", "launcher-1", "conv-1") + + key = "dify:conversation:bot-1:pipe-1:private:launcher-1" + assert redis_mgr.expirations[key] == 456 + assert store.idle_timeout_seconds == 456 + assert store.ttl_seconds == 456 + + +@pytest.mark.asyncio +async def test_constructor_clamps_idle_timeout_and_lock_ttl_to_minimum_safe_positive_value(): + redis_mgr = FakeRedisManager() + store = DifyConversationStore(redis_mgr, idle_timeout_seconds=0, lock_ttl_seconds=-9) + + await store.set_conversation_binding( + "bot-1", + "pipe-1", + "private", + "launcher-1", + "conv-1", + now_ts=111, + ) + owner = await store.acquire_lock("bot-1", "pipe-1", "private", "launcher-1") + + key = "dify:conversation:bot-1:pipe-1:private:launcher-1" + lock_key = "dify:conversation_lock:bot-1:pipe-1:private:launcher-1" + + assert store.idle_timeout_seconds == 1 + assert store.ttl_seconds == 1 + assert store.lock_ttl_seconds == 1 + assert redis_mgr.expirations[key] == 1 + assert owner is not None + assert redis_mgr.expirations[lock_key] == 1 + + +@pytest.mark.asyncio +async def test_store_fallback_path_works_without_get_json_or_set_json_helpers(): + redis_mgr = LegacyRedisManagerNoJson() + store = DifyConversationStore(redis_mgr, idle_timeout_seconds=77) + + await store.set_conversation_binding( + "bot-1", + "pipe-1", + "private", + "launcher-1", + "conv-fallback", + now_ts=222, + ) + + key = "dify:conversation:bot-1:pipe-1:private:launcher-1" + assert isinstance(redis_mgr.values[key], str) + assert redis_mgr.expirations[key] == 77 + + binding = await store.get_conversation_binding("bot-1", "pipe-1", "private", "launcher-1") + + assert binding == { + "conversation_id": "conv-fallback", + "created_at": 222, + "last_active_at": 222, + "policy_version": 2, + } + + @pytest.mark.asyncio async def test_invalid_payload_is_ignored(): redis_mgr = FakeRedisManager() @@ -136,6 +327,19 @@ async def test_blank_conversation_id_is_not_persisted(): assert await store.get_conversation_id("bot-1", "pipe-1", "private", "launcher-1") is None +@pytest.mark.asyncio +async def test_conversation_id_with_surrounding_whitespace_is_normalized_and_persisted(): + redis_mgr = FakeRedisManager() + store = DifyConversationStore(redis_mgr) + + await store.set_conversation_id("bot-1", "pipe-1", "private", "launcher-1", " conv-1 ") + + key = "dify:conversation:bot-1:pipe-1:private:launcher-1" + payload = json.loads(redis_mgr.values[key]) + assert payload["conversation_id"] == "conv-1" + assert await store.get_conversation_id("bot-1", "pipe-1", "private", "launcher-1") == "conv-1" + + @pytest.mark.asyncio async def test_graceful_behavior_when_disabled_or_unavailable_or_redis_failures(): redis_mgr = FakeRedisManager() @@ -204,6 +408,21 @@ async def test_release_lock_does_not_delete_new_owner_lock_on_owner_switch(): assert redis_mgr.values.get(lock_key) == "new-owner" +@pytest.mark.asyncio +async def test_release_lock_fallback_handles_bytes_owner_value(): + redis_mgr = FakeRedisManager() + redis_mgr.client = None + store = DifyConversationStore(redis_mgr) + + lock_key = "dify:conversation_lock:bot-1:pipe-1:private:launcher-1" + redis_mgr.values[lock_key] = b"owner-1" + + released = await store.release_lock("bot-1", "pipe-1", "private", "launcher-1", "owner-1") + + assert released is True + assert lock_key not in redis_mgr.values + + @pytest.mark.asyncio async def test_key_boundary_isolation_by_pipeline_and_launcher_dimensions(): redis_mgr = FakeRedisManager() diff --git a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx index 1f99161fe..f2a17b359 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx +++ b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx @@ -25,7 +25,7 @@ export default function DynamicFormComponent({ }: { itemConfigList: IDynamicFormItemSchema[]; onSubmit?: (val: object) => unknown; - initialValues?: Record; + initialValues?: Record; onFileUploaded?: (fileKey: string) => void; isEditing?: boolean; externalDependentValues?: Record; @@ -63,7 +63,7 @@ export default function DynamicFormComponent({ return value; }; - // 根据 itemConfigList 动态生成 zod schema + // Build a zod schema dynamically from the form item configuration. const formSchema = z.object( itemConfigList.reduce( (acc, item) => { @@ -144,7 +144,7 @@ export default function DynamicFormComponent({ const form = useForm({ resolver: zodResolver(formSchema), defaultValues: itemConfigList.reduce((acc, item) => { - // 优先使用 initialValues,如果没有则使用默认值 + // Prefer initialValues, otherwise fall back to the schema default. const rawValue = initialValues?.[item.name] ?? item.default; return { ...acc, @@ -153,31 +153,31 @@ export default function DynamicFormComponent({ }, {} as FormValues), }); - // 当 initialValues 变化时更新表单值 - // 但要避免因为内部表单更新触发的 onSubmit 导致的 initialValues 变化而重新设置表单 + // Update form values when initialValues changes. + // Avoid resetting the form when parent state echoes back values emitted by this form itself. useEffect(() => { - // 首次挂载时,使用 initialValues 初始化表单 + // On first mount, trust initialValues as the baseline snapshot. if (isInitialMount.current) { isInitialMount.current = false; previousInitialValues.current = initialValues; return; } - // 检查 initialValues 是否真的发生了实质性变化 - // 使用 JSON.stringify 进行深度比较 + // Only react when initialValues has changed materially. + // Use JSON.stringify for a pragmatic deep comparison here. const hasRealChange = JSON.stringify(previousInitialValues.current) !== JSON.stringify(initialValues); if (initialValues && hasRealChange) { - // 合并默认值和初始值 + // Merge schema defaults with the incoming initial values. const mergedValues = itemConfigList.reduce( (acc, item) => { const rawValue = initialValues[item.name] ?? item.default; - acc[item.name] = normalizeFieldValue(item, rawValue) as object; + acc[item.name] = normalizeFieldValue(item, rawValue); return acc; }, - {} as Record, + {} as Record, ); Object.entries(mergedValues).forEach(([key, value]) => { @@ -204,10 +204,16 @@ export default function DynamicFormComponent({ ? watchedValues[config.show_if.field as keyof typeof watchedValues] : externalDependentValues?.[config.show_if.field]; - if (config.show_if.operator === 'eq' && dependValue !== config.show_if.value) { + if ( + config.show_if.operator === 'eq' && + dependValue !== config.show_if.value + ) { return false; } - if (config.show_if.operator === 'neq' && dependValue === config.show_if.value) { + if ( + config.show_if.operator === 'neq' && + dependValue === config.show_if.value + ) { return false; } if ( @@ -221,7 +227,7 @@ export default function DynamicFormComponent({ return true; }); - // 监听表单值变化 + // Watch form value changes and emit a normalized snapshot upward. useEffect(() => { // Emit initial form values immediately so the parent always has a valid snapshot, // even if the user saves without modifying any field. @@ -232,7 +238,7 @@ export default function DynamicFormComponent({ acc[item.name] = formValues[item.name] ?? item.default; return acc; }, - {} as Record, + {} as Record, ); onSubmitRef.current?.(initialFinalValues); @@ -242,7 +248,7 @@ export default function DynamicFormComponent({ // and won't trigger an infinite update loop. previousInitialValues.current = initialFinalValues as Record< string, - object + unknown >; const subscription = form.watch(() => { @@ -252,10 +258,10 @@ export default function DynamicFormComponent({ acc[item.name] = formValues[item.name] ?? item.default; return acc; }, - {} as Record, + {} as Record, ); onSubmitRef.current?.(finalValues); - previousInitialValues.current = finalValues as Record; + previousInitialValues.current = finalValues as Record; }); return () => subscription.unsubscribe(); }, [form, itemConfigList]); @@ -273,7 +279,8 @@ export default function DynamicFormComponent({ index > 0 && visibleItemConfigList[index - 1].section ? extractI18nObject(visibleItemConfigList[index - 1].section!) : ''; - const showSectionHeader = Boolean(currentSection) && currentSection !== previousSection; + const showSectionHeader = + Boolean(currentSection) && currentSection !== previousSection; return (
@@ -291,12 +298,16 @@ export default function DynamicFormComponent({ {extractI18nObject(config.label)}{' '} - {config.required && *} + {config.required && ( + * + )}
void; pipelineId?: string; isEditMode?: boolean; - isDefaultPipeline?: boolean; onFinish: () => void; onNewPipelineCreated?: (pipelineId: string) => void; onDeletePipeline: () => void; @@ -141,7 +140,7 @@ export default function PipelineDialog({ return t('pipelines.debugDialog.title'); }; - // 创建新流水线时的对话框 + // Dialog layout for creating a new pipeline if (!isEditMode) { return ( @@ -156,7 +155,6 @@ export default function PipelineDialog({ onNewPipelineCreated={handleNewPipelineCreated} isEditMode={isEditMode} pipelineId={pipelineId} - disableForm={false} showButtons={true} onDeletePipeline={onDeletePipeline} onCancel={() => { @@ -170,7 +168,7 @@ export default function PipelineDialog({ ); } - // 编辑流水线时的对话框 + // Dialog layout for editing an existing pipeline return ( @@ -190,10 +188,10 @@ export default function PipelineDialog({ isActive={currentMode === item.key} onClick={() => setCurrentMode(item.key as DialogMode)} > - + ))} @@ -238,7 +236,6 @@ export default function PipelineDialog({ onNewPipelineCreated={handleNewPipelineCreated} isEditMode={isEditMode} pipelineId={pipelineId} - disableForm={false} showButtons={true} onDeletePipeline={onDeletePipeline} onCancel={() => { diff --git a/web/src/app/home/pipelines/components/pipeline-form/DifySessionSettingsSection.tsx b/web/src/app/home/pipelines/components/pipeline-form/DifySessionSettingsSection.tsx new file mode 100644 index 000000000..b6a928582 --- /dev/null +++ b/web/src/app/home/pipelines/components/pipeline-form/DifySessionSettingsSection.tsx @@ -0,0 +1,73 @@ +import { useState } from 'react'; +import { ChevronDown, ChevronRight } from 'lucide-react'; +import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent'; +import { + DifySessionSettingsValues, + PipelineConfigStage, +} from '@/app/infra/entities/pipeline'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible'; +import { extractI18nObject } from '@/i18n/I18nProvider'; +import { useTranslation } from 'react-i18next'; + +export default function DifySessionSettingsSection({ + stage, + initialValues, + onSubmit, +}: { + stage: PipelineConfigStage; + initialValues: DifySessionSettingsValues; + onSubmit: (values: DifySessionSettingsValues) => void; +}) { + const [isOpen, setIsOpen] = useState(false); + const { t } = useTranslation(); + + return ( + + + + + + + } + onSubmit={(values) => { + onSubmit(values as DifySessionSettingsValues); + }} + /> + + + ); +} diff --git a/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx b/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx index 8da5c4aed..73e18fcbb 100644 --- a/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx +++ b/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx @@ -44,7 +44,6 @@ export default function PipelineFormComponent({ }: { pipelineId?: string; isEditMode: boolean; - disableForm: boolean; showButtons?: boolean; onFinish: () => void; onNewPipelineCreated: (pipelineId: string) => void; @@ -85,8 +84,8 @@ export default function PipelineFormComponent({ }); type FormValues = z.infer; - // 这里不好,可以改成enum等 - const formLabelList: FormLabel[] = isEditMode + // This could be improved with a dedicated enum or typed config section in the future. + const formLabelList: FormTabLabel[] = isEditMode ? [ { label: t('pipelines.basicInfo'), name: 'basic' }, { label: t('pipelines.aiCapabilities'), name: 'ai' }, @@ -129,25 +128,42 @@ export default function PipelineFormComponent({ }, [isEditMode, watchedValues]); useEffect(() => { - // get config schema from metadata - httpClient.getGeneralPipelineMetadata().then((resp) => { - for (const config of resp.configs) { - if (config.name === 'ai') { - setAIConfigTabSchema(config); - } else if (config.name === 'trigger') { - setTriggerConfigTabSchema(config); - } else if (config.name === 'safety') { - setSafetyConfigTabSchema(config); - } else if (config.name === 'output') { - setOutputConfigTabSchema(config); + let isActive = true; + + httpClient + .getGeneralPipelineMetadata() + .then((resp) => { + if (!isActive) { + return; } - } - }); - if (isEditMode) { + for (const config of resp.configs) { + if (config.name === 'ai') { + setAIConfigTabSchema(config); + } else if (config.name === 'trigger') { + setTriggerConfigTabSchema(config); + } else if (config.name === 'safety') { + setSafetyConfigTabSchema(config); + } else if (config.name === 'output') { + setOutputConfigTabSchema(config); + } + } + }) + .catch((err) => { + if (!isActive) { + return; + } + toast.error(t('pipelines.loadError') + err.msg); + }); + + if (isEditMode && pipelineId) { httpClient - .getPipeline(pipelineId || '') + .getPipeline(pipelineId) .then((resp: GetPipelineResponseData) => { + if (!isActive) { + return; + } + setIsDefaultPipeline(resp.pipeline.is_default ?? false); const loadedValues = { basic: { @@ -155,17 +171,27 @@ export default function PipelineFormComponent({ description: resp.pipeline.description, emoji: resp.pipeline.emoji || '⚙️', }, - ai: resp.pipeline.config.ai, - trigger: resp.pipeline.config.trigger, - safety: resp.pipeline.config.safety, - output: resp.pipeline.config.output, + ai: resp.pipeline.config.ai ?? {}, + trigger: resp.pipeline.config.trigger ?? {}, + safety: resp.pipeline.config.safety ?? {}, + output: resp.pipeline.config.output ?? {}, }; form.reset(loadedValues); savedSnapshotRef.current = JSON.stringify(loadedValues); initializedStagesRef.current.clear(); + }) + .catch((err) => { + if (!isActive) { + return; + } + toast.error(t('pipelines.loadError') + err.msg); }); } - }, []); + + return () => { + isActive = false; + }; + }, [form, isEditMode, pipelineId, t]); useEffect(() => { if (!isEditMode) { @@ -207,6 +233,11 @@ export default function PipelineFormComponent({ } function handleModify(values: FormValues) { + if (!pipelineId) { + toast.error(t('pipelines.saveError')); + return; + } + const realConfig = { ai: values.ai, trigger: values.trigger, @@ -227,7 +258,7 @@ export default function PipelineFormComponent({ // is_default: false, }; httpClient - .updatePipeline(pipelineId || '', pipeline) + .updatePipeline(pipelineId, pipeline) .then(() => { savedSnapshotRef.current = JSON.stringify(form.getValues()); onFinish(); @@ -259,9 +290,15 @@ export default function PipelineFormComponent({ if (isFirstEmission) { initializedStagesRef.current.add(stageKey); - // Synchronously re-capture snapshot so that the useMemo comparison - // in the same render cycle still returns false. - savedSnapshotRef.current = JSON.stringify(form.getValues()); + const hadUnsavedChanges = + !!savedSnapshotRef.current && + JSON.stringify(form.getValues()) !== savedSnapshotRef.current; + + if (!hadUnsavedChanges) { + // Keep the initial mount emission from showing a false positive unsaved state, + // but do not clear an unsaved state created by prior user edits. + savedSnapshotRef.current = JSON.stringify(form.getValues()); + } } } @@ -277,9 +314,9 @@ export default function PipelineFormComponent({ } } - // 如果是 AI 配置,需要特殊处理 + // AI stages need runner-aware conditional rendering. if (formName === 'ai') { - // 如果是 runner 配置项,直接渲染 + // Render the runner selector stage unconditionally. if (stage.name === 'runner') { return (
@@ -306,12 +343,12 @@ export default function PipelineFormComponent({ ); } - // 如果不是当前选择的 runner 对应的配置项,则不渲染 + // Hide runner-specific stages that do not match the current runner. if (stage.name !== currentRunner) { return null; } - // 对于n8n-service-api配置,使用N8nAuthFormComponent处理表单联动 + // n8n uses a dedicated auth-aware form component. if (stage.name === 'n8n-service-api') { return (
@@ -535,7 +572,7 @@ export default function PipelineFormComponent({
- {/* 按钮栏移到 Tabs 外部,始终固定底部 */} + {/* Keep action buttons outside the tabs so they stay pinned at the bottom. */} {showButtons && (
{isEditMode && hasUnsavedChanges && ( @@ -583,7 +620,7 @@ export default function PipelineFormComponent({
- {/* 删除确认对话框 */} + {/* Delete confirmation dialog */} @@ -604,7 +641,7 @@ export default function PipelineFormComponent({ - {/* 复制确认对话框 */} + {/* Copy confirmation dialog */} @@ -622,7 +659,7 @@ export default function PipelineFormComponent({ ); } -interface FormLabel { +interface FormTabLabel { label: string; name: string; } diff --git a/web/src/app/infra/entities/pipeline/index.ts b/web/src/app/infra/entities/pipeline/index.ts index cc411c9f5..09f48f5c5 100644 --- a/web/src/app/infra/entities/pipeline/index.ts +++ b/web/src/app/infra/entities/pipeline/index.ts @@ -21,3 +21,10 @@ export interface PipelineConfigStage { description?: I18nObject; config: IDynamicFormItemSchema[]; } + +export interface DifySessionSettingsValues { + idle_timeout_seconds?: number; + lock_ttl_seconds?: number; + contention_wait_retry_count?: number; + contention_wait_interval_ms?: number; +} diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 601a32897..b31bef614 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -631,6 +631,9 @@ const enUS = { copyConfirmation: 'Are you sure you want to copy this pipeline? This will create a new pipeline with all configurations.', unsavedChanges: 'You have unsaved changes', + difySessionSettings: { + scopeHint: 'Saved per pipeline. Defaults come from backend metadata.', + }, extensions: { title: 'Extensions', loadError: 'Failed to load plugins', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 982350804..1fe768e8e 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -1,4 +1,4 @@ -const jaJP = { +const jaJP = { common: { login: 'ログイン', logout: 'ログアウト', @@ -633,6 +633,10 @@ copyConfirmation: 'このパイプラインをコピーしますか?すべての設定を含む新しいパイプラインが作成されます。', unsavedChanges: '未保存の変更があります', + difySessionSettings: { + scopeHint: + 'パイプラインごとに保存されます。既定値はバックエンドのメタデータから取得します。', + }, extensions: { title: 'プラグイン統合', loadError: 'プラグインリストの読み込みに失敗しました', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 555598d9d..86adef91c 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -604,6 +604,9 @@ const zhHans = { copyConfirmation: '确定要复制这个流水线吗?复制将创建一个包含完整配置的新流水线。', unsavedChanges: '有未保存的更改', + difySessionSettings: { + scopeHint: '按流水线单独保存,默认值由后端元数据提供。', + }, extensions: { title: '扩展集成', loadError: '加载插件列表失败', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 791f6352d..601230288 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -597,6 +597,9 @@ const zhHant = { copyConfirmation: '確定要複製這個流程線嗎?複製將創建一個包含完整配置的新流程線。', unsavedChanges: '有未儲存的變更', + difySessionSettings: { + scopeHint: '按流程線分別儲存,預設值由後端元資料提供。', + }, extensions: { title: '擴展集成', loadError: '載入插件清單失敗', From c156439825b1eddda8da145bbc65283ba3e50ffa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=9C=E9=9B=A8?= <58286951@qq.com> Date: Tue, 31 Mar 2026 14:21:34 +0800 Subject: [PATCH 31/36] fix(web): show dify session settings in create and edit flows --- .../pipeline-form/PipelineFormComponent.tsx | 112 ++++++++++-------- 1 file changed, 65 insertions(+), 47 deletions(-) diff --git a/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx b/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx index 73e18fcbb..57f3f40a3 100644 --- a/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx +++ b/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx @@ -2,12 +2,14 @@ import { useEffect, useRef, useState, useMemo } from 'react'; import { httpClient } from '@/app/infra/http/HttpClient'; import { GetPipelineResponseData, Pipeline } from '@/app/infra/entities/api'; import { + DifySessionSettingsValues, PipelineConfigTab, PipelineConfigStage, } from '@/app/infra/entities/pipeline'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent'; import N8nAuthFormComponent from '@/app/home/components/dynamic-form/N8nAuthFormComponent'; +import DifySessionSettingsSection from '@/app/home/pipelines/components/pipeline-form/DifySessionSettingsSection'; import { Button } from '@/components/ui/button'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -85,15 +87,13 @@ export default function PipelineFormComponent({ type FormValues = z.infer; // This could be improved with a dedicated enum or typed config section in the future. - const formLabelList: FormTabLabel[] = isEditMode - ? [ - { label: t('pipelines.basicInfo'), name: 'basic' }, - { label: t('pipelines.aiCapabilities'), name: 'ai' }, - { label: t('pipelines.triggerConditions'), name: 'trigger' }, - { label: t('pipelines.safetyControls'), name: 'safety' }, - { label: t('pipelines.outputProcessing'), name: 'output' }, - ] - : [{ label: t('pipelines.basicInfo'), name: 'basic' }]; + const formLabelList: FormTabLabel[] = [ + { label: t('pipelines.basicInfo'), name: 'basic' }, + { label: t('pipelines.aiCapabilities'), name: 'ai' }, + { label: t('pipelines.triggerConditions'), name: 'trigger' }, + { label: t('pipelines.safetyControls'), name: 'safety' }, + { label: t('pipelines.outputProcessing'), name: 'output' }, + ]; const [aiConfigTabSchema, setAIConfigTabSchema] = useState(); @@ -215,7 +215,12 @@ export default function PipelineFormComponent({ function handleCreate(values: FormValues) { const pipeline: Pipeline = { - config: {}, + config: { + ai: values.ai ?? {}, + trigger: values.trigger ?? {}, + safety: values.safety ?? {}, + output: values.output ?? {}, + }, description: values.basic.description, name: values.basic.name, emoji: values.basic.emoji, @@ -307,6 +312,12 @@ export default function PipelineFormComponent({ formName: keyof FormValues, ) { const currentRunner = form.watch('ai.runner.runner'); + const currentFormValues = + (form.watch(formName) as Record) || {}; + const stageInitialValues = (currentFormValues?.[stage.name] || + {}) as Record; + const stageStringInitialValues = (currentFormValues?.[stage.name] || + {}) as Record; if (formName === 'output' && stage.name === 'dify-stream') { if (currentRunner !== 'dify-service-api') { @@ -343,6 +354,23 @@ export default function PipelineFormComponent({ ); } + if (stage.name === 'dify_conversation_store') { + if (currentRunner !== 'dify-service-api') { + return null; + } + + return ( + { + handleDynamicFormEmit(formName, stage.name, values); + }} + /> + ); + } + // Hide runner-specific stages that do not match the current runner. if (stage.name !== currentRunner) { return null; @@ -362,11 +390,7 @@ export default function PipelineFormComponent({ )} )?.[stage.name] || - {} - } + initialValues={stageStringInitialValues} onSubmit={(values) => { handleDynamicFormEmit(formName, stage.name, values); }} @@ -528,43 +552,37 @@ export default function PipelineFormComponent({
)} - {isEditMode && ( - <> - {formLabel.name === 'ai' && aiConfigTabSchema && ( -
- {aiConfigTabSchema.stages.map((stage) => - renderDynamicForms(stage, 'ai'), - )} -
+ {formLabel.name === 'ai' && aiConfigTabSchema && ( +
+ {aiConfigTabSchema.stages.map((stage) => + renderDynamicForms(stage, 'ai'), )} +
+ )} - {formLabel.name === 'trigger' && - triggerConfigTabSchema && ( -
- {triggerConfigTabSchema.stages.map((stage) => - renderDynamicForms(stage, 'trigger'), - )} -
+ {formLabel.name === 'trigger' && + triggerConfigTabSchema && ( +
+ {triggerConfigTabSchema.stages.map((stage) => + renderDynamicForms(stage, 'trigger'), )} +
+ )} - {formLabel.name === 'safety' && - safetyConfigTabSchema && ( -
- {safetyConfigTabSchema.stages.map((stage) => - renderDynamicForms(stage, 'safety'), - )} -
- )} + {formLabel.name === 'safety' && safetyConfigTabSchema && ( +
+ {safetyConfigTabSchema.stages.map((stage) => + renderDynamicForms(stage, 'safety'), + )} +
+ )} - {formLabel.name === 'output' && - outputConfigTabSchema && ( -
- {outputConfigTabSchema.stages.map((stage) => - renderDynamicForms(stage, 'output'), - )} -
- )} - + {formLabel.name === 'output' && outputConfigTabSchema && ( +
+ {outputConfigTabSchema.stages.map((stage) => + renderDynamicForms(stage, 'output'), + )} +
)} ))} From a9c585aa293148b9217e90bbd9f577f311a68692 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=9C=E9=9B=A8?= <58286951@qq.com> Date: Tue, 31 Mar 2026 14:29:59 +0800 Subject: [PATCH 32/36] chore(gitignore): ignore local agent workflow artifacts --- .gitignore | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitignore b/.gitignore index 7d870c336..c8a26ca34 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,9 @@ src/langbot/web/ /dist /build *.egg-info + +# Local agent/workflow artifacts +.codex/ +docs/superpowers/ +openspec/ +plan/ From 168360f33b196a5b1959a79326b16a7b3f79fe16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=9C=E9=9B=A8?= <58286951@qq.com> Date: Tue, 31 Mar 2026 18:29:05 +0800 Subject: [PATCH 33/36] fix(wecomcs): restore sharding config and prevent history replay --- src/langbot/pkg/platform/sources/wecomcs.py | 24 ++++++- .../pkg/platform/wecomcs/config_resolver.py | 39 ++++++++-- .../pkg/platform/wecomcs/pull_worker.py | 7 +- .../platform/test_wecomcs_adapter_config.py | 24 +++++-- .../platform/test_wecomcs_pull_worker.py | 72 +++++++++++++++++++ 5 files changed, 149 insertions(+), 17 deletions(-) diff --git a/src/langbot/pkg/platform/sources/wecomcs.py b/src/langbot/pkg/platform/sources/wecomcs.py index 7a5a0ae49..489951243 100644 --- a/src/langbot/pkg/platform/sources/wecomcs.py +++ b/src/langbot/pkg/platform/sources/wecomcs.py @@ -18,6 +18,7 @@ import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger from ..wecomcs.config_resolver import resolve_wecomcs_runtime_settings from ..wecomcs.runtime import WecomCSSchedulerRuntime +from ..wecomcs.pull_trigger_publisher import WecomCSPullTriggerPublisher class WecomMessageConverter(abstract_platform_adapter.AbstractMessageConverter): @@ -258,18 +259,35 @@ def set_bot_uuid(self, bot_uuid: str): self.bot.bot_uuid = bot_uuid ap = getattr(self.logger, 'ap', None) - if ap is None or getattr(ap, 'redis_mgr', None) is None or not ap.redis_mgr.is_available(): + if ap is None or getattr(ap, 'instance_config', None) is None: return scheduler_config = dict(ap.instance_config.data.get('wecomcs_scheduler', {})) scheduler_config.update(self.resolved_wecomcs_runtime_settings) - if not scheduler_config.get('enabled', False): + + redis_mgr = getattr(ap, 'redis_mgr', None) + redis_available = redis_mgr is not None and redis_mgr.is_available() + self.bot.scheduler_enabled = bool(scheduler_config.get('enabled', False)) + if self.bot.scheduler_enabled and redis_available: + self.bot.pull_trigger_publisher = WecomCSPullTriggerPublisher( + redis_mgr, + int(scheduler_config.get('pull_stream_shard_count', 8) or 8), + ) + else: + self.bot.pull_trigger_publisher = None + + if not redis_available: + return + + if not self.bot.scheduler_enabled: + self.scheduler_runtime = None + self.bot.state_store = None return self.scheduler_runtime = WecomCSSchedulerRuntime( bot_uuid=bot_uuid, client=self.bot, - redis_mgr=ap.redis_mgr, + redis_mgr=redis_mgr, scheduler_config=scheduler_config, persistence_mgr=ap.persistence_mgr, ) diff --git a/src/langbot/pkg/platform/wecomcs/config_resolver.py b/src/langbot/pkg/platform/wecomcs/config_resolver.py index 3b19f3515..1741a0688 100644 --- a/src/langbot/pkg/platform/wecomcs/config_resolver.py +++ b/src/langbot/pkg/platform/wecomcs/config_resolver.py @@ -13,8 +13,8 @@ 'retry_max_attempts': 3, 'retry_backoff_seconds': [15, 30, 45], 'lock_ttl_seconds': 60, - 'pull_stream_shard_count': 1, - 'process_stream_shard_count': 1, + 'pull_stream_shard_count': 8, + 'process_stream_shard_count': 16, } @@ -159,9 +159,36 @@ def resolve_wecomcs_runtime_settings(bot_config: dict[str, Any], global_schedule or list(WECOMCS_RUNTIME_DEFAULTS['retry_backoff_seconds']) ) - # English comment: We currently pin WeCom CS scheduling to one pull stream and one process stream per bot. - # This keeps routing deterministic and avoids cross-bot queue coupling until a broader runtime redesign lands. - resolved['pull_stream_shard_count'] = WECOMCS_RUNTIME_DEFAULTS['pull_stream_shard_count'] - resolved['process_stream_shard_count'] = WECOMCS_RUNTIME_DEFAULTS['process_stream_shard_count'] + resolved['pull_stream_shard_count'] = ( + _coerce_int( + bot_config.get('pull_stream_shard_count'), + minimum=1, + field_name='pull_stream_shard_count', + source='bot', + ) + or _coerce_int( + global_scheduler_config.get('pull_stream_shard_count'), + minimum=1, + field_name='pull_stream_shard_count', + source='global', + ) + or WECOMCS_RUNTIME_DEFAULTS['pull_stream_shard_count'] + ) + + resolved['process_stream_shard_count'] = ( + _coerce_int( + bot_config.get('process_stream_shard_count'), + minimum=1, + field_name='process_stream_shard_count', + source='bot', + ) + or _coerce_int( + global_scheduler_config.get('process_stream_shard_count'), + minimum=1, + field_name='process_stream_shard_count', + source='global', + ) + or WECOMCS_RUNTIME_DEFAULTS['process_stream_shard_count'] + ) return resolved diff --git a/src/langbot/pkg/platform/wecomcs/pull_worker.py b/src/langbot/pkg/platform/wecomcs/pull_worker.py index f072ed39b..2a9ae1320 100644 --- a/src/langbot/pkg/platform/wecomcs/pull_worker.py +++ b/src/langbot/pkg/platform/wecomcs/pull_worker.py @@ -155,7 +155,12 @@ async def handle_trigger(self, trigger_payload: dict) -> int: try: current_cursor = await self.state_store.get_cursor(bot_uuid, open_kfid) is_bootstrapped = await self.state_store.is_bootstrapped(bot_uuid, open_kfid) - if not current_cursor and not is_bootstrapped and self.state_store.cursor_bootstrap_mode == 'latest': + if not current_cursor and self.state_store.cursor_bootstrap_mode == 'latest': + # English comment: Some sync_msg batches are single-page and never return a resumable cursor. + # In that case we must keep using latest-bootstrap mode, otherwise the next webhook would replay full history. + _logger.debug( + f'[wecomcs][pull-worker] 使用latest bootstrap处理空cursor检查点: bot_uuid={bot_uuid}, open_kfid={open_kfid}, already_bootstrapped={is_bootstrapped}' + ) final_cursor, latest_messages = await self._bootstrap_latest_cursor(bot_uuid, open_kfid, callback_token, webhook_received_at) processed_count = await self._dispatch_messages(bot_uuid, open_kfid, latest_messages) await self.state_store.mark_bootstrapped(bot_uuid, open_kfid, final_cursor) diff --git a/tests/unit_tests/platform/test_wecomcs_adapter_config.py b/tests/unit_tests/platform/test_wecomcs_adapter_config.py index ec66919e6..63d27588d 100644 --- a/tests/unit_tests/platform/test_wecomcs_adapter_config.py +++ b/tests/unit_tests/platform/test_wecomcs_adapter_config.py @@ -55,6 +55,8 @@ async def test_wecomcs_bot_config_overrides_global_scheduler_settings(base_confi logger = FakeLogger( { 'enabled': True, + 'pull_stream_shard_count': 8, + 'process_stream_shard_count': 16, 'history_message_drop_threshold_seconds': 180, 'retry_max_attempts': 7, 'retry_backoff_seconds': [9, 18, 27], @@ -77,8 +79,10 @@ async def test_wecomcs_bot_config_overrides_global_scheduler_settings(base_confi adapter.set_bot_uuid('bot-1') assert adapter.scheduler_runtime is not None - assert adapter.scheduler_runtime.scheduler_config['pull_stream_shard_count'] == 1 - assert adapter.scheduler_runtime.scheduler_config['process_stream_shard_count'] == 1 + assert adapter.scheduler_runtime.scheduler_config['pull_stream_shard_count'] == 8 + assert adapter.scheduler_runtime.scheduler_config['process_stream_shard_count'] == 16 + assert adapter.bot.pull_trigger_publisher is not None + assert adapter.bot.pull_trigger_publisher.shard_count == 8 assert adapter.scheduler_runtime.scheduler_config['history_message_drop_threshold_seconds'] == 45 assert adapter.scheduler_runtime.scheduler_config['retry_max_attempts'] == 2 assert adapter.scheduler_runtime.scheduler_config['retry_backoff_seconds'] == [3, 6, 9] @@ -91,6 +95,8 @@ async def test_wecomcs_adapter_falls_back_to_global_scheduler_settings(base_conf logger = FakeLogger( { 'enabled': True, + 'pull_stream_shard_count': 6, + 'process_stream_shard_count': 12, 'history_message_drop_threshold_seconds': 150, 'retry_max_attempts': 5, 'retry_backoff_seconds': [10, 20], @@ -103,8 +109,10 @@ async def test_wecomcs_adapter_falls_back_to_global_scheduler_settings(base_conf adapter.set_bot_uuid('bot-1') assert adapter.scheduler_runtime is not None - assert adapter.scheduler_runtime.scheduler_config['pull_stream_shard_count'] == 1 - assert adapter.scheduler_runtime.scheduler_config['process_stream_shard_count'] == 1 + assert adapter.scheduler_runtime.scheduler_config['pull_stream_shard_count'] == 6 + assert adapter.scheduler_runtime.scheduler_config['process_stream_shard_count'] == 12 + assert adapter.bot.pull_trigger_publisher is not None + assert adapter.bot.pull_trigger_publisher.shard_count == 6 assert adapter.scheduler_runtime.scheduler_config['history_message_drop_threshold_seconds'] == 150 assert adapter.scheduler_runtime.scheduler_config['retry_max_attempts'] == 5 assert adapter.scheduler_runtime.scheduler_config['retry_backoff_seconds'] == [10, 20] @@ -114,14 +122,16 @@ async def test_wecomcs_adapter_falls_back_to_global_scheduler_settings(base_conf @pytest.mark.asyncio async def test_wecomcs_adapter_falls_back_to_code_defaults_when_configs_missing(base_config): - logger = FakeLogger({'enabled': True}) + logger = FakeLogger({'enabled': True, 'pull_stream_shard_count': 4, 'process_stream_shard_count': 5}) adapter = WecomCSAdapter(base_config, logger) adapter.set_bot_uuid('bot-1') assert adapter.scheduler_runtime is not None - assert adapter.scheduler_runtime.scheduler_config['pull_stream_shard_count'] == 1 - assert adapter.scheduler_runtime.scheduler_config['process_stream_shard_count'] == 1 + assert adapter.scheduler_runtime.scheduler_config['pull_stream_shard_count'] == 4 + assert adapter.scheduler_runtime.scheduler_config['process_stream_shard_count'] == 5 + assert adapter.bot.pull_trigger_publisher is not None + assert adapter.bot.pull_trigger_publisher.shard_count == 4 assert adapter.scheduler_runtime.scheduler_config['history_message_drop_threshold_seconds'] == 90 assert adapter.scheduler_runtime.scheduler_config['retry_max_attempts'] == 3 assert adapter.scheduler_runtime.scheduler_config['retry_backoff_seconds'] == [15, 30, 45] diff --git a/tests/unit_tests/platform/test_wecomcs_pull_worker.py b/tests/unit_tests/platform/test_wecomcs_pull_worker.py index 4f5460c2b..2db563f7c 100644 --- a/tests/unit_tests/platform/test_wecomcs_pull_worker.py +++ b/tests/unit_tests/platform/test_wecomcs_pull_worker.py @@ -179,6 +179,78 @@ async def on_message(message_data: dict): assert await store.is_bootstrapped('bot-1', 'kf-1') is True +@pytest.mark.asyncio +async def test_pull_worker_bootstrap_latest_reuses_latest_mode_when_checkpoint_cursor_is_empty(): + class SinglePageBootstrapClient: + def __init__(self): + self.calls: list[str | None] = [] + self.responses = [ + { + 'msg_list': [ + {'msgid': 'bootstrap-msg', 'msgtype': 'text', 'send_time': 1000}, + ], + 'next_cursor': '', + 'has_more': False, + }, + { + 'msg_list': [ + {'msgid': 'history-msg', 'msgtype': 'text', 'send_time': 1800}, + {'msgid': 'recent-msg', 'msgtype': 'text', 'send_time': 2000}, + ], + 'next_cursor': '', + 'has_more': False, + }, + ] + + async def fetch_sync_msg_page(self, callback_token: str, open_kfid: str, cursor: str | None = None): + self.calls.append(cursor) + return self.responses[min(len(self.calls) - 1, len(self.responses) - 1)] + + redis_mgr = FakeRedisManager() + store = WecomCSStateStore(redis_mgr, cursor_bootstrap_mode='latest') + client = SinglePageBootstrapClient() + handled_messages: list[str] = [] + + async def on_message(message_data: dict): + handled_messages.append(message_data['msgid']) + + worker = WecomCSPullWorker( + client, + store, + on_message, + message_state_ttl_seconds=600, + lock_ttl_seconds=60, + history_message_drop_threshold_seconds=60, + ) + + first_processed = await worker.handle_trigger( + { + 'bot_uuid': 'bot-1', + 'open_kfid': 'kf-1', + 'callback_token': 'token-1', + 'webhook_received_at': '1000', + } + ) + + assert first_processed == 1 + assert handled_messages == ['bootstrap-msg'] + assert await store.get_cursor('bot-1', 'kf-1') == '' + assert await store.is_bootstrapped('bot-1', 'kf-1') is True + + second_processed = await worker.handle_trigger( + { + 'bot_uuid': 'bot-1', + 'open_kfid': 'kf-1', + 'callback_token': 'token-2', + 'webhook_received_at': '2000', + } + ) + + assert second_processed == 1 + assert handled_messages == ['bootstrap-msg', 'recent-msg'] + assert client.calls == [None, None] + + @pytest.mark.asyncio async def test_pull_worker_clears_stale_cursor_and_restarts_with_current_token(): class InvalidCursorClient(FakeWecomClient): From e2673e2f8d29e800c2e4ecd39eac0350cb59c31f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=9C=E9=9B=A8?= <58286951@qq.com> Date: Wed, 1 Apr 2026 12:01:19 +0800 Subject: [PATCH 34/36] fix(web): prevent pipeline AI tab crash on create --- .../templates/metadata/pipeline/ai.yaml | 3 + .../dynamic-form/DynamicFormComponent.tsx | 34 +----- .../dynamic-form/DynamicFormItemComponent.tsx | 54 ++++++--- .../__tests__/dynamicFormValueUtils.test.ts | 75 ++++++++++++ .../dynamic-form/dynamicFormValueUtils.ts | 107 ++++++++++++++++++ .../home/pipelines/PipelineDetailDialog.tsx | 7 ++ .../pipeline-form/PipelineFormComponent.tsx | 7 ++ 7 files changed, 240 insertions(+), 47 deletions(-) create mode 100644 web/src/app/home/components/dynamic-form/__tests__/dynamicFormValueUtils.test.ts create mode 100644 web/src/app/home/components/dynamic-form/dynamicFormValueUtils.ts diff --git a/src/langbot/templates/metadata/pipeline/ai.yaml b/src/langbot/templates/metadata/pipeline/ai.yaml index 5d125760e..12cabfe4a 100644 --- a/src/langbot/templates/metadata/pipeline/ai.yaml +++ b/src/langbot/templates/metadata/pipeline/ai.yaml @@ -83,6 +83,9 @@ stages: zh_Hans: 除非您了解消息结构,否则请只使用 system 单提示词 type: prompt-editor required: true + default: + - role: system + content: '' - name: knowledge-bases label: en_US: Knowledge Bases diff --git a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx index f2a17b359..b579897e9 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx +++ b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx @@ -12,6 +12,7 @@ import { } from '@/components/ui/form'; import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent'; import { useEffect, useRef } from 'react'; +import { normalizeDynamicFieldValue } from '@/app/home/components/dynamic-form/dynamicFormValueUtils'; import { extractI18nObject } from '@/i18n/I18nProvider'; import { useTranslation } from 'react-i18next'; @@ -34,35 +35,6 @@ export default function DynamicFormComponent({ const previousInitialValues = useRef(initialValues); const { t } = useTranslation(); - // Normalize a form value according to its field type. - // This ensures legacy/malformed data (e.g. a plain string for - // model-fallback-selector) is coerced to the expected shape - // so that downstream components never crash. - const normalizeFieldValue = ( - item: IDynamicFormItemSchema, - value: unknown, - ): unknown => { - if (item.type === 'model-fallback-selector') { - if (value != null && typeof value === 'object' && !Array.isArray(value)) { - const obj = value as Record; - return { - primary: typeof obj.primary === 'string' ? obj.primary : '', - fallbacks: Array.isArray(obj.fallbacks) - ? (obj.fallbacks as unknown[]).filter( - (v): v is string => typeof v === 'string', - ) - : [], - }; - } - // Legacy string format or any other unexpected type - return { - primary: typeof value === 'string' ? value : '', - fallbacks: [], - }; - } - return value; - }; - // Build a zod schema dynamically from the form item configuration. const formSchema = z.object( itemConfigList.reduce( @@ -148,7 +120,7 @@ export default function DynamicFormComponent({ const rawValue = initialValues?.[item.name] ?? item.default; return { ...acc, - [item.name]: normalizeFieldValue(item, rawValue), + [item.name]: normalizeDynamicFieldValue(item, rawValue), }; }, {} as FormValues), }); @@ -174,7 +146,7 @@ export default function DynamicFormComponent({ const mergedValues = itemConfigList.reduce( (acc, item) => { const rawValue = initialValues[item.name] ?? item.default; - acc[item.name] = normalizeFieldValue(item, rawValue); + acc[item.name] = normalizeDynamicFieldValue(item, rawValue); return acc; }, {} as Record, diff --git a/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx index 0b4dd6f0f..0014bf3fc 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx +++ b/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx @@ -3,6 +3,7 @@ import { IDynamicFormItemSchema, IFileConfig, } from '@/app/infra/entities/form/dynamic'; +import { getDynamicFieldFallbackValue } from '@/app/home/components/dynamic-form/dynamicFormValueUtils'; import { Input } from '@/components/ui/input'; import { Select, @@ -195,16 +196,20 @@ export default function DynamicFormItemComponent({ case DynamicFormItemType.BOOLEAN: return ; - case DynamicFormItemType.STRING_ARRAY: + case DynamicFormItemType.STRING_ARRAY: { + const stringArrayValue = Array.isArray(field.value) + ? field.value.filter((item): item is string => typeof item === 'string') + : []; + return (
- {field.value.map((item: string, index: number) => ( + {stringArrayValue.map((item: string, index: number) => (
{ - const newValue = [...field.value]; + const newValue = [...stringArrayValue]; newValue[index] = e.target.value; field.onChange(newValue); }} @@ -213,7 +218,7 @@ export default function DynamicFormItemComponent({ type="button" className="p-2 hover:bg-gray-100 rounded" onClick={() => { - const newValue = field.value.filter( + const newValue = stringArrayValue.filter( (_: string, i: number) => i !== index, ); field.onChange(newValue); @@ -234,13 +239,14 @@ export default function DynamicFormItemComponent({ type="button" variant="outline" onClick={() => { - field.onChange([...field.value, '']); + field.onChange([...stringArrayValue, '']); }} > {t('common.add')}
); + } case DynamicFormItemType.SELECT: return ( @@ -562,7 +568,11 @@ export default function DynamicFormItemComponent({ ); - case DynamicFormItemType.KNOWLEDGE_BASE_MULTI_SELECTOR: + case DynamicFormItemType.KNOWLEDGE_BASE_MULTI_SELECTOR: { + const selectedKnowledgeBaseIds = Array.isArray(field.value) + ? field.value.filter((id): id is string => typeof id === 'string') + : []; + // Group KBs by Knowledge Engine name for multi-selector const multiKbsByEngine = knowledgeBases.reduce( (acc, kb) => { @@ -581,9 +591,9 @@ export default function DynamicFormItemComponent({ return ( <>
- {field.value && field.value.length > 0 ? ( + {selectedKnowledgeBaseIds.length > 0 ? (
- {field.value.map((kbId: string) => { + {selectedKnowledgeBaseIds.map((kbId: string) => { const currentKb = knowledgeBases.find( (base) => base.uuid === kbId, ); @@ -618,7 +628,7 @@ export default function DynamicFormItemComponent({ variant="ghost" size="icon" onClick={() => { - const newValue = field.value.filter( + const newValue = selectedKnowledgeBaseIds.filter( (id: string) => id !== kbId, ); field.onChange(newValue); @@ -642,7 +652,7 @@ export default function DynamicFormItemComponent({
); + } case DynamicFormItemType.BOT_SELECTOR: return ( @@ -738,10 +749,17 @@ export default function DynamicFormItemComponent({ ); - case DynamicFormItemType.PROMPT_EDITOR: + case DynamicFormItemType.PROMPT_EDITOR: { + const promptEditorValue = Array.isArray(field.value) + ? field.value + : ((getDynamicFieldFallbackValue(DynamicFormItemType.PROMPT_EDITOR) as { + role: string; + content: string; + }[]) ?? []); + return (
- {field.value.map( + {promptEditorValue.map( (item: { role: string; content: string }, index: number) => (
{/* 角色选择 */} @@ -753,7 +771,7 @@ export default function DynamicFormItemComponent({