diff --git a/src/langbot/libs/dingtalk_api/api.py b/src/langbot/libs/dingtalk_api/api.py index 552989ed1..5c9df66cb 100644 --- a/src/langbot/libs/dingtalk_api/api.py +++ b/src/langbot/libs/dingtalk_api/api.py @@ -23,6 +23,26 @@ _stdout_logger = logging.getLogger('langbot.dingtalk_api') DINGTALK_OPENAPI_BASE = 'https://api.dingtalk.com' +def _stringify_card_param_map(card_param_map: Optional[dict]) -> dict: + """DingTalk cardParamMap only accepts string values. + + Keep callers free to pass structured values for template variables such + as button groups or select options, then encode them once at the API + boundary. + """ + if not card_param_map: + return {} + result = {} + for key, value in card_param_map.items(): + if value is None: + result[key] = '' + elif isinstance(value, str): + result[key] = value + else: + result[key] = json.dumps(value, ensure_ascii=False) + return result + + class DingTalkClient: def __init__( self, @@ -608,7 +628,7 @@ class DingTalkClient: if not await self.check_access_token(): await self.get_access_token() - cardData: dict = {'cardParamMap': card_param_map or {}} + cardData: dict = {'cardParamMap': _stringify_card_param_map(card_param_map)} if card_data_config is not None: cardData['config'] = json.dumps(card_data_config) @@ -732,7 +752,7 @@ class DingTalkClient: body: dict = { 'outTrackId': out_track_id, - 'cardData': {'cardParamMap': card_param_map or {}}, + 'cardData': {'cardParamMap': _stringify_card_param_map(card_param_map)}, } if private_data: body['privateData'] = private_data @@ -746,7 +766,7 @@ class DingTalkClient: _stdout_logger.info( 'DingTalk update_card_data request: out_track_id=%s body=%s', out_track_id, - json.dumps(body, ensure_ascii=False)[:500], + json.dumps(body, ensure_ascii=False)[:1500], ) async with httpx.AsyncClient() as client: response = await client.put(url, headers=headers, json=body, timeout=30.0) diff --git a/src/langbot/libs/dingtalk_api/card_callback.py b/src/langbot/libs/dingtalk_api/card_callback.py index 1bbe93ccb..4634a696b 100644 --- a/src/langbot/libs/dingtalk_api/card_callback.py +++ b/src/langbot/libs/dingtalk_api/card_callback.py @@ -29,6 +29,7 @@ _PARAM_PATHS = ( ('params',), ('cardPrivateData', 'params'), ('userPrivateData', 'params'), + ('actionData', 'cardPrivateData', 'params'), ) @@ -48,6 +49,14 @@ def _extract_params(content: dict) -> dict: return {} +def _merge_params(*sources: dict) -> dict: + merged = {} + for source in sources: + if isinstance(source, dict): + merged.update(source) + return merged + + class DingTalkCardActionHandler(dingtalk_stream.CallbackHandler): def __init__( self, @@ -61,12 +70,13 @@ class DingTalkCardActionHandler(dingtalk_stream.CallbackHandler): async def process(self, callback: dingtalk_stream.CallbackMessage): try: message = CardCallbackMessage.from_dict(callback.data) - params = _extract_params(message.content if isinstance(message.content, dict) else {}) + content = message.content if isinstance(message.content, dict) else {} # `CardCallbackMessage.from_dict` does not surface `actionId` (the # top-level field that ButtonGroup's sendCardRequest event puts # there). Pull it from the raw callback.data instead. raw = callback.data if isinstance(callback.data, dict) else {} + params = _merge_params(_extract_params(content), _extract_params(raw)) action_id = raw.get('actionId') or '' if not action_id: # Some templates nest it under actionData / cardPrivateData. diff --git a/src/langbot/libs/wecom_ai_bot_api/api.py b/src/langbot/libs/wecom_ai_bot_api/api.py index 79d169997..60a3f45ba 100644 --- a/src/langbot/libs/wecom_ai_bot_api/api.py +++ b/src/langbot/libs/wecom_ai_bot_api/api.py @@ -779,6 +779,308 @@ def _wecom_button_style(action: dict, *, selected: bool = False) -> int: return 1 +def _wecom_field_display_name(field: dict, fallback: str = '') -> str: + label = ( + field.get('label') or field.get('title') or field.get('name') or field.get('output_variable_name') or fallback + ) + return str(label or fallback).strip() + + +def _wecom_input_hint_lines(form_data: dict) -> list[str]: + lines: list[str] = [] + current_field = str(form_data.get('_current_input_field') or '').strip() + for field in form_data.get('input_defs') or []: + field_name = str(field.get('output_variable_name') or '').strip() + field_type = str(field.get('type') or 'text').strip().lower() + field_label = _wecom_field_display_name(field, field_name) + if current_field and field_name != current_field: + continue + if not field_name: + continue + if field_type == 'select': + source = field.get('option_source') or {} + options = source.get('value') if isinstance(source, dict) else [] + if isinstance(options, list) and options: + option_text = ', '.join(f'{idx}. {option}' for idx, option in enumerate(options, start=1)) + lines.append(f'- {field_label}: {option_text}') + else: + lines.append(f'- {field_label}: choose one option') + elif field_type in {'file', 'file-list'}: + limit = field.get('number_limits') if field_type == 'file-list' else 1 + allowed_types = ', '.join(field.get('allowed_file_types') or []) + suffix = f', up to {limit}' if field_type == 'file-list' and limit else '' + allowed = f' ({allowed_types})' if allowed_types else '' + lines.append(f'- {field_label}: upload file(s){allowed}{suffix} or reply `{field_name}: `') + else: + lines.append(f'请直接回复:{field_label}') + return lines + + +def _wecom_pending_input_defs(form_data: dict) -> list[dict]: + if form_data.get('_action_select_only'): + return [] + inputs = form_data.get('inputs') or {} + current_field = str(form_data.get('_current_input_field') or '').strip() + pending = [] + for field in form_data.get('input_defs') or []: + field_name = str(field.get('output_variable_name') or '').strip() + if not field_name: + continue + if current_field and field_name != current_field: + continue + if str(field.get('type') or '').strip().lower() in {'file', 'file-list'}: + continue + if inputs.get(field_name) in (None, '', []): + pending.append(field) + return pending + + +def _wecom_select_options(field: dict) -> list[str]: + source = field.get('option_source') or {} + options = source.get('value') if isinstance(source, dict) else [] + if not isinstance(options, list): + return [] + return [str(option) for option in options] + + +def _wecom_select_option_id(index: int) -> str: + return f'opt_{index + 1}' + + +def _wecom_pending_select_defs(form_data: dict) -> list[dict]: + return [ + field + for field in _wecom_pending_input_defs(form_data) + if str(field.get('type') or '').strip().lower() == 'select' and _wecom_select_options(field) + ] + + +def _wecom_field_title(field: dict, fallback: str) -> str: + title = _wecom_field_display_name(field, fallback) + return str(title or fallback).strip()[:13] or fallback + + +def _wecom_form_desc(form_data: dict) -> str: + current_field = str(form_data.get('_current_input_field') or '').strip() + if current_field: + for field in form_data.get('input_defs') or form_data.get('all_input_defs') or []: + if str(field.get('output_variable_name') or '').strip() != current_field: + continue + field_type = str(field.get('type') or 'text').strip().lower() + if field_type != 'select': + return f'请直接回复:{_wecom_field_display_name(field, current_field)}' + form_content = _wecom_clean_form_content(form_data) + if form_content: + return form_content[:512] + return 'Please choose an option to continue.' + + +def build_human_input_text_prompt(form_data: dict) -> Optional[str]: + """Build a plain-text prompt for a current non-select input field.""" + + current_field = str(form_data.get('_current_input_field') or '').strip() + if not current_field: + return None + for field in form_data.get('input_defs') or form_data.get('all_input_defs') or []: + if str(field.get('output_variable_name') or '').strip() != current_field: + continue + field_type = str(field.get('type') or 'text').strip().lower() + if field_type == 'select': + return None + label = _wecom_field_display_name(field, current_field) + return f'{str(form_data.get("node_title") or "人工介入").strip()}\n\n请直接回复:{label}' + return None + + +def build_multiple_interaction_payload( + form_data: dict, + task_id: str, + *, + source: Optional[dict] = None, +) -> dict[str, Any]: + """Build a WeCom multiple_interaction card for pending select fields.""" + + select_fields = _wecom_pending_select_defs(form_data) + node_title = (form_data.get('node_title') or '').strip() or 'Human Input' + inputs = form_data.get('inputs') or {} + + select_list = [] + for field_index, field in enumerate(select_fields[:10]): + field_name = str(field.get('output_variable_name') or '').strip() + if not field_name: + continue + options = _wecom_select_options(field)[:10] + option_list = [ + { + 'id': _wecom_select_option_id(idx), + 'text': option_text[:10] or _wecom_select_option_id(idx), + } + for idx, option_text in enumerate(options) + ] + selected_id = _wecom_select_option_id(0) + current_value = inputs.get(field_name) + if current_value not in (None, '', []): + for idx, option_text in enumerate(options): + if str(current_value) == option_text: + selected_id = _wecom_select_option_id(idx) + break + select_list.append( + { + 'question_key': field_name, + 'title': _wecom_field_title(field, f'Select {field_index + 1}'), + 'selected_id': selected_id, + 'option_list': option_list, + } + ) + + card: dict[str, Any] = { + 'card_type': 'multiple_interaction', + 'main_title': { + 'title': node_title, + 'desc': _wecom_form_desc(form_data), + }, + 'select_list': select_list, + 'submit_button': { + 'text': 'Submit', + 'key': 'submit_human_input', + }, + 'task_id': task_id, + } + if source: + card['source'] = source + return { + 'msgtype': 'template_card', + 'template_card': card, + } + + +_SELECT_BUTTON_KEY_PREFIX = '__dify_select__' + + +def _encode_select_button_key(field_name: str, option_index: int) -> str: + data = json.dumps({'f': field_name, 'i': option_index}, ensure_ascii=False, separators=(',', ':')) + encoded = base64.urlsafe_b64encode(data.encode('utf-8')).decode('ascii').rstrip('=') + return f'{_SELECT_BUTTON_KEY_PREFIX}:{encoded}' + + +def parse_select_button_action(action_id: str, form_data: dict) -> dict[str, str]: + """Decode a select option represented as a button_interaction click.""" + + action_id = str(action_id or '').strip() + prefix = f'{_SELECT_BUTTON_KEY_PREFIX}:' + if not action_id.startswith(prefix): + return {} + encoded = action_id[len(prefix) :] + try: + padded = encoded + '=' * (-len(encoded) % 4) + data = json.loads(base64.urlsafe_b64decode(padded.encode('ascii')).decode('utf-8')) + except Exception: + return {} + field_name = str(data.get('f') or '').strip() + option_index = data.get('i') + if not field_name or not isinstance(option_index, int): + return {} + for field in form_data.get('input_defs') or form_data.get('all_input_defs') or []: + if str(field.get('output_variable_name') or '').strip() != field_name: + continue + options = _wecom_select_options(field) + if 0 <= option_index < len(options): + return {field_name: options[option_index]} + return {} + + +def build_select_button_interaction_payload( + form_data: dict, + task_id: str, + *, + source: Optional[dict] = None, +) -> dict[str, Any]: + """Build a button_interaction card that emulates a select field. + + WeCom AI Bot long-connection callbacks are reliable for button clicks, so + this is used as a fallback when multiple_interaction submit callbacks are + not delivered by the platform. + """ + + select_fields = _wecom_pending_select_defs(form_data) + field = select_fields[0] if select_fields else {} + field_name = str(field.get('output_variable_name') or '').strip() + options = _wecom_select_options(field)[:10] if field else [] + visible_options = options[:6] + overflow_options = options[6:] + + node_title = (form_data.get('node_title') or '').strip() or 'Human Input' + field_title = _wecom_field_title(field, field_name or 'Select') if field else 'Select' + form_content = _wecom_clean_form_content(form_data) + + sub_title_parts: list[str] = [] + if form_content: + sub_title_parts.append(form_content) + sub_title_parts.append(f'Choose {field_title}.') + if overflow_options: + extra_lines = [f' - {idx + 7}. {option}' for idx, option in enumerate(overflow_options)] + sub_title_parts.append( + 'More options can be entered by replying with the option text:\n' + '\n'.join(extra_lines) + ) + + button_list = [ + { + 'text': option_text[:10] or f'Option {idx + 1}', + 'style': 2 if idx == 0 else 0, + 'key': _encode_select_button_key(field_name, idx), + } + for idx, option_text in enumerate(visible_options) + ] + + card: dict[str, Any] = { + 'card_type': 'button_interaction', + 'main_title': { + 'title': node_title, + }, + 'sub_title_text': '\n\n'.join(sub_title_parts), + 'button_list': button_list, + 'task_id': task_id, + } + if source: + card['source'] = source + return { + 'msgtype': 'template_card', + 'template_card': card, + } + + +def build_human_input_template_card_payload( + form_data: dict, + task_id: str, + *, + source: Optional[dict] = None, + select_as_buttons: bool = False, +) -> dict[str, Any]: + """Build the best WeCom template card for a Dify human-input form.""" + + if _wecom_pending_select_defs(form_data): + if select_as_buttons: + return build_select_button_interaction_payload(form_data, task_id, source=source) + return build_multiple_interaction_payload(form_data, task_id, source=source) + return build_button_interaction_payload(form_data, task_id, source=source) + + +def _wecom_clean_form_content(form_data: dict) -> str: + content = form_data.get('raw_form_content') or form_data.get('form_content') or '' + field_names = { + str(field.get('output_variable_name') or '').strip() + for field in form_data.get('input_defs') or [] + if str(field.get('output_variable_name') or '').strip() + } + kept_lines: list[str] = [] + for line in str(content).splitlines(): + placeholder = re.fullmatch(r'\s*\{\{#\$output\.([^#{}]+)#\}\}\s*', line) + if placeholder and placeholder.group(1) in field_names: + continue + kept_lines.append(line) + return re.sub(r'\n{3,}', '\n\n', '\n'.join(kept_lines).strip()) + + def build_button_interaction_payload( form_data: dict, task_id: str, @@ -815,14 +1117,20 @@ def build_button_interaction_payload( """ actions = list(form_data.get('actions') or []) node_title = (form_data.get('node_title') or '').strip() or '人工介入' - form_content = (form_data.get('form_content') or '').strip() + form_content = _wecom_clean_form_content(form_data) + should_show_actions = not _wecom_pending_input_defs(form_data) - visible_actions = actions[:6] - overflow = actions[6:] + visible_actions = actions[:6] if should_show_actions else [] + overflow = actions[6:] if should_show_actions else [] sub_title_parts: list[str] = [] if form_content: sub_title_parts.append(form_content) + 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)) @@ -844,6 +1152,7 @@ def build_button_interaction_payload( 'card_type': 'button_interaction', 'main_title': { 'title': node_title, + 'desc': _wecom_form_desc(form_data), }, 'sub_title_text': sub_title_text, 'button_list': button_list, @@ -890,6 +1199,224 @@ def extract_template_card_action(tce: dict[str, Any]) -> tuple[str, str, str]: return str(task_id or ''), str(event_key or ''), str(card_type or '') +def extract_wecom_event_type(payload: dict[str, Any]) -> str: + """Extract eventtype from common WeCom callback wrapper shapes.""" + + event = payload.get('event') if isinstance(payload, dict) else {} + if not isinstance(event, dict): + event = {} + event_type = ( + event.get('eventtype') + or event.get('event_type') + or event.get('eventType') + or event.get('EventType') + or payload.get('eventtype') + or payload.get('event_type') + or payload.get('eventType') + or payload.get('EventType') + or '' + ) + if event_type: + return str(event_type) + + tce = extract_template_card_event_payload(payload) + task_id, event_key, card_type = extract_template_card_action(tce) + if task_id or event_key or card_type or extract_template_card_selections(tce): + return 'template_card_event' + return '' + + +def extract_template_card_event_payload(payload: dict[str, Any]) -> dict[str, Any]: + """Extract template_card_event from common WeCom callback wrapper shapes.""" + + if not isinstance(payload, dict): + return {} + event = payload.get('event') if isinstance(payload.get('event'), dict) else {} + candidates = ( + event.get('template_card_event'), + event.get('templateCardEvent'), + event.get('TemplateCardEvent'), + event.get('template_card'), + payload.get('template_card_event'), + payload.get('templateCardEvent'), + payload.get('TemplateCardEvent'), + payload.get('template_card'), + ) + for candidate in candidates: + if isinstance(candidate, dict): + return candidate + if any( + key in payload + for key in ( + 'TaskId', + 'task_id', + 'taskId', + 'EventKey', + 'event_key', + 'eventKey', + 'CardType', + 'card_type', + 'cardType', + 'ResponseData', + 'response_data', + 'select_list', + 'SelectList', + ) + ): + return payload + if any( + key in event + for key in ( + 'TaskId', + 'task_id', + 'taskId', + 'EventKey', + 'event_key', + 'eventKey', + 'CardType', + 'card_type', + 'cardType', + 'ResponseData', + 'response_data', + 'select_list', + 'SelectList', + ) + ): + return event + return {} + + +def extract_template_card_selections(tce: dict[str, Any], form_data: Optional[dict] = None) -> dict[str, str]: + """Extract multiple_interaction select values from a WeCom callback. + + WeCom callback examples differ between webhook and websocket docs, so this + parser accepts common snake_case/camelCase/PascalCase variants and maps the + selected option id back to the Dify select option text when form_data is + available. + """ + + fields_by_name: dict[str, dict] = {} + if form_data: + for field in form_data.get('input_defs') or form_data.get('all_input_defs') or []: + field_name = str(field.get('output_variable_name') or '').strip() + if field_name: + fields_by_name[field_name] = field + + def _maybe_decode_json(value: Any) -> Any: + if not isinstance(value, str): + return value + text = value.strip() + if not text or text[0] not in '[{': + return value + try: + return json.loads(text) + except json.JSONDecodeError: + return value + + def _walk(value: Any) -> list[dict]: + value = _maybe_decode_json(value) + found: list[dict] = [] + if isinstance(value, dict): + found.append(value) + for child in value.values(): + found.extend(_walk(child)) + elif isinstance(value, list): + for child in value: + found.extend(_walk(child)) + return found + + def _lookup(item: dict, *keys: str) -> Any: + for key in keys: + if key in item: + return item.get(key) + lower_map = {str(key).lower(): value for key, value in item.items()} + for key in keys: + lowered = key.lower() + if lowered in lower_map: + return lower_map[lowered] + return '' + + def _normalise_selected_value(question_key: str, selected: Any) -> str: + selected = _maybe_decode_json(selected) + if isinstance(selected, dict): + selected = _lookup( + selected, + 'selected_id', + 'SelectedId', + 'selected_option_id', + 'SelectedOptionId', + 'option_id', + 'OptionId', + 'id', + 'Id', + 'value', + 'Value', + ) + selected_id = str(selected or '').strip() + if not selected_id: + return '' + selected_value = selected_id + field = fields_by_name.get(question_key) + if field: + options = _wecom_select_options(field) + for idx, option_text in enumerate(options): + if selected_id in {_wecom_select_option_id(idx), option_text}: + selected_value = option_text + break + return selected_value + + selections: dict[str, str] = {} + for item in _walk(tce): + question_key = _lookup( + item, + 'question_key', + 'questionKey', + 'QuestionKey', + 'question', + 'Question', + 'key', + 'Key', + ) + selected_id = _lookup( + item, + 'selected_id', + 'selectedId', + 'SelectedId', + 'selected_option_id', + 'selectedOptionId', + 'SelectedOptionId', + 'option_id', + 'optionId', + 'OptionId', + 'value', + 'Value', + ) + question_key = str(question_key or '').strip() + selected_id = str(selected_id or '').strip() + if question_key not in fields_by_name or not selected_id: + continue + + selected_value = _normalise_selected_value(question_key, selected_id) + if not selected_value: + continue + selections[question_key] = selected_value + + # Some WeCom callbacks encode ResponseData as a direct mapping: + # {"xiala": "id_two"} rather than a select_list item array. + for item in _walk(tce): + if not isinstance(item, dict): + continue + for question_key, selected in item.items(): + question_key = str(question_key or '').strip() + if question_key not in fields_by_name or question_key in selections: + continue + selected_value = _normalise_selected_value(question_key, selected) + if selected_value: + selections[question_key] = selected_value + + return selections + + def resolve_form_action_title(form_data: dict, action_id: str) -> str: """Resolve a Dify form action title from its id.""" @@ -954,6 +1481,73 @@ def build_button_interaction_update_card( return card +def build_multiple_interaction_update_card( + form_data: dict, + task_id: str, + selections: Optional[dict[str, str]] = None, + source: Optional[dict[str, Any]] = None, +) -> dict[str, Any]: + """Build an update card that freezes submitted select values.""" + + node_title = str(form_data.get('node_title') or '').strip() or 'Human Input' + selected_values = dict(form_data.get('inputs') or {}) + selected_values.update(selections or {}) + + select_list = [] + fields = _wecom_pending_select_defs(form_data) + if not fields: + fields = [ + field + for field in (form_data.get('input_defs') or form_data.get('all_input_defs') or []) + if str(field.get('type') or '').strip().lower() == 'select' + ] + for field_index, field in enumerate(fields[:10]): + field_name = str(field.get('output_variable_name') or '').strip() + if not field_name: + continue + options = _wecom_select_options(field)[:10] + option_list = [ + { + 'id': _wecom_select_option_id(idx), + 'text': option_text[:10] or _wecom_select_option_id(idx), + } + for idx, option_text in enumerate(options) + ] + selected_id = _wecom_select_option_id(0) + current_value = selected_values.get(field_name) + if current_value not in (None, '', []): + for idx, option_text in enumerate(options): + if str(current_value) == option_text or str(current_value) == _wecom_select_option_id(idx): + selected_id = _wecom_select_option_id(idx) + break + select_list.append( + { + 'question_key': field_name, + 'title': _wecom_field_title(field, f'Select {field_index + 1}'), + 'disable': True, + 'selected_id': selected_id, + 'option_list': option_list, + } + ) + + card: dict[str, Any] = { + 'card_type': 'multiple_interaction', + 'main_title': { + 'title': node_title, + 'desc': 'Submitted', + }, + 'select_list': select_list, + 'submit_button': { + 'text': 'Submitted', + 'key': 'submit_human_input', + }, + 'task_id': task_id, + } + if source: + card['source'] = source + return card + + class WecomBotClient: def __init__( self, @@ -1000,6 +1594,7 @@ class WecomBotClient: self._feedback_callback: Optional[Callable] = None self._card_action_callback: Optional[Callable] = None + self._stream_last_content: dict[str, str] = {} # Optional `source` block injected into every interactive template_card # the client builds. Set via `set_card_source` from the adapter after # reading config. Format: {icon_url, desc, desc_color}. @@ -1065,7 +1660,7 @@ class WecomBotClient: """Class-level shim — delegates to module-level builder and auto- injects the client's configured `source` block so every card emitted through this client carries the LangBot header.""" - return build_button_interaction_payload(form_data, task_id, source=self.card_source) + return build_human_input_template_card_payload(form_data, task_id, source=self.card_source) async def _encrypt_and_reply(self, payload: dict[str, Any], nonce: str) -> tuple[Response, int]: """对响应进行加密封装并返回给企业微信。 @@ -1277,8 +1872,7 @@ class WecomBotClient: msg_json = json.loads(decrypted_xml) - event = msg_json.get('event', {}) - event_type = event.get('eventtype', '') + event_type = extract_wecom_event_type(msg_json) if event_type == 'feedback_event': return await self._handle_feedback_event(msg_json, nonce) @@ -1302,7 +1896,7 @@ class WecomBotClient: the button's ``key`` (which we set to the Dify ``action_id``). """ try: - tce = msg_json.get('event', {}).get('template_card_event', {}) + tce = extract_template_card_event_payload(msg_json) task_id, event_key, card_type = extract_template_card_action(tce) await self.logger.info(f'收到按钮点击: task_id={task_id} event_key={event_key!r} card_type={card_type}') @@ -1428,9 +2022,25 @@ class WecomBotClient: if not stream_id: return False - chunk = StreamChunk(content=content, is_final=is_final) + previous_content = self._stream_last_content.get(msg_id, '') + if previous_content and content.startswith(previous_content): + delta_content = content[len(previous_content) :] + next_content = content + elif previous_content and not content: + delta_content = '' + next_content = previous_content + else: + delta_content = content + next_content = previous_content + content if previous_content else content + + if not is_final and not delta_content: + return True + + chunk = StreamChunk(content=delta_content, is_final=is_final) await self.stream_sessions.publish(stream_id, chunk) + self._stream_last_content[msg_id] = next_content if is_final: + self._stream_last_content.pop(msg_id, None) self.stream_sessions.mark_finished(stream_id) return True diff --git a/src/langbot/libs/wecom_ai_bot_api/ws_client.py b/src/langbot/libs/wecom_ai_bot_api/ws_client.py index 0d8951f73..3b0a3972c 100644 --- a/src/langbot/libs/wecom_ai_bot_api/ws_client.py +++ b/src/langbot/libs/wecom_ai_bot_api/ws_client.py @@ -23,9 +23,15 @@ from langbot.libs.wecom_ai_bot_api import wecombotevent from langbot.libs.wecom_ai_bot_api.api import ( parse_wecom_bot_message, StreamSession, - build_button_interaction_payload, + build_human_input_template_card_payload, + build_human_input_text_prompt, build_button_interaction_update_card, + build_multiple_interaction_update_card, extract_template_card_action, + extract_template_card_event_payload, + extract_template_card_selections, + extract_wecom_event_type, + parse_select_button_action, ) from langbot.pkg.platform.logger import EventLogger @@ -49,6 +55,10 @@ def _generate_req_id(prefix: str) -> str: return f'{prefix}_{ts}_{rand}' +def _frame_snippet(frame: dict, limit: int = 1000) -> str: + return json.dumps(frame, ensure_ascii=False, default=str)[:limit] + + class WecomBotWsClient: """WeChat Work AI Bot WebSocket long connection client. @@ -334,6 +344,21 @@ class WecomBotWsClient: task_id = f'dify-{secrets.token_hex(12)}' session_info = self._stream_sessions.get(msg_id) or {} + text_prompt = build_human_input_text_prompt(form_data) + if text_prompt: + try: + ack = await self.reply_text(req_id, text_prompt) + if ack is None: + return False, stream_id, None + except Exception: + await self.logger.error(f'Failed to send human-input text prompt: {traceback.format_exc()}') + return False, stream_id, None + + self._stream_ids.pop(msg_id, None) + self._stream_last_content.pop(msg_id, None) + self._stream_sessions.pop(msg_id, None) + return True, stream_id, None + self._pending_forms_by_task[task_id] = { 'form_data': form_data, 'msg_id': msg_id, @@ -344,7 +369,12 @@ class WecomBotWsClient: } self._task_id_by_msg[msg_id] = task_id - card_payload = build_button_interaction_payload(form_data, task_id, source=self.card_source) + card_payload = build_human_input_template_card_payload( + form_data, + task_id, + source=self.card_source, + select_as_buttons=True, + ) try: await self.reply_template_card(req_id, card_payload) except Exception: @@ -421,8 +451,19 @@ class WecomBotWsClient: return False req_id, stream_id = key.split('|', 1) try: + previous_content = self._stream_last_content.get(msg_id, '') + if previous_content and content.startswith(previous_content): + delta_content = content[len(previous_content) :] + next_content = content + elif previous_content and not content: + delta_content = '' + next_content = previous_content + else: + delta_content = content + next_content = previous_content + content if previous_content else content + # Skip sending if content hasn't changed (e.g. during tool call argument streaming) - if not is_final and content == self._stream_last_content.get(msg_id): + if not is_final and not delta_content: return True # Skip empty/whitespace-only chunks — the runner injects a @@ -435,7 +476,7 @@ class WecomBotWsClient: if not is_final: import re as _re - if not _re.sub(r'[\s​‌‍]', '', content): + if not _re.sub(r'[\s​‌‍]', '', delta_content): return True # Generate feedback_id for final chunk @@ -448,8 +489,8 @@ class WecomBotWsClient: if session_info: self._feedback_sessions[feedback_id] = session_info - await self.reply_stream(req_id, stream_id, content, finish=is_final, feedback_id=feedback_id) - self._stream_last_content[msg_id] = content + await self.reply_stream(req_id, stream_id, delta_content, finish=is_final, feedback_id=feedback_id) + self._stream_last_content[msg_id] = next_content if is_final: self._stream_ids.pop(msg_id, None) self._stream_last_content.pop(msg_id, None) @@ -623,7 +664,7 @@ class WecomBotWsClient: return # Unknown frame - await self.logger.warning(f'Unknown frame: {json.dumps(frame, ensure_ascii=False)[:200]}') + await self.logger.warning(f'Unknown frame: {_frame_snippet(frame)}') async def _handle_message_callback(self, frame: dict): """Handle an incoming message callback frame.""" @@ -631,6 +672,13 @@ class WecomBotWsClient: body = frame.get('body', {}) req_id = frame.get('headers', {}).get('req_id', '') + event_type = extract_wecom_event_type(body) + if event_type == 'template_card_event': + await self._handle_template_card_event_frame(frame, body) + return + if event_type: + await self.logger.debug(f'Received msg_callback event_type={event_type}: {_frame_snippet(frame)}') + # Parse message using shared logic message_data = await parse_wecom_bot_message(body, self.encoding_aes_key, self.logger) if not message_data: @@ -664,8 +712,12 @@ class WecomBotWsClient: body = frame.get('body', {}) req_id = frame.get('headers', {}).get('req_id', '') - event_info = body.get('event', {}) - event_type = event_info.get('eventtype', '') + event_info = body.get('event', {}) if isinstance(body.get('event'), dict) else body + event_type = extract_wecom_event_type(body) + if not event_type: + await self.logger.warning(f'Received event_callback without event_type: {_frame_snippet(frame)}') + else: + await self.logger.debug(f'Received event_callback event_type={event_type}') message_data = { 'msgtype': 'event', @@ -727,64 +779,7 @@ class WecomBotWsClient: return if event_type == 'template_card_event': - tce = event_info.get('template_card_event', {}) - task_id, event_key, card_type = extract_template_card_action(tce) - await self.logger.info( - f'收到按钮点击 (ws): task_id={task_id} event_key={event_key!r} card_type={card_type}' - ) - pending = self._pending_forms_by_task.get(task_id) - if pending is None: - await self.logger.warning(f'未找到 task_id={task_id} 对应的 pending_form (ws),按钮点击被丢弃') - else: - # Update the card in-place to show which button was clicked. - # Must happen within 5s of the event, using the same req_id. - req_id_for_update = frame.get('headers', {}).get('req_id', '') - form_data = pending.get('form_data', {}) or {} - action_title = event_key - node_title = form_data.get('node_title', '') or '' - main_title_text = f'{node_title} - 已选择' if node_title else '已选择' - main_title_desc = f'✅ {action_title}' - update_card = { - 'card_type': 'button_interaction', - 'main_title': { - 'title': main_title_text, - 'desc': main_title_desc, - }, - 'button_list': [], - 'task_id': task_id, - } - if self.card_source: - update_card['source'] = self.card_source - update_card = build_button_interaction_update_card( - form_data, - task_id, - event_key, - source=self.card_source, - ) - try: - await self.update_template_card(req_id_for_update, update_card) - except Exception: - await self.logger.warning(f'更新卡片失败 (ws): {traceback.format_exc()}') - - if self._card_action_callback is not None: - try: - session = StreamSession( - stream_id=pending.get('stream_id', ''), - msg_id=pending.get('msg_id', ''), - chat_id=pending.get('chat_id') or None, - user_id=pending.get('user_id') or None, - ) - session.pending_form = pending.get('form_data') - session.pending_form_task_id = task_id - await self._card_action_callback(session, event_key, task_id, body) - except Exception: - await self.logger.error(f'card action callback raised (ws): {traceback.format_exc()}') - # Consume — drop bookkeeping so a stale click can't re-fire. - self._pending_forms_by_task.pop(task_id, None) - msg_id = pending.get('msg_id', '') - if msg_id: - self._task_id_by_msg.pop(msg_id, None) - self._stream_sessions.pop(msg_id, None) + await self._handle_template_card_event_frame(frame, body) return event = wecombotevent.WecomBotEvent(message_data) @@ -800,6 +795,72 @@ class WecomBotWsClient: except Exception: await self.logger.error(f'Error in event callback: {traceback.format_exc()}') + async def _handle_template_card_event_frame(self, frame: dict, body: dict): + """Handle template_card_event frames from event_callback or msg_callback.""" + tce = extract_template_card_event_payload(body) + task_id, event_key, card_type = extract_template_card_action(tce) + await self.logger.info( + f'Received template_card_event (ws): task_id={task_id} event_key={event_key!r} card_type={card_type}' + ) + + pending = self._pending_forms_by_task.get(task_id) + if pending is None: + await self.logger.warning(f'No pending_form found for task_id={task_id} (ws); card event ignored') + return + + req_id_for_update = frame.get('headers', {}).get('req_id', '') + form_data = pending.get('form_data', {}) or {} + selections = extract_template_card_selections(tce, form_data) + if not selections: + selections = parse_select_button_action(event_key, form_data) + if card_type == 'multiple_interaction' and not selections: + await self.logger.warning( + f'multiple_interaction callback has no parseable selections (ws): raw={str(tce)[:1000]}' + ) + self._drop_pending_form_task(task_id, pending) + return + + update_card = build_button_interaction_update_card( + form_data, + task_id, + event_key, + source=self.card_source, + ) + if card_type == 'multiple_interaction' or selections: + update_card = build_multiple_interaction_update_card( + form_data, + task_id, + selections, + source=self.card_source, + ) + try: + await self.update_template_card(req_id_for_update, update_card) + except Exception: + await self.logger.warning(f'Failed to update template card (ws): {traceback.format_exc()}') + + if self._card_action_callback is not None: + try: + session = StreamSession( + stream_id=pending.get('stream_id', ''), + msg_id=pending.get('msg_id', ''), + chat_id=pending.get('chat_id') or None, + user_id=pending.get('user_id') or None, + ) + session.pending_form = pending.get('form_data') + session.pending_form_task_id = task_id + await self._card_action_callback(session, event_key, task_id, body) + except Exception: + await self.logger.error(f'card action callback raised (ws): {traceback.format_exc()}') + + self._drop_pending_form_task(task_id, pending) + + def _drop_pending_form_task(self, task_id: str, pending: dict) -> None: + self._pending_forms_by_task.pop(task_id, None) + msg_id = pending.get('msg_id', '') + if msg_id: + self._task_id_by_msg.pop(msg_id, None) + self._stream_sessions.pop(msg_id, None) + async def _dispatch_event(self, event: wecombotevent.WecomBotEvent): """Dispatch a message event to registered handlers with deduplication.""" try: diff --git a/src/langbot/pkg/platform/sources/dingtalk.py b/src/langbot/pkg/platform/sources/dingtalk.py index ec7375861..09e52c6a5 100644 --- a/src/langbot/pkg/platform/sources/dingtalk.py +++ b/src/langbot/pkg/platform/sources/dingtalk.py @@ -1,6 +1,7 @@ import asyncio import json import pathlib +import re import traceback import typing import uuid @@ -168,6 +169,261 @@ class DingTalkEventConverter(abstract_platform_adapter.AbstractEventConverter): ) +def _dingtalk_input_hint_lines(form_data: dict) -> list[str]: + lines: list[str] = [] + current_field = str(form_data.get('_current_input_field') or '').strip() + for field in form_data.get('input_defs') or []: + field_name = str(field.get('output_variable_name') or '').strip() + field_type = str(field.get('type') or 'text').strip().lower() + if current_field and field_name != current_field: + continue + if not field_name: + continue + if field_type == 'select': + source = field.get('option_source') or {} + options = source.get('value') if isinstance(source, dict) else [] + if isinstance(options, list) and options: + option_text = ', '.join(f'{idx}. {option}' for idx, option in enumerate(options, start=1)) + lines.append(f'- {field_name}: {option_text}') + else: + lines.append(f'- {field_name}: choose one option') + elif field_type in {'file', 'file-list'}: + limit = field.get('number_limits') if field_type == 'file-list' else 1 + allowed_types = ', '.join(field.get('allowed_file_types') or []) + suffix = f', up to {limit}' if field_type == 'file-list' and limit else '' + allowed = f' ({allowed_types})' if allowed_types else '' + lines.append(f'- {field_name}: upload file(s){allowed}{suffix} or reply `{field_name}: `') + else: + lines.append(f'- {field_name}: reply `{field_name}: `') + return lines + + +def _dingtalk_pending_input_defs(form_data: dict) -> list[dict]: + if form_data.get('_action_select_only'): + return [] + inputs = form_data.get('inputs') or {} + pending = [] + for field in form_data.get('input_defs') or []: + field_name = str(field.get('output_variable_name') or '').strip() + if not field_name: + continue + if inputs.get(field_name) in (None, '', []): + pending.append(field) + return pending + + +def _dingtalk_clean_form_content(form_data: dict) -> str: + content = form_data.get('raw_form_content') or form_data.get('form_content') or '' + field_names = { + str(field.get('output_variable_name') or '').strip() + for field in _dingtalk_form_input_defs(form_data) + if str(field.get('output_variable_name') or '').strip() + } + kept_lines: list[str] = [] + for line in str(content).splitlines(): + placeholder = re.fullmatch(r'\s*\{\{#\$output\.([^#{}]+)#\}\}\s*', line) + if placeholder and placeholder.group(1) in field_names: + continue + kept_lines.append(line) + return re.sub(r'\n{3,}', '\n\n', '\n'.join(kept_lines).strip()) + + +def _dingtalk_form_input_defs(form_data: dict) -> list[dict]: + return list(form_data.get('all_input_defs') or form_data.get('input_defs') or []) + + +def _dingtalk_display_input_value(field: dict, value: typing.Any) -> str: + field_type = _dingtalk_field_type(field) + if field_type == 'file': + if isinstance(value, dict): + return value.get('url') or value.get('upload_file_id') or '1 file' + return str(value) + if field_type == 'file-list': + if isinstance(value, list): + return f'{len(value)} file(s)' + return str(value) + return str(value) + + +def _dingtalk_completed_input_lines(form_data: dict) -> list[str]: + inputs = form_data.get('inputs') or {} + if not isinstance(inputs, dict): + return [] + + lines: list[str] = [] + for field in _dingtalk_form_input_defs(form_data): + field_name = _dingtalk_field_name(field) + if not field_name: + continue + 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}**') + return lines + + +def _dingtalk_supports_native_field(form_data: dict) -> bool: + current_name = str(form_data.get('_current_input_field') or '').strip() + if not current_name or form_data.get('_action_select_only'): + return False + for field in _dingtalk_form_input_defs(form_data): + if str(field.get('output_variable_name') or '').strip() != current_name: + continue + return str(field.get('type') or 'text').strip().lower() not in {'file', 'file-list'} + return False + + +def _dingtalk_field_name(field: dict) -> str: + return str(field.get('output_variable_name') or field.get('name') or field.get('id') or '').strip() + + +def _dingtalk_field_type(field: dict) -> str: + return str(field.get('type') or 'text').strip().lower() + + +def _dingtalk_select_options(field: dict) -> list[str]: + source = field.get('option_source') or {} + value = source.get('value') if isinstance(source, dict) else None + if isinstance(value, list): + return [str(item) for item in value] + if isinstance(value, str): + return [part.strip() for part in value.splitlines() if part.strip()] + options = field.get('options') + if isinstance(options, list): + result = [] + for item in options: + if isinstance(item, dict): + result.append(str(item.get('label') or item.get('value') or '')) + else: + result.append(str(item)) + return [item for item in result if item] + return [] + + +def _dingtalk_select_block_options(options: list[str]) -> list[dict]: + """Build the option shape consumed by DingTalk SelectBlock templates.""" + locales = ( + 'zh_CN', + 'zh_TW', + 'en_US', + 'ja_JP', + 'vi_VN', + 'th_TH', + 'id_ID', + 'ne_NP', + 'ms_MY', + 'ko_KR', + 'ru_RU', + 'es_EA', + 'tr_TR', + 'fr_FR', + 'pt_BR', + ) + return [{'value': option, 'text': {locale: option for locale in locales}} for option in options] + + +def _dingtalk_current_input_field(form_data: dict) -> dict | None: + current_name = str(form_data.get('_current_input_field') or '').strip() + if not current_name or form_data.get('_action_select_only'): + return None + for field in _dingtalk_form_input_defs(form_data): + if _dingtalk_field_name(field) == current_name: + return field + return None + + +def _dingtalk_form_component_params(form_data: dict) -> dict: + field = _dingtalk_current_input_field(form_data) + params = { + 'input_visible': '', + 'input_title': '', + 'input_placeholder': '', + 'input_value': '', + 'select_visible': '', + 'select_placeholder': '', + 'select_options': [], + 'index_o': [], + 'test_index': [], + 'select_index': -1, + } + if not field: + return params + + field_name = _dingtalk_field_name(field) + field_type = _dingtalk_field_type(field) + value = form_data.get('inputs', {}).get(field_name, '') + if field_type == 'select': + options = _dingtalk_select_options(field) + selected_index = -1 + if value not in (None, ''): + try: + selected_index = options.index(str(value)) + except ValueError: + selected_index = -1 + params.update( + { + 'select_visible': 'true', + 'select_placeholder': field_name or 'Select', + 'select_options': options, + 'index_o': _dingtalk_select_block_options(options), + 'test_index': _dingtalk_select_block_options(options), + 'select_index': selected_index, + } + ) + elif field_type not in {'file', 'file-list'}: + params.update( + { + 'input_visible': 'true', + 'input_title': field_name or 'Input', + 'input_placeholder': field_name or 'Input', + 'input_value': '' if value is None else str(value), + } + ) + return params + + +def _dingtalk_empty_form_component_params() -> dict: + return _dingtalk_form_component_params({}) + + +def _dingtalk_extract_component_inputs(params: dict) -> dict: + """Normalize DingTalk native Input/SelectBlock callback payloads.""" + if not isinstance(params, dict): + return {} + + result = {} + input_value = params.get('input') + if input_value in (None, ''): + input_result = params.get('inputResult') + if isinstance(input_result, dict): + input_value = input_result.get('value') or input_result.get('input') + elif input_result not in (None, ''): + input_value = input_result + if input_value not in (None, ''): + result['input'] = input_value + + select_value = params.get('select') + if select_value in (None, ''): + for key in ('selectResult', 'select_result', '__built_in_selectResult__'): + candidate = params.get(key) + if candidate not in (None, ''): + select_value = candidate + break + if isinstance(select_value, dict): + select_value = select_value.get('value') or select_value.get('label') or select_value.get('index') + if select_value not in (None, ''): + result['select'] = select_value + + return result + + class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): bot: DingTalkClient bot_account_id: str @@ -329,8 +585,12 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): content=content, btns='[]', flowStatus='3' if is_final else '1', + **_dingtalk_empty_form_component_params(), ), ) + session_key = self._session_key_from_event(message_source) + if session_key: + self.active_turn_text[session_key] = content except Exception: if self.ap is not None: self.ap.logger.exception('DingTalk: update card content failed') @@ -343,7 +603,10 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): try: await self.bot.update_card_data( out_track_id=card_instance_id, - card_param_map=self._card_params(flowStatus='3'), + card_param_map=self._card_params( + flowStatus='3', + **_dingtalk_empty_form_component_params(), + ), ) except Exception: pass @@ -396,7 +659,12 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): out_track_id=out_track_id, open_space_id=open_space_id, is_group=is_group, - card_param_map=self._card_params(content='', btns='[]', flowStatus='1'), + card_param_map=self._card_params( + content='', + btns='[]', + flowStatus='1', + **_dingtalk_empty_form_component_params(), + ), callback_type='STREAM', ) except Exception: @@ -606,7 +874,15 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): """Update an existing card's content + buttons for human-input.""" actions = list(form_data.get('actions') or []) node_title = form_data.get('node_title', '') or 'Human Input Required' - form_content = form_data.get('form_content', '') or '' + form_content = _dingtalk_clean_form_content(form_data) + should_show_actions = not _dingtalk_pending_input_defs(form_data) + component_params = _dingtalk_form_component_params(form_data) + native_field = _dingtalk_supports_native_field(form_data) + if self.ap is not None and component_params.get('select_visible'): + self.ap.logger.info( + f'DingTalk form select params: field={form_data.get("_current_input_field", "")!r} ' + f'options={len(component_params.get("select_options") or [])}' + ) # Record form state for the click-handler. launcher_type, launcher_id, sender_user_id = self._derive_session_descriptor(message_source) @@ -617,12 +893,16 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): 'sender_user_id': sender_user_id, 'form_token': form_data.get('form_token', ''), 'workflow_run_id': form_data.get('workflow_run_id', ''), - 'actions': actions, + 'actions': actions if should_show_actions else [], + 'all_actions': actions, 'node_title': node_title, 'form_content': form_content, + 'current_input_field': str(form_data.get('_current_input_field') or ''), + 'input_defs': _dingtalk_form_input_defs(form_data), + 'inputs': form_data.get('inputs') or {}, } - btns = self._build_btns(actions, out_track_id) + btns = self._build_btns(actions if should_show_actions else [], out_track_id) parts: list[str] = [] prior = self.active_turn_text.get(session_key, '') if session_key else '' if prior.strip(): @@ -636,6 +916,14 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): parts.append(f'**{node_title}**') if form_content: parts.append(form_content) + completed_lines = _dingtalk_completed_input_lines(form_data) + if completed_lines: + parts.append('
' + '
'.join(completed_lines)) + 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 '请选择一个操作以继续。' try: @@ -645,6 +933,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): content=display_content, btns=json.dumps(btns, ensure_ascii=False), flowStatus='3', + **component_params, ), ) except Exception: @@ -653,9 +942,6 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): await self.send_message_text_form(message_source, form_data) return - if session_key: - self.active_turn_text[session_key] = display_content - @staticmethod def _build_btns(actions: list, out_track_id: str) -> list: btns = [] @@ -699,7 +985,15 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): actions = list(form_data.get('actions') or []) node_title = form_data.get('node_title', '') or 'Human Input Required' - form_content = form_data.get('form_content', '') or '' + form_content = _dingtalk_clean_form_content(form_data) + should_show_actions = not _dingtalk_pending_input_defs(form_data) + component_params = _dingtalk_form_component_params(form_data) + native_field = _dingtalk_supports_native_field(form_data) + if self.ap is not None and component_params.get('select_visible'): + self.ap.logger.info( + f'DingTalk form select params: field={form_data.get("_current_input_field", "")!r} ' + f'options={len(component_params.get("select_options") or [])}' + ) self.card_state[out_track_id] = { 'session_key': session_key, @@ -708,9 +1002,13 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): 'sender_user_id': sender_user_id, 'form_token': form_data.get('form_token', ''), 'workflow_run_id': form_data.get('workflow_run_id', ''), - 'actions': actions, + 'actions': actions if should_show_actions else [], + 'all_actions': actions, 'node_title': node_title, 'form_content': form_content, + 'current_input_field': str(form_data.get('_current_input_field') or ''), + 'input_defs': _dingtalk_form_input_defs(form_data), + 'inputs': form_data.get('inputs') or {}, 'open_space_id': open_space_id, 'is_group': is_group, } @@ -720,33 +1018,17 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): parts.append(f'**{node_title}**') if form_content: parts.append(form_content) + completed_lines = _dingtalk_completed_input_lines(form_data) + if completed_lines: + parts.append('
' + '
'.join(completed_lines)) + 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 '请选择一个操作以继续。' - btns = [] - for idx, action in enumerate(actions): - action_id = str(action.get('id') or '') - title = str(action.get('title') or action_id or f'选项 {idx + 1}') - style = (action.get('button_style') or '').lower() - if style == 'primary' or (style == '' and idx == 0): - color = 'blue' - elif style == 'danger': - color = 'red' - else: - color = 'gray' - btns.append( - { - 'text': title, - 'color': color, - 'status': 'normal', - 'event': { - 'type': 'sendCardRequest', - 'params': { - 'actionId': action_id, - 'params': {'action_id': action_id, 'out_track_id': out_track_id}, - }, - }, - } - ) + btns = self._build_btns(actions if should_show_actions else [], out_track_id) try: if self.ap is not None: @@ -763,6 +1045,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): content=display_content, btns=json.dumps(btns, ensure_ascii=False), flowStatus='3', + **component_params, ), callback_type='STREAM', ) @@ -790,7 +1073,12 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): out_track_id = uuid.uuid4().hex open_space_id, is_group = self._derive_open_space(message_source) if form_template_id: - card_param_map = self._card_params(content='', btns='[]', flowStatus='1') + card_param_map = self._card_params( + content='', + btns='[]', + flowStatus='1', + **_dingtalk_empty_form_component_params(), + ) card_data_config = None else: # Legacy chat-card template doesn't carry a `bot_avatar` @@ -829,11 +1117,21 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): form_data: dict, ) -> None: """Fallback: send the human-input prompt as plain text.""" - display_text = _format_human_input_text( - form_data.get('node_title', ''), - form_data.get('form_content', ''), - form_data.get('actions', []) or [], - ) + if form_data.get('_current_input_field') and not form_data.get('_action_select_only'): + parts = [] + node_title = form_data.get('node_title', '') + if node_title: + parts.append(f'[Human Input Required] {node_title}') + form_content = form_data.get('form_content') or '' + if form_content: + parts.append(form_content) + display_text = '\n\n'.join(parts) + else: + display_text = _format_human_input_text( + form_data.get('node_title', ''), + form_data.get('form_content', ''), + form_data.get('actions', []) or [], + ) await self._send_proactive_to_event(message_source, display_text) async def _send_proactive_to_event( @@ -891,16 +1189,21 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): or (params.get('actionId') or '').strip() or (params.get('id') or '').strip() ) - if not raw_action_id: - await self.logger.warning(f'DingTalk: card action with no action_id, payload={payload}') - return - state = self.card_state.get(out_track_id) if state is None: await self.logger.warning(f'DingTalk: card action received for unknown out_track_id={out_track_id}') return actions = state.get('actions', []) or [] + known_action_ids = {str(action.get('id', '')) for action in actions} + component_inputs = _dingtalk_extract_component_inputs(params) + if component_inputs and (not raw_action_id or raw_action_id not in known_action_ids): + await self._enqueue_card_form_progress(payload, state, component_inputs) + return + if not raw_action_id: + await self.logger.warning(f'DingTalk: card action with no action_id, payload={payload}') + return + action_id = raw_action_id action_title = raw_action_id for action in actions: @@ -1002,6 +1305,8 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): action_title, node_title=state.get('node_title', ''), form_content=state.get('form_content', ''), + input_defs=state.get('input_defs') or [], + inputs=state.get('inputs') or {}, ) ) @@ -1018,6 +1323,94 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): # Once consumed, drop the state — the runner clears _PENDING_FORMS too. self.card_state.pop(out_track_id, None) + async def _enqueue_card_form_progress( + self, + payload: dict, + state: dict, + component_inputs: dict, + ) -> None: + out_track_id = payload.get('out_track_id') or '' + launcher_type = ( + provider_session.LauncherTypes.GROUP + if state.get('launcher_type') == provider_session.LauncherTypes.GROUP.value + else provider_session.LauncherTypes.PERSON + ) + launcher_id = state.get('launcher_id', '') + sender_user_id = state.get('sender_user_id') or payload.get('user_id') or launcher_id + form_action_data = { + 'form_token': state.get('form_token', ''), + 'workflow_run_id': state.get('workflow_run_id', ''), + 'action_id': '', + 'action_title': '', + 'node_title': state.get('node_title', ''), + 'user': f'{launcher_type.value}_{launcher_id}', + 'inputs': component_inputs, + '_current_input_field': state.get('current_input_field', ''), + '_input_progress': True, + } + message_chain = platform_message.MessageChain([platform_message.Plain(text='[Form Input]')]) + + if launcher_type == provider_session.LauncherTypes.GROUP: + synthetic_event = platform_events.GroupMessage( + sender=platform_entities.GroupMember( + id=sender_user_id, + member_name='', + permission=platform_entities.Permission.Member, + group=platform_entities.Group( + id=launcher_id, + name='', + permission=platform_entities.Permission.Member, + ), + special_title='', + ), + message_chain=message_chain, + time=int(datetime.datetime.now().timestamp()), + source_platform_object=None, + ) + else: + synthetic_event = platform_events.FriendMessage( + sender=platform_entities.Friend( + id=sender_user_id, + nickname='', + remark='', + ), + message_chain=message_chain, + time=int(datetime.datetime.now().timestamp()), + source_platform_object=None, + ) + + if self.ap is None: + return + + bot_uuid = '' + pipeline_uuid = None + for bot in self.ap.platform_mgr.bots: + if bot.adapter is self: + bot_uuid = bot.bot_entity.uuid + pipeline_uuid = bot.bot_entity.use_pipeline_uuid + break + try: + await self.ap.query_pool.add_query( + bot_uuid=bot_uuid, + launcher_type=launcher_type, + launcher_id=launcher_id, + sender_id=sender_user_id, + message_event=synthetic_event, + message_chain=message_chain, + adapter=self, + pipeline_uuid=pipeline_uuid, + variables={ + '_dify_form_action': form_action_data, + '_routed_by_rule': True, + }, + ) + self.ap.logger.info( + f'DingTalk card form input enqueued: out_track_id={out_track_id} ' + f'field={state.get("current_input_field", "")!r}' + ) + except Exception: + self.ap.logger.exception('DingTalk: enqueue form input query failed') + async def _mark_card_resolved( self, out_track_id: str, @@ -1025,6 +1418,8 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): *, node_title: str = '', form_content: str = '', + input_defs: list | None = None, + inputs: dict | None = None, ) -> None: """Update the form card to acknowledge the user's selection. @@ -1037,6 +1432,14 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): parts.append(f'**{node_title}**') if form_content: parts.append(form_content) + completed_lines = _dingtalk_completed_input_lines( + { + 'input_defs': input_defs or [], + 'inputs': inputs or {}, + } + ) + if completed_lines: + parts.append('
' + '
'.join(completed_lines)) parts.append(f'
✅ 已选择:**{action_title}**') content = '

'.join(parts) if self.ap is not None: @@ -1048,6 +1451,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): content=content, btns='[]', flowStatus='3', + **_dingtalk_empty_form_component_params(), ), ) except Exception: diff --git a/src/langbot/pkg/platform/sources/lark.py b/src/langbot/pkg/platform/sources/lark.py index 536e54954..81b0530ca 100644 --- a/src/langbot/pkg/platform/sources/lark.py +++ b/src/langbot/pkg/platform/sources/lark.py @@ -34,6 +34,163 @@ import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_ import langbot_plugin.api.entities.builtin.provider.session as provider_session +def _lark_form_component_name(prefix: str, field_name: str, index: int) -> str: + safe_name = re.sub(r'[^A-Za-z0-9_]', '_', field_name)[:8] or 'field' + digest = hashlib.sha1(field_name.encode('utf-8')).hexdigest()[:6] + return f'{prefix}_{index}_{safe_name}_{digest}'[:32] + + +def _dify_field_name(field: dict) -> str: + return str(field.get('output_variable_name') or field.get('name') or field.get('id') or '').strip() + + +def _dify_field_type(field: dict) -> str: + return str(field.get('type') or 'text').strip().lower() + + +def _dify_select_options(field: dict) -> list[str]: + source = field.get('option_source') or {} + value = source.get('value') if isinstance(source, dict) else None + if isinstance(value, list): + return [str(item) for item in value] + if isinstance(value, str): + return [part.strip() for part in value.splitlines() if part.strip()] + options = field.get('options') + if isinstance(options, list): + result: list[str] = [] + for item in options: + if isinstance(item, dict): + result.append(str(item.get('label') or item.get('value') or '')) + else: + result.append(str(item)) + return [item for item in result if item] + return [] + + +def _dify_default_value(field: dict) -> str: + default = field.get('default') + if isinstance(default, dict): + value = default.get('value') if default.get('type') == 'constant' or 'value' in default else '' + else: + value = default + return '' if value is None else str(value) + + +def _lark_clean_form_content(form_content: str, input_defs: list[dict]) -> str: + field_names = {_dify_field_name(field) for field in input_defs if _dify_field_name(field)} + kept_lines: list[str] = [] + for line in (form_content or '').splitlines(): + placeholder = re.fullmatch(r'\s*\{\{#\$output\.([^#{}]+)#\}\}\s*', line) + if placeholder and placeholder.group(1) in field_names: + continue + kept_lines.append(line) + return re.sub(r'\n{3,}', '\n\n', '\n'.join(kept_lines).strip()) + + +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_display_input_value(field: dict, value: typing.Any) -> str: + field_type = _dify_field_type(field) + if field_type == 'file': + if isinstance(value, dict): + return value.get('url') or value.get('upload_file_id') or '1 file' + return str(value) + if field_type == 'file-list': + if isinstance(value, list): + return f'{len(value)} file(s)' + return str(value) + if isinstance(value, dict): + if 'value' in value and value.get('value') not in (None, ''): + return str(value.get('value')) + text = value.get('text') + if isinstance(text, dict): + content = text.get('content') + if content not in (None, ''): + return str(content) + if text not in (None, ''): + return str(text) + if isinstance(value, list): + return ', '.join(_lark_display_input_value(field, item) for item in value if item not in (None, '')) + return str(value) + + +def _lark_completed_input_lines(form_data: dict) -> list[str]: + inputs = form_data.get('inputs') or {} + if not isinstance(inputs, dict): + return [] + + lines: list[str] = [] + for field in _lark_form_input_defs(form_data): + field_name = _dify_field_name(field) + if not field_name: + continue + value = inputs.get(field_name) + 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}**') + return lines + + +def _lark_mapping_from_value(value: typing.Any) -> dict: + if isinstance(value, dict): + return value + if isinstance(value, str): + try: + parsed = json.loads(value) + except (json.JSONDecodeError, TypeError): + return {} + return parsed if isinstance(parsed, dict) else {} + return {} + + +def _lark_action_attr(action: typing.Any, name: str) -> typing.Any: + if isinstance(action, dict): + return action.get(name) + return getattr(action, name, None) + + +def _lark_extract_action_form_inputs(action: typing.Any, action_value_obj: dict) -> dict: + input_name_map = action_value_obj.get('input_name_map', {}) + if not isinstance(input_name_map, dict): + input_name_map = {} + + form_value = _lark_mapping_from_value(_lark_action_attr(action, 'form_value')) + if not form_value: + for key in ('form_value', 'formValue', 'form_values', 'formValues'): + form_value = _lark_mapping_from_value(action_value_obj.get(key)) + if form_value: + break + + if not form_value: + action_name = _lark_action_attr(action, 'name') + input_value = _lark_action_attr(action, 'input_value') + option_value = _lark_action_attr(action, 'option') + if action_name and input_value not in (None, ''): + form_value = {action_name: input_value} + elif action_name and option_value not in (None, ''): + form_value = {action_name: option_value} + + form_inputs = {} + for component_name, value in form_value.items(): + field_name = input_name_map.get(component_name) + if not field_name and isinstance(component_name, str) and '.' in component_name: + field_name = input_name_map.get(component_name.rsplit('.', 1)[-1], component_name) + if not field_name: + field_name = component_name + if field_name and value not in (None, '', []): + form_inputs[str(field_name)] = value + return form_inputs + + class AESCipher(object): def __init__(self, key): self.bs = AES.block_size @@ -804,6 +961,9 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): card_pre_pause_text: dict[str, str] # card_id → form_content captured when the form is first shown (for resume notice) card_form_content: dict[str, str] + # card_id → input_defs / inputs captured for the selected-action notice + card_form_input_defs: dict[str, list[dict]] + card_form_inputs: dict[str, dict] # set of card_ids that have already transitioned from "buttons visible" to "resume layout" card_resume_transitioned: set[str] _MONITORING_MAPPING_TTL = 600 # 10 minutes @@ -834,13 +994,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): try: action_value_raw = getattr(getattr(event.event, 'action', None), 'value', {}) # Parse JSON string values (from form action buttons) - if isinstance(action_value_raw, str): - try: - action_value_obj = json.loads(action_value_raw) - except (json.JSONDecodeError, TypeError): - action_value_obj = {} - else: - action_value_obj = action_value_raw if isinstance(action_value_raw, dict) else {} + action_value_obj = _lark_mapping_from_value(action_value_raw) action_value = action_value_obj.get('feedback', '') if isinstance(action_value_obj, dict) else '' # Handle Dify form action button clicks @@ -849,6 +1003,8 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): workflow_run_id = action_value_obj.get('workflow_run_id', '') action_id = action_value_obj.get('action_id', '') session_key = action_value_obj.get('session_key', '') + action = getattr(event.event, 'action', None) + form_inputs = _lark_extract_action_form_inputs(action, action_value_obj) if session_key.startswith('group_') or session_key.startswith('g:'): launcher_type = provider_session.LauncherTypes.GROUP @@ -879,11 +1035,26 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): 'workflow_run_id': workflow_run_id, 'action_id': action_id, 'user': f'{launcher_type.value}_{launcher_id}', - 'inputs': {}, + 'inputs': form_inputs, } context = getattr(event.event, 'context', None) open_message_id = getattr(context, 'open_message_id', None) + if open_message_id and form_inputs: + card_id = self.reply_message_card_ids.get(str(open_message_id)) + else: + card_id = None + if not card_id: + card_id = str(action_value_obj.get('card_id') or '') + if card_id and form_inputs: + cached_inputs = dict(self.card_form_inputs.get(card_id) or {}) + cached_inputs.update(form_inputs) + self.card_form_inputs[card_id] = cached_inputs + if self.ap is not None: + self.ap.logger.info( + f'Lark form action inputs cached: card_id={card_id} ' + f'open_message_id={open_message_id} keys={list(form_inputs.keys())}' + ) source_time = datetime.datetime.now() event_time = source_time.timestamp() action_text = action_value_obj.get('action_id', 'confirm') @@ -1033,6 +1204,8 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): card_streaming_text={}, card_pre_pause_text={}, card_form_content={}, + card_form_input_defs={}, + card_form_inputs={}, card_resume_transitioned=set(), seq=1, listeners={}, @@ -1299,6 +1472,8 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): self.card_streaming_text.pop(card_id, None) self.card_pre_pause_text.pop(card_id, None) self.card_form_content.pop(card_id, None) + self.card_form_input_defs.pop(card_id, None) + self.card_form_inputs.pop(card_id, None) self.card_resume_transitioned.discard(card_id) async def create_card_id(self, message_id): @@ -1809,6 +1984,14 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): stored_form_content = self.card_form_content.get(card_id, '') if stored_form_content: notice_parts.append(stored_form_content) + completed_lines = _lark_completed_input_lines( + { + 'input_defs': self.card_form_input_defs.get(card_id, []), + 'inputs': self.card_form_inputs.get(card_id, {}), + } + ) + if completed_lines: + notice_parts.append('---\n' + '\n'.join(completed_lines)) notice_parts.append(f'---\n✅ 已选择:**{action_title}**') selected_notice = '\n\n'.join(notice_parts) else: @@ -1937,6 +2120,8 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): self.card_streaming_text.pop(card_id, None) self.card_pre_pause_text.pop(card_id, None) self.card_form_content.pop(card_id, None) + self.card_form_input_defs.pop(card_id, None) + self.card_form_inputs.pop(card_id, None) else: # The old card is now a frozen snapshot; let go of its # streaming-side state but keep its source registrations @@ -1945,6 +2130,8 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): self.card_streaming_text.pop(card_id, None) self.card_pre_pause_text.pop(card_id, None) self.card_form_content.pop(card_id, None) + self.card_form_input_defs.pop(card_id, None) + self.card_form_inputs.pop(card_id, None) self.card_resume_transitioned.discard(card_id) else: # Initial pause path: render prompt + buttons in place on @@ -1962,8 +2149,14 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): # transitions to resume mode. self.card_pre_pause_text[card_id] = self.card_streaming_text.get(card_id, '') self.card_streaming_text[card_id] = '' - # Store form_content for the resume notice - self.card_form_content[card_id] = form_data.get('form_content', '') if form_data else '' + # 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_input_defs[card_id] = _lark_form_input_defs(form_data) + self.card_form_inputs[card_id] = dict(form_data.get('inputs') or {}) else: # Normal finish: keep pre-pause + resume content visible, # remove buttons/notice, drop the resume placeholder. @@ -2040,6 +2233,78 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): form_data=None, ) + def _build_lark_form_field_elements(self, form_data: dict) -> tuple[list[dict], dict[str, str], list[str]]: + elements: list[dict] = [] + input_name_map: dict[str, str] = {} + file_help_lines: list[str] = [] + + for idx, field in enumerate(form_data.get('input_defs') or [], start=1): + field_name = _dify_field_name(field) + if not field_name: + continue + field_type = _dify_field_type(field) + + if field_type == 'select': + options = _dify_select_options(field) + component_name = _lark_form_component_name('Select', field_name, idx) + input_name_map[component_name] = field_name + elements.append( + { + 'tag': 'select_static', + 'name': component_name, + 'label': {'tag': 'plain_text', 'content': field_name}, + 'placeholder': {'tag': 'plain_text', 'content': '请选择'}, + 'options': [ + { + 'text': {'tag': 'plain_text', 'content': option}, + 'value': option, + } + for option in options + ], + 'type': 'default', + 'width': 'fill', + 'required': False, + } + ) + elif field_type in {'file', 'file-list'}: + allowed_types = ', '.join(field.get('allowed_file_types') or []) + allowed = f' ({allowed_types})' if allowed_types else '' + if field_type == 'file-list': + limit = field.get('number_limits') + suffix = f', up to {limit}' if limit else '' + file_help_lines.append( + f'- {field_name}: upload file(s){allowed}{suffix} in chat or reply `{field_name}: `' + ) + else: + file_help_lines.append( + f'- {field_name}: upload a file{allowed} in chat or reply `{field_name}: `' + ) + else: + component_name = _lark_form_component_name('Input', field_name, idx) + input_name_map[component_name] = field_name + is_multiline = field_type in {'paragraph', 'long_text', 'multiline_text', 'textarea'} + input_element = { + 'tag': 'input', + 'name': component_name, + 'label': {'tag': 'plain_text', 'content': field_name}, + 'placeholder': {'tag': 'plain_text', 'content': '请输入'}, + 'default_value': _dify_default_value(field), + 'width': 'fill', + 'required': False, + } + if is_multiline: + input_element.update( + { + 'input_type': 'multiline_text', + 'rows': 3, + 'auto_resize': True, + 'max_rows': 6, + } + ) + elements.append(input_element) + + return elements, input_name_map, file_help_lines + async def _update_card_layout( self, card_id: str, @@ -2063,6 +2328,8 @@ 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 # interactive container, so the top streaming text should stay empty @@ -2083,6 +2350,13 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): # Build button elements matching the existing card template's thumbsup/down format action_buttons = [] + 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) + 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 {}) for action in actions: action_id = action.get('id', '') action_title = action.get('title', action_id) @@ -2095,29 +2369,33 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): else: lark_button_type = 'default' - action_buttons.append( - { - 'tag': 'button', - 'text': {'tag': 'plain_text', 'content': action_title}, - 'type': lark_button_type, - 'width': 'fill', - 'size': 'medium', - 'hover_tips': {'tag': 'plain_text', 'content': action_title}, - 'behaviors': [ - { - 'type': 'callback', - 'value': { - 'form_action': True, - 'form_token': form_token, - 'workflow_run_id': workflow_run_id, - 'action_id': action_id, - 'session_key': session_key, - }, - } - ], - 'margin': '0px 0px 0px 0px', - } - ) + button = { + 'tag': 'button', + 'text': {'tag': 'plain_text', 'content': action_title}, + 'type': lark_button_type, + 'width': 'fill', + 'size': 'medium', + 'hover_tips': {'tag': 'plain_text', 'content': action_title}, + 'behaviors': [ + { + 'type': 'callback', + 'value': { + 'form_action': True, + 'form_token': form_token, + 'workflow_run_id': workflow_run_id, + 'action_id': action_id, + 'session_key': session_key, + 'card_id': card_id, + 'input_name_map': input_name_map, + }, + } + ], + 'margin': '0px 0px 0px 0px', + } + if uses_form_container: + button['name'] = _lark_form_component_name('Button', action_id or action_title, len(action_buttons) + 1) + button['form_action_type'] = 'submit' + action_buttons.append(button) interactive_elements = [] if form_data: @@ -2141,38 +2419,84 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): 'margin': '0px 0px 8px 0px', } ) - interactive_elements.append( - { - 'tag': 'column_set', - 'horizontal_spacing': '8px', - 'horizontal_align': 'left', - 'margin': '0px 0px 0px 0px', - 'columns': [ + completed_lines = _lark_completed_input_lines( + { + 'input_defs': input_defs, + 'inputs': form_data.get('inputs') or {}, + } + ) + if completed_lines: + interactive_elements.append( { - 'tag': 'column', - 'width': 'weighted', - 'elements': [btn], - 'padding': '0px 0px 0px 0px', + 'tag': 'markdown', + 'content': '---\n' + '\n'.join(completed_lines), + 'text_align': 'left', + 'text_size': 'normal', + 'margin': '0px 0px 8px 0px', } - for btn in action_buttons - ], - } - ) + ) + if file_help_lines and uses_form_container: + interactive_elements.append( + { + 'tag': 'markdown', + 'content': '\n'.join(file_help_lines), + 'text_align': 'left', + 'text_size': 'normal', + 'text_color': 'grey', + 'margin': '0px 0px 8px 0px', + } + ) + if action_buttons: + interactive_elements.append( + { + 'tag': 'column_set', + 'horizontal_spacing': '8px', + 'horizontal_align': 'left', + 'margin': '0px 0px 0px 0px', + 'columns': [ + { + 'tag': 'column', + 'width': 'weighted', + 'elements': [btn], + 'padding': '0px 0px 0px 0px', + } + for btn in action_buttons + ], + } + ) # Build the full card JSON with buttons, same structure as create_card_id # ── mid_section: either form buttons, resume notice, or empty ── mid_section_elements = [] if form_data: - mid_section_elements = [ - { - 'tag': 'interactive_container', - 'margin': '12px 0px 8px 0px', - 'padding': '12px 12px 12px 12px', - 'has_border': True, - 'elements': interactive_elements, - }, - {'tag': 'hr', 'margin': '0px 0px 0px 0px'}, - ] + if uses_form_container: + form_elements = interactive_elements[:-1] if action_buttons else interactive_elements[:] + form_elements.extend(form_field_elements) + if action_buttons: + form_elements.append(interactive_elements[-1]) + mid_section_elements = [ + { + 'tag': 'form', + 'name': _lark_form_component_name('Form', form_token or workflow_run_id or card_id, 1), + 'direction': 'vertical', + 'vertical_spacing': '12px', + 'margin': '12px 0px 8px 0px', + 'padding': '12px 12px 12px 12px', + 'elements': form_elements, + }, + {'tag': 'hr', 'margin': '0px 0px 0px 0px'}, + ] + else: + mid_section_elements = [ + { + 'tag': 'interactive_container', + 'margin': '12px 0px 8px 0px', + 'padding': '12px 12px 12px 12px', + 'has_border': True, + 'elements': interactive_elements, + }, + {'tag': 'hr', 'margin': '0px 0px 0px 0px'}, + ] elif notice_text: mid_section_elements = [ { @@ -2442,9 +2766,121 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): action = event_data.get('action', {}) context_data = event_data.get('context', {}) - action_value_obj = action.get('value', {}) + action_value_obj = _lark_mapping_from_value(action.get('value', {})) action_value = action_value_obj.get('feedback', '') if isinstance(action_value_obj, dict) else '' + if isinstance(action_value_obj, dict) and action_value_obj.get('form_action'): + form_token = action_value_obj.get('form_token', '') + workflow_run_id = action_value_obj.get('workflow_run_id', '') + action_id = action_value_obj.get('action_id', '') + session_key = action_value_obj.get('session_key', '') + form_inputs = _lark_extract_action_form_inputs(action, action_value_obj) + + if session_key.startswith('group_') or session_key.startswith('g:'): + launcher_type = provider_session.LauncherTypes.GROUP + launcher_id = ( + session_key.split(':', 1)[1] + if session_key.startswith('g:') + else session_key[len('group_') :] + ) + else: + launcher_type = provider_session.LauncherTypes.PERSON + launcher_id = ( + session_key.split(':', 1)[1] + if session_key.startswith('p:') + else session_key[len('person_') :] + ) + + form_action_data = { + 'form_token': form_token, + 'workflow_run_id': workflow_run_id, + 'action_id': action_id, + 'user': f'{launcher_type.value}_{launcher_id}', + 'inputs': form_inputs, + } + + 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 + if not card_id: + card_id = str(action_value_obj.get('card_id') or '') + if card_id and form_inputs: + cached_inputs = dict(self.card_form_inputs.get(card_id) or {}) + cached_inputs.update(form_inputs) + self.card_form_inputs[card_id] = cached_inputs + if self.ap is not None: + self.ap.logger.info( + f'Lark form action inputs cached: card_id={card_id} ' + f'open_message_id={open_message_id} keys={list(form_inputs.keys())}' + ) + + source_time = datetime.datetime.now() + message_chain = platform_message.MessageChain( + [platform_message.Plain(text=f'[Form Action: {action_id or "confirm"}]')] + ) + if open_message_id: + message_chain.insert( + 0, + platform_message.Source( + id=open_message_id, + time=source_time, + ), + ) + + user_id = operator.get('open_id') or operator.get('user_id') or str(launcher_id) + event_time = source_time.timestamp() + if launcher_type == provider_session.LauncherTypes.GROUP: + synthetic_event = platform_events.GroupMessage( + sender=platform_entities.GroupMember( + id=user_id, + member_name='', + permission=platform_entities.Permission.Member, + group=platform_entities.Group( + id=launcher_id, + name='', + permission=platform_entities.Permission.Member, + ), + ), + message_chain=message_chain, + time=event_time, + source_platform_object=data, + ) + else: + synthetic_event = platform_events.FriendMessage( + sender=platform_entities.Friend( + id=user_id, + nickname='', + remark='', + ), + message_chain=message_chain, + time=event_time, + source_platform_object=data, + ) + + bot_uuid = '' + pipeline_uuid = None + for bot in self.ap.platform_mgr.bots: + if bot.adapter is self: + bot_uuid = bot.bot_entity.uuid + pipeline_uuid = bot.bot_entity.use_pipeline_uuid + break + + await self.ap.query_pool.add_query( + bot_uuid=bot_uuid, + launcher_type=launcher_type, + launcher_id=launcher_id, + sender_id=user_id, + message_event=synthetic_event, + message_chain=message_chain, + adapter=self, + pipeline_uuid=pipeline_uuid, + variables={ + '_dify_form_action': form_action_data, + '_routed_by_rule': True, + }, + ) + + return {'toast': {'type': 'success', 'content': '操作成功'}} + if action_value == '有帮助': feedback_type = 1 elif action_value == '无帮助': diff --git a/src/langbot/pkg/platform/sources/qqofficial.py b/src/langbot/pkg/platform/sources/qqofficial.py index 8b35dbac3..e9f07af5b 100644 --- a/src/langbot/pkg/platform/sources/qqofficial.py +++ b/src/langbot/pkg/platform/sources/qqofficial.py @@ -833,12 +833,47 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter node_title = form_data.get('node_title') or 'Confirmation needed' 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}'] if form_content.strip(): parts.append(form_content.strip()) parts.append('请点击下方按钮选择:') markdown_content = '\n\n'.join(parts) + if is_field_step: + 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 + keyboard = build_keyboard_from_form(form_data, buttons_per_row=2) if not keyboard.get('content', {}).get('rows'): # No actions to render — fall back to plain text. diff --git a/src/langbot/pkg/platform/sources/wecombot.py b/src/langbot/pkg/platform/sources/wecombot.py index 883ea3501..4175817e2 100644 --- a/src/langbot/pkg/platform/sources/wecombot.py +++ b/src/langbot/pkg/platform/sources/wecombot.py @@ -11,7 +11,13 @@ import langbot_plugin.api.entities.builtin.platform.events as platform_events import langbot_plugin.api.entities.builtin.platform.entities as platform_entities 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.api import ( + WecomBotClient, + extract_template_card_action, + extract_template_card_event_payload, + extract_template_card_selections, + parse_select_button_action, +) from langbot.libs.wecom_ai_bot_api.ws_client import WecomBotWsClient @@ -511,18 +517,25 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): object.__setattr__(self, '_synthetic_buffers', {}) buffers: dict[str, str] = self._synthetic_buffers if content and not form_data: - buffers[buf_key] = buffers.get(buf_key, '') + content + previous = buffers.get(buf_key, '') + if previous and content.startswith(previous): + buffers[buf_key] = content + elif previous and previous.endswith(content): + buffers[buf_key] = previous + else: + buffers[buf_key] = previous + content if not is_final: return {'stream': True, 'synthetic': True, 'buffered': True} final_content = buffers.pop(buf_key, '') - if content and final_content.startswith(content): - # is_final chunk re-emitted the full accumulated text — keep - # whichever is longer. - final_content = final_content if len(final_content) >= len(content) else content - elif content and not final_content: - final_content = content + if content: + if final_content and content.startswith(final_content): + final_content = content + elif final_content and final_content.endswith(content): + pass + else: + final_content = final_content + content if not ws_mode: await self.logger.warning( @@ -575,12 +588,17 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): need the task_id registered so button clicks find pending_form. For ws mode we stash it directly on the ws_client's pending dict. """ - from langbot.libs.wecom_ai_bot_api.api import build_button_interaction_payload + from langbot.libs.wecom_ai_bot_api.api import build_human_input_template_card_payload import secrets as _secrets task_id = f'dify-{_secrets.token_hex(12)}' source = getattr(self.bot, 'card_source', None) - payload = build_button_interaction_payload(form_data, task_id, source=source) + payload = build_human_input_template_card_payload( + form_data, + task_id, + source=source, + select_as_buttons=not self.config.get('enable-webhook', False), + ) # Register task_id → form_data so the click callback can find it. # user_id / chat_id are required so _on_card_action can route the @@ -766,13 +784,87 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): ) actions = form.get('actions') or [] - clean_action_id = (action_id or '').strip() + tce = extract_template_card_event_payload(raw_event) if isinstance(raw_event, dict) else {} + _, _, card_type = extract_template_card_action(tce) + selections = extract_template_card_selections(tce, form) + if not selections: + selections = parse_select_button_action(action_id, form) + await self.logger.info( + f'WeComBot template_card selections: task_id={task_id} card_type={card_type} selections={selections}' + ) + if card_type == 'multiple_interaction' and not selections: + await self.logger.warning( + f'WeComBot: multiple_interaction callback has no parseable selections; raw={str(tce)[:1000]}' + ) + return + is_select_submit = card_type == 'multiple_interaction' or bool(selections) + + clean_action_id = '' if is_select_submit else (action_id or '').strip() action_title = clean_action_id for a in actions: if str(a.get('id', '')) == clean_action_id: action_title = a.get('title') or clean_action_id break + inputs = dict(form.get('inputs') or {}) + inputs.update(selections) + + def _missing_fields_after_select() -> list[str]: + missing: list[str] = [] + for field in form.get('input_defs') or form.get('all_input_defs') or []: + field_name = str(field.get('output_variable_name') or '').strip() + if not field_name: + continue + if inputs.get(field_name) in (None, '', []): + missing.append(field_name) + return missing + + input_progress = False + if is_select_submit: + missing_fields = _missing_fields_after_select() + if not missing_fields and len(actions) == 1: + action = actions[0] + clean_action_id = str(action.get('id') or '').strip() + action_title = action.get('title') or clean_action_id + elif not missing_fields and len(actions) > 1: + if not self.config.get('enable-webhook', False): + action_form_data = { + 'form_content': form.get('raw_form_content') or form.get('form_content') or '', + 'raw_form_content': form.get('raw_form_content') or form.get('form_content') or '', + 'input_defs': [], + 'all_input_defs': form.get('all_input_defs') or form.get('input_defs') or [], + 'inputs': inputs, + 'actions': actions, + 'node_title': form.get('node_title', ''), + 'workflow_run_id': form.get('workflow_run_id', ''), + 'form_token': form.get('form_token', ''), + '_action_select_only': True, + } + target_chat_id = session.chat_id or session.user_id or '' + try: + payload = self._build_button_interaction_payload_from_form( + action_form_data, + user_id=session.user_id or '', + chat_id=session.chat_id or '', + ) + await self.bot.send_template_card(target_chat_id, payload) + await self.logger.info( + f'WeComBot: sent action-select button card after select submit ' + f'task_id={task_id} action_count={len(actions)}' + ) + except Exception: + await self.logger.error( + f'WeComBot: failed to send action-select button card: {traceback.format_exc()}' + ) + return + await self.logger.warning( + 'WeComBot webhook mode cannot proactively send action-select button card after select submit' + ) + return + else: + input_progress = True + action_title = 'Submit' + launcher_id = session.user_id or session.chat_id or '' sender_user_id = session.user_id or launcher_id # WeCom AI bot has both single-chat and group-chat; chat_id present @@ -791,8 +883,10 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): 'action_title': action_title, 'node_title': form.get('node_title', ''), 'user': f'{launcher_type.value}_{launcher_id}', - 'inputs': {}, + 'inputs': inputs, } + if input_progress: + form_action_data['_input_progress'] = True message_chain = platform_message.MessageChain([platform_message.Plain(text=f'[Form Action: {action_title}]')]) diff --git a/src/langbot/pkg/provider/runners/difysvapi.py b/src/langbot/pkg/provider/runners/difysvapi.py index 52bbbe103..226bbfaae 100644 --- a/src/langbot/pkg/provider/runners/difysvapi.py +++ b/src/langbot/pkg/provider/runners/difysvapi.py @@ -6,12 +6,16 @@ import time import uuid import base64 import mimetypes +import os +import re from collections import OrderedDict +from urllib.parse import urlparse from langbot.pkg.provider import runner from langbot.pkg.core import app import langbot_plugin.api.entities.builtin.provider.message as provider_message +import langbot_plugin.api.entities.builtin.platform.message as platform_message 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 @@ -24,6 +28,7 @@ import httpx # Dify workflows to be paused simultaneously for the same session. _PENDING_FORMS: dict[str, 'OrderedDict[str, dict[str, typing.Any]]'] = {} _PENDING_FORM_DEFAULT_TTL = 30 * 60 # 30 minutes safety cap +_STREAM_FORM_PLACEHOLDER = '\u200b' def _session_key_from_query(query: pipeline_query.Query) -> str: @@ -118,25 +123,439 @@ def _format_human_input_text( node_title: str, form_content: str, actions: list[dict[str, typing.Any]], + input_defs: list[dict[str, typing.Any]] | None = None, ) -> 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. """ + input_defs = input_defs or [] + form_content = _strip_form_field_placeholders(form_content, input_defs) lines: list[str] = [f'[Human Input Required] {node_title or ""}'.rstrip()] if form_content: lines.append('') lines.append(form_content) + field_help = _format_human_input_fields_text(input_defs) + if field_help: + lines.append('') + lines.append(field_help) if actions: lines.append('') - lines.append('Reply with the number or title to continue:') + if input_defs: + lines.append('Reply with action plus field values to continue:') + lines.append(' action: ') + else: + 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) +def _normalize_form_input_defs(raw_inputs: typing.Any) -> list[dict[str, typing.Any]]: + if not isinstance(raw_inputs, list): + return [] + normalized: list[dict[str, typing.Any]] = [] + for item in raw_inputs: + if not isinstance(item, dict): + continue + field = dict(item) + name = str( + field.get('output_variable_name') or field.get('variable') or field.get('name') or field.get('id') or '' + ).strip() + if not name: + continue + field['output_variable_name'] = name + normalized.append(field) + return normalized + + +def _field_name(field: dict[str, typing.Any]) -> str: + return str(field.get('output_variable_name') or '').strip() + + +def _field_type(field: dict[str, typing.Any]) -> str: + return str(field.get('type') or 'text').strip().lower() + + +def _select_options(field: dict[str, typing.Any]) -> list[str]: + source = field.get('option_source') or {} + value = source.get('value') if isinstance(source, dict) else None + if isinstance(value, list): + return [str(item) for item in value] + if isinstance(value, str): + return [part.strip() for part in value.splitlines() if part.strip()] + options = field.get('options') + if isinstance(options, list): + result: list[str] = [] + for item in options: + if isinstance(item, dict): + result.append(str(item.get('label') or item.get('value') or '')) + else: + result.append(str(item)) + return [item for item in result if item] + return [] + + +def _default_field_value(field: dict[str, typing.Any]) -> typing.Any: + default = field.get('default') + if isinstance(default, dict): + if default.get('type') == 'constant': + return default.get('value') + if 'value' in default: + return default.get('value') + return default + + +def _initial_form_inputs( + input_defs: list[dict[str, typing.Any]], + resolved_default_values: typing.Any = None, +) -> dict[str, typing.Any]: + inputs: dict[str, typing.Any] = {} + if isinstance(resolved_default_values, dict): + inputs.update(resolved_default_values) + for field in input_defs: + name = _field_name(field) + if not name or name in inputs: + continue + value = _default_field_value(field) + if value not in (None, ''): + inputs[name] = value + return inputs + + +def _format_human_input_fields_text(input_defs: list[dict[str, typing.Any]]) -> str: + if not input_defs: + return '' + + lines = ['Fields:'] + for field in input_defs: + name = _field_name(field) + typ = _field_type(field) + if typ == 'select': + options = _select_options(field) + option_text = ', '.join(f'{idx}. {value}' for idx, value in enumerate(options, start=1)) + lines.append(f' - {name} (select): {option_text or "choose one option"}') + elif typ in {'file', 'file-list'}: + limit = field.get('number_limits') if typ == 'file-list' else 1 + allowed_types = ', '.join(field.get('allowed_file_types') or []) + suffix = f', up to {limit}' if typ == 'file-list' and limit else '' + allowed = f' ({allowed_types})' if allowed_types else '' + lines.append(f' - {name} ({typ}{allowed}{suffix}): upload file(s) or reply "{name}: "') + else: + lines.append(f' - {name} ({typ}): reply "{name}: "') + + lines.append('You can reply with one or more lines like "field_name: value".') + return '\n'.join(lines) + + +def _format_human_input_actions_text(actions: list[dict[str, typing.Any]], require_action_key: bool = False) -> str: + if not actions: + return '' + lines: list[str] = [] + if require_action_key: + lines.append('Actions: reply with "action: " plus field values.') + else: + lines.append('Actions:') + 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) + + +def _strip_form_field_placeholders(form_content: str, input_defs: list[dict[str, typing.Any]]) -> str: + if not form_content: + return '' + + field_names = {_field_name(field) for field in input_defs if _field_name(field)} + kept_lines: list[str] = [] + for line in form_content.splitlines(): + placeholder = re.fullmatch(r'\s*\{\{#\$output\.([^#{}]+)#\}\}\s*', line) + if placeholder and placeholder.group(1) in field_names: + continue + kept_lines.append(line) + + cleaned = '\n'.join(kept_lines).strip() + return re.sub(r'\n{3,}', '\n\n', cleaned) + + +def _form_content_for_platform( + form_content: str, + input_defs: list[dict[str, typing.Any]], + actions: list[dict[str, typing.Any]], +) -> str: + del actions + form_content = _strip_form_field_placeholders(form_content, input_defs) + field_help = _format_human_input_fields_text(input_defs) + parts = [part for part in (form_content, field_help) if part] + if not parts: + return form_content + return '\n\n'.join(parts) + + +def _extract_form_snapshot( + workflow_run_id: str, + reason: dict[str, typing.Any], + user: str, +) -> tuple[dict[str, typing.Any], str, list[dict[str, typing.Any]], str]: + raw_form_content = reason.get('form_content', '') or '' + input_defs = _normalize_form_input_defs(reason.get('inputs', [])) + actions = reason.get('actions', []) + display_form_content = _form_content_for_platform(raw_form_content, input_defs, actions) + snapshot = { + 'workflow_run_id': workflow_run_id, + 'form_id': reason.get('form_id'), + 'form_token': reason.get('form_token'), + 'node_id': reason.get('node_id'), + 'node_title': reason.get('node_title', ''), + 'form_content': display_form_content, + 'raw_form_content': raw_form_content, + 'input_defs': input_defs, + 'inputs': _initial_form_inputs(input_defs, reason.get('resolved_default_values')), + 'actions': actions, + 'expiration_time': reason.get('expiration_time'), + 'user': user, + } + return snapshot, raw_form_content, input_defs, display_form_content + + +def _extract_key_value_inputs(text: str) -> dict[str, str]: + stripped = text.strip() + if not stripped: + return {} + try: + parsed = json.loads(stripped) + except json.JSONDecodeError: + parsed = None + if isinstance(parsed, dict): + return {str(k).strip(): str(v).strip() for k, v in parsed.items() if str(k).strip()} + + values: dict[str, str] = {} + for line in stripped.splitlines(): + if ':' in line: + key, value = line.split(':', 1) + elif '=' in line: + key, value = line.split('=', 1) + else: + continue + key = key.strip() + if key: + values[key] = value.strip() + return values + + +def _extract_urls(text: str) -> list[str]: + return re.findall(r'https?://[^\s,,]+', text or '') + + +def _file_type_from_mime(content_type: str) -> str: + if content_type and content_type.startswith('image/'): + return 'image' + if content_type and content_type.startswith('audio/'): + return 'audio' + if content_type and content_type.startswith('video/'): + return 'video' + return 'document' + + +def _format_partial_form_notice(pending_form: dict[str, typing.Any]) -> str: + actions = pending_form.get('actions') or [] + lines = ['Received the form value(s).'] + action_help = _format_human_input_actions_text(actions, require_action_key=True) + if action_help: + lines.append('') + lines.append(action_help) + return '\n'.join(lines) + + +def _next_missing_form_field(form: dict[str, typing.Any], inputs: dict[str, typing.Any] | None = None) -> dict | None: + values = inputs if inputs is not None else form.get('inputs') or {} + for field in form.get('input_defs') or []: + name = _field_name(field) + if not name: + continue + if values.get(name) in (None, '', []): + return field + return None + + +def _format_single_form_field_text(field: dict[str, typing.Any]) -> str: + name = _field_name(field) + typ = _field_type(field) + if typ == 'select': + options = _select_options(field) + option_text = ', '.join(f'{idx}. {value}' for idx, value in enumerate(options, start=1)) + return f'{name} (select): {option_text or "choose one option"}' + if typ in {'file', 'file-list'}: + limit = field.get('number_limits') if typ == 'file-list' else 1 + allowed_types = ', '.join(field.get('allowed_file_types') or []) + suffix = f', up to {limit}' if typ == 'file-list' and limit else '' + allowed = f' ({allowed_types})' if allowed_types else '' + return f'{name} ({typ}{allowed}{suffix}): upload file(s) or reply "{name}: "' + return f'{name} ({typ}): reply "{name}: "' + + +def _field_input_form_data(pending_form: dict[str, typing.Any], field: dict[str, typing.Any] | None) -> dict | None: + if not field: + return None + return { + 'form_content': _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 [], + 'inputs': pending_form.get('inputs', {}), + 'actions': pending_form.get('actions') or [], + 'node_title': pending_form.get('node_title', ''), + 'workflow_run_id': pending_form.get('workflow_run_id', ''), + 'form_token': pending_form.get('form_token', ''), + '_current_input_field': _field_name(field), + } + + +def _action_select_form_data(pending_form: dict[str, typing.Any]) -> dict[str, typing.Any] | None: + actions = pending_form.get('actions') or [] + if not actions: + 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 []), + 'raw_form_content': form_content, + 'input_defs': [], + 'all_input_defs': pending_form.get('input_defs') or [], + 'inputs': pending_form.get('inputs', {}), + 'actions': actions, + 'node_title': pending_form.get('node_title', ''), + 'workflow_run_id': pending_form.get('workflow_run_id', ''), + 'form_token': pending_form.get('form_token', ''), + '_action_select_only': True, + } + + +def _initial_interactive_form_data(pending_form: dict[str, typing.Any]) -> dict[str, typing.Any] | None: + next_field = _next_missing_form_field(pending_form) + pending_form['current_input_field'] = _field_name(next_field) if next_field else '' + if next_field: + return _field_input_form_data(pending_form, next_field) + return _action_select_form_data(pending_form) + + +def _attach_partial_form_data(message: typing.Any, form_action: dict[str, typing.Any]) -> typing.Any: + form_data = form_action.get('_form_data') + if form_data: + message._form_data = form_data + return message + + +def _missing_required_form_fields(form: dict[str, typing.Any], inputs: dict[str, typing.Any]) -> list[str]: + missing: list[str] = [] + for field in form.get('input_defs') or []: + name = _field_name(field) + if not name: + continue + value = inputs.get(name) + if value in (None, '', []): + missing.append(name) + return missing + + +def _format_missing_form_inputs_notice(form: dict[str, typing.Any], missing: list[str]) -> str: + lines = ['Some required form fields are still missing.'] + if missing: + lines.append('') + lines.append('Missing fields: ' + ', '.join(missing)) + field_help = _format_human_input_fields_text( + [field for field in form.get('input_defs') or [] if _field_name(field) in set(missing)] + ) + if field_help: + lines.append('') + lines.append(field_help) + action_help = _format_human_input_actions_text(form.get('actions') or [], require_action_key=True) + if action_help: + lines.append('') + lines.append(action_help) + return '\n'.join(lines) + + +def _normalize_form_action_inputs( + pending_form: dict[str, typing.Any], + raw_inputs: dict[str, typing.Any], +) -> dict[str, typing.Any]: + if not raw_inputs: + return {} + fields = {_field_name(field): field for field in pending_form.get('input_defs') or [] if _field_name(field)} + normalized = dict(raw_inputs) + for name, value in list(normalized.items()): + field = fields.get(name) + if not field or _field_type(field) != 'select': + continue + selected = value + if isinstance(value, str): + stripped = value.strip() + try: + parsed = json.loads(stripped) + except json.JSONDecodeError: + parsed = None + if isinstance(parsed, dict): + selected = parsed.get('value', selected) + elif stripped.isdigit(): + options = _select_options(field) + index = int(stripped) + if 0 <= index < len(options): + selected = options[index] + elif 1 <= index <= len(options): + selected = options[index - 1] + normalized[name] = selected + return normalized + + +def _build_input_progress_action( + pending_form: dict[str, typing.Any], + inputs: dict[str, typing.Any], + *, + force_partial: bool = False, +) -> dict[str, typing.Any]: + """Update a pending form after collecting field values. + + `force_partial` is used by native card controls (for example DingTalk + Input/SelectBlock): those callbacks mean "store this field value and + render the next step", not "submit a workflow action" unless there is a + single action and no further choice is required. + """ + actions = pending_form.get('actions') or [] + pending_form['inputs'] = inputs + next_field = _next_missing_form_field(pending_form, inputs) + pending_form['current_input_field'] = _field_name(next_field) if next_field else '' + form_data = ( + _field_input_form_data(pending_form, next_field) if next_field else _action_select_form_data(pending_form) + ) + + if force_partial or len(actions) > 1 or next_field: + return { + '_partial': True, + 'form_token': pending_form.get('form_token', ''), + 'workflow_run_id': pending_form.get('workflow_run_id', ''), + 'node_title': pending_form.get('node_title', ''), + 'inputs': inputs, + 'user': pending_form.get('user', ''), + 'notice': ( + form_data.get('form_content') if next_field and form_data else _format_partial_form_notice(pending_form) + ), + '_form_data': form_data, + } + + action = actions[0] if actions else {} + 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': inputs, + 'user': pending_form.get('user', ''), + } + + @runner.runner_class('dify-service-api') class DifyServiceAPIRunner(runner.RequestRunner): """Dify Service API 对话请求器""" @@ -291,6 +710,168 @@ class DifyServiceAPIRunner(runner.RequestRunner): return plain_text, upload_files + async def _upload_file_bytes_for_user( + self, file_name: str, file_bytes: bytes, content_type: str, user: str + ) -> dict: + file_name = file_name or 'file' + content_type = content_type or 'application/octet-stream' + resp = await self.dify_client.upload_file((file_name, file_bytes, content_type), user) + return { + 'type': _file_type_from_mime(content_type), + 'transfer_method': 'local_file', + 'upload_file_id': resp['id'], + } + + async def _download_file_for_form(self, file_url: str) -> tuple[bytes, str, str]: + async with httpx.AsyncClient() as client_session: + resp = await client_session.get(file_url) + resp.raise_for_status() + content_type = ( + resp.headers.get('content-type') or mimetypes.guess_type(file_url)[0] or 'application/octet-stream' + ) + parsed = urlparse(file_url) + file_name = os.path.basename(parsed.path) or 'file' + return resp.content, content_type, file_name + + async def _platform_file_to_dify(self, item: typing.Any, user: str) -> dict | None: + try: + if isinstance(item, platform_message.Image): + file_bytes, content_type = await item.get_bytes() + ext = (content_type or 'image/jpeg').split('/')[-1] or 'jpg' + return await self._upload_file_bytes_for_user(f'image.{ext}', file_bytes, content_type, user) + if isinstance(item, platform_message.File): + file_name = item.name or 'file' + if item.base64: + header, b64_data = item.base64.split(',', 1) if ',' in item.base64 else ('', item.base64) + content_type = 'application/octet-stream' + if header.startswith('data:') and ';' in header: + content_type = header.split(';', 1)[0][5:] or content_type + return await self._upload_file_bytes_for_user( + file_name, + base64.b64decode(b64_data), + content_type, + user, + ) + if item.path: + with open(item.path, 'rb') as f: + file_bytes = f.read() + content_type = mimetypes.guess_type(str(item.path))[0] or 'application/octet-stream' + file_name = item.name or os.path.basename(str(item.path)) or 'file' + return await self._upload_file_bytes_for_user(file_name, file_bytes, content_type, user) + if item.url: + file_bytes, content_type, downloaded_name = await self._download_file_for_form(item.url) + return await self._upload_file_bytes_for_user( + file_name or downloaded_name, + file_bytes, + content_type, + user, + ) + except Exception as e: + self.ap.logger.warning(f'dify human-input file upload failed: {e}') + return None + + async def _collect_form_inputs_from_query( + self, + query: pipeline_query.Query, + pending_form: dict, + user_text: str, + ) -> dict[str, typing.Any]: + input_defs = pending_form.get('input_defs') or [] + if not input_defs: + return dict(pending_form.get('inputs') or {}) + + values = dict(pending_form.get('inputs') or {}) + keyed_values = _extract_key_value_inputs(user_text) + user = pending_form.get('user') or _session_key_from_query(query) + current_field_name = str(pending_form.get('current_input_field') or '').strip() + + file_fields = [field for field in input_defs if _field_type(field) in {'file', 'file-list'}] + uploaded_files: list[dict] = [] + if file_fields: + for component in query.message_chain: + if isinstance(component, (platform_message.Image, platform_message.File)): + uploaded = await self._platform_file_to_dify(component, user) + if uploaded: + uploaded_files.append(uploaded) + + for field in input_defs: + name = _field_name(field) + typ = _field_type(field) + if not name: + continue + + raw_value = keyed_values.get(name) + if raw_value is None and current_field_name == name and user_text.strip(): + raw_value = user_text.strip() + if raw_value is None and current_field_name == name and typ in {'file', 'file-list'} and uploaded_files: + raw_value = '' + if raw_value is None: + continue + + if typ == 'select': + options = _select_options(field) + selected = raw_value.strip() + if selected.isdigit() and 1 <= int(selected) <= len(options): + selected = options[int(selected) - 1] + elif options: + lowered = selected.lower() + selected = next((option for option in options if option.lower() == lowered), selected) + values[name] = selected + elif typ == 'file': + urls = _extract_urls(raw_value) + if urls: + values[name] = { + 'type': _file_type_from_mime(mimetypes.guess_type(urls[0])[0] or ''), + 'transfer_method': 'remote_url', + 'url': urls[0], + } + elif uploaded_files: + values[name] = uploaded_files.pop(0) + elif typ == 'file-list': + urls = _extract_urls(raw_value) + file_values = [ + { + 'type': _file_type_from_mime(mimetypes.guess_type(url)[0] or ''), + 'transfer_method': 'remote_url', + 'url': url, + } + for url in urls + ] + if uploaded_files: + file_values.extend(uploaded_files) + uploaded_files = [] + limit = field.get('number_limits') + if isinstance(limit, int) and limit > 0: + file_values = file_values[:limit] + if file_values: + values[name] = file_values + else: + values[name] = raw_value + + for field in file_fields: + if not uploaded_files: + break + name = _field_name(field) + if not name or values.get(name): + continue + if _field_type(field) == 'file-list': + limit = field.get('number_limits') + count = limit if isinstance(limit, int) and limit > 0 else len(uploaded_files) + values[name] = uploaded_files[:count] + uploaded_files = uploaded_files[count:] + else: + values[name] = uploaded_files.pop(0) + + text_field_names = [ + _field_name(field) + for field in input_defs + if _field_type(field) not in {'file', 'file-list', 'select'} and _field_name(field) + ] + if not current_field_name and not keyed_values and len(text_field_names) == 1 and user_text.strip(): + values[text_field_names[0]] = user_text.strip() + + return values + async def _chat_messages( self, query: pipeline_query.Query ) -> typing.AsyncGenerator[provider_message.Message, None]: @@ -302,9 +883,15 @@ class DifyServiceAPIRunner(runner.RequestRunner): if form_action_raw: form_action = self._merge_pending_form_action(session_key, form_action_raw) else: - form_action = self._match_pending_form_action(session_key, str(query.message_chain)) + form_action = await self._match_pending_form_action(query, session_key, str(query.message_chain)) if form_action: + if form_action.get('_partial'): + yield _attach_partial_form_data( + provider_message.Message(role='assistant', content=form_action.get('notice', 'Received.')), + form_action, + ) + return _clear_pending_form(session_key, form_action.get('form_token') or None) async for msg in self._submit_workflow_form_blocking(form_action): yield msg @@ -354,33 +941,25 @@ class DifyServiceAPIRunner(runner.RequestRunner): for reason in reasons: if reason.get('TYPE') != 'human_input_required': continue - form_content = reason.get('form_content', '') - actions = reason.get('actions', []) - node_title = reason.get('node_title', '') - - _set_pending_form( - _session_key_from_query(query), - { - 'workflow_run_id': workflow_run_id, - 'form_id': reason.get('form_id'), - 'form_token': reason.get('form_token'), - 'node_id': reason.get('node_id'), - 'node_title': node_title, - 'form_content': form_content, - 'inputs': reason.get('inputs', {}), - 'actions': actions, - 'expiration_time': reason.get('expiration_time'), - 'user': f'{query.session.launcher_type.value}_{query.session.launcher_id}', - }, + user = f'{query.session.launcher_type.value}_{query.session.launcher_id}' + form_snapshot, raw_form_content, input_defs, _ = _extract_form_snapshot( + workflow_run_id, + reason, + user, ) + actions = form_snapshot.get('actions', []) + node_title = form_snapshot.get('node_title', '') + + _set_pending_form(_session_key_from_query(query), form_snapshot) query.variables['_dify_form_render'] = { - 'form_content': form_content, + 'form_content': raw_form_content, + 'input_defs': input_defs, 'actions': actions, 'node_title': node_title, } - display_text = _format_human_input_text(node_title, form_content, actions) + display_text = _format_human_input_text(node_title, raw_form_content, actions, input_defs) yield provider_message.Message( role='assistant', content=display_text, @@ -547,28 +1126,22 @@ class DifyServiceAPIRunner(runner.RequestRunner): for reason in reasons: if reason.get('TYPE') != 'human_input_required': continue - form_content = reason.get('form_content', '') - actions = reason.get('actions', []) - paused_node_title = reason.get('node_title', '') - raw_inputs = reason.get('inputs', {}) - - _set_pending_form( + form_snapshot, raw_form_content, input_defs, _ = _extract_form_snapshot( + new_run_id, + reason, user, - { - 'workflow_run_id': new_run_id, - 'form_id': reason.get('form_id'), - 'form_token': reason.get('form_token'), - 'node_id': reason.get('node_id'), - 'node_title': paused_node_title, - 'form_content': form_content, - 'inputs': raw_inputs if isinstance(raw_inputs, dict) else {}, - 'actions': actions, - 'expiration_time': reason.get('expiration_time'), - 'user': user, - }, ) + actions = form_snapshot.get('actions', []) + paused_node_title = form_snapshot.get('node_title', '') - display_text = _format_human_input_text(paused_node_title, form_content, actions) + _set_pending_form(user, form_snapshot) + + display_text = _format_human_input_text( + paused_node_title, + raw_form_content, + actions, + input_defs, + ) yield provider_message.Message( role='assistant', content=display_text, @@ -615,9 +1188,36 @@ class DifyServiceAPIRunner(runner.RequestRunner): merged_action['workflow_run_id'] = merged_action.get('workflow_run_id') or pending_form.get( 'workflow_run_id', '' ) - merged_action.setdefault('inputs', pending_form.get('inputs', {})) + inputs = dict(pending_form.get('inputs') or {}) + component_inputs = merged_action.get('inputs') or {} + current_field_name = str( + merged_action.pop('_current_input_field', None) or pending_form.get('current_input_field') or '' + ).strip() + if current_field_name and current_field_name not in component_inputs: + for component_key in ('input', 'select'): + if component_key in component_inputs: + component_inputs[current_field_name] = component_inputs.pop(component_key) + break + component_inputs = _normalize_form_action_inputs(pending_form, component_inputs) + inputs.update(component_inputs) + merged_action['inputs'] = inputs merged_action.setdefault('user', pending_form.get('user', '')) merged_action.setdefault('node_title', pending_form.get('node_title', '')) + if merged_action.pop('_input_progress', False): + return _build_input_progress_action(pending_form, inputs, force_partial=True) + missing_fields = _missing_required_form_fields(pending_form, inputs) + if missing_fields: + pending_form['inputs'] = inputs + next_field = _next_missing_form_field(pending_form, inputs) + pending_form['current_input_field'] = _field_name(next_field) if next_field else '' + form_data = ( + _field_input_form_data(pending_form, next_field) + if next_field + else _action_select_form_data(pending_form) + ) + merged_action['_partial'] = True + merged_action['notice'] = _format_missing_form_inputs_notice(pending_form, missing_fields) + merged_action['_form_data'] = form_data # Resolve clicked action's display title from the stored actions list if 'action_title' not in merged_action: @@ -629,7 +1229,12 @@ class DifyServiceAPIRunner(runner.RequestRunner): return merged_action - def _match_pending_form_action(self, session_key: str, user_text: str) -> dict | None: + async def _match_pending_form_action( + self, + query: pipeline_query.Query, + session_key: str, + user_text: str, + ) -> dict | None: """Match plain text replies against pending Dify form actions. Resolution order: @@ -641,8 +1246,26 @@ class DifyServiceAPIRunner(runner.RequestRunner): two forms share a button label the newer one resolves. """ normalized_text = user_text.strip().lower() - if not normalized_text: + latest_form = _get_latest_pending_form(session_key) + has_file_upload = bool( + latest_form + and latest_form.get('input_defs') + and any(_field_type(field) in {'file', 'file-list'} for field in latest_form.get('input_defs') or []) + and any( + isinstance(component, (platform_message.Image, platform_message.File)) + for component in query.message_chain + ) + ) + if not normalized_text and not has_file_upload: return None + keyed_values = _extract_key_value_inputs(user_text) + requested_action = ( + keyed_values.get('action') + or keyed_values.get('Action') + or keyed_values.get('action_id') + or keyed_values.get('actionId') + ) + normalized_action = requested_action.strip().lower() if requested_action else '' def _build(pending_form: dict, action: dict) -> dict: return { @@ -655,13 +1278,34 @@ class DifyServiceAPIRunner(runner.RequestRunner): 'user': pending_form.get('user', ''), } - if normalized_text.isdigit(): - position = int(normalized_text) - latest_form = _get_latest_pending_form(session_key) + if latest_form and latest_form.get('current_input_field') and not normalized_action: + inputs = await self._collect_form_inputs_from_query(query, latest_form, user_text) + if inputs != (latest_form.get('inputs') or {}): + return _build_input_progress_action(latest_form, inputs) + + if latest_form and latest_form.get('input_defs') and not normalized_action: + current_inputs = dict(latest_form.get('inputs') or {}) + missing_fields = _missing_required_form_fields(latest_form, current_inputs) + if missing_fields: + next_field = _next_missing_form_field(latest_form, current_inputs) + if next_field: + latest_form['current_input_field'] = _field_name(next_field) + inputs = await self._collect_form_inputs_from_query(query, latest_form, user_text) + if inputs != current_inputs: + return _build_input_progress_action(latest_form, inputs) + + if normalized_text.isdigit() or normalized_action.isdigit(): + position = int(normalized_action or normalized_text) if latest_form is not None: actions = latest_form.get('actions', []) if 1 <= position <= len(actions): - return _build(latest_form, actions[position - 1]) + form_action = _build(latest_form, actions[position - 1]) + form_action['inputs'] = await self._collect_form_inputs_from_query( + query, + latest_form, + user_text, + ) + return form_action for pending_form in _iter_pending_forms(session_key): for action in pending_form.get('actions', []): @@ -669,8 +1313,19 @@ class DifyServiceAPIRunner(runner.RequestRunner): str(action.get('title', '')).strip().lower(), str(action.get('id', '')).strip().lower(), } - if normalized_text in titles: - return _build(pending_form, action) + if normalized_text in titles or (normalized_action and normalized_action in titles): + form_action = _build(pending_form, action) + form_action['inputs'] = await self._collect_form_inputs_from_query( + query, + pending_form, + user_text, + ) + return form_action + + if latest_form and latest_form.get('input_defs'): + inputs = await self._collect_form_inputs_from_query(query, latest_form, user_text) + if inputs != (latest_form.get('inputs') or {}): + return _build_input_progress_action(latest_form, inputs) return None @@ -686,9 +1341,15 @@ class DifyServiceAPIRunner(runner.RequestRunner): if form_action_raw: form_action = self._merge_pending_form_action(session_key, form_action_raw) else: - form_action = self._match_pending_form_action(session_key, str(query.message_chain)) + form_action = await self._match_pending_form_action(query, session_key, str(query.message_chain)) if form_action: + if form_action.get('_partial'): + yield _attach_partial_form_data( + provider_message.Message(role='assistant', content=form_action.get('notice', 'Received.')), + form_action, + ) + return _clear_pending_form(session_key, form_action.get('form_token') or None) async for msg in self._submit_workflow_form_blocking(form_action): yield msg @@ -737,33 +1398,25 @@ class DifyServiceAPIRunner(runner.RequestRunner): workflow_run_id = chunk['data'].get('workflow_run_id', '') for reason in reasons: if reason.get('TYPE') == 'human_input_required': - form_content = reason.get('form_content', '') - actions = reason.get('actions', []) - node_title = reason.get('node_title', '') - - _set_pending_form( - _session_key_from_query(query), - { - 'workflow_run_id': workflow_run_id, - 'form_id': reason.get('form_id'), - 'form_token': reason.get('form_token'), - 'node_id': reason.get('node_id'), - 'node_title': node_title, - 'form_content': form_content, - 'inputs': reason.get('inputs', {}), - 'actions': actions, - 'expiration_time': reason.get('expiration_time'), - 'user': f'{query.session.launcher_type.value}_{query.session.launcher_id}', - }, + user = f'{query.session.launcher_type.value}_{query.session.launcher_id}' + form_snapshot, raw_form_content, input_defs, _ = _extract_form_snapshot( + workflow_run_id, + reason, + user, ) + actions = form_snapshot.get('actions', []) + node_title = form_snapshot.get('node_title', '') + + _set_pending_form(_session_key_from_query(query), form_snapshot) query.variables['_dify_form_render'] = { - 'form_content': form_content, + 'form_content': raw_form_content, + 'input_defs': input_defs, 'actions': actions, 'node_title': node_title, } - display_text = _format_human_input_text(node_title, form_content, actions) + display_text = _format_human_input_text(node_title, raw_form_content, actions, input_defs) human_input_yielded = True yield provider_message.Message( @@ -817,9 +1470,19 @@ class DifyServiceAPIRunner(runner.RequestRunner): if form_action_raw: form_action = self._merge_pending_form_action(session_key, form_action_raw) else: - form_action = self._match_pending_form_action(session_key, str(query.message_chain)) + form_action = await self._match_pending_form_action(query, session_key, str(query.message_chain)) if form_action: + if form_action.get('_partial'): + yield _attach_partial_form_data( + provider_message.MessageChunk( + role='assistant', + content=form_action.get('notice', 'Received.'), + is_final=True, + ), + form_action, + ) + return _clear_pending_form(session_key, form_action.get('form_token') or None) async for msg in self._submit_workflow_form(form_action): yield msg @@ -855,7 +1518,6 @@ class DifyServiceAPIRunner(runner.RequestRunner): yielded_final = False human_input_yielded = False pending_form_data = None - display_text = '' remove_think = self.pipeline_config['output'].get('misc', {}).get('remove-think') @@ -910,34 +1572,24 @@ class DifyServiceAPIRunner(runner.RequestRunner): for reason in reasons: if reason.get('TYPE') != 'human_input_required': continue - form_content = reason.get('form_content', '') - actions = reason.get('actions', []) - node_title = reason.get('node_title', '') - - raw_inputs = reason.get('inputs', {}) - _set_pending_form( - _session_key_from_query(query), - { - 'workflow_run_id': workflow_run_id, - 'form_id': reason.get('form_id'), - 'form_token': reason.get('form_token'), - 'node_id': reason.get('node_id'), - 'node_title': node_title, - 'form_content': form_content, - 'inputs': raw_inputs if isinstance(raw_inputs, dict) else {}, - 'actions': actions, - 'expiration_time': reason.get('expiration_time'), - 'user': f'{query.session.launcher_type.value}_{query.session.launcher_id}', - }, + user = f'{query.session.launcher_type.value}_{query.session.launcher_id}' + form_snapshot, raw_form_content, input_defs, display_form_content = _extract_form_snapshot( + workflow_run_id, + reason, + user, ) + actions = form_snapshot.get('actions', []) + node_title = form_snapshot.get('node_title', '') + + _set_pending_form(_session_key_from_query(query), form_snapshot) query.variables['_dify_form_render'] = { - 'form_content': form_content, + 'form_content': raw_form_content, + 'input_defs': input_defs, 'actions': actions, 'node_title': node_title, } - display_text = _format_human_input_text(node_title, form_content, actions) # Use a zero-width space so ResponseWrapper lets the chunk # propagate to SendResponseBackStage, but the adapter # detects _form_data and renders buttons instead of the @@ -945,8 +1597,11 @@ class DifyServiceAPIRunner(runner.RequestRunner): if not basic_mode_pending_chunk: basic_mode_pending_chunk = '​' - pending_form_data = { - 'form_content': form_content, + pending_form_data = _initial_interactive_form_data(form_snapshot) or { + 'form_content': display_form_content, + 'raw_form_content': raw_form_content, + 'input_defs': input_defs, + 'inputs': form_snapshot.get('inputs', {}), 'actions': actions, 'node_title': node_title, 'workflow_run_id': workflow_run_id, @@ -984,7 +1639,7 @@ class DifyServiceAPIRunner(runner.RequestRunner): if human_input_yielded and not yielded_final: msg = provider_message.MessageChunk( role='assistant', - content=basic_mode_pending_chunk or display_text, + content=basic_mode_pending_chunk or '', is_final=True, ) msg._form_data = pending_form_data @@ -1163,35 +1818,24 @@ class DifyServiceAPIRunner(runner.RequestRunner): for reason in reasons: if reason.get('TYPE') != 'human_input_required': continue - form_content = reason.get('form_content', '') - actions = reason.get('actions', []) + form_snapshot, raw_form_content, input_defs, display_form_content = _extract_form_snapshot( + new_run_id, + reason, + user, + ) + actions = form_snapshot.get('actions', []) # Use a distinct name — `node_title` (the just-resolved step) # must keep its value so the resume notice on the previous # card still shows which step the user acted on. - paused_node_title = reason.get('node_title', '') - raw_inputs = reason.get('inputs', {}) + paused_node_title = form_snapshot.get('node_title', '') - _set_pending_form( - # Use the same session-key format as - # _session_key_from_query (launcher_type_launcher_id). - # The 'user' field is set by adapters in this format. - user, - { - 'workflow_run_id': new_run_id, - 'form_id': reason.get('form_id'), - 'form_token': reason.get('form_token'), - 'node_id': reason.get('node_id'), - 'node_title': paused_node_title, - 'form_content': form_content, - 'inputs': raw_inputs if isinstance(raw_inputs, dict) else {}, - 'actions': actions, - 'expiration_time': reason.get('expiration_time'), - 'user': user, - }, - ) + _set_pending_form(user, form_snapshot) - repause_form_data = { - 'form_content': form_content, + repause_form_data = _initial_interactive_form_data(form_snapshot) or { + 'form_content': display_form_content, + 'raw_form_content': raw_form_content, + 'input_defs': input_defs, + 'inputs': form_snapshot.get('inputs', {}), 'actions': actions, 'node_title': paused_node_title, 'workflow_run_id': new_run_id, @@ -1284,9 +1928,19 @@ class DifyServiceAPIRunner(runner.RequestRunner): if form_action_raw: form_action = self._merge_pending_form_action(session_key, form_action_raw) else: - form_action = self._match_pending_form_action(session_key, str(query.message_chain)) + form_action = await self._match_pending_form_action(query, session_key, str(query.message_chain)) if form_action: + if form_action.get('_partial'): + yield _attach_partial_form_data( + provider_message.MessageChunk( + role='assistant', + content=form_action.get('notice', 'Received.'), + is_final=True, + ), + form_action, + ) + return _clear_pending_form(session_key, form_action.get('form_token') or None) # Resume paused workflow via submit endpoint async for msg in self._submit_workflow_form(form_action): @@ -1330,7 +1984,6 @@ class DifyServiceAPIRunner(runner.RequestRunner): # Saved form data to attach to the final MessageChunk so the adapter # can detect it when is_final=True and render buttons. pending_form_data = None - display_text = '' remove_think = self.pipeline_config['output'].get('misc', {}).get('remove-think') async for chunk in self.dify_client.workflow_run( @@ -1350,45 +2003,35 @@ class DifyServiceAPIRunner(runner.RequestRunner): workflow_run_id = chunk['data'].get('workflow_run_id', workflow_run_id) for reason in reasons: if reason.get('TYPE') == 'human_input_required': - form_content = reason.get('form_content', '') - actions = reason.get('actions', []) - node_title = reason.get('node_title', '') - - # Persist form state in module-level store keyed by session - raw_inputs = reason.get('inputs', {}) - _set_pending_form( - _session_key_from_query(query), - { - 'workflow_run_id': workflow_run_id, - 'form_id': reason.get('form_id'), - 'form_token': reason.get('form_token'), - 'node_id': reason.get('node_id'), - 'node_title': node_title, - 'form_content': form_content, - 'inputs': raw_inputs if isinstance(raw_inputs, dict) else {}, - 'actions': actions, - 'expiration_time': reason.get('expiration_time'), - 'user': f'{query.session.launcher_type.value}_{query.session.launcher_id}', - }, + user = f'{query.session.launcher_type.value}_{query.session.launcher_id}' + form_snapshot, raw_form_content, input_defs, display_form_content = _extract_form_snapshot( + workflow_run_id, + reason, + user, ) + actions = form_snapshot.get('actions', []) + node_title = form_snapshot.get('node_title', '') + + _set_pending_form(_session_key_from_query(query), form_snapshot) # Pass form render metadata to downstream stages query.variables['_dify_form_render'] = { - 'form_content': form_content, + 'form_content': raw_form_content, + 'input_defs': input_defs, 'actions': actions, 'node_title': node_title, } - 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. # We do NOT yield here — the form content will be sent # as the final MessageChunk (with is_final=True and # _form_data) so the adapter can update the card and # add buttons in one pass. - pending_form_data = { - 'form_content': form_content, + pending_form_data = _initial_interactive_form_data(form_snapshot) or { + 'form_content': display_form_content, + 'raw_form_content': raw_form_content, + 'input_defs': input_defs, + 'inputs': form_snapshot.get('inputs', {}), 'actions': actions, 'node_title': node_title, 'workflow_run_id': workflow_run_id, @@ -1443,7 +2086,11 @@ class DifyServiceAPIRunner(runner.RequestRunner): yield msg if messsage_idx % 8 == 0 or is_final: - final_content = workflow_contents if workflow_contents.strip() else '' + final_content = ( + workflow_contents + if workflow_contents.strip() + else (_STREAM_FORM_PLACEHOLDER if is_final and pending_form_data else '') + ) msg = provider_message.MessageChunk( role='assistant', content=final_content, @@ -1461,7 +2108,7 @@ class DifyServiceAPIRunner(runner.RequestRunner): if human_input_yielded and not is_final: msg = provider_message.MessageChunk( role='assistant', - content=workflow_contents or display_text, + content=workflow_contents if workflow_contents.strip() else _STREAM_FORM_PLACEHOLDER, is_final=True, ) msg._form_data = pending_form_data diff --git a/src/langbot/templates/dingtalk_human_input_card.json b/src/langbot/templates/dingtalk_human_input_card.json index 140584f21..e178e7249 100644 --- a/src/langbot/templates/dingtalk_human_input_card.json +++ b/src/langbot/templates/dingtalk_human_input_card.json @@ -1,5 +1,5 @@ { - "editorData": "{\"schemaVersion\":\"3.0.0\",\"schema\":{\"config\":null,\"componentsMap\":[{\"package\":\"@ali/dxComponent\",\"version\":\"1.0.0\",\"exportName\":\"AIPending\",\"main\":\"./src/index.tsx\",\"destructuring\":false,\"subName\":\"\",\"componentName\":\"AIPending\"},{\"package\":\"@ali/dxComponent\",\"version\":\"1.0.0\",\"exportName\":\"AICardStatusContainer\",\"main\":\"./src/index.tsx\",\"destructuring\":false,\"subName\":\"\",\"componentName\":\"AICardStatusContainer\"},{\"package\":\"@ali/dxComponent\",\"version\":\"1.0.0\",\"exportName\":\"BaseText\",\"main\":\"./src/index.tsx\",\"destructuring\":false,\"subName\":\"\",\"componentName\":\"BaseText\"},{\"package\":\"@ali/dxComponent\",\"version\":\"1.0.0\",\"exportName\":\"AICardContent\",\"main\":\"./src/index.tsx\",\"destructuring\":false,\"subName\":\"\",\"componentName\":\"AICardContent\"},{\"package\":\"@ali/dxComponent\",\"version\":\"1.0.0\",\"exportName\":\"AICardContainer\",\"main\":\"./src/index.tsx\",\"destructuring\":false,\"subName\":\"\",\"componentName\":\"AICardContainer\"},{\"package\":\"@ali/dxComponent\",\"version\":\"1.0.0\",\"exportName\":\"ButtonGroup\",\"main\":\"./src/index.tsx\",\"destructuring\":false,\"subName\":\"\",\"componentName\":\"ButtonGroup\"},{\"package\":\"@ali/dxComponent\",\"version\":\"1.0.0\",\"exportName\":\"MarkdownBlock\",\"main\":\"./src/index.tsx\",\"destructuring\":false,\"subName\":\"\",\"componentName\":\"MarkdownBlock\"},{\"package\":\"@ali/dxComponent\",\"version\":\"1.0.0\",\"exportName\":\"Avatar\",\"main\":\"./src/index.tsx\",\"destructuring\":false,\"subName\":\"\",\"componentName\":\"Avatar\"}],\"componentsTree\":[{\"componentName\":\"AICardContainer\",\"id\":\"node_root\",\"props\":{\"marginLeft\":0,\"marginRight\":0,\"marginTop\":0,\"marginBottom\":0,\"enablePending\":true,\"enableWriting\":true,\"enableDoing\":true,\"enableFailed\":true,\"summaryContent\":{\"type\":\"variableValue\",\"variableType\":\"global\",\"variable\":\"\"},\"enableTitle\":false,\"flowStatusVar\":{\"type\":\"variableValue\",\"variableType\":\"global\",\"variable\":\"flowStatus\"},\"operationPenalType\":\"custom\",\"enableFlowAbort\":true,\"innerOffset\":0,\"enableGradientBorder\":true,\"cardSizeMode\":\"adaptive\",\"cardSizeHeightMode\":\"adaptive\",\"cardSizeWidthMode\":\"adaptive\",\"cardSizeHeight\":{\"type\":\"dynamicNumber\",\"valueType\":\"fixed\",\"value\":226,\"variable\":\"\",\"variableType\":\"global\"},\"hasBackground\":false,\"backgroundType\":\"Standard\",\"standardBackgroundColor\":\"gray\",\"backgroundColor\":\"#F6F6F6\",\"darkModeBackgroundColor\":\"#3C3C3C\",\"enableEngineUpgrade\":false,\"enableExposeStatPoint\":false,\"enableDebugTool\":false},\"hidden\":false,\"title\":\"\",\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\",\"children\":[{\"componentName\":\"AICardStatusContainer\",\"id\":\"node_status_pending\",\"props\":{\"status\":1,\"marginLeft\":0,\"marginRight\":0,\"marginTop\":0,\"marginBottom\":0,\"enableExtend\":false,\"autoFoldConfig\":{\"needFold\":true,\"heightLimit\":480,\"foldStatusLocalDataKey\":\"_cardFoldStatusLocalDataKey\"},\"innerOffset\":0,\"enableCollapse\":false,\"margin\":-2},\"title\":\"处理中状态\",\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\",\"children\":[{\"componentName\":\"AIPending\",\"id\":\"node_pending_inner\",\"props\":{\"marginLeft\":0,\"marginRight\":0,\"marginTop\":0,\"marginBottom\":0,\"pendingTip\":{\"type\":\"dynamicString\",\"content\":\"处理中...\",\"i18n\":false},\"style\":\"embed\",\"hideIcon\":false},\"hidden\":false,\"title\":\"\",\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\"}]},{\"componentName\":\"AICardStatusContainer\",\"id\":\"node_status_writing\",\"props\":{\"status\":2,\"marginLeft\":0,\"marginRight\":0,\"marginTop\":0,\"marginBottom\":0,\"enableExtend\":false,\"autoFoldConfig\":{\"needFold\":true,\"heightLimit\":480,\"foldStatusLocalDataKey\":\"_cardFoldStatusLocalDataKey\"},\"innerOffset\":0,\"enableCollapse\":false,\"margin\":-2},\"title\":\"状态2占位\",\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\",\"children\":[{\"componentName\":\"AICardContent\",\"id\":\"node_status_writing_content\",\"props\":{\"marginLeft\":0,\"marginRight\":0,\"marginTop\":0,\"marginBottom\":0,\"visible\":{\"type\":\"dynamicVisible\",\"value\":true,\"valueType\":\"fixed\",\"condition\":{\"op\":\"and\",\"conditions\":[]}},\"innerOffset\":0,\"disabledWhileForward\":false,\"statPoint\":{\"type\":\"dynamicString\",\"content\":\"\",\"i18n\":false},\"statPointParams\":[{\"type\":\"fixed\",\"variable\":\"\",\"value\":\"\",\"name\":\"\",\"variableType\":\"global\",\"id\":\"1\"}],\"margin\":-2,\"transformToEventChain\":false,\"enableStatPoint\":false},\"hidden\":false,\"title\":\"\",\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\",\"children\":[]}]},{\"componentName\":\"AICardStatusContainer\",\"id\":\"node_status_doing\",\"props\":{\"status\":4,\"marginLeft\":0,\"marginRight\":0,\"marginTop\":0,\"marginBottom\":0,\"enableExtend\":false,\"autoFoldConfig\":{\"needFold\":true,\"heightLimit\":480,\"foldStatusLocalDataKey\":\"_cardFoldStatusLocalDataKey\"},\"innerOffset\":0,\"enableCollapse\":false,\"margin\":-2},\"title\":\"状态4占位\",\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\",\"children\":[{\"componentName\":\"AICardContent\",\"id\":\"node_status_doing_content\",\"props\":{\"marginLeft\":0,\"marginRight\":0,\"marginTop\":0,\"marginBottom\":0,\"visible\":{\"type\":\"dynamicVisible\",\"value\":true,\"valueType\":\"fixed\",\"condition\":{\"op\":\"and\",\"conditions\":[]}},\"innerOffset\":0,\"disabledWhileForward\":false,\"statPoint\":{\"type\":\"dynamicString\",\"content\":\"\",\"i18n\":false},\"statPointParams\":[{\"type\":\"fixed\",\"variable\":\"\",\"value\":\"\",\"name\":\"\",\"variableType\":\"global\",\"id\":\"1\"}],\"margin\":-2,\"transformToEventChain\":false,\"enableStatPoint\":false},\"hidden\":false,\"title\":\"\",\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\",\"children\":[]}]},{\"componentName\":\"AICardStatusContainer\",\"id\":\"node_status_done\",\"props\":{\"status\":3,\"marginLeft\":0,\"marginRight\":0,\"marginTop\":0,\"marginBottom\":0,\"enableExtend\":false,\"autoFoldConfig\":{\"needFold\":true,\"heightLimit\":480,\"foldStatusLocalDataKey\":\"_cardFoldStatusLocalDataKey\"},\"innerOffset\":0,\"enableCollapse\":false,\"margin\":-2},\"title\":\"完成状态\",\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\",\"children\":[{\"componentName\":\"AICardContent\",\"id\":\"node_done_content\",\"props\":{\"marginLeft\":0,\"marginRight\":0,\"marginTop\":0,\"marginBottom\":0,\"visible\":{\"type\":\"dynamicVisible\",\"value\":true,\"valueType\":\"fixed\",\"condition\":{\"op\":\"and\",\"conditions\":[]}},\"innerOffset\":0,\"disabledWhileForward\":false,\"statPoint\":{\"type\":\"dynamicString\",\"content\":\"\",\"i18n\":false},\"statPointParams\":[{\"type\":\"fixed\",\"variable\":\"\",\"value\":\"\",\"name\":\"\",\"variableType\":\"global\",\"id\":\"1\"}],\"margin\":-2,\"transformToEventChain\":false,\"enableStatPoint\":false},\"hidden\":false,\"title\":\"\",\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\",\"children\":[{\"componentName\":\"Avatar\",\"id\":\"node_avatar\",\"props\":{\"imageUrl\":{\"value\":\"\",\"valueType\":\"variable\",\"type\":\"dynamicImage\",\"variable\":\"bot_avatar\",\"variableType\":\"global\"},\"name\":{\"i18n\":false,\"type\":\"dynamicString\",\"content\":\"LangBot\"},\"sizeType\":\"Standard\",\"size\":\"extraSmall\",\"customSize\":48,\"marginLeft\":12,\"marginRight\":12,\"marginTop\":6,\"marginBottom\":6,\"visible\":{\"type\":\"dynamicVisible\",\"value\":true,\"valueType\":\"fixed\",\"condition\":{\"op\":\"and\",\"conditions\":[]}},\"mode\":\"userInfo\",\"margin\":-2,\"innerOffset\":0},\"title\":\"头像\",\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\"},{\"componentName\":\"MarkdownBlock\",\"id\":\"node_text_content\",\"props\":{\"mdVer\":0,\"icon\":{\"type\":\"icon\",\"icon\":\"\",\"iconType\":\"emoji\"},\"content\":{\"variable\":\"content\",\"variableType\":\"global\",\"type\":\"variableValue\",\"varType\":\"markdown\"},\"visible\":{\"type\":\"dynamicVisible\",\"value\":true,\"valueType\":\"fixed\",\"condition\":{\"op\":\"and\",\"conditions\":[]}},\"isStreaming\":false,\"enableLinkStatPoint\":false,\"linkStatPoint\":{\"type\":\"dynamicString\",\"content\":\"Page_InteractiveCard__Click_markdownOpenlink\",\"i18n\":false},\"linkStatPointParams\":[],\"marginTop\":6,\"marginBottom\":6,\"marginLeft\":12,\"marginRight\":12},\"title\":\"AI 流式富文本\",\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\"},{\"componentName\":\"ButtonGroup\",\"id\":\"node_btn_group\",\"props\":{\"dynamicButtons\":{\"type\":\"variableValue\",\"variableType\":\"global\",\"variable\":\"btns\"},\"marginLeft\":12,\"marginRight\":12,\"marginTop\":6,\"marginBottom\":12,\"visible\":{\"type\":\"dynamicVisible\",\"value\":true,\"valueType\":\"fixed\",\"condition\":{\"op\":\"and\",\"conditions\":[]}},\"responsiveLayoutWidth\":350,\"buttonsSource\":\"variable\",\"fixedButtonIds\":[],\"fixedButtons\":[],\"enableResponsiveLayout\":false,\"matchContent\":false,\"buttonSpacing\":8,\"margin\":-2,\"innerOffset\":0},\"title\":\"按钮组\",\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\"}]}]},{\"componentName\":\"AICardStatusContainer\",\"id\":\"node_status_failed\",\"props\":{\"status\":5,\"marginLeft\":0,\"marginRight\":0,\"marginTop\":0,\"marginBottom\":0,\"enableExtend\":false,\"autoFoldConfig\":{\"needFold\":true,\"heightLimit\":480,\"foldStatusLocalDataKey\":\"_cardFoldStatusLocalDataKey\"},\"innerOffset\":0,\"enableCollapse\":false,\"margin\":-2},\"title\":\"失败状态\",\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\",\"children\":[{\"componentName\":\"AICardContent\",\"id\":\"node_failed_content\",\"props\":{\"visible\":{\"type\":\"dynamicVisible\",\"value\":true,\"valueType\":\"fixed\",\"condition\":{\"op\":\"and\",\"conditions\":[]}},\"marginLeft\":0,\"marginRight\":0,\"marginTop\":0,\"marginBottom\":0,\"innerOffset\":0,\"disabledWhileForward\":false,\"statPoint\":{\"type\":\"dynamicString\",\"content\":\"\",\"i18n\":false},\"statPointParams\":[{\"type\":\"fixed\",\"variable\":\"\",\"value\":\"\",\"name\":\"\",\"variableType\":\"global\",\"id\":\"1\"}],\"margin\":-2,\"transformToEventChain\":false,\"enableStatPoint\":false},\"hidden\":false,\"title\":\"\",\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\",\"children\":[{\"componentName\":\"BaseText\",\"id\":\"node_failed_text\",\"props\":{\"text\":{\"i18n\":false,\"type\":\"dynamicString\",\"content\":\"操作失败,请稍后重试。\"},\"hoverText\":{\"type\":\"dynamicString\",\"content\":\"\",\"i18n\":false},\"iconType\":\"iconCode\",\"iconFont\":{\"type\":\"icon\",\"icon\":\"\",\"iconType\":\"ddIcon\"},\"icon\":{\"type\":\"dynamicLink\",\"value\":\"\",\"valueType\":\"fixed\",\"variable\":\"\",\"variableType\":\"global\"},\"darkIcon\":{\"type\":\"dynamicLink\",\"value\":\"\",\"valueType\":\"fixed\",\"variable\":\"\",\"variableType\":\"global\"},\"autoWidth\":false,\"maxWidth\":{\"type\":\"dynamicNumber\",\"valueType\":\"fixed\",\"value\":0,\"variable\":\"\",\"variableType\":\"global\"},\"fixedWidth\":{\"type\":\"dynamicNumber\",\"valueType\":\"fixed\",\"value\":0,\"variable\":\"\",\"variableType\":\"global\"},\"marginLeft\":10,\"marginRight\":10,\"marginTop\":10,\"marginBottom\":10,\"fontColorType\":\"Standard\",\"enableHighlight\":false,\"maxLine\":{\"type\":\"dynamicNumber\",\"valueType\":\"fixed\",\"value\":2,\"variable\":\"\",\"variableType\":\"global\"},\"color\":{\"type\":\"dynamicColor\",\"valueType\":\"fixed\",\"value\":\"common_level1_base_color\",\"variable\":\"\",\"variableType\":\"global\"},\"customLightColor\":{\"type\":\"dynamicColor\",\"valueType\":\"fixed\",\"value\":\"#35404b\",\"variable\":\"\",\"variableType\":\"global\"},\"customDarkColor\":{\"type\":\"dynamicColor\",\"valueType\":\"fixed\",\"value\":\"#f6f6f6\",\"variable\":\"\",\"variableType\":\"global\"},\"gravity\":\"center\",\"fontSizeType\":\"Standard\",\"styleType\":\"custom\",\"styleToken\":\"common_body_text_style\",\"size\":\"middle\",\"customFontSize\":15,\"customFontLineHeight\":22,\"bold\":false,\"italic\":false,\"strikeThrough\":false,\"lineHeight\":\"normal\",\"visible\":{\"type\":\"dynamicVisible\",\"value\":true,\"valueType\":\"fixed\",\"condition\":{\"op\":\"and\",\"conditions\":[]}},\"autoMaxWidth\":false,\"innerOffset\":0,\"enableIcon\":false,\"widthMode\":\"match_parent\",\"margin\":-2},\"title\":\"基础文本\",\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\"}]}]}]}],\"i18n\":{},\"version\":\"1.0.0\"},\"mockData\":{\"cardData\":{\"flowStatus\":3,\"content\":\"请审核以下报销申请:\\n\\n- 申请人:张三\\n- 金额:¥1,200\\n- 类别:差旅\",\"btns\":[{\"text\":\"通过\",\"color\":\"blue\",\"status\":\"normal\",\"event\":{\"type\":\"sendCardRequest\",\"params\":{\"actionId\":\"approve\",\"params\":{\"action_id\":\"approve\"}}}},{\"text\":\"驳回\",\"color\":\"gray\",\"status\":\"normal\",\"event\":{\"type\":\"sendCardRequest\",\"params\":{\"actionId\":\"reject\",\"params\":{\"action_id\":\"reject\"}}}},{\"text\":\"补充资料\",\"color\":\"gray\",\"status\":\"normal\",\"event\":{\"type\":\"sendCardRequest\",\"params\":{\"actionId\":\"more_info\",\"params\":{\"action_id\":\"more_info\"}}}}]},\"cardPrivateData\":{},\"localData\":{\"flowStatus\":\"\",\"_cardFoldStatusLocalDataKey\":\"\"},\"richTextData\":{}},\"renderContext\":{\"regenerateEnabled\":\"1\",\"regenerateIndex\":\"2\",\"regenerateTotal\":\"5\"},\"editVersion\":0,\"customWidgetInfo\":\"\",\"useCustomWidgetInfo\":false,\"variableList\":[{\"id\":\"content\",\"type\":\"markdown\",\"name\":\"content\",\"description\":\"人工输入提示词(Dify form_content 含可选 node_title 前缀)\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":false},{\"id\":\"flowStatus\",\"type\":\"string\",\"name\":\"flowStatus\",\"description\":\"AI卡片状态:pending(1)、writing(2)、done(3)、failed(5)\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":true,\"visible\":false},{\"id\":\"bot_avatar\",\"type\":\"string\",\"name\":\"bot_avatar\",\"description\":\"机器人头像 DingTalk 媒体 ID(@xxx 格式,启动时由 /media/upload 拿到)\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":false},{\"name\":\"btns\",\"private\":false,\"type\":\"buttonGroup\",\"id\":\"btns\",\"description\":\"动态按钮列表(Dify actions)\",\"editorVarType\":\"variables\",\"disabled\":false,\"schema\":[{\"id\":\"btns.text\",\"type\":\"string\",\"name\":\"text\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":true,\"description\":\"按钮文案\"},{\"id\":\"btns.color\",\"type\":\"string\",\"name\":\"color\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":true,\"description\":\"按钮颜色\"},{\"id\":\"btns.status\",\"type\":\"string\",\"name\":\"status\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":true,\"description\":\"按钮状态\"},{\"id\":\"btns.event\",\"type\":\"dynamicEvent\",\"name\":\"event\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":true,\"description\":\"按钮点击事件\",\"schema\":[{\"id\":\"btns.type\",\"type\":\"string\",\"name\":\"type\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":true,\"description\":\"事件类型:openLink / sendCardRequest\"},{\"id\":\"btns.params\",\"type\":\"object\",\"name\":\"params\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":true,\"description\":\"事件参数\",\"schema\":[{\"id\":\"btns.url\",\"type\":\"string\",\"name\":\"url\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":true,\"description\":\"点击跳转链接(type=openLink)\"},{\"id\":\"btns.actionId\",\"type\":\"string\",\"name\":\"actionId\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":true,\"description\":\"回传请求 id(type=sendCardRequest)\"},{\"id\":\"btns.params\",\"type\":\"object\",\"name\":\"params\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":true,\"description\":\"回传请求参数(type=sendCardRequest)\"}]}]}]}],\"formList\":[],\"customContextList\":[],\"expList\":[],\"localList\":[],\"hsfList\":[],\"lwpList\":[],\"pageData\":{},\"extension\":{\"extendType\":\"AI\",\"aiStatusList\":[3,1,5,2,4],\"fileTypeList\":[]}}", + "editorData": "{\"schemaVersion\":\"3.0.0\",\"schema\":{\"config\":null,\"componentsMap\":[{\"package\":\"@ali/dxComponent\",\"version\":\"1.0.0\",\"exportName\":\"AIPending\",\"main\":\"./src/index.tsx\",\"destructuring\":false,\"subName\":\"\",\"componentName\":\"AIPending\"},{\"package\":\"@ali/dxComponent\",\"version\":\"1.0.0\",\"exportName\":\"AICardStatusContainer\",\"main\":\"./src/index.tsx\",\"destructuring\":false,\"subName\":\"\",\"componentName\":\"AICardStatusContainer\"},{\"package\":\"@ali/dxComponent\",\"version\":\"1.0.0\",\"exportName\":\"BaseText\",\"main\":\"./src/index.tsx\",\"destructuring\":false,\"subName\":\"\",\"componentName\":\"BaseText\"},{\"package\":\"@ali/dxComponent\",\"version\":\"1.0.0\",\"exportName\":\"AICardContent\",\"main\":\"./src/index.tsx\",\"destructuring\":false,\"subName\":\"\",\"componentName\":\"AICardContent\"},{\"package\":\"@ali/dxComponent\",\"version\":\"1.0.0\",\"exportName\":\"AICardContainer\",\"main\":\"./src/index.tsx\",\"destructuring\":false,\"subName\":\"\",\"componentName\":\"AICardContainer\"},{\"package\":\"@ali/dxComponent\",\"version\":\"1.0.0\",\"exportName\":\"ButtonGroup\",\"main\":\"./src/index.tsx\",\"destructuring\":false,\"subName\":\"\",\"componentName\":\"ButtonGroup\"},{\"package\":\"@ali/dxComponent\",\"version\":\"1.0.0\",\"exportName\":\"MarkdownBlock\",\"main\":\"./src/index.tsx\",\"destructuring\":false,\"subName\":\"\",\"componentName\":\"MarkdownBlock\"},{\"package\":\"@ali/dxComponent\",\"version\":\"1.0.0\",\"exportName\":\"Avatar\",\"main\":\"./src/index.tsx\",\"destructuring\":false,\"subName\":\"\",\"componentName\":\"Avatar\"},{\"package\":\"@ali/dxComponent\",\"version\":\"1.0.0\",\"exportName\":\"Input\",\"main\":\"./src/index.tsx\",\"destructuring\":false,\"subName\":\"\",\"componentName\":\"Input\"},{\"package\":\"@ali/dxComponent\",\"version\":\"1.0.0\",\"exportName\":\"SelectBlock\",\"main\":\"./src/index.tsx\",\"destructuring\":false,\"subName\":\"\",\"componentName\":\"SelectBlock\"}],\"componentsTree\":[{\"componentName\":\"AICardContainer\",\"id\":\"node_root\",\"props\":{\"marginLeft\":0,\"marginRight\":0,\"marginTop\":0,\"marginBottom\":0,\"enablePending\":true,\"enableWriting\":true,\"enableDoing\":true,\"enableFailed\":true,\"summaryContent\":{\"type\":\"variableValue\",\"variableType\":\"global\",\"variable\":\"\"},\"enableTitle\":false,\"flowStatusVar\":{\"type\":\"variableValue\",\"variableType\":\"global\",\"variable\":\"flowStatus\"},\"operationPenalType\":\"custom\",\"enableFlowAbort\":true,\"innerOffset\":0,\"enableGradientBorder\":true,\"cardSizeMode\":\"adaptive\",\"cardSizeHeightMode\":\"adaptive\",\"cardSizeWidthMode\":\"adaptive\",\"cardSizeHeight\":{\"type\":\"dynamicNumber\",\"valueType\":\"fixed\",\"value\":226,\"variable\":\"\",\"variableType\":\"global\"},\"hasBackground\":false,\"backgroundType\":\"Standard\",\"standardBackgroundColor\":\"gray\",\"backgroundColor\":\"#F6F6F6\",\"darkModeBackgroundColor\":\"#3C3C3C\",\"enableEngineUpgrade\":false,\"enableExposeStatPoint\":false,\"enableDebugTool\":false},\"hidden\":false,\"title\":\"\",\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\",\"children\":[{\"componentName\":\"AICardStatusContainer\",\"id\":\"node_status_pending\",\"props\":{\"status\":1,\"marginLeft\":0,\"marginRight\":0,\"marginTop\":0,\"marginBottom\":0,\"enableExtend\":false,\"autoFoldConfig\":{\"needFold\":true,\"heightLimit\":480,\"foldStatusLocalDataKey\":\"_cardFoldStatusLocalDataKey\"},\"innerOffset\":0,\"enableCollapse\":false,\"margin\":-2},\"title\":\"处理中状态\",\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\",\"children\":[{\"componentName\":\"AIPending\",\"id\":\"node_pending_inner\",\"props\":{\"marginLeft\":0,\"marginRight\":0,\"marginTop\":0,\"marginBottom\":0,\"pendingTip\":{\"type\":\"dynamicString\",\"content\":\"处理中...\",\"i18n\":false},\"style\":\"embed\",\"hideIcon\":false},\"hidden\":false,\"title\":\"\",\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\"}]},{\"componentName\":\"AICardStatusContainer\",\"id\":\"node_status_writing\",\"props\":{\"status\":2,\"marginLeft\":0,\"marginRight\":0,\"marginTop\":0,\"marginBottom\":0,\"enableExtend\":false,\"autoFoldConfig\":{\"needFold\":true,\"heightLimit\":480,\"foldStatusLocalDataKey\":\"_cardFoldStatusLocalDataKey\"},\"innerOffset\":0,\"enableCollapse\":false,\"margin\":-2},\"title\":\"状态2占位\",\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\",\"children\":[{\"componentName\":\"AICardContent\",\"id\":\"node_status_writing_content\",\"props\":{\"marginLeft\":0,\"marginRight\":0,\"marginTop\":0,\"marginBottom\":0,\"visible\":{\"type\":\"dynamicVisible\",\"value\":true,\"valueType\":\"fixed\",\"condition\":{\"op\":\"and\",\"conditions\":[]}},\"innerOffset\":0,\"disabledWhileForward\":false,\"statPoint\":{\"type\":\"dynamicString\",\"content\":\"\",\"i18n\":false},\"statPointParams\":[{\"type\":\"fixed\",\"variable\":\"\",\"value\":\"\",\"name\":\"\",\"variableType\":\"global\",\"id\":\"1\"}],\"margin\":-2,\"transformToEventChain\":false,\"enableStatPoint\":false},\"hidden\":false,\"title\":\"\",\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\",\"children\":[]}]},{\"componentName\":\"AICardStatusContainer\",\"id\":\"node_status_doing\",\"props\":{\"status\":4,\"marginLeft\":0,\"marginRight\":0,\"marginTop\":0,\"marginBottom\":0,\"enableExtend\":false,\"autoFoldConfig\":{\"needFold\":true,\"heightLimit\":480,\"foldStatusLocalDataKey\":\"_cardFoldStatusLocalDataKey\"},\"innerOffset\":0,\"enableCollapse\":false,\"margin\":-2},\"title\":\"状态4占位\",\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\",\"children\":[{\"componentName\":\"AICardContent\",\"id\":\"node_status_doing_content\",\"props\":{\"marginLeft\":0,\"marginRight\":0,\"marginTop\":0,\"marginBottom\":0,\"visible\":{\"type\":\"dynamicVisible\",\"value\":true,\"valueType\":\"fixed\",\"condition\":{\"op\":\"and\",\"conditions\":[]}},\"innerOffset\":0,\"disabledWhileForward\":false,\"statPoint\":{\"type\":\"dynamicString\",\"content\":\"\",\"i18n\":false},\"statPointParams\":[{\"type\":\"fixed\",\"variable\":\"\",\"value\":\"\",\"name\":\"\",\"variableType\":\"global\",\"id\":\"1\"}],\"margin\":-2,\"transformToEventChain\":false,\"enableStatPoint\":false},\"hidden\":false,\"title\":\"\",\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\",\"children\":[]}]},{\"componentName\":\"AICardStatusContainer\",\"id\":\"node_status_done\",\"props\":{\"status\":3,\"marginLeft\":0,\"marginRight\":0,\"marginTop\":0,\"marginBottom\":0,\"enableExtend\":false,\"autoFoldConfig\":{\"needFold\":true,\"heightLimit\":480,\"foldStatusLocalDataKey\":\"_cardFoldStatusLocalDataKey\"},\"innerOffset\":0,\"enableCollapse\":false,\"margin\":-2},\"title\":\"完成状态\",\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\",\"children\":[{\"componentName\":\"AICardContent\",\"id\":\"node_done_content\",\"props\":{\"marginLeft\":0,\"marginRight\":0,\"marginTop\":0,\"marginBottom\":0,\"visible\":{\"type\":\"dynamicVisible\",\"value\":true,\"valueType\":\"fixed\",\"condition\":{\"op\":\"and\",\"conditions\":[]}},\"innerOffset\":0,\"disabledWhileForward\":false,\"statPoint\":{\"type\":\"dynamicString\",\"content\":\"\",\"i18n\":false},\"statPointParams\":[{\"type\":\"fixed\",\"variable\":\"\",\"value\":\"\",\"name\":\"\",\"variableType\":\"global\",\"id\":\"1\"}],\"margin\":-2,\"transformToEventChain\":false,\"enableStatPoint\":false},\"hidden\":false,\"title\":\"\",\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\",\"children\":[{\"componentName\":\"Avatar\",\"id\":\"node_avatar\",\"props\":{\"imageUrl\":{\"value\":\"\",\"valueType\":\"variable\",\"type\":\"dynamicImage\",\"variable\":\"bot_avatar\",\"variableType\":\"global\"},\"name\":{\"i18n\":false,\"type\":\"dynamicString\",\"content\":\"LangBot\"},\"sizeType\":\"Standard\",\"size\":\"extraSmall\",\"customSize\":48,\"marginLeft\":12,\"marginRight\":12,\"marginTop\":6,\"marginBottom\":6,\"visible\":{\"type\":\"dynamicVisible\",\"value\":true,\"valueType\":\"fixed\",\"condition\":{\"op\":\"and\",\"conditions\":[]}},\"mode\":\"userInfo\",\"margin\":-2,\"innerOffset\":0},\"title\":\"头像\",\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\"},{\"componentName\":\"Input\",\"id\":\"node_input\",\"props\":{\"placeholder\":{\"type\":\"dynamicString\",\"content\":\"\",\"i18n\":false,\"variable\":\"input_placeholder\",\"variableType\":\"global\"},\"currentValue\":{\"type\":\"dynamicString\",\"content\":\"\",\"i18n\":false,\"variable\":\"input_value\",\"variableType\":\"global\"},\"message\":{\"type\":\"dynamicString\",\"content\":\"\",\"i18n\":false,\"variable\":\"input_placeholder\",\"variableType\":\"global\"},\"title\":{\"type\":\"dynamicString\",\"content\":\"\",\"i18n\":false,\"variable\":\"input_title\",\"variableType\":\"global\"},\"id\":{\"type\":\"dynamicString\",\"content\":\"input\",\"i18n\":false},\"params\":[{\"type\":\"builtIn\",\"variable\":\"\",\"value\":\"\",\"name\":\"input\",\"variableType\":\"global\",\"id\":\"__built_in_inputResult__\"}],\"visible\":{\"type\":\"dynamicVisible\",\"value\":true,\"valueType\":\"variable\",\"variable\":\"input_visible\",\"variableType\":\"global\",\"condition\":{\"op\":\"and\",\"conditions\":[]}},\"status\":{\"type\":\"dynamicSelect\",\"valueType\":\"fixed\",\"value\":\"normal\",\"variable\":\"\",\"variableType\":\"global\"},\"actionType\":\"request\",\"localVarAction\":{\"type\":\"variableValue\",\"variableType\":\"global\",\"variable\":\"\"},\"keyOfDynamicObject\":{\"type\":\"dynamicString\",\"content\":\"\",\"i18n\":false},\"inlineMode\":false,\"textArea\":true,\"minRows\":{\"type\":\"dynamicNumber\",\"valueType\":\"fixed\",\"value\":2,\"variable\":\"\",\"variableType\":\"global\"},\"maxRows\":{\"type\":\"dynamicNumber\",\"valueType\":\"fixed\",\"value\":6,\"variable\":\"\",\"variableType\":\"global\"},\"marginLeft\":12,\"marginRight\":12,\"marginTop\":6,\"marginBottom\":6,\"margin\":12,\"innerOffset\":0},\"title\":\"Text input\",\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\"},{\"componentName\":\"SelectBlock\",\"id\":\"node_select\",\"props\":{\"id\":{\"type\":\"dynamicString\",\"content\":\"select\",\"i18n\":false},\"placeholder\":{\"type\":\"dynamicString\",\"content\":\"\",\"i18n\":false,\"variable\":\"select_placeholder\",\"variableType\":\"global\"},\"currentIndex\":{\"type\":\"dynamicNumber\",\"valueType\":\"variable\",\"value\":-1,\"variable\":\"select_index\",\"variableType\":\"global\"},\"options\":{\"type\":\"dynamicSelectOptions\",\"valueType\":\"variable\",\"value\":[],\"variable\":\"index_o\",\"variableType\":\"global\"},\"optionLabelMaxLines\":3,\"params\":[{\"type\":\"builtIn\",\"variable\":\"\",\"value\":\"{\\\"index\\\": ${index}, \\\"value\\\": \\\"${value}\\\"}\",\"name\":\"select\",\"variableType\":\"global\",\"id\":\"__built_in_selectResult__\"}],\"actionType\":\"request\",\"localVarAction\":{\"type\":\"variableValue\",\"variableType\":\"global\",\"variable\":\"\"},\"keyOfDynamicObject\":{\"type\":\"dynamicString\",\"content\":\"\",\"i18n\":false},\"status\":{\"type\":\"dynamicSelect\",\"valueType\":\"fixed\",\"value\":\"normal\",\"variable\":\"\",\"variableType\":\"global\"},\"visible\":{\"type\":\"dynamicVisible\",\"value\":true,\"valueType\":\"variable\",\"variable\":\"select_visible\",\"variableType\":\"global\",\"condition\":{\"op\":\"and\",\"conditions\":[]}},\"marginLeft\":12,\"marginRight\":12,\"marginTop\":6,\"marginBottom\":6,\"pullOptionsWhileOpen\":false,\"pullOptionsRequestParams\":[],\"margin\":12,\"innerOffset\":0},\"title\":\"Select\",\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\"},{\"componentName\":\"MarkdownBlock\",\"id\":\"node_text_content\",\"props\":{\"mdVer\":0,\"icon\":{\"type\":\"icon\",\"icon\":\"\",\"iconType\":\"emoji\"},\"content\":{\"variable\":\"content\",\"variableType\":\"global\",\"type\":\"variableValue\",\"varType\":\"markdown\"},\"visible\":{\"type\":\"dynamicVisible\",\"value\":true,\"valueType\":\"fixed\",\"condition\":{\"op\":\"and\",\"conditions\":[]}},\"isStreaming\":false,\"enableLinkStatPoint\":false,\"linkStatPoint\":{\"type\":\"dynamicString\",\"content\":\"Page_InteractiveCard__Click_markdownOpenlink\",\"i18n\":false},\"linkStatPointParams\":[],\"marginTop\":6,\"marginBottom\":6,\"marginLeft\":12,\"marginRight\":12},\"title\":\"AI 流式富文本\",\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\"},{\"componentName\":\"ButtonGroup\",\"id\":\"node_btn_group\",\"props\":{\"dynamicButtons\":{\"type\":\"variableValue\",\"variableType\":\"global\",\"variable\":\"btns\"},\"marginLeft\":12,\"marginRight\":12,\"marginTop\":6,\"marginBottom\":12,\"visible\":{\"type\":\"dynamicVisible\",\"value\":true,\"valueType\":\"fixed\",\"condition\":{\"op\":\"and\",\"conditions\":[]}},\"responsiveLayoutWidth\":350,\"buttonsSource\":\"variable\",\"fixedButtonIds\":[],\"fixedButtons\":[],\"enableResponsiveLayout\":false,\"matchContent\":false,\"buttonSpacing\":8,\"margin\":-2,\"innerOffset\":0},\"title\":\"按钮组\",\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\"}]}]},{\"componentName\":\"AICardStatusContainer\",\"id\":\"node_status_failed\",\"props\":{\"status\":5,\"marginLeft\":0,\"marginRight\":0,\"marginTop\":0,\"marginBottom\":0,\"enableExtend\":false,\"autoFoldConfig\":{\"needFold\":true,\"heightLimit\":480,\"foldStatusLocalDataKey\":\"_cardFoldStatusLocalDataKey\"},\"innerOffset\":0,\"enableCollapse\":false,\"margin\":-2},\"title\":\"失败状态\",\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\",\"children\":[{\"componentName\":\"AICardContent\",\"id\":\"node_failed_content\",\"props\":{\"visible\":{\"type\":\"dynamicVisible\",\"value\":true,\"valueType\":\"fixed\",\"condition\":{\"op\":\"and\",\"conditions\":[]}},\"marginLeft\":0,\"marginRight\":0,\"marginTop\":0,\"marginBottom\":0,\"innerOffset\":0,\"disabledWhileForward\":false,\"statPoint\":{\"type\":\"dynamicString\",\"content\":\"\",\"i18n\":false},\"statPointParams\":[{\"type\":\"fixed\",\"variable\":\"\",\"value\":\"\",\"name\":\"\",\"variableType\":\"global\",\"id\":\"1\"}],\"margin\":-2,\"transformToEventChain\":false,\"enableStatPoint\":false},\"hidden\":false,\"title\":\"\",\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\",\"children\":[{\"componentName\":\"BaseText\",\"id\":\"node_failed_text\",\"props\":{\"text\":{\"i18n\":false,\"type\":\"dynamicString\",\"content\":\"操作失败,请稍后重试。\"},\"hoverText\":{\"type\":\"dynamicString\",\"content\":\"\",\"i18n\":false},\"iconType\":\"iconCode\",\"iconFont\":{\"type\":\"icon\",\"icon\":\"\",\"iconType\":\"ddIcon\"},\"icon\":{\"type\":\"dynamicLink\",\"value\":\"\",\"valueType\":\"fixed\",\"variable\":\"\",\"variableType\":\"global\"},\"darkIcon\":{\"type\":\"dynamicLink\",\"value\":\"\",\"valueType\":\"fixed\",\"variable\":\"\",\"variableType\":\"global\"},\"autoWidth\":false,\"maxWidth\":{\"type\":\"dynamicNumber\",\"valueType\":\"fixed\",\"value\":0,\"variable\":\"\",\"variableType\":\"global\"},\"fixedWidth\":{\"type\":\"dynamicNumber\",\"valueType\":\"fixed\",\"value\":0,\"variable\":\"\",\"variableType\":\"global\"},\"marginLeft\":10,\"marginRight\":10,\"marginTop\":10,\"marginBottom\":10,\"fontColorType\":\"Standard\",\"enableHighlight\":false,\"maxLine\":{\"type\":\"dynamicNumber\",\"valueType\":\"fixed\",\"value\":2,\"variable\":\"\",\"variableType\":\"global\"},\"color\":{\"type\":\"dynamicColor\",\"valueType\":\"fixed\",\"value\":\"common_level1_base_color\",\"variable\":\"\",\"variableType\":\"global\"},\"customLightColor\":{\"type\":\"dynamicColor\",\"valueType\":\"fixed\",\"value\":\"#35404b\",\"variable\":\"\",\"variableType\":\"global\"},\"customDarkColor\":{\"type\":\"dynamicColor\",\"valueType\":\"fixed\",\"value\":\"#f6f6f6\",\"variable\":\"\",\"variableType\":\"global\"},\"gravity\":\"center\",\"fontSizeType\":\"Standard\",\"styleType\":\"custom\",\"styleToken\":\"common_body_text_style\",\"size\":\"middle\",\"customFontSize\":15,\"customFontLineHeight\":22,\"bold\":false,\"italic\":false,\"strikeThrough\":false,\"lineHeight\":\"normal\",\"visible\":{\"type\":\"dynamicVisible\",\"value\":true,\"valueType\":\"fixed\",\"condition\":{\"op\":\"and\",\"conditions\":[]}},\"autoMaxWidth\":false,\"innerOffset\":0,\"enableIcon\":false,\"widthMode\":\"match_parent\",\"margin\":-2},\"title\":\"基础文本\",\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\"}]}]}]}],\"i18n\":{},\"version\":\"1.0.0\"},\"mockData\":{\"cardData\":{\"flowStatus\":3,\"content\":\"请审核以下报销申请:\\n\\n- 申请人:张三\\n- 金额:¥1,200\\n- 类别:差旅\",\"input_visible\":\"\",\"input_title\":\"\",\"input_placeholder\":\"\",\"input_value\":\"\",\"select_visible\":\"\",\"select_placeholder\":\"\",\"index_o\":[{\"value\":\"\",\"text\":{\"zh_CN\":\"\",\"zh_TW\":\"\",\"en_US\":\"\",\"ja_JP\":\"\",\"vi_VN\":\"\",\"th_TH\":\"\",\"id_ID\":\"\",\"ne_NP\":\"\",\"ms_MY\":\"\",\"ko_KR\":\"\",\"ru_RU\":\"\",\"es_EA\":\"\",\"tr_TR\":\"\",\"fr_FR\":\"\",\"pt_BR\":\"\"}}],\"select_options\":[],\"select_index\":-1,\"btns\":[{\"text\":\"通过\",\"color\":\"blue\",\"status\":\"normal\",\"event\":{\"type\":\"sendCardRequest\",\"params\":{\"actionId\":\"approve\",\"params\":{\"action_id\":\"approve\"}}}},{\"text\":\"驳回\",\"color\":\"gray\",\"status\":\"normal\",\"event\":{\"type\":\"sendCardRequest\",\"params\":{\"actionId\":\"reject\",\"params\":{\"action_id\":\"reject\"}}}},{\"text\":\"补充资料\",\"color\":\"gray\",\"status\":\"normal\",\"event\":{\"type\":\"sendCardRequest\",\"params\":{\"actionId\":\"more_info\",\"params\":{\"action_id\":\"more_info\"}}}}]},\"cardPrivateData\":{},\"localData\":{\"flowStatus\":\"\",\"_cardFoldStatusLocalDataKey\":\"\"},\"richTextData\":{}},\"renderContext\":{\"regenerateEnabled\":\"1\",\"regenerateIndex\":\"2\",\"regenerateTotal\":\"5\"},\"editVersion\":0,\"customWidgetInfo\":\"\",\"useCustomWidgetInfo\":false,\"variableList\":[{\"id\":\"content\",\"type\":\"markdown\",\"name\":\"content\",\"description\":\"人工输入提示词(Dify form_content 含可选 node_title 前缀)\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":false},{\"id\":\"flowStatus\",\"type\":\"string\",\"name\":\"flowStatus\",\"description\":\"AI卡片状态:pending(1)、writing(2)、done(3)、failed(5)\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":true,\"visible\":false},{\"id\":\"bot_avatar\",\"type\":\"string\",\"name\":\"bot_avatar\",\"description\":\"机器人头像 DingTalk 媒体 ID(@xxx 格式,启动时由 /media/upload 拿到)\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":false},{\"id\":\"input_visible\",\"type\":\"string\",\"name\":\"input_visible\",\"description\":\"Whether to show the text input component\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":false},{\"id\":\"input_title\",\"type\":\"string\",\"name\":\"input_title\",\"description\":\"Text input title\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":false},{\"id\":\"input_placeholder\",\"type\":\"string\",\"name\":\"input_placeholder\",\"description\":\"Text input placeholder\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":false},{\"id\":\"input_value\",\"type\":\"string\",\"name\":\"input_value\",\"description\":\"Text input current value\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":false},{\"id\":\"select_visible\",\"type\":\"string\",\"name\":\"select_visible\",\"description\":\"Whether to show the select component\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":false},{\"id\":\"select_placeholder\",\"type\":\"string\",\"name\":\"select_placeholder\",\"description\":\"Select placeholder\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":false},{\"name\":\"index_o\",\"private\":false,\"type\":\"selectOptions\",\"id\":\"index_o\",\"description\":\"Select options\",\"editorVarType\":\"variables\",\"disabled\":false,\"schema\":[{\"id\":\"index_o.value\",\"type\":\"string\",\"name\":\"value\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":true,\"description\":\"\"},{\"id\":\"index_o.text\",\"type\":\"object\",\"name\":\"text\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":true,\"description\":\"\",\"schema\":[{\"id\":\"index_o.zh_CN\",\"type\":\"string\",\"name\":\"zh_CN\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":true,\"description\":\"\"},{\"id\":\"index_o.zh_TW\",\"type\":\"string\",\"name\":\"zh_TW\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":true,\"description\":\"\"},{\"id\":\"index_o.en_US\",\"type\":\"string\",\"name\":\"en_US\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":true,\"description\":\"\"},{\"id\":\"index_o.ja_JP\",\"type\":\"string\",\"name\":\"ja_JP\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":true,\"description\":\"\"},{\"id\":\"index_o.vi_VN\",\"type\":\"string\",\"name\":\"vi_VN\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":true,\"description\":\"\"},{\"id\":\"index_o.th_TH\",\"type\":\"string\",\"name\":\"th_TH\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":true,\"description\":\"\"},{\"id\":\"index_o.id_ID\",\"type\":\"string\",\"name\":\"id_ID\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":true,\"description\":\"\"},{\"id\":\"index_o.ne_NP\",\"type\":\"string\",\"name\":\"ne_NP\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":true,\"description\":\"\"},{\"id\":\"index_o.ms_MY\",\"type\":\"string\",\"name\":\"ms_MY\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":true,\"description\":\"\"},{\"id\":\"index_o.ko_KR\",\"type\":\"string\",\"name\":\"ko_KR\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":true,\"description\":\"\"},{\"id\":\"index_o.ru_RU\",\"type\":\"string\",\"name\":\"ru_RU\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":true,\"description\":\"\"},{\"id\":\"index_o.es_EA\",\"type\":\"string\",\"name\":\"es_EA\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":true,\"description\":\"\"},{\"id\":\"index_o.tr_TR\",\"type\":\"string\",\"name\":\"tr_TR\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":true,\"description\":\"\"},{\"id\":\"index_o.fr_FR\",\"type\":\"string\",\"name\":\"fr_FR\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":true,\"description\":\"\"},{\"id\":\"index_o.pt_BR\",\"type\":\"string\",\"name\":\"pt_BR\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":true,\"description\":\"\"}]}]},{\"id\":\"select_options\",\"type\":\"array\",\"name\":\"select_options\",\"description\":\"Legacy select options\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":false},{\"id\":\"select_index\",\"type\":\"number\",\"name\":\"select_index\",\"description\":\"Current select index\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":false},{\"name\":\"btns\",\"private\":false,\"type\":\"buttonGroup\",\"id\":\"btns\",\"description\":\"动态按钮列表(Dify actions)\",\"editorVarType\":\"variables\",\"disabled\":false,\"schema\":[{\"id\":\"btns.text\",\"type\":\"string\",\"name\":\"text\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":true,\"description\":\"按钮文案\"},{\"id\":\"btns.color\",\"type\":\"string\",\"name\":\"color\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":true,\"description\":\"按钮颜色\"},{\"id\":\"btns.status\",\"type\":\"string\",\"name\":\"status\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":true,\"description\":\"按钮状态\"},{\"id\":\"btns.event\",\"type\":\"dynamicEvent\",\"name\":\"event\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":true,\"description\":\"按钮点击事件\",\"schema\":[{\"id\":\"btns.type\",\"type\":\"string\",\"name\":\"type\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":true,\"description\":\"事件类型:openLink / sendCardRequest\"},{\"id\":\"btns.params\",\"type\":\"object\",\"name\":\"params\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":true,\"description\":\"事件参数\",\"schema\":[{\"id\":\"btns.url\",\"type\":\"string\",\"name\":\"url\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":true,\"description\":\"点击跳转链接(type=openLink)\"},{\"id\":\"btns.actionId\",\"type\":\"string\",\"name\":\"actionId\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":true,\"description\":\"回传请求 id(type=sendCardRequest)\"},{\"id\":\"btns.params\",\"type\":\"object\",\"name\":\"params\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":true,\"description\":\"回传请求参数(type=sendCardRequest)\"}]}]}]}],\"formList\":[],\"customContextList\":[],\"expList\":[],\"localList\":[],\"hsfList\":[],\"lwpList\":[],\"pageData\":{},\"extension\":{\"extendType\":\"AI\",\"aiStatusList\":[3,1,5,2,4],\"fileTypeList\":[]}}", "widgetInfo": "", "type": "im", "mode": "card" diff --git a/tests/unit_tests/platform/test_dingtalk_adapter.py b/tests/unit_tests/platform/test_dingtalk_adapter.py new file mode 100644 index 000000000..3eede810b --- /dev/null +++ b/tests/unit_tests/platform/test_dingtalk_adapter.py @@ -0,0 +1,104 @@ +"""Tests for DingTalk adapter helper behavior.""" + +from langbot.pkg.platform.sources.dingtalk import ( + _dingtalk_clean_form_content, + _dingtalk_completed_input_lines, + _dingtalk_extract_component_inputs, + _dingtalk_form_component_params, + _dingtalk_pending_input_defs, +) + + +def test_dingtalk_select_component_params_expose_options(): + params = _dingtalk_form_component_params( + { + '_current_input_field': 'choice', + 'input_defs': [ + { + 'output_variable_name': 'choice', + 'type': 'select', + 'option_source': {'type': 'constant', 'value': ['A', 'B']}, + } + ], + 'inputs': {}, + } + ) + + assert params['select_visible'] == 'true' + assert params['select_placeholder'] == 'choice' + assert params['select_options'] == ['A', 'B'] + assert [option['value'] for option in params['index_o']] == ['A', 'B'] + assert [option['value'] for option in params['test_index']] == ['A', 'B'] + assert params['index_o'][0]['text']['zh_CN'] == 'A' + assert params['index_o'][0]['text']['en_US'] == 'A' + assert params['select_index'] == -1 + + +def test_dingtalk_extract_select_from_builtin_result_dict(): + inputs = _dingtalk_extract_component_inputs({'selectResult': {'index': 1, 'value': 'B'}}) + + assert inputs == {'select': 'B'} + + +def test_dingtalk_extract_select_from_template_param_string(): + inputs = _dingtalk_extract_component_inputs({'select': '{"index": 1, "value": "B"}'}) + + assert inputs == {'select': '{"index": 1, "value": "B"}'} + + +def test_dingtalk_extract_input_and_select_together(): + inputs = _dingtalk_extract_component_inputs( + { + 'inputResult': {'value': 'looks good'}, + '__built_in_selectResult__': {'index': 0, 'value': 'A'}, + } + ) + + assert inputs == {'input': 'looks good', 'select': 'A'} + + +def test_dingtalk_pending_input_defs_includes_file_fields(): + pending = _dingtalk_pending_input_defs( + { + 'input_defs': [ + {'output_variable_name': 'comment', 'type': 'paragraph'}, + {'output_variable_name': 'files', 'type': 'file-list'}, + ], + 'inputs': {'comment': 'ready'}, + } + ) + + assert [field['output_variable_name'] for field in pending] == ['files'] + + +def test_dingtalk_completed_input_lines_include_text_and_select_values(): + lines = _dingtalk_completed_input_lines( + { + 'all_input_defs': [ + {'output_variable_name': 'comment', 'type': 'paragraph'}, + { + 'output_variable_name': 'choice', + 'type': 'select', + 'option_source': {'type': 'constant', 'value': ['A', 'B']}, + }, + ], + 'inputs': {'comment': 'looks good', 'choice': 'B'}, + } + ) + + assert lines == ['✅ 已填写 comment:**looks good**', '✅ 已选择 choice:**B**'] + + +def test_dingtalk_clean_form_content_uses_all_input_defs(): + content = _dingtalk_clean_form_content( + { + 'raw_form_content': 'Hello\n\n{{#$output.comment#}}\n\n{{#$output.choice#}}\n', + 'input_defs': [], + 'all_input_defs': [ + {'output_variable_name': 'comment', 'type': 'paragraph'}, + {'output_variable_name': 'choice', 'type': 'select'}, + ], + } + ) + + assert content == 'Hello' diff --git a/tests/unit_tests/platform/test_dingtalk_api.py b/tests/unit_tests/platform/test_dingtalk_api.py new file mode 100644 index 000000000..03c84e97b --- /dev/null +++ b/tests/unit_tests/platform/test_dingtalk_api.py @@ -0,0 +1,42 @@ +"""Tests for DingTalk API payload helpers.""" + +import json + +from langbot.libs.dingtalk_api.api import _stringify_card_param_map + + +def test_dingtalk_card_param_map_stringifies_select_component_arrays(): + params = _stringify_card_param_map( + { + 'content': 'Pick one', + 'btns': json.dumps([{'text': 'OK'}], ensure_ascii=False), + 'select_options': ['A', 'B'], + 'index_o': [ + { + 'value': 'A', + 'text': {'zh_CN': 'A', 'en_US': 'A'}, + } + ], + 'test_index': [ + { + 'value': 'A', + 'text': {'zh_CN': 'A', 'en_US': 'A'}, + } + ], + 'select_index': -1, + } + ) + + assert params['content'] == 'Pick one' + assert params['btns'] == '[{"text": "OK"}]' + assert params['select_options'] == '["A", "B"]' + assert json.loads(params['index_o'])[0]['value'] == 'A' + assert json.loads(params['test_index'])[0]['value'] == 'A' + assert params['select_index'] == '-1' + + +def test_dingtalk_card_param_map_stringifies_unregistered_structures(): + params = _stringify_card_param_map({'other': ['A'], 'empty': None}) + + assert params['other'] == '["A"]' + assert params['empty'] == '' diff --git a/tests/unit_tests/platform/test_lark_adapter.py b/tests/unit_tests/platform/test_lark_adapter.py new file mode 100644 index 000000000..d7c372610 --- /dev/null +++ b/tests/unit_tests/platform/test_lark_adapter.py @@ -0,0 +1,111 @@ +"""Tests for Lark adapter helper behavior.""" + +from langbot.pkg.platform.sources.lark import ( + _lark_clean_form_content, + _lark_completed_input_lines, + _lark_extract_action_form_inputs, +) + + +def test_lark_completed_input_lines_include_text_select_and_files(): + lines = _lark_completed_input_lines( + { + 'all_input_defs': [ + {'output_variable_name': 'us_input', 'type': 'paragraph'}, + {'output_variable_name': 'xiala', 'type': 'select'}, + {'output_variable_name': 'files', 'type': 'file-list'}, + ], + 'inputs': { + 'us_input': '你好', + 'xiala': 'or', + 'files': [{'upload_file_id': 'file-1'}, {'upload_file_id': 'file-2'}], + }, + } + ) + + assert lines == [ + '✅ 已填写 us_input:**你好**', + '✅ 已选择 xiala:**or**', + '✅ 已上传 files:**2 file(s)**', + ] + + +def test_lark_clean_form_content_removes_all_input_placeholders(): + content = _lark_clean_form_content( + '人工介入\n\n{{#$output.us_input#}}\n\n{{#$output.xiala#}}\n', + [ + {'output_variable_name': 'us_input', 'type': 'paragraph'}, + {'output_variable_name': 'xiala', 'type': 'select'}, + ], + ) + + assert content == '人工介入' + + +def test_lark_extract_action_form_inputs_from_json_form_value(): + class Action: + form_value = '{"Input_1_us_input_abcd12": "hello", "Select_2_xiala_abcd12": "B"}' + input_value = None + option = None + name = None + + inputs = _lark_extract_action_form_inputs( + Action(), + { + 'input_name_map': { + 'Input_1_us_input_abcd12': 'us_input', + 'Select_2_xiala_abcd12': 'xiala', + } + }, + ) + + assert inputs == {'us_input': 'hello', 'xiala': 'B'} + + +def test_lark_extract_action_form_inputs_from_webhook_dict_action(): + inputs = _lark_extract_action_form_inputs( + { + 'form_value': { + 'Input_1_us_input_abcd12': 'hello', + 'Select_2_xiala_abcd12': {'value': 'B', 'text': {'content': 'Option B'}}, + } + }, + { + 'input_name_map': { + 'Input_1_us_input_abcd12': 'us_input', + 'Select_2_xiala_abcd12': 'xiala', + } + }, + ) + + assert inputs == {'us_input': 'hello', 'xiala': {'value': 'B', 'text': {'content': 'Option B'}}} + + +def test_lark_extract_action_form_inputs_maps_dotted_component_names(): + inputs = _lark_extract_action_form_inputs( + { + 'form_value': { + 'Form_1_token_abcd12.Input_1_us_input_abcd12': 'hello', + } + }, + { + 'input_name_map': { + 'Input_1_us_input_abcd12': 'us_input', + } + }, + ) + + assert inputs == {'us_input': 'hello'} + + +def test_lark_completed_input_lines_display_select_value_from_object(): + lines = _lark_completed_input_lines( + { + 'all_input_defs': [ + {'output_variable_name': 'xiala', 'type': 'select'}, + ], + 'inputs': {'xiala': {'value': 'B', 'text': {'content': 'Option B'}}}, + } + ) + + assert lines == ['✅ 已选择 xiala:**B**'] diff --git a/tests/unit_tests/platform/test_wecombot_template_card.py b/tests/unit_tests/platform/test_wecombot_template_card.py index 7ca48d844..7bdb387ca 100644 --- a/tests/unit_tests/platform/test_wecombot_template_card.py +++ b/tests/unit_tests/platform/test_wecombot_template_card.py @@ -1,6 +1,8 @@ import sys import types +import pytest + logger_module = types.ModuleType('langbot.pkg.platform.logger') logger_module.EventLogger = object @@ -8,9 +10,17 @@ sys.modules.setdefault('langbot.pkg.platform.logger', logger_module) from langbot.libs.wecom_ai_bot_api.api import ( # noqa: E402 build_button_interaction_payload, + build_human_input_template_card_payload, build_button_interaction_update_card, + build_multiple_interaction_update_card, + extract_template_card_event_payload, + extract_template_card_selections, + extract_wecom_event_type, extract_template_card_action, + build_human_input_text_prompt, + parse_select_button_action, ) +from langbot.libs.wecom_ai_bot_api.ws_client import WecomBotWsClient # noqa: E402 def test_extract_template_card_action_supports_nested_button_key(): @@ -27,6 +37,31 @@ def test_extract_template_card_action_supports_nested_button_key(): assert card_type == 'button_interaction' +def test_extract_wecom_event_type_supports_top_level_template_card_event(): + payload = { + 'eventtype': 'template_card_event', + 'template_card_event': { + 'TaskId': 'task-1', + 'CardType': 'multiple_interaction', + 'ResponseData': '{"select_list":[{"question_key":"choice","option_id":"opt_2"}]}', + }, + } + + assert extract_wecom_event_type(payload) == 'template_card_event' + assert extract_template_card_event_payload(payload)['TaskId'] == 'task-1' + + +def test_extract_wecom_event_type_infers_template_card_event_from_top_level_card_fields(): + payload = { + 'TaskId': 'task-1', + 'CardType': 'button_interaction', + 'EventKey': 'approve', + } + + assert extract_wecom_event_type(payload) == 'template_card_event' + assert extract_template_card_event_payload(payload)['EventKey'] == 'approve' + + def test_build_button_interaction_update_card_marks_clicked_button(): card = build_button_interaction_update_card( { @@ -70,3 +105,281 @@ def test_build_button_interaction_payload_uses_preselected_button_styles_before_ {'text': 'Approve', 'style': 2, 'key': 'approve'}, {'text': 'Reject', 'style': 2, 'key': 'reject'}, ] + + +def test_build_payload_uses_multiple_interaction_for_pending_select_field(): + payload = build_human_input_template_card_payload( + { + 'node_title': 'Manual Review', + 'form_content': 'Choose a label\n\n{{#$output.choice#}}', + 'input_defs': [ + { + 'output_variable_name': 'choice', + 'type': 'select', + 'option_source': {'type': 'constant', 'value': ['A', 'B']}, + } + ], + 'inputs': {}, + 'actions': [{'id': 'yes', 'title': 'Yes'}], + }, + task_id='task-1', + source={'desc': 'LangBot'}, + ) + + card = payload['template_card'] + assert card['card_type'] == 'multiple_interaction' + assert card['source'] == {'desc': 'LangBot'} + assert card['select_list'] == [ + { + 'question_key': 'choice', + 'title': 'choice', + 'selected_id': 'opt_1', + 'option_list': [ + {'id': 'opt_1', 'text': 'A'}, + {'id': 'opt_2', 'text': 'B'}, + ], + } + ] + assert card['submit_button'] == {'text': 'Submit', 'key': 'submit_human_input'} + + +def test_build_payload_can_emulate_select_as_buttons_for_wecombot_ws(): + form_data = { + 'node_title': 'Manual Review', + 'form_content': 'Choose a label\n\n{{#$output.choice#}}', + 'input_defs': [ + { + 'output_variable_name': 'choice', + 'type': 'select', + 'option_source': {'type': 'constant', 'value': ['A', 'B']}, + } + ], + 'inputs': {}, + 'actions': [{'id': 'yes', 'title': 'Yes'}], + } + payload = build_human_input_template_card_payload( + form_data, + task_id='task-1', + select_as_buttons=True, + ) + + card = payload['template_card'] + assert card['card_type'] == 'button_interaction' + assert card['button_list'][0]['text'] == 'A' + assert parse_select_button_action(card['button_list'][1]['key'], form_data) == {'choice': 'B'} + + +def test_text_input_card_shows_direct_reply_prompt(): + payload = build_human_input_template_card_payload( + { + 'node_title': '人工介入', + 'form_content': '请输入你的问题\n\n{{#$output.us_input#}}', + 'input_defs': [ + { + 'output_variable_name': 'us_input', + 'type': 'paragraph', + 'label': '请输入你的问题', + } + ], + 'inputs': {}, + 'actions': [{'id': 'yes', 'title': 'yes'}], + '_current_input_field': 'us_input', + }, + task_id='task-1', + ) + + card = payload['template_card'] + assert card['main_title']['desc'] == '请直接回复:请输入你的问题' + assert '请直接回复:请输入你的问题' in card['sub_title_text'] + assert card['button_list'] == [] + + +def test_build_human_input_text_prompt_for_current_text_field(): + prompt = build_human_input_text_prompt( + { + 'node_title': '人工介入', + 'input_defs': [ + { + 'output_variable_name': 'us_input', + 'type': 'paragraph', + 'label': '请输入你的问题', + } + ], + '_current_input_field': 'us_input', + } + ) + + assert prompt == '人工介入\n\n请直接回复:请输入你的问题' + + +@pytest.mark.asyncio +async def test_ws_push_form_pause_sends_text_prompt_without_empty_card(): + client = WecomBotWsClient('bot-id', 'secret', object()) + client._stream_ids['msg-1'] = 'req-1|stream-1' + client._stream_sessions['msg-1'] = {'user_id': 'user-1'} + sent = [] + + async def fake_reply_text(req_id, content): + sent.append((req_id, content)) + return {} + + client.reply_text = fake_reply_text + + ok, stream_id, task_id = await client.push_form_pause( + 'msg-1', + { + 'node_title': '人工介入', + 'input_defs': [ + { + 'output_variable_name': 'us_input', + 'type': 'paragraph', + 'label': '请输入你的问题', + } + ], + '_current_input_field': 'us_input', + }, + ) + + assert ok is True + assert stream_id == 'stream-1' + assert task_id is None + assert sent == [('req-1', '人工介入\n\n请直接回复:请输入你的问题')] + assert client._pending_forms_by_task == {} + assert 'msg-1' not in client._stream_ids + + +@pytest.mark.asyncio +async def test_ws_stream_sends_delta_chunks_to_wecom(): + client = WecomBotWsClient('bot-id', 'secret', object()) + client._stream_ids['msg-1'] = 'req-1|stream-1' + client._stream_sessions['msg-1'] = {} + sent = [] + + async def fake_reply_stream(req_id, stream_id, content, finish=False, feedback_id=''): + sent.append((req_id, stream_id, content, finish)) + return {} + + client.reply_stream = fake_reply_stream + + assert await client.push_stream_chunk('msg-1', '你', is_final=False) + assert await client.push_stream_chunk('msg-1', '你好', is_final=False) + assert await client.push_stream_chunk('msg-1', '你好', is_final=True) + + assert sent == [ + ('req-1', 'stream-1', '你', False), + ('req-1', 'stream-1', '好', False), + ('req-1', 'stream-1', '', True), + ] + + +def test_human_input_payload_keeps_action_select_stage_as_buttons(): + payload = build_human_input_template_card_payload( + { + 'node_title': 'Manual Review', + 'input_defs': [ + { + 'output_variable_name': 'choice', + 'type': 'select', + 'option_source': {'type': 'constant', 'value': ['A', 'B']}, + } + ], + 'inputs': {'choice': 'B'}, + 'actions': [ + {'id': 'approve', 'title': 'Approve'}, + {'id': 'reject', 'title': 'Reject'}, + ], + '_action_select_only': True, + }, + task_id='task-1', + ) + + card = payload['template_card'] + assert card['card_type'] == 'button_interaction' + assert card['button_list'] == [ + {'text': 'Approve', 'style': 2, 'key': 'approve'}, + {'text': 'Reject', 'style': 2, 'key': 'reject'}, + ] + + +def test_extract_template_card_selections_maps_selected_id_to_option_text(): + selections = extract_template_card_selections( + { + 'SelectedItems': [ + {'QuestionKey': 'choice', 'SelectedId': 'opt_2'}, + ], + }, + { + 'input_defs': [ + { + 'output_variable_name': 'choice', + 'type': 'select', + 'option_source': {'type': 'constant', 'value': ['A', 'B']}, + } + ], + }, + ) + + assert selections == {'choice': 'B'} + + +def test_extract_template_card_selections_reads_nested_response_data_json(): + selections = extract_template_card_selections( + { + 'CardType': 'multiple_interaction', + 'ResponseData': '{"select_list":[{"question_key":"choice","option_id":"opt_2"}]}', + }, + { + 'input_defs': [ + { + 'output_variable_name': 'choice', + 'type': 'select', + 'option_source': {'type': 'constant', 'value': ['A', 'B']}, + } + ], + }, + ) + + assert selections == {'choice': 'B'} + + +def test_extract_template_card_selections_reads_response_data_direct_mapping(): + selections = extract_template_card_selections( + { + 'CardType': 'multiple_interaction', + 'EventKey': 'submit_human_input', + 'ResponseData': '{"choice":"opt_2"}', + }, + { + 'input_defs': [ + { + 'output_variable_name': 'choice', + 'type': 'select', + 'option_source': {'type': 'constant', 'value': ['A', 'B']}, + } + ], + }, + ) + + assert selections == {'choice': 'B'} + + +def test_build_multiple_interaction_update_card_disables_submitted_select(): + card = build_multiple_interaction_update_card( + { + 'node_title': 'Manual Review', + 'input_defs': [ + { + 'output_variable_name': 'choice', + 'type': 'select', + 'option_source': {'type': 'constant', 'value': ['A', 'B']}, + } + ], + }, + task_id='task-1', + selections={'choice': 'B'}, + ) + + assert card['card_type'] == 'multiple_interaction' + assert card['main_title']['desc'] == 'Submitted' + assert card['select_list'][0]['disable'] is True + assert card['select_list'][0]['selected_id'] == 'opt_2' diff --git a/tests/unit_tests/provider/runners/test_difysvapi_runner.py b/tests/unit_tests/provider/runners/test_difysvapi_runner.py index b00c9a10a..8db124386 100644 --- a/tests/unit_tests/provider/runners/test_difysvapi_runner.py +++ b/tests/unit_tests/provider/runners/test_difysvapi_runner.py @@ -6,6 +6,9 @@ Tests the helper methods that don't require real Dify API calls. from __future__ import annotations import pytest +from unittest.mock import AsyncMock, MagicMock + +import langbot_plugin.api.entities.builtin.platform.message as platform_message class TestDifyExtractTextOutput: @@ -26,7 +29,7 @@ class TestDifyExtractTextOutput: 'base-url': 'https://api.dify.ai', } }, - 'output': {'misc': {}} + 'output': {'misc': {}}, } runner = DifyServiceAPIRunner(mock_app, pipeline_config) @@ -111,7 +114,7 @@ class TestDifyRunnerConfigValidation: 'base-url': 'https://api.dify.ai', } }, - 'output': {'misc': {}} + 'output': {'misc': {}}, } with pytest.raises(DifyAPIError, match='不支持'): @@ -134,7 +137,7 @@ class TestDifyRunnerConfigValidation: 'base-url': 'https://api.dify.ai', } }, - 'output': {'misc': {}} + 'output': {'misc': {}}, } runner = DifyServiceAPIRunner(mock_app, pipeline_config) @@ -160,10 +163,509 @@ class TestDifyRunnerInit: 'base-url': 'https://api.dify.ai', } }, - 'output': {'misc': {}} + 'output': {'misc': {}}, } runner = DifyServiceAPIRunner(mock_app, pipeline_config) assert runner.pipeline_config == pipeline_config - assert runner.ap == mock_app \ No newline at end of file + assert runner.ap == mock_app + + +class TestDifyHumanInputForms: + """Tests for Dify human-input form helpers.""" + + def _create_runner(self): + from langbot.pkg.provider.runners.difysvapi import DifyServiceAPIRunner + + mock_app = MagicMock() + mock_app.logger = MagicMock() + pipeline_config = { + 'ai': { + 'dify-service-api': { + 'app-type': 'workflow', + 'api-key': 'test-key', + 'base-url': 'https://api.dify.ai', + 'base-prompt': '', + } + }, + 'output': {'misc': {}}, + } + runner = DifyServiceAPIRunner(mock_app, pipeline_config) + runner.dify_client = MagicMock() + runner.dify_client.upload_file = AsyncMock(return_value={'id': 'upload-1'}) + return runner + + def test_format_human_input_text_includes_field_help(self): + from langbot.pkg.provider.runners.difysvapi import _format_human_input_text + + text = _format_human_input_text( + 'Manual Review', + 'Please fill fields.', + [{'id': 'yes', 'title': 'Yes'}], + [ + {'output_variable_name': 'comment', 'type': 'paragraph'}, + { + 'output_variable_name': 'choice', + 'type': 'select', + 'option_source': {'type': 'constant', 'value': ['A', 'B']}, + }, + {'output_variable_name': 'attachment', 'type': 'file-list', 'number_limits': 2}, + ], + ) + + assert 'comment: ' in text + assert 'choice (select): 1. A, 2. B' in text + assert 'attachment (file-list' in text + assert 'action: ' in text + + def test_form_snapshot_for_platform_omits_action_text_and_placeholders(self): + from langbot.pkg.provider.runners.difysvapi import _extract_form_snapshot + + snapshot, _, _, display_form_content = _extract_form_snapshot( + 'run-1', + { + 'form_token': 'token-1', + 'node_title': 'Manual Review', + 'form_content': 'Hello\n\n{{#$output.comment#}}\n\n{{#$output.choice#}}\n', + 'inputs': [ + {'output_variable_name': 'comment', 'type': 'paragraph'}, + { + 'output_variable_name': 'choice', + 'type': 'select', + 'option_source': {'type': 'constant', 'value': ['A', 'B']}, + }, + ], + 'actions': [{'id': 'yes', 'title': 'Yes'}], + }, + 'person_user-1', + ) + + assert '{{#$output.comment#}}' not in display_form_content + assert 'Actions:' not in display_form_content + assert 'comment (paragraph)' in display_form_content + assert snapshot['form_content'] == display_form_content + + @pytest.mark.asyncio + async def test_match_pending_form_collects_select_and_text_inputs(self): + from langbot.pkg.provider.runners import difysvapi + + runner = self._create_runner() + session_key = 'person_user-1' + difysvapi._PENDING_FORMS.clear() + difysvapi._set_pending_form( + session_key, + { + 'form_token': 'token-1', + 'workflow_run_id': 'run-1', + 'node_title': 'Manual Review', + 'actions': [{'id': 'yes', 'title': 'Yes'}], + 'input_defs': [ + {'output_variable_name': 'comment', 'type': 'paragraph'}, + { + 'output_variable_name': 'choice', + 'type': 'select', + 'option_source': {'type': 'constant', 'value': ['A', 'B']}, + }, + ], + 'inputs': {}, + 'user': session_key, + }, + ) + query = MagicMock() + query.message_chain = platform_message.MessageChain([platform_message.Plain(text='')]) + + action = await runner._match_pending_form_action( + query, + session_key, + 'action: yes\ncomment: looks good\nchoice: 2', + ) + + assert action['action_id'] == 'yes' + assert action['inputs'] == {'comment': 'looks good', 'choice': 'B'} + + @pytest.mark.asyncio + async def test_collect_form_inputs_uploads_files(self): + runner = self._create_runner() + image = platform_message.Image(base64='data:image/png;base64,aGVsbG8=') + query = MagicMock() + query.message_chain = platform_message.MessageChain([image]) + + inputs = await runner._collect_form_inputs_from_query( + query, + { + 'input_defs': [{'output_variable_name': 'photo', 'type': 'file'}], + 'inputs': {}, + 'user': 'person_user-1', + }, + '', + ) + + assert inputs['photo'] == { + 'type': 'image', + 'transfer_method': 'local_file', + 'upload_file_id': 'upload-1', + } + + @pytest.mark.asyncio + async def test_partial_input_with_multiple_actions_waits_for_missing_fields(self): + from langbot.pkg.provider.runners import difysvapi + + runner = self._create_runner() + session_key = 'person_user-1' + difysvapi._PENDING_FORMS.clear() + difysvapi._set_pending_form( + session_key, + { + 'form_token': 'token-1', + 'workflow_run_id': 'run-1', + 'node_title': 'Manual Review', + 'actions': [{'id': 'yes', 'title': 'Yes'}, {'id': 'no', 'title': 'No'}], + 'input_defs': [ + {'output_variable_name': 'comment', 'type': 'paragraph'}, + { + 'output_variable_name': 'choice', + 'type': 'select', + 'option_source': {'type': 'constant', 'value': ['A', 'B']}, + }, + ], + 'inputs': {}, + 'user': session_key, + }, + ) + query = MagicMock() + query.message_chain = platform_message.MessageChain([platform_message.Plain(text='')]) + + action = await runner._match_pending_form_action(query, session_key, 'comment: ready') + + assert action['_partial'] is True + assert action['inputs'] == {'comment': 'ready'} + assert action['_form_data']['_current_input_field'] == 'choice' + assert 'choice (select)' in action['notice'] + assert difysvapi._get_latest_pending_form(session_key)['inputs'] == {'comment': 'ready'} + + @pytest.mark.asyncio + async def test_complete_partial_input_with_multiple_actions_renders_action_form(self): + from langbot.pkg.provider.runners import difysvapi + + runner = self._create_runner() + session_key = 'person_user-1' + difysvapi._PENDING_FORMS.clear() + difysvapi._set_pending_form( + session_key, + { + 'form_token': 'token-1', + 'workflow_run_id': 'run-1', + 'node_title': 'Manual Review', + 'raw_form_content': 'Please review\n\n{{#$output.comment#}}\n', + 'form_content': 'Please review\n\nFields:\n - comment (paragraph): reply "comment: "', + 'actions': [{'id': 'yes', 'title': 'Yes'}, {'id': 'no', 'title': 'No'}], + 'input_defs': [{'output_variable_name': 'comment', 'type': 'paragraph'}], + 'inputs': {}, + 'user': session_key, + }, + ) + query = MagicMock() + query.message_chain = platform_message.MessageChain([platform_message.Plain(text='')]) + + action = await runner._match_pending_form_action(query, session_key, 'comment: ready') + + assert action['_partial'] is True + assert action['inputs'] == {'comment': 'ready'} + assert 'action: ' in action['notice'] + assert action['_form_data']['_action_select_only'] is True + assert action['_form_data']['input_defs'] == [] + assert action['_form_data']['actions'] == [{'id': 'yes', 'title': 'Yes'}, {'id': 'no', 'title': 'No'}] + assert '{{#$output.comment#}}' not in action['_form_data']['form_content'] + assert difysvapi._get_latest_pending_form(session_key)['inputs'] == {'comment': 'ready'} + + @pytest.mark.asyncio + async def test_sequential_field_collection_advances_one_field_at_a_time(self): + from langbot.pkg.provider.runners import difysvapi + + runner = self._create_runner() + session_key = 'person_user-1' + difysvapi._PENDING_FORMS.clear() + difysvapi._set_pending_form( + session_key, + { + 'form_token': 'token-1', + 'workflow_run_id': 'run-1', + 'node_title': 'Manual Review', + 'actions': [{'id': 'yes', 'title': 'Yes'}, {'id': 'no', 'title': 'No'}], + 'input_defs': [ + {'output_variable_name': 'comment', 'type': 'paragraph'}, + { + 'output_variable_name': 'choice', + 'type': 'select', + 'option_source': {'type': 'constant', 'value': ['A', 'B']}, + }, + ], + 'inputs': {}, + 'current_input_field': 'comment', + 'user': session_key, + }, + ) + query = MagicMock() + query.message_chain = platform_message.MessageChain([platform_message.Plain(text='')]) + + first = await runner._match_pending_form_action(query, session_key, 'looks good') + + assert first['_partial'] is True + assert first['inputs'] == {'comment': 'looks good'} + assert first['_form_data']['_current_input_field'] == 'choice' + assert first['_form_data']['input_defs'][0]['output_variable_name'] == 'comment' + assert 'choice (select)' in first['_form_data']['form_content'] + + second = await runner._match_pending_form_action(query, session_key, '2') + + assert second['_partial'] is True + assert second['inputs'] == {'comment': 'looks good', 'choice': 'B'} + assert second['_form_data']['_action_select_only'] is True + + @pytest.mark.asyncio + async def test_digit_reply_fills_missing_select_before_matching_action_number(self): + from langbot.pkg.provider.runners import difysvapi + + runner = self._create_runner() + session_key = 'person_user-1' + difysvapi._PENDING_FORMS.clear() + difysvapi._set_pending_form( + session_key, + { + 'form_token': 'token-1', + 'workflow_run_id': 'run-1', + 'node_title': 'Manual Review', + 'actions': [ + {'id': 'yes', 'title': 'yes'}, + {'id': 'no', 'title': 'no'}, + {'id': 'or', 'title': 'or'}, + {'id': 'but', 'title': 'but'}, + ], + 'input_defs': [ + {'output_variable_name': 'us_input', 'type': 'paragraph'}, + { + 'output_variable_name': 'xiala', + 'type': 'select', + 'option_source': {'type': 'constant', 'value': ['1', '2']}, + }, + ], + 'inputs': {'us_input': 'hello'}, + 'user': session_key, + }, + ) + query = MagicMock() + query.message_chain = platform_message.MessageChain([platform_message.Plain(text='')]) + + action = await runner._match_pending_form_action(query, session_key, '2') + + assert action['_partial'] is True + assert action['inputs'] == {'us_input': 'hello', 'xiala': '2'} + assert action['_form_data']['_action_select_only'] is True + + @pytest.mark.asyncio + async def test_workflow_pause_without_text_yields_form_chunk(self): + from langbot.pkg.provider.runners import difysvapi + + async def workflow_run(**kwargs): + del kwargs + yield { + 'event': 'workflow_started', + 'data': {'workflow_run_id': 'run-1'}, + } + yield { + 'event': 'workflow_paused', + 'data': { + 'workflow_run_id': 'run-1', + 'reasons': [ + { + 'TYPE': 'human_input_required', + 'form_token': 'token-1', + 'node_title': 'Manual Review', + 'form_content': 'Please review\n\n{{#$output.comment#}}\n', + 'inputs': [{'output_variable_name': 'comment', 'type': 'paragraph'}], + 'actions': [{'id': 'yes', 'title': 'Yes'}], + } + ], + }, + } + + runner = self._create_runner() + runner.dify_client.workflow_run = workflow_run + query = MagicMock() + query.session.launcher_type.value = 'person' + query.session.launcher_id = 'user-1' + query.session.using_conversation.uuid = 'conversation-1' + query.variables = { + 'session_id': 'session-1', + 'conversation_id': 'conversation-1', + 'msg_create_time': '0', + } + query.message_chain = platform_message.MessageChain([platform_message.Plain(text='hello')]) + + difysvapi._PENDING_FORMS.clear() + chunks = [chunk async for chunk in runner._workflow_messages_chunk(query)] + + assert chunks[-1].is_final is True + assert chunks[-1].content == difysvapi._STREAM_FORM_PLACEHOLDER + assert chunks[-1]._form_data['form_token'] == 'token-1' + assert chunks[-1]._form_data['_current_input_field'] == 'comment' + + @pytest.mark.asyncio + async def test_action_after_partial_input_reuses_saved_inputs(self): + from langbot.pkg.provider.runners import difysvapi + + runner = self._create_runner() + session_key = 'person_user-1' + difysvapi._PENDING_FORMS.clear() + difysvapi._set_pending_form( + session_key, + { + 'form_token': 'token-1', + 'workflow_run_id': 'run-1', + 'node_title': 'Manual Review', + 'actions': [{'id': 'yes', 'title': 'Yes'}, {'id': 'no', 'title': 'No'}], + 'input_defs': [{'output_variable_name': 'comment', 'type': 'paragraph'}], + 'inputs': {}, + 'user': session_key, + }, + ) + query = MagicMock() + query.message_chain = platform_message.MessageChain([platform_message.Plain(text='')]) + + await runner._match_pending_form_action(query, session_key, 'comment: ready') + action = await runner._match_pending_form_action(query, session_key, 'action: yes') + + assert action.get('_partial') is not True + assert action['action_id'] == 'yes' + assert action['inputs'] == {'comment': 'ready'} + + def test_form_action_merges_card_inputs_with_saved_inputs(self): + from langbot.pkg.provider.runners import difysvapi + + runner = self._create_runner() + session_key = 'person_user-1' + difysvapi._PENDING_FORMS.clear() + difysvapi._set_pending_form( + session_key, + { + 'form_token': 'token-1', + 'workflow_run_id': 'run-1', + 'actions': [{'id': 'yes', 'title': 'Yes'}], + 'input_defs': [ + {'output_variable_name': 'comment', 'type': 'paragraph'}, + {'output_variable_name': 'photo', 'type': 'file'}, + ], + 'inputs': {'photo': {'type': 'image', 'transfer_method': 'local_file', 'upload_file_id': 'upload-1'}}, + 'user': session_key, + }, + ) + + action = runner._merge_pending_form_action( + session_key, + { + 'form_token': 'token-1', + 'workflow_run_id': 'run-1', + 'action_id': 'yes', + 'inputs': {'comment': 'ready'}, + }, + ) + + assert action['inputs'] == { + 'comment': 'ready', + 'photo': {'type': 'image', 'transfer_method': 'local_file', 'upload_file_id': 'upload-1'}, + } + + def test_form_action_with_missing_required_file_fields_stays_partial(self): + from langbot.pkg.provider.runners import difysvapi + + runner = self._create_runner() + session_key = 'person_user-1' + difysvapi._PENDING_FORMS.clear() + difysvapi._set_pending_form( + session_key, + { + 'form_token': 'token-1', + 'workflow_run_id': 'run-1', + 'actions': [{'id': 'yes', 'title': 'Yes'}], + 'input_defs': [ + {'output_variable_name': 'comment', 'type': 'paragraph'}, + {'output_variable_name': 'file', 'type': 'file'}, + {'output_variable_name': 'files', 'type': 'file-list', 'number_limits': 5}, + ], + 'inputs': {}, + 'user': session_key, + }, + ) + + action = runner._merge_pending_form_action( + session_key, + { + 'form_token': 'token-1', + 'workflow_run_id': 'run-1', + 'action_id': 'yes', + 'inputs': {'comment': 'ready'}, + }, + ) + + assert action['_partial'] is True + assert action['inputs'] == {'comment': 'ready'} + assert 'file, files' in action['notice'] + assert action['_form_data']['_current_input_field'] == 'file' + assert action['_form_data']['input_defs'][1]['output_variable_name'] == 'file' + + def test_card_component_input_progress_maps_to_current_field(self): + from langbot.pkg.provider.runners import difysvapi + + runner = self._create_runner() + session_key = 'person_user-1' + difysvapi._PENDING_FORMS.clear() + difysvapi._set_pending_form( + session_key, + { + 'form_token': 'token-1', + 'workflow_run_id': 'run-1', + 'actions': [{'id': 'yes', 'title': 'Yes'}, {'id': 'no', 'title': 'No'}], + 'input_defs': [ + {'output_variable_name': 'comment', 'type': 'paragraph'}, + { + 'output_variable_name': 'choice', + 'type': 'select', + 'option_source': {'type': 'constant', 'value': ['A', 'B']}, + }, + ], + 'inputs': {}, + 'current_input_field': 'comment', + 'user': session_key, + }, + ) + + first = runner._merge_pending_form_action( + session_key, + { + 'form_token': 'token-1', + 'workflow_run_id': 'run-1', + 'inputs': {'input': 'looks good'}, + '_current_input_field': 'comment', + '_input_progress': True, + }, + ) + + assert first['_partial'] is True + assert first['inputs'] == {'comment': 'looks good'} + assert first['_form_data']['_current_input_field'] == 'choice' + + second = runner._merge_pending_form_action( + session_key, + { + 'form_token': 'token-1', + 'workflow_run_id': 'run-1', + 'inputs': {'select': '{"index": 1, "value": "B"}'}, + '_current_input_field': 'choice', + '_input_progress': True, + }, + ) + + assert second['_partial'] is True + assert second['inputs'] == {'comment': 'looks good', 'choice': 'B'} + assert second['_form_data']['_action_select_only'] is True