From 6cae954a673ed493e2bb6c5010990cded3e93343 Mon Sep 17 00:00:00 2001 From: fdc310 <2213070223@qq.com> Date: Sat, 11 Jul 2026 19:23:42 +0800 Subject: [PATCH] feat: Refactor input handling and feedback messages across multiple adapters --- src/langbot/libs/qq_official_api/api.py | 5 +- src/langbot/libs/wecom_ai_bot_api/api.py | 10 ++-- src/langbot/pkg/platform/sources/dingtalk.py | 26 +++------- src/langbot/pkg/platform/sources/discord.py | 3 +- src/langbot/pkg/platform/sources/lark.py | 14 ++---- .../pkg/platform/sources/qqofficial.py | 49 ++++--------------- src/langbot/pkg/platform/sources/telegram.py | 15 ++---- .../platform/test_dingtalk_adapter.py | 2 +- .../unit_tests/platform/test_lark_adapter.py | 8 +-- .../platform/test_qqofficial_api.py | 31 ++++++++++++ .../platform/test_telegram_adapter.py | 5 +- .../platform/test_wecombot_template_card.py | 4 +- 12 files changed, 77 insertions(+), 95 deletions(-) diff --git a/src/langbot/libs/qq_official_api/api.py b/src/langbot/libs/qq_official_api/api.py index 69ab414a8..0ba1917d9 100644 --- a/src/langbot/libs/qq_official_api/api.py +++ b/src/langbot/libs/qq_official_api/api.py @@ -693,7 +693,7 @@ class QQOfficialClient: target_type: str, target_id: str, markdown_content: str, - keyboard: dict, + keyboard: Optional[dict] = None, msg_id: Optional[str] = None, event_id: Optional[str] = None, msg_seq: int = 1, @@ -734,9 +734,10 @@ class QQOfficialClient: body: dict[str, Any] = { 'msg_type': 2, 'markdown': {'content': markdown_content}, - 'keyboard': keyboard, 'msg_seq': msg_seq, } + if keyboard and keyboard.get('content', {}).get('rows'): + body['keyboard'] = keyboard if msg_id: body['msg_id'] = msg_id if event_id: diff --git a/src/langbot/libs/wecom_ai_bot_api/api.py b/src/langbot/libs/wecom_ai_bot_api/api.py index 60a3f45ba..4bbe30222 100644 --- a/src/langbot/libs/wecom_ai_bot_api/api.py +++ b/src/langbot/libs/wecom_ai_bot_api/api.py @@ -1129,12 +1129,10 @@ def build_button_interaction_payload( input_hint_lines = _wecom_input_hint_lines(form_data) if input_hint_lines: sub_title_parts.append('Fill these fields in chat before choosing an action:\n' + '\n'.join(input_hint_lines)) - elif should_show_actions and actions: - sub_title_parts.append('Choose an action to continue.') if overflow: extra_lines = [f' - {a.get("title") or a.get("id") or ""} (回复 id: {a.get("id") or ""})' for a in overflow] sub_title_parts.append(f'另有 {len(overflow)} 个选项不在按钮列表中,可直接回复 id:\n' + '\n'.join(extra_lines)) - sub_title_text = '\n\n'.join(sub_title_parts) or '请选择一个操作以继续。' + sub_title_text = '\n\n'.join(sub_title_parts) button_list = [] for idx, action in enumerate(visible_actions): @@ -1452,8 +1450,8 @@ def build_button_interaction_update_card( } if action_key == clean_action_id: button['style'] = _wecom_button_style(action, selected=True) - button['text'] = f'已选择:{button_title}' - button['replace_text'] = f'已选择:{button_title}' + button['text'] = f'✅ {button_title}' + button['replace_text'] = f'✅ {button_title}' matched = True button_list.append(button) @@ -1463,7 +1461,7 @@ def build_button_interaction_update_card( 'text': action_title or clean_action_id, 'style': 1, 'key': clean_action_id, - 'replace_text': f'已选择:{action_title or clean_action_id}', + 'replace_text': f'✅ {action_title or clean_action_id}', } ) diff --git a/src/langbot/pkg/platform/sources/dingtalk.py b/src/langbot/pkg/platform/sources/dingtalk.py index e412cf0ae..7e7a7e16a 100644 --- a/src/langbot/pkg/platform/sources/dingtalk.py +++ b/src/langbot/pkg/platform/sources/dingtalk.py @@ -296,14 +296,8 @@ def _dingtalk_completed_input_lines(form_data: dict) -> list[str]: value = inputs.get(field_name) if value in (None, '', []): continue - field_type = _dingtalk_field_type(field) display_value = _dingtalk_display_input_value(field, value) - if field_type == 'select': - lines.append(f'✅ 已选择 {field_name}:{display_value}') - elif field_type in {'file', 'file-list'}: - lines.append(f'✅ 已上传 {field_name}:{display_value}') - else: - lines.append(f'✅ 已填写 {field_name}:{display_value}') + lines.append(f'✅ {field_name}:{display_value}') return lines @@ -960,9 +954,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): input_hint_lines = [] if native_field else _dingtalk_input_hint_lines(form_data) if input_hint_lines: parts.append('Fill these fields in chat before choosing an action:
' + '
'.join(input_hint_lines)) - elif should_show_actions and actions: - parts.append('Choose an action to continue.') - display_content = '

'.join(parts) or '请选择一个操作以继续。' + display_content = '

'.join(parts) try: await self.bot.update_card_data( @@ -1062,9 +1054,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): input_hint_lines = [] if native_field else _dingtalk_input_hint_lines(form_data) if input_hint_lines: parts.append('Fill these fields in chat before choosing an action:
' + '
'.join(input_hint_lines)) - elif should_show_actions and actions: - parts.append('Choose an action to continue.') - display_content = '

'.join(parts) or '请选择一个操作以继续。' + display_content = '

'.join(parts) btns = self._build_btns(actions if should_show_actions else [], out_track_id) @@ -1100,7 +1090,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): """Create a new card for resumed-workflow streaming output. Used after a button click triggers a synthetic event — the form - card stays put with the "已选择" notice, and a fresh card is + card stays put with the selection notice, and a fresh card is spawned here for the LLM reply to stream into. """ form_template_id = (self.config.get('human_input_card_template_id') or '').strip() @@ -1349,7 +1339,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): return # Visual feedback on the form card itself: keep the prompt visible, - # add a "已选择" line, remove the buttons. The resumed-workflow + # add a selection line, remove the buttons. The resumed-workflow # output lives on a separate new card (lazy-created in # reply_message_chunk on the synthetic event), so the form card # stays put as a record of the user's selection. @@ -1367,7 +1357,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): # Crucial: do NOT leave the form card's out_track_id in # active_turn_card — otherwise create_message_card for the # synthetic event would reuse it for the resume output, painting - # the LLM reply on top of the "已选择" notice. Clear it so the + # the LLM reply on top of the selection notice. Clear it so the # resume goes through the lazy-create path and spawns a fresh card. session_key = state.get('session_key', '') if session_key and self.active_turn_card.get(session_key) == out_track_id: @@ -1487,7 +1477,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): ) -> None: """Update the form card to acknowledge the user's selection. - Keeps the original prompt visible, adds a "已选择: X" notice, and + Keeps the original prompt visible, adds a selection notice, and clears the buttons. The card stays as a permanent record of the choice; the resumed workflow's output goes to a separate new card. """ @@ -1504,7 +1494,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): ) if completed_lines and not all(line in form_content for line in completed_lines): parts.append('
' + '
'.join(completed_lines)) - parts.append(f'
✅ 已选择:**{action_title}**') + parts.append(f'
✅ {action_title}') content = '

