diff --git a/src/langbot/pkg/platform/sources/lark.py b/src/langbot/pkg/platform/sources/lark.py index 16e7fc1d..b5f9ac74 100644 --- a/src/langbot/pkg/platform/sources/lark.py +++ b/src/langbot/pkg/platform/sources/lark.py @@ -2250,7 +2250,59 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): 'vertical_align': 'top', 'margin': '0px 0px 0px 0px', 'weight': 1, - } + }, + *( + [] + if form_data + else [ + { + 'tag': 'column', + 'width': '20px', + 'elements': [ + { + 'tag': 'button', + 'text': {'tag': 'plain_text', 'content': ''}, + 'type': 'text', + 'width': 'fill', + 'size': 'medium', + 'icon': {'tag': 'standard_icon', 'token': 'thumbsup_outlined'}, + 'hover_tips': {'tag': 'plain_text', 'content': '有帮助'}, + 'behaviors': [{'type': 'callback', 'value': {'feedback': '有帮助'}}], + 'margin': '0px 0px 0px 0px', + } + ], + 'padding': '0px 0px 0px 0px', + 'direction': 'vertical', + 'horizontal_spacing': '8px', + 'vertical_spacing': '8px', + 'horizontal_align': 'left', + 'vertical_align': 'top', + 'margin': '0px 0px 0px 0px', + }, + { + 'tag': 'column', + 'width': '30px', + 'elements': [ + { + 'tag': 'button', + 'text': {'tag': 'plain_text', 'content': ''}, + 'type': 'text', + 'width': 'default', + 'size': 'medium', + 'icon': {'tag': 'standard_icon', 'token': 'thumbdown_outlined'}, + 'hover_tips': {'tag': 'plain_text', 'content': '无帮助'}, + 'behaviors': [{'type': 'callback', 'value': {'feedback': '无帮助'}}], + 'margin': '0px 0px 0px 0px', + } + ], + 'padding': '0px 0px 0px 0px', + 'vertical_spacing': '8px', + 'horizontal_align': 'left', + 'vertical_align': 'top', + 'margin': '0px 0px 0px 0px', + }, + ] + ), ], 'margin': '0px 0px 4px 0px', }, diff --git a/src/langbot/pkg/provider/runners/difysvapi.py b/src/langbot/pkg/provider/runners/difysvapi.py index 14ffddec..9f408482 100644 --- a/src/langbot/pkg/provider/runners/difysvapi.py +++ b/src/langbot/pkg/provider/runners/difysvapi.py @@ -114,6 +114,29 @@ def _clear_pending_form(session_key: str, form_token: str | None = None) -> None _PENDING_FORMS.pop(session_key, None) +def _format_human_input_text( + node_title: str, + form_content: str, + actions: list[dict[str, typing.Any]], +) -> str: + """Render a paused-workflow human-input prompt as plain text. + + Used by adapters without rich UI (no buttons/cards) so users can reply + with the option number or the option title to resume the workflow. + """ + lines: list[str] = [f'[Human Input Required] {node_title or ""}'.rstrip()] + if form_content: + lines.append('') + lines.append(form_content) + if actions: + lines.append('') + lines.append('Reply with the number or title to continue:') + for idx, action in enumerate(actions, start=1): + title = action.get('title') or action.get('id') or '' + lines.append(f' {idx}. {title}') + return '\n'.join(lines) + + @runner.runner_class('dify-service-api') class DifyServiceAPIRunner(runner.RequestRunner): """Dify Service API 对话请求器""" @@ -520,14 +543,37 @@ class DifyServiceAPIRunner(runner.RequestRunner): def _match_pending_form_action(self, session_key: str, user_text: str) -> dict | None: """Match plain text replies against pending Dify form actions. - Iterates all pending forms newest-first; the first action whose - title/id matches the text wins. This means when multiple forms are - pending with the same button label, the most recent one resolves. + Resolution order: + 1. A pure digit reply (e.g. "1", "2") maps to the 1-indexed action of + the most recent pending form. Lets users on plain-text platforms + pick options without retyping titles. + 2. Otherwise, iterate pending forms newest-first and match each + action's title/id case-insensitively. The first hit wins, so when + two forms share a button label the newer one resolves. """ normalized_text = user_text.strip().lower() if not normalized_text: return None + def _build(pending_form: dict, action: dict) -> dict: + return { + 'form_token': pending_form.get('form_token', ''), + 'workflow_run_id': pending_form.get('workflow_run_id', ''), + 'action_id': action.get('id', ''), + 'action_title': action.get('title', action.get('id', '')), + 'node_title': pending_form.get('node_title', ''), + 'inputs': pending_form.get('inputs', {}), + 'user': pending_form.get('user', ''), + } + + if normalized_text.isdigit(): + position = int(normalized_text) + latest_form = _get_latest_pending_form(session_key) + if latest_form is not None: + actions = latest_form.get('actions', []) + if 1 <= position <= len(actions): + return _build(latest_form, actions[position - 1]) + for pending_form in _iter_pending_forms(session_key): for action in pending_form.get('actions', []): titles = { @@ -535,15 +581,7 @@ class DifyServiceAPIRunner(runner.RequestRunner): str(action.get('id', '')).strip().lower(), } if normalized_text in titles: - return { - 'form_token': pending_form.get('form_token', ''), - 'workflow_run_id': pending_form.get('workflow_run_id', ''), - 'action_id': action.get('id', ''), - 'action_title': action.get('title', action.get('id', '')), - 'node_title': pending_form.get('node_title', ''), - 'inputs': pending_form.get('inputs', {}), - 'user': pending_form.get('user', ''), - } + return _build(pending_form, action) return None @@ -636,8 +674,7 @@ class DifyServiceAPIRunner(runner.RequestRunner): 'node_title': node_title, } - action_lines = '\n'.join(f'- [{a.get("title", a.get("id", ""))}]' for a in actions) - display_text = f'[Human Input Required] {node_title}\n{form_content}\n{action_lines}' + display_text = _format_human_input_text(node_title, form_content, actions) human_input_yielded = True yield provider_message.Message( @@ -1139,8 +1176,7 @@ class DifyServiceAPIRunner(runner.RequestRunner): 'node_title': node_title, } - action_lines = '\n'.join(f'- [{a.get("title", a.get("id", ""))}]' for a in actions) - display_text = f'[Human Input Required] {node_title}\n{form_content}\n{action_lines}' + display_text = _format_human_input_text(node_title, form_content, actions) workflow_contents += display_text + '\n' # Save form data to attach to the final chunk later.