mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-17 09:56:06 +00:00
feat: add supports for dify hitl (#2226)
* feat: Implement workflow form handling for paused workflows - Added module-level storage for pending forms to manage state across sessions. - Introduced functions to set, get, and clear pending forms with expiration handling. - Enhanced DifyServiceAPIRunner to support resuming paused workflows via form actions. - Implemented logic to yield human input requests and display appropriate messages. - Updated workflow submission methods to handle paused states and resume actions. - Ensured proper merging of pending form actions with user inputs for seamless interaction. * feat: Add '_routed_by_rule' variable to form action in Lark and Telegram adapters * feat: Enhance Lark and Telegram adapters with new form handling for paused workflows * feat: Enhance TelegramAdapter to handle form action buttons and message threading * feat: Improve TelegramAdapter message handling with enhanced error management and draft message support * feat: Add the function for formatting human input text to support adapters without rich UI. * feat(dingtalk): implement human input card support and card action handling - Add a new module `card_callback.py` to handle card action button clicks from DingTalk. - Introduce `DingTalkCardActionHandler` to process card action callbacks and extract parameters. - Update `DingTalkAdapter` to manage card state and handle form input through a single card template. - Add configuration for `human_input_card_template_id` in `dingtalk.yaml` to specify the template for human input. - Create a new card template `dingtalk_human_input_card.json` for rendering human input prompts and buttons. * feat(dingtalk): enhance human input card functionality with streaming support and active turn management - Updated the DingTalk card template to enable streaming mode and multi-update configuration. - Removed the obsolete delete_card method from DingTalkClient to streamline card management. - Enhanced DingTalkAdapter to manage active turn cards and accumulated streaming text, ensuring a seamless user experience during human input prompts. - Modified the create_message_card method to utilize existing active cards for resumed workflows, preventing duplication. - Improved the _paint_form_on_card method to update existing cards with human input prompts and buttons dynamically. - Updated the dingtalk_human_input_card.json template to reflect the new streaming capabilities and configuration options. * feat(wecom): implement Dify human input pause handling with button interaction support * feat(qqofficial): implement Dify human input button interaction handling and markdown keyboard support * feat(qqofficial): implement one-click QR binding and enhance localization support * feat(discord): implement Discord form view with button interactions for Dify actions * fix(telegram): correct group chat type check and handle oversized callback data for Telegram actions fix(difysvapi): ensure safe access to remove-think configuration in pipeline settings * feat(dify): add support for chatflow app type and enhance human input handling * feat(telegram): add action title feedback for user selections in Telegram messages * feat(lark): enhance LarkAdapter to store form content for resume notices * feat(dingtalk): update display formatting for card content with HTML line breaks * feat(dingtalk): add feedback functionality to cards with 👍/👎 buttons - Implemented feedback state management for cards, allowing users to provide feedback via thumbs up/down buttons. - Enhanced card rendering to include feedback buttons when appropriate. - Registered feedback listeners to handle feedback events and update card states accordingly. - Updated the card template to support dynamic button rendering for feedback actions. - Improved error handling and logging for feedback actions and card updates. * fix: add Avatar component to dingtalk_human_input_card.json for enhanced user interaction * feat(wecom): add optional source block to interactive template cards for enhanced branding * feat(wecom): add functions for template card action extraction and update, enhance button interaction handling * feat(qqofficial): synchronize passive-reply counter with inbound message sequence * feat(qqofficial): add method to identify invisible form placeholder chunks in messages * feat(dingtalk): add download link for human input card template and enhance dynamic form configuration * feat(telegram): enhance message handling with group stream deletion and form placeholder detection * Add unit tests for DingTalk, Lark, WeComBot, and Dify service API runners - Implement tests for DingTalk adapter helper functions including form content cleaning, input extraction, and completed input lines. - Create unit tests for Lark adapter helper functions focusing on input extraction and completed input lines. - Add tests for WeComBot template card functionalities, including event extraction and payload building for human input. - Enhance Dify service API runner tests to cover human input forms, including input collection, action handling, and form snapshot extraction. * feat: Enhance Telegram and QQ Official adapters with select field handling and form action processing - Added support for select fields in Telegram adapter, including option extraction and callback handling. - Implemented form action processing for Telegram callbacks, improving user interaction feedback. - Introduced new helper functions for building keyboards and resolving select button actions in QQ Official adapter. - Enhanced DifyServiceAPIRunner to handle cumulative streaming responses and improve error handling during workflow resumes. - Added unit tests for new functionalities in Telegram and QQ Official adapters, ensuring robust behavior for select fields and form actions. * 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 * Add tests for DingTalk adapter content processing and markdown formatting - Updated the assertion in `test_dingtalk_completed_input_lines_include_text_and_select_values` to remove unnecessary markdown formatting. - Added new tests to verify that `_dingtalk_clean_form_content` maintains the order of prompts and completed values in various scenarios. - Introduced `test_dingtalk_card_markdown_preserves_internal_line_breaks` to ensure internal line breaks are correctly converted to HTML line breaks. * feat: Refactor input handling and feedback messages across multiple adapters * feat: Update the human-computer interaction template cards, and optimize the prompt information and content display. * feat: Refactor pending form handling to isolate by bot and pipeline * feat: Enhance error handling and caching for Dify and WeCom interactions * feat: Enhance select input handling and validation in Dify API runner and Telegram adapter * feat: Add missing completed input lines handling in DingTalk adapter * feat: Add pipeline_uuid handling across multiple adapters and update related tests
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
"""Tests for QQ Official keyboard payload helpers."""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
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,
|
||||
get_select_field_options,
|
||||
resolve_select_button_action,
|
||||
)
|
||||
|
||||
|
||||
def _select_form_data() -> dict:
|
||||
return {
|
||||
'_current_input_field': 'choice',
|
||||
'input_defs': [
|
||||
{
|
||||
'output_variable_name': 'choice',
|
||||
'type': 'select',
|
||||
'option_source': {'type': 'constant', 'value': ['A', 'B', 'C']},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_qq_select_field_builds_callback_buttons():
|
||||
keyboard = build_keyboard_from_select_field(_select_form_data(), buttons_per_row=2)
|
||||
|
||||
rows = keyboard['content']['rows']
|
||||
assert [[button['render_data']['label'] for button in row['buttons']] for row in rows] == [
|
||||
['A', 'B'],
|
||||
['C'],
|
||||
]
|
||||
assert rows[0]['buttons'][0]['action']['data'] == f'{QQ_SELECT_ACTION_PREFIX}0'
|
||||
assert rows[0]['buttons'][1]['action']['data'] == f'{QQ_SELECT_ACTION_PREFIX}1'
|
||||
|
||||
|
||||
def test_qq_select_button_resolves_field_and_value():
|
||||
form_data = _select_form_data()
|
||||
|
||||
assert get_select_field_options(form_data) == ('choice', ['A', 'B', 'C'])
|
||||
assert resolve_select_button_action(form_data, f'{QQ_SELECT_ACTION_PREFIX}1') == ('choice', 'B')
|
||||
assert resolve_select_button_action(form_data, f'{QQ_SELECT_ACTION_PREFIX}99') is None
|
||||
|
||||
|
||||
def test_qq_select_keyboard_fits_twenty_five_options():
|
||||
form_data = _select_form_data()
|
||||
form_data['input_defs'][0]['option_source']['value'] = [f'Option {idx}' for idx in range(25)]
|
||||
|
||||
rows = build_keyboard_from_select_field(form_data)['content']['rows']
|
||||
|
||||
assert len(rows) == 5
|
||||
assert all(len(row['buttons']) == 5 for row in rows)
|
||||
|
||||
|
||||
def test_qq_non_select_field_does_not_build_keyboard():
|
||||
form_data = {
|
||||
'_current_input_field': 'comment',
|
||||
'input_defs': [{'output_variable_name': 'comment', 'type': 'paragraph'}],
|
||||
}
|
||||
|
||||
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.bot.send_markdown_keyboard = AsyncMock(return_value={'id': 'message-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='<think>one')]),
|
||||
)
|
||||
await adapter.reply_message_chunk(
|
||||
source,
|
||||
{'resp_message_id': 'message-1'},
|
||||
platform_message.MessageChain([platform_message.Plain(text='<think>one two')]),
|
||||
is_final=True,
|
||||
)
|
||||
|
||||
assert [call.kwargs['content'] for call in adapter.bot.send_stream_msg.await_args_list] == [
|
||||
'<think>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_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
|
||||
from langbot.pkg.platform.sources.qqofficial import QQOfficialAdapter
|
||||
|
||||
adapter = QQOfficialAdapter.model_construct()
|
||||
adapter.logger = AsyncMock()
|
||||
adapter.bot = MagicMock()
|
||||
adapter.bot.ack_interaction = AsyncMock()
|
||||
adapter.ap = MagicMock()
|
||||
adapter.ap.platform_mgr.bots = []
|
||||
adapter.ap.query_pool.add_query = AsyncMock()
|
||||
adapter._pending_forms = {
|
||||
'group_group-1': {
|
||||
'form_data': {
|
||||
**_select_form_data(),
|
||||
'form_token': 'token-1',
|
||||
'workflow_run_id': 'run-1',
|
||||
'node_title': 'Review',
|
||||
'actions': [{'id': 'approve', 'title': 'Approve'}],
|
||||
},
|
||||
'sender_id': 'initiator-1',
|
||||
'posted_at': time.time(),
|
||||
}
|
||||
}
|
||||
adapter._session_event_ids = {}
|
||||
adapter._anchor_msg_seq = {}
|
||||
|
||||
await adapter._handle_interaction_create(
|
||||
{
|
||||
'id': 'interaction-1',
|
||||
'chat_type': 1,
|
||||
'group_openid': 'group-1',
|
||||
'member_openid': 'reviewer-2',
|
||||
'data': {'resolved': {'button_data': f'{QQ_SELECT_ACTION_PREFIX}1'}},
|
||||
},
|
||||
ws_event_id='event-1',
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
call = adapter.ap.query_pool.add_query.await_args
|
||||
form_action = call.kwargs['variables']['_dify_form_action']
|
||||
assert call.kwargs['launcher_id'] == 'group-1'
|
||||
assert call.kwargs['sender_id'] == 'reviewer-2'
|
||||
assert form_action['action_id'] == ''
|
||||
assert form_action['inputs'] == {'select': 'B'}
|
||||
assert form_action['_current_input_field'] == 'choice'
|
||||
assert form_action['_input_progress'] is True
|
||||
adapter.bot.ack_interaction.assert_awaited_once_with('interaction-1', code=0)
|
||||
Reference in New Issue
Block a user