'.join(parts) if self.ap is not None: self.ap.logger.info(f'DingTalk _mark_card_resolved: out_track_id={out_track_id} action={action_title!r}') diff --git a/src/langbot/pkg/platform/sources/discord.py b/src/langbot/pkg/platform/sources/discord.py index 9613e9bcc..3cb13a251 100644 --- a/src/langbot/pkg/platform/sources/discord.py +++ b/src/langbot/pkg/platform/sources/discord.py @@ -1396,7 +1396,6 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): body_parts: list[str] = [] if form_content: body_parts.append(form_content) - body_parts.append('Please select an option below:') embed_body = '\n\n'.join(body_parts) # Discord embed.description has a 4096 char limit — defensive trim. if len(embed_body) > 4000: @@ -1589,7 +1588,7 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): stale: bool = False, ) -> None: """Disable all buttons on the form view and annotate the chosen - one — mirrors DingTalk/Lark's in-card 已选择 feedback.""" + one — mirrors DingTalk/Lark's in-card selection feedback.""" try: for child in view.children: if not isinstance(child, discord_ui.Button): diff --git a/src/langbot/pkg/platform/sources/lark.py b/src/langbot/pkg/platform/sources/lark.py index e100bab9f..23dcfd90c 100644 --- a/src/langbot/pkg/platform/sources/lark.py +++ b/src/langbot/pkg/platform/sources/lark.py @@ -187,13 +187,7 @@ def _lark_completed_input_lines(form_data: dict) -> list[str]: if value in (None, '', []): continue display_value = _lark_display_input_value(field, value) - field_type = _dify_field_type(field) - if field_type == 'select': - lines.append(f'✅ 已选择 {field_name}:**{display_value}**') - elif field_type in {'file', 'file-list'}: - lines.append(f'✅ 已上传 {field_name}:**{display_value}**') - else: - lines.append(f'✅ 已填写 {field_name}:**{display_value}**') + lines.append(f'✅ {field_name}:{display_value}') return lines @@ -2014,7 +2008,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): Supports Dify form-action resume: when the runner yields a chunk with ``_resume_from_form=True``, the card transitions from buttons to a - grey "已选择" notice and a new ``streaming_txt_resume`` element is added + grey selection notice and a new ``streaming_txt_resume`` element is added for subsequent resume chunks to stream into. When ``_open_new_card=True`` on the final chunk, the existing card is @@ -2051,7 +2045,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): ) 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}**') + notice_parts.append(f'---\n✅ {action_title}') selected_notice = '\n\n'.join(notice_parts) else: selected_notice = '' @@ -2379,7 +2373,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): """Update the entire card layout. • form_data → show interactive buttons (initial Dify pause) - • notice_text → replace buttons with a grey "已选择" notice (resume transition) + • notice_text → replace buttons with a grey selection notice (resume transition) • resume_placeholder_text → add a streaming_txt_resume markdown element """ form_data = form_data or {} diff --git a/src/langbot/pkg/platform/sources/qqofficial.py b/src/langbot/pkg/platform/sources/qqofficial.py index f5923bf1c..3369391e8 100644 --- a/src/langbot/pkg/platform/sources/qqofficial.py +++ b/src/langbot/pkg/platform/sources/qqofficial.py @@ -842,51 +842,22 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter form_content = form_data.get('form_content') or '' is_field_step = bool(form_data.get('_current_input_field')) and not form_data.get('_action_select_only') parts = [f'### {node_title}'] + plain_parts = [node_title] if form_content.strip(): parts.append(form_content.strip()) - parts.append('请点击下方按钮选择:') + plain_parts.append(form_content.strip()) markdown_content = '\n\n'.join(parts) + plain_content = '\n\n'.join(plain_parts) keyboard = build_keyboard_from_select_field(form_data) if is_field_step else None - if is_field_step and not keyboard.get('content', {}).get('rows'): - field_parts = parts[:-1] if len(parts) > 1 else parts - text_msg = platform_message.MessageChain([platform_message.Plain(text='\n\n'.join(field_parts))]) - try: - await self.reply_message(message_source, text_msg) - except Exception: - await self.logger.error(f'QQ Official: field-step text send failed: {traceback.format_exc()}') - return - - sender_id = '' - if source is not None: - sender_id = ( - getattr(source, 'user_openid', None) - or getattr(source, 'member_openid', None) - or getattr(source, 'd_author_id', None) - or '' - ) - if not sender_id and message_source.sender is not None: - sender_id = str(getattr(message_source.sender, 'id', '') or '') - self._pending_forms[session_key] = { - 'form_data': form_data, - 'msg_id': msg_id, - 'sender_id': sender_id, - 'target_type': target_type, - 'target_id': target_id, - 'source_event_t': source.t if source is not None else None, - 'posted_at': time.time(), - } - await self.logger.info( - f'QQ Official: form field step posted session={session_key} ' - f'field={form_data.get("_current_input_field")!r}' - ) - return - - if keyboard is None: + is_text_field_step = is_field_step and not keyboard.get('content', {}).get('rows') + if is_text_field_step: + keyboard = None + if keyboard is None and not is_text_field_step: keyboard = build_keyboard_from_form(form_data, buttons_per_row=2) - if not keyboard.get('content', {}).get('rows'): + if keyboard is not None and not keyboard.get('content', {}).get('rows') and not is_text_field_step: # No actions to render — fall back to plain text. - text_msg = platform_message.MessageChain([platform_message.Plain(text=markdown_content)]) + text_msg = platform_message.MessageChain([platform_message.Plain(text=plain_content)]) try: await self.reply_message(message_source, text_msg) except Exception: @@ -935,7 +906,7 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter await self.logger.error( f'QQ Official: send_markdown_keyboard failed, falling back to text: {traceback.format_exc()}' ) - text_msg = platform_message.MessageChain([platform_message.Plain(text=markdown_content)]) + text_msg = platform_message.MessageChain([platform_message.Plain(text=plain_content)]) try: await self.reply_message(message_source, text_msg) except Exception: diff --git a/src/langbot/pkg/platform/sources/telegram.py b/src/langbot/pkg/platform/sources/telegram.py index afb583674..62d5ee49c 100644 --- a/src/langbot/pkg/platform/sources/telegram.py +++ b/src/langbot/pkg/platform/sources/telegram.py @@ -304,7 +304,7 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): action_title = self._form_action_titles[query.data] try: original_text = query.message.text or '' - selected_text = f'{original_text}\n\n✅ Selected: {action_title}' + selected_text = f'{original_text}\n\n✅ {action_title}' await query.edit_message_text(text=selected_text, reply_markup=None) except Exception: # If edit fails (e.g. message too long), just pass @@ -687,15 +687,10 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): effective_message = update.effective_message message_thread_id = getattr(effective_message, 'message_thread_id', None) if effective_message else None - if is_select_field: - prompt = f'Please select {select_field}:' - elif is_field_step: - prompt = f'Please reply with a value for {current_field}.' - else: - prompt = 'Please select an action:' - text_lines = [f'[{node_title}] {prompt}'] + heading = f'[{node_title}]' + text_lines = [heading] if form_content: - text_lines.insert(0, form_content) + text_lines.append(form_content) if oversized: # callback_data exceeds Telegram's 64-byte limit — fall back to @@ -722,7 +717,7 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): # prompts even when they cannot read ordinary group messages. 'reply_markup': ForceReply( selective=False, - input_field_placeholder=f'Enter {current_field}', + input_field_placeholder=current_field, ), } else: diff --git a/tests/unit_tests/platform/test_dingtalk_adapter.py b/tests/unit_tests/platform/test_dingtalk_adapter.py index 0850eab08..bf51cf55a 100644 --- a/tests/unit_tests/platform/test_dingtalk_adapter.py +++ b/tests/unit_tests/platform/test_dingtalk_adapter.py @@ -93,7 +93,7 @@ def test_dingtalk_completed_input_lines_include_text_and_select_values(): } ) - assert lines == ['✅ 已填写 comment:looks good', '✅ 已选择 choice:B'] + assert lines == ['✅ comment:looks good', '✅ choice:B'] def test_dingtalk_clean_form_content_uses_all_input_defs(): diff --git a/tests/unit_tests/platform/test_lark_adapter.py b/tests/unit_tests/platform/test_lark_adapter.py index 933d27cf0..59b91ee60 100644 --- a/tests/unit_tests/platform/test_lark_adapter.py +++ b/tests/unit_tests/platform/test_lark_adapter.py @@ -109,9 +109,9 @@ def test_lark_completed_input_lines_include_text_select_and_files(): ) assert lines == [ - '✅ 已填写 us_input:**你好**', - '✅ 已选择 xiala:**or**', - '✅ 已上传 files:**2 file(s)**', + '✅ us_input:你好', + '✅ xiala:or', + '✅ files:2 file(s)', ] @@ -193,4 +193,4 @@ def test_lark_completed_input_lines_display_select_value_from_object(): } ) - assert lines == ['✅ 已选择 xiala:**B**'] + assert lines == ['✅ xiala:B'] diff --git a/tests/unit_tests/platform/test_qqofficial_api.py b/tests/unit_tests/platform/test_qqofficial_api.py index db86a2483..0f791f612 100644 --- a/tests/unit_tests/platform/test_qqofficial_api.py +++ b/tests/unit_tests/platform/test_qqofficial_api.py @@ -75,6 +75,7 @@ def _stream_test_adapter(): adapter.logger = AsyncMock() adapter.bot = MagicMock() adapter.bot.send_stream_msg = AsyncMock(return_value={'id': 'stream-1'}) + adapter.bot.send_markdown_keyboard = AsyncMock(return_value={'id': 'message-1'}) adapter.ap = None adapter._stream_ctx = {} adapter._stream_ctx_ts = {} @@ -142,6 +143,36 @@ async def test_qq_non_streaming_fallback_keeps_latest_snapshot_only(): assert str(sent_chain) == 'Hello' +@pytest.mark.asyncio +async def test_qq_text_field_prompt_keeps_form_content(): + from langbot.pkg.platform.sources.qqofficial import QQOfficialAdapter + + adapter = _stream_test_adapter() + adapter._pending_forms = {} + adapter._session_event_ids = {} + adapter._anchor_msg_seq = {} + source = MagicMock() + source.d_id = 'source-1' + source.t = 'C2C_MESSAGE_CREATE' + event = MagicMock() + event.source_platform_object = source + event.sender.id = 'user-1' + form_data = { + '_current_input_field': 'us_input', + 'node_title': 'Manual input', + 'form_content': '1234\nEnter your question', + 'input_defs': [{'output_variable_name': 'us_input', 'type': 'paragraph'}], + 'actions': [{'id': 'yes', 'title': 'yes'}], + } + + with patch.object(QQOfficialAdapter, '_resolve_target_from_event', return_value=('c2c', 'user-1')): + await adapter._handle_form_chunk(event, platform_message.MessageChain([]), form_data) + + send_call = adapter.bot.send_markdown_keyboard.await_args.kwargs + assert send_call['markdown_content'] == '### Manual input\n\n1234\nEnter your question' + assert send_call['keyboard'] is None + + @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/platform/test_telegram_adapter.py b/tests/unit_tests/platform/test_telegram_adapter.py index 247d51b16..f3308f16e 100644 --- a/tests/unit_tests/platform/test_telegram_adapter.py +++ b/tests/unit_tests/platform/test_telegram_adapter.py @@ -116,5 +116,8 @@ async def test_telegram_text_field_does_not_show_action_buttons(): args = bot.send_message.await_args.kwargs assert isinstance(args['reply_markup'], ForceReply) assert args['reply_markup'].selective is False - assert 'Please reply with a value for us_input.' in args['text'] + assert args['reply_markup'].input_field_placeholder == 'us_input' + assert 'Please reply' not in args['text'] + assert args['text'].startswith('[人工介入]') + assert 'us_input (paragraph)' in args['text'] assert adapter._form_action_titles == {} diff --git a/tests/unit_tests/platform/test_wecombot_template_card.py b/tests/unit_tests/platform/test_wecombot_template_card.py index 7bdb387ca..0fc650aa2 100644 --- a/tests/unit_tests/platform/test_wecombot_template_card.py +++ b/tests/unit_tests/platform/test_wecombot_template_card.py @@ -81,10 +81,10 @@ def test_build_button_interaction_update_card_marks_clicked_button(): assert card['sub_title_text'] == 'Please choose one action.' assert card['button_list'][0] == {'text': 'Approve', 'style': 2, 'key': 'approve'} assert card['button_list'][1] == { - 'text': '已选择:Reject', + 'text': '✅ Reject', 'style': 1, 'key': 'reject', - 'replace_text': '已选择:Reject', + 'replace_text': '✅ Reject', } assert card['source'] == {'desc': 'LangBot'}