From 56b3c152139aac1adb0113286d1f295dfb735cd8 Mon Sep 17 00:00:00 2001 From: fdc310 <2213070223@qq.com> Date: Sat, 11 Jul 2026 15:31:29 +0800 Subject: [PATCH] feat(lark): add functions for current input definitions and visible form content handling feat(qqofficial): update fallback text handling for non-streaming scenarios feat(difysvapi): enhance form content processing for interactive fields and actions test: add unit tests for Lark and QQ Official adapter functionalities --- src/langbot/pkg/platform/sources/lark.py | 105 +++++++++++++++--- .../pkg/platform/sources/qqofficial.py | 5 +- src/langbot/pkg/provider/runners/difysvapi.py | 33 +++++- .../unit_tests/platform/test_lark_adapter.py | 85 ++++++++++++++ .../platform/test_qqofficial_api.py | 78 ++++++++++++- .../provider/runners/test_difysvapi_runner.py | 40 +++++++ 6 files changed, 325 insertions(+), 21 deletions(-) diff --git a/src/langbot/pkg/platform/sources/lark.py b/src/langbot/pkg/platform/sources/lark.py index 81b0530ca..e100bab9f 100644 --- a/src/langbot/pkg/platform/sources/lark.py +++ b/src/langbot/pkg/platform/sources/lark.py @@ -91,6 +91,28 @@ def _lark_form_input_defs(form_data: dict) -> list[dict]: return list(form_data.get('all_input_defs') or form_data.get('input_defs') or []) +def _lark_current_input_defs(form_data: dict) -> list[dict]: + """Return only the field that belongs to the current interactive step.""" + if form_data.get('_action_select_only'): + return [] + input_defs = list(form_data.get('input_defs') or []) + current_field = str(form_data.get('_current_input_field') or '').strip() + if not current_field: + return input_defs + return [field for field in input_defs if _dify_field_name(field) == current_field] + + +def _lark_should_update_stream_element( + *, + resume_from: bool, + form_data: dict | None, + msg_seq: int, + is_final: bool, +) -> bool: + """Return whether the still-open streaming element should be updated.""" + return not resume_from and not form_data and (msg_seq % 8 == 0 or is_final) + + def _lark_display_input_value(field: dict, value: typing.Any) -> str: field_type = _dify_field_type(field) if field_type == 'file': @@ -116,6 +138,41 @@ def _lark_display_input_value(field: dict, value: typing.Any) -> str: return str(value) +def _lark_visible_form_content(form_data: dict) -> str: + """Return stage content with completed values interleaved for final actions.""" + source_content = form_data.get('form_content') or '' + if form_data.get('_action_select_only'): + source_content = form_data.get('raw_form_content') or source_content + + fields = { + _dify_field_name(field): field for field in _lark_form_input_defs(form_data) if _dify_field_name(field) + } + inputs = form_data.get('inputs') or {} + + def replace_placeholder(match: re.Match[str]) -> str: + field_name = match.group(1).strip() + field = fields.get(field_name) + if not field or inputs.get(field_name) in (None, '', []): + return '' + lines = _lark_completed_input_lines( + { + 'input_defs': [field], + 'inputs': {field_name: inputs[field_name]}, + } + ) + return lines[0] if lines else '' + + source_content = re.sub( + r'\{\{#\$output\.([^#{}]+)#\}\}', + replace_placeholder, + str(source_content), + ) + return _lark_clean_form_content( + str(source_content), + _lark_form_input_defs(form_data), + ) + + def _lark_completed_input_lines(form_data: dict) -> list[str]: inputs = form_data.get('inputs') or {} if not isinstance(inputs, dict): @@ -1037,6 +1094,8 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): 'user': f'{launcher_type.value}_{launcher_id}', 'inputs': form_inputs, } + if action_value_obj.get('_input_progress'): + form_action_data['_input_progress'] = True context = getattr(event.event, 'context', None) open_message_id = getattr(context, 'open_message_id', None) @@ -1990,7 +2049,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): 'inputs': self.card_form_inputs.get(card_id, {}), } ) - if completed_lines: + if completed_lines and not all(line in stored_form_content for line in completed_lines): notice_parts.append('---\n' + '\n'.join(completed_lines)) notice_parts.append(f'---\n✅ 已选择:**{action_title}**') selected_notice = '\n\n'.join(notice_parts) @@ -2067,7 +2126,12 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): return # ── NORMAL streaming (non-resume): update streaming_txt in-place ────── - if not resume_from and (msg_seq % 8 == 0 or is_final): + if _lark_should_update_stream_element( + resume_from=resume_from, + form_data=form_data, + msg_seq=msg_seq, + is_final=is_final, + ): cached = self.card_streaming_text.get(card_id) if text_message != cached: self.card_streaming_text[card_id] = text_message @@ -2150,11 +2214,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): self.card_pre_pause_text[card_id] = self.card_streaming_text.get(card_id, '') self.card_streaming_text[card_id] = '' # Store cleaned form state for the resume notice. - input_defs = _lark_form_input_defs(form_data) - self.card_form_content[card_id] = _lark_clean_form_content( - form_data.get('raw_form_content') or form_data.get('form_content', ''), - input_defs, - ) + self.card_form_content[card_id] = _lark_visible_form_content(form_data) self.card_form_input_defs[card_id] = _lark_form_input_defs(form_data) self.card_form_inputs[card_id] = dict(form_data.get('inputs') or {}) else: @@ -2238,7 +2298,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): input_name_map: dict[str, str] = {} file_help_lines: list[str] = [] - for idx, field in enumerate(form_data.get('input_defs') or [], start=1): + for idx, field in enumerate(_lark_current_input_defs(form_data), start=1): field_name = _dify_field_name(field) if not field_name: continue @@ -2328,7 +2388,6 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): workflow_run_id = form_data.get('workflow_run_id', '') node_title = form_data.get('node_title', '') or 'Human Input Required' form_content = form_data.get('form_content', '') - raw_form_content = form_data.get('raw_form_content') or form_content input_defs = _lark_form_input_defs(form_data) # When form_data is set, the visible content is rendered inside the @@ -2353,10 +2412,17 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): form_field_elements, input_name_map, file_help_lines = self._build_lark_form_field_elements(form_data) uses_form_container = bool(form_field_elements or input_name_map) if form_data: - form_content = _lark_clean_form_content(raw_form_content, input_defs) + form_content = _lark_visible_form_content(form_data) self.card_form_content[card_id] = form_content self.card_form_input_defs[card_id] = input_defs self.card_form_inputs[card_id] = dict(form_data.get('inputs') or {}) + is_field_step = bool(form_data.get('_current_input_field')) and not form_data.get('_action_select_only') + if is_field_step: + actions = ( + [{'_input_progress': True, 'id': '', 'title': 'Next', 'button_style': 'primary'}] + if uses_form_container + else [] + ) for action in actions: action_id = action.get('id', '') action_title = action.get('title', action_id) @@ -2387,6 +2453,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): 'session_key': session_key, 'card_id': card_id, 'input_name_map': input_name_map, + '_input_progress': bool(action.get('_input_progress')), }, } ], @@ -2419,11 +2486,15 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): 'margin': '0px 0px 8px 0px', } ) - completed_lines = _lark_completed_input_lines( - { - 'input_defs': input_defs, - 'inputs': form_data.get('inputs') or {}, - } + completed_lines = ( + [] + if form_data.get('_action_select_only') + else _lark_completed_input_lines( + { + 'input_defs': input_defs, + 'inputs': form_data.get('inputs') or {}, + } + ) ) if completed_lines: interactive_elements.append( @@ -2435,7 +2506,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): 'margin': '0px 0px 8px 0px', } ) - if file_help_lines and uses_form_container: + if file_help_lines: interactive_elements.append( { 'tag': 'markdown', @@ -2798,6 +2869,8 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): 'user': f'{launcher_type.value}_{launcher_id}', 'inputs': form_inputs, } + if action_value_obj.get('_input_progress'): + form_action_data['_input_progress'] = True open_message_id = context_data.get('open_message_id') card_id = self.reply_message_card_ids.get(str(open_message_id)) if open_message_id else None diff --git a/src/langbot/pkg/platform/sources/qqofficial.py b/src/langbot/pkg/platform/sources/qqofficial.py index db823eef7..f5923bf1c 100644 --- a/src/langbot/pkg/platform/sources/qqofficial.py +++ b/src/langbot/pkg/platform/sources/qqofficial.py @@ -576,7 +576,8 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter if not message_id or message_id not in self._stream_ctx: # 非流式场景(如群聊不支持流式),累积文本后一次性回复 if chunk_text: - self._fallback_text[message_id] = self._fallback_text.get(message_id, '') + chunk_text + # Chunks carry the latest full snapshot, not a text delta. + self._fallback_text[message_id] = chunk_text self._fallback_text_ts[message_id] = time.time() if is_final: full_text = self._fallback_text.pop(message_id, '') @@ -589,7 +590,7 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter # 累积文本 if chunk_text: - ctx['accumulated_text'] += chunk_text + ctx['accumulated_text'] = chunk_text # 未启动会话时,等第一个有内容的 chunk 来建立会话 if not ctx['session_started']: diff --git a/src/langbot/pkg/provider/runners/difysvapi.py b/src/langbot/pkg/provider/runners/difysvapi.py index d7b365a55..b1845f0aa 100644 --- a/src/langbot/pkg/provider/runners/difysvapi.py +++ b/src/langbot/pkg/provider/runners/difysvapi.py @@ -408,11 +408,40 @@ def _format_single_form_field_text(field: dict[str, typing.Any]) -> str: return f'{name} ({typ}): reply "{name}: "' +def _form_content_placeholder_matches(form_content: str) -> list[re.Match[str]]: + return list(re.finditer(r'\{\{#\$output\.([^#{}]+)#\}\}', form_content or '')) + + +def _form_content_for_field(form_content: str, field: dict[str, typing.Any]) -> str: + """Return the template section immediately preceding a field placeholder.""" + field_name = _field_name(field) + matches = _form_content_placeholder_matches(form_content) + for index, match in enumerate(matches): + if match.group(1).strip() != field_name: + continue + start = matches[index - 1].end() if index else 0 + return form_content[start : match.start()].strip() + return '' + + +def _form_content_for_actions(form_content: str, input_defs: list[dict[str, typing.Any]]) -> str: + """Return content after the last form-field placeholder for the action step.""" + field_names = {_field_name(field) for field in input_defs if _field_name(field)} + matches = [ + match for match in _form_content_placeholder_matches(form_content) if match.group(1).strip() in field_names + ] + if not matches: + return _strip_form_field_placeholders(form_content, input_defs) + return form_content[matches[-1].end() :].strip() + + def _field_input_form_data(pending_form: dict[str, typing.Any], field: dict[str, typing.Any] | None) -> dict | None: if not field: return None + raw_form_content = pending_form.get('raw_form_content') or '' + field_content = _form_content_for_field(raw_form_content, field) return { - 'form_content': _format_single_form_field_text(field), + 'form_content': field_content or _format_single_form_field_text(field), 'raw_form_content': pending_form.get('raw_form_content') or pending_form.get('form_content') or '', 'input_defs': pending_form.get('input_defs') or [], 'all_input_defs': pending_form.get('input_defs') or [], @@ -431,7 +460,7 @@ def _action_select_form_data(pending_form: dict[str, typing.Any]) -> dict[str, t return None form_content = pending_form.get('raw_form_content') or pending_form.get('form_content') or '' return { - 'form_content': _strip_form_field_placeholders(form_content, pending_form.get('input_defs') or []), + 'form_content': _form_content_for_actions(form_content, pending_form.get('input_defs') or []), 'raw_form_content': form_content, 'input_defs': [], 'all_input_defs': pending_form.get('input_defs') or [], diff --git a/tests/unit_tests/platform/test_lark_adapter.py b/tests/unit_tests/platform/test_lark_adapter.py index d7c372610..933d27cf0 100644 --- a/tests/unit_tests/platform/test_lark_adapter.py +++ b/tests/unit_tests/platform/test_lark_adapter.py @@ -1,12 +1,97 @@ """Tests for Lark adapter helper behavior.""" from langbot.pkg.platform.sources.lark import ( + LarkAdapter, _lark_clean_form_content, _lark_completed_input_lines, + _lark_current_input_defs, _lark_extract_action_form_inputs, + _lark_should_update_stream_element, + _lark_visible_form_content, ) +def test_lark_current_input_defs_only_returns_active_stage(): + input_defs = [ + {'output_variable_name': 'us_input', 'type': 'paragraph'}, + {'output_variable_name': 'xiala', 'type': 'select'}, + ] + + assert _lark_current_input_defs( + { + '_current_input_field': 'xiala', + 'input_defs': input_defs, + } + ) == [input_defs[1]] + assert ( + _lark_current_input_defs( + { + '_action_select_only': True, + 'input_defs': input_defs, + } + ) + == [] + ) + + +def test_lark_form_field_elements_only_render_active_stage(): + adapter = LarkAdapter.model_construct() + form_data = { + '_current_input_field': 'xiala', + 'input_defs': [ + {'output_variable_name': 'us_input', 'type': 'paragraph'}, + { + 'output_variable_name': 'xiala', + 'type': 'select', + 'option_source': {'type': 'constant', 'value': ['1', '2']}, + }, + ], + } + + elements, input_name_map, file_help_lines = adapter._build_lark_form_field_elements(form_data) + + assert len(elements) == 1 + assert elements[0]['tag'] == 'select_static' + assert elements[0]['label']['content'] == 'xiala' + assert list(input_name_map.values()) == ['xiala'] + assert file_help_lines == [] + + +def test_lark_form_stage_skips_closed_streaming_element_update(): + assert not _lark_should_update_stream_element( + resume_from=False, + form_data={'_current_input_field': 'xiala'}, + msg_seq=1, + is_final=True, + ) + assert _lark_should_update_stream_element( + resume_from=False, + form_data=None, + msg_seq=1, + is_final=True, + ) + + +def test_lark_final_action_stage_interleaves_prompts_and_completed_values(): + form_content = _lark_visible_form_content( + { + '_action_select_only': True, + 'raw_form_content': ('11\nQuestion\n{{#$output.us_input#}}\nChoose an answer\n{{#$output.xiala#}}\n'), + 'all_input_defs': [ + {'output_variable_name': 'us_input', 'type': 'paragraph'}, + {'output_variable_name': 'xiala', 'type': 'select'}, + ], + 'inputs': {'us_input': 'hello', 'xiala': '2'}, + } + ) + + assert '{{#$output.' not in form_content + assert form_content.startswith('11\nQuestion') + assert form_content.index('Question') < form_content.index('us_input') + assert form_content.index('us_input') < form_content.index('Choose an answer') + assert form_content.index('Choose an answer') < form_content.index('xiala') + + def test_lark_completed_input_lines_include_text_select_and_files(): lines = _lark_completed_input_lines( { diff --git a/tests/unit_tests/platform/test_qqofficial_api.py b/tests/unit_tests/platform/test_qqofficial_api.py index df63f583b..db86a2483 100644 --- a/tests/unit_tests/platform/test_qqofficial_api.py +++ b/tests/unit_tests/platform/test_qqofficial_api.py @@ -2,10 +2,12 @@ import asyncio import time -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch import pytest +import langbot_plugin.api.entities.builtin.platform.message as platform_message + from langbot.libs.qq_official_api.api import ( QQ_SELECT_ACTION_PREFIX, build_keyboard_from_select_field, @@ -66,6 +68,80 @@ def test_qq_non_select_field_does_not_build_keyboard(): assert build_keyboard_from_select_field(form_data)['content']['rows'] == [] +def _stream_test_adapter(): + from langbot.pkg.platform.sources.qqofficial import QQOfficialAdapter + + adapter = QQOfficialAdapter.model_construct() + adapter.logger = AsyncMock() + adapter.bot = MagicMock() + adapter.bot.send_stream_msg = AsyncMock(return_value={'id': 'stream-1'}) + adapter.ap = None + adapter._stream_ctx = {} + adapter._stream_ctx_ts = {} + adapter._fallback_text = {} + adapter._fallback_text_ts = {} + return adapter + + +@pytest.mark.asyncio +async def test_qq_stream_uses_cumulative_chunks_as_snapshots(): + adapter = _stream_test_adapter() + adapter._stream_ctx['message-1'] = { + 'user_openid': 'user-1', + 'msg_id': 'source-1', + 'stream_msg_id': None, + 'msg_seq': 1, + 'index': 0, + 'last_update_ts': 0, + 'accumulated_text': '', + 'sent_length': 0, + 'session_started': False, + } + adapter._stream_ctx_ts['message-1'] = time.time() + source = MagicMock() + + await adapter.reply_message_chunk( + source, + {'resp_message_id': 'message-1'}, + platform_message.MessageChain([platform_message.Plain(text='one')]), + ) + await adapter.reply_message_chunk( + source, + {'resp_message_id': 'message-1'}, + platform_message.MessageChain([platform_message.Plain(text='one two')]), + is_final=True, + ) + + assert [call.kwargs['content'] for call in adapter.bot.send_stream_msg.await_args_list] == [ + 'one', + ' two', + ] + + +@pytest.mark.asyncio +async def test_qq_non_streaming_fallback_keeps_latest_snapshot_only(): + from langbot.pkg.platform.sources.qqofficial import QQOfficialAdapter + + adapter = _stream_test_adapter() + source = MagicMock() + + with patch.object(QQOfficialAdapter, 'reply_message', new=AsyncMock()) as reply_message: + await adapter.reply_message_chunk( + source, + {'resp_message_id': 'message-1'}, + platform_message.MessageChain([platform_message.Plain(text='Hel')]), + ) + await adapter.reply_message_chunk( + source, + {'resp_message_id': 'message-1'}, + platform_message.MessageChain([platform_message.Plain(text='Hello')]), + is_final=True, + ) + + sent_chain = reply_message.await_args.args[1] + assert str(sent_chain) == 'Hello' + + @pytest.mark.asyncio async def test_qq_select_click_enqueues_input_progress_query(): import langbot.pkg.core.app # noqa: F401 diff --git a/tests/unit_tests/provider/runners/test_difysvapi_runner.py b/tests/unit_tests/provider/runners/test_difysvapi_runner.py index 80e77b563..911e61fe9 100644 --- a/tests/unit_tests/provider/runners/test_difysvapi_runner.py +++ b/tests/unit_tests/provider/runners/test_difysvapi_runner.py @@ -246,6 +246,46 @@ class TestDifyHumanInputForms: assert 'comment (paragraph)' in display_form_content assert snapshot['form_content'] == display_form_content + def test_interactive_form_content_is_split_by_field_placeholders(self): + from langbot.pkg.provider.runners.difysvapi import ( + _action_select_form_data, + _field_input_form_data, + ) + + input_defs = [ + {'output_variable_name': 'us_input', 'type': 'paragraph'}, + { + 'output_variable_name': 'xiala', + 'type': 'select', + 'option_source': {'type': 'constant', 'value': ['1', '2']}, + }, + ] + pending_form = { + 'raw_form_content': ( + '1\n请输入你的问题\n{{#$output.us_input#}}\n请选择你的答案\n{{#$output.xiala#}}\n提交前请确认' + ), + 'input_defs': input_defs, + 'actions': [{'id': 'yes', 'title': 'yes'}], + 'inputs': {}, + } + + first_step = _field_input_form_data(pending_form, input_defs[0]) + second_step = _field_input_form_data(pending_form, input_defs[1]) + action_step = _action_select_form_data(pending_form) + + assert first_step['form_content'] == '1\n请输入你的问题' + assert second_step['form_content'] == '请选择你的答案' + assert action_step['form_content'] == '提交前请确认' + + def test_interactive_form_content_without_placeholder_uses_compatibility_fallback(self): + from langbot.pkg.provider.runners.difysvapi import _field_input_form_data + + field = {'output_variable_name': 'comment', 'type': 'paragraph'} + + form_data = _field_input_form_data({'raw_form_content': 'Please review', 'input_defs': [field]}, field) + + assert form_data['form_content'] == 'comment (paragraph): reply "comment: "' + @pytest.mark.asyncio async def test_match_pending_form_collects_select_and_text_inputs(self): from langbot.pkg.provider.runners import difysvapi