From 83b0d26e997743ca84977ff4b213090e715334c3 Mon Sep 17 00:00:00 2001 From: fdc310 <2213070223@qq.com> Date: Mon, 15 Jun 2026 15:51:52 +0800 Subject: [PATCH] feat(dingtalk): implement human input card support and card action handling - Add a new module `card_callback.py` to handle card action button clicks from DingTalk. - Introduce `DingTalkCardActionHandler` to process card action callbacks and extract parameters. - Update `DingTalkAdapter` to manage card state and handle form input through a single card template. - Add configuration for `human_input_card_template_id` in `dingtalk.yaml` to specify the template for human input. - Create a new card template `dingtalk_human_input_card.json` for rendering human input prompts and buttons. --- scripts/build_dingtalk_card_template.py | 648 ++++++++++++++++++ src/langbot/libs/dingtalk_api/api.py | 322 ++++++++- .../libs/dingtalk_api/card_callback.py | 96 +++ src/langbot/pkg/platform/sources/dingtalk.py | 587 +++++++++++++++- .../pkg/platform/sources/dingtalk.yaml | 12 + .../templates/dingtalk_human_input_card.json | 6 + 6 files changed, 1630 insertions(+), 41 deletions(-) create mode 100644 scripts/build_dingtalk_card_template.py create mode 100644 src/langbot/libs/dingtalk_api/card_callback.py create mode 100644 src/langbot/templates/dingtalk_human_input_card.json diff --git a/scripts/build_dingtalk_card_template.py b/scripts/build_dingtalk_card_template.py new file mode 100644 index 00000000..d419e5b9 --- /dev/null +++ b/scripts/build_dingtalk_card_template.py @@ -0,0 +1,648 @@ +"""Generate the DingTalk human-input card template JSON. + +The output is wrapped in the {editorData, widgetInfo, type, mode} envelope +the DingTalk card builder expects on import. editorData is itself a JSON +string (NOT a nested object), matching real exports from the builder. + +Run from the repo root: python scripts/build_dingtalk_card_template.py +""" + +from __future__ import annotations + +import json +from pathlib import Path + +OUTPUT = Path('src/langbot/templates/dingtalk_human_input_card.json') + + +def markdown_block(node_id, variable='content'): + """A MarkdownBlock whose content is bound to a global variable. + + Unlike BaseText (whose `text` field requires editor-side manual binding), + MarkdownBlock's `content` accepts `variableValue` binding at JSON load + time — the imported template renders the variable straight away. + """ + return { + 'componentName': 'MarkdownBlock', + 'id': node_id, + 'props': { + 'mdVer': 0, + 'icon': {'type': 'icon', 'icon': '', 'iconType': 'emoji'}, + 'content': {'variable': variable, 'variableType': 'global', 'type': 'variableValue'}, + 'visible': { + 'type': 'dynamicVisible', + 'value': True, + 'valueType': 'fixed', + 'condition': {'op': 'and', 'conditions': []}, + }, + 'isStreaming': False, + 'enableLinkStatPoint': False, + 'linkStatPoint': {'type': 'dynamicString', 'content': '', 'i18n': False}, + 'linkStatPointParams': [], + 'marginTop': 6, + 'marginBottom': 6, + 'marginLeft': 12, + 'marginRight': 12, + }, + 'title': 'AI 流式富文本', + 'hidden': False, + 'isLocked': False, + 'condition': True, + 'conditionGroup': '', + } + + +def text_block( + node_id, + text, + *, + bold=False, + gravity='left', + font_size=14, + line_height=22, + max_lines=20, + ml=12, + mr=12, + mt=4, + mb=4, + color_token='common_level1_base_color', + style_token='common_body_text_style', +): + return { + 'componentName': 'BaseText', + 'id': node_id, + 'props': { + 'text': {'i18n': False, 'type': 'dynamicString', 'content': text}, + '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': ml, + 'marginRight': mr, + 'marginTop': mt, + 'marginBottom': mb, + 'fontColorType': 'Standard', + 'enableHighlight': False, + 'maxLine': { + 'type': 'dynamicNumber', + 'valueType': 'fixed', + 'value': max_lines, + 'variable': '', + 'variableType': 'global', + }, + 'color': { + 'type': 'dynamicColor', + 'valueType': 'fixed', + 'value': color_token, + 'variable': '', + 'variableType': 'global', + }, + 'customLightColor': { + 'type': 'dynamicColor', + 'valueType': 'fixed', + 'value': '#35404b', + 'variable': '', + 'variableType': 'global', + }, + 'customDarkColor': { + 'type': 'dynamicColor', + 'valueType': 'fixed', + 'value': '#f6f6f6', + 'variable': '', + 'variableType': 'global', + }, + 'gravity': gravity, + 'fontSizeType': 'Standard', + 'styleType': 'custom', + 'styleToken': style_token, + 'size': 'middle', + 'customFontSize': font_size, + 'customFontLineHeight': line_height, + 'bold': bold, + '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': '', + } + + +def button_group(node_id): + return { + 'componentName': 'ButtonGroup', + 'id': node_id, + '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': '', + } + + +def build_editor_data(): + component_names = [ + 'AIPending', + 'AICardStatusContainer', + 'BaseText', + 'AICardContent', + 'AICardContainer', + 'ButtonGroup', + 'MarkdownBlock', + ] + components_map = [ + { + 'package': '@ali/dxComponent', + 'version': '1.0.0', + 'exportName': n, + 'main': './src/index.tsx', + 'destructuring': False, + 'subName': '', + 'componentName': n, + } + for n in component_names + ] + + pending_state = { + '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': '', + } + ], + } + + done_state = { + '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': [ + markdown_block('node_text_content', variable='content'), + button_group('node_btn_group'), + ], + } + ], + } + + failed_state = { + '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': [ + text_block( + 'node_failed_text', + '操作失败,请稍后重试。', + gravity='center', + mt=10, + mb=10, + ml=10, + mr=10, + max_lines=2, + font_size=15, + ) + ], + } + ], + } + + root = { + 'componentName': 'AICardContainer', + 'id': 'node_root', + 'props': { + 'marginLeft': 0, + 'marginRight': 0, + 'marginTop': 0, + 'marginBottom': 0, + 'enablePending': True, + 'enableWriting': False, + 'enableDoing': False, + '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': [pending_state, done_state, failed_state], + } + + btns_var = { + '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)', + }, + ], + }, + ], + }, + ], + } + + return { + 'schemaVersion': '3.0.0', + 'schema': { + 'componentsMap': components_map, + 'componentsTree': [root], + '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, + }, + btns_var, + ], + 'formList': [], + 'customContextList': [], + 'expList': [], + 'localList': [], + 'hsfList': [], + 'lwpList': [], + 'pageData': {}, + 'extension': {'extendType': 'AI', 'aiStatusList': [3, 1, 5], 'fileTypeList': []}, + } + + +def main(): + editor_data = build_editor_data() + wrapper = { + 'editorData': json.dumps(editor_data, ensure_ascii=False, separators=(',', ':')), + 'widgetInfo': '', + 'type': 'im', + 'mode': 'card', + } + OUTPUT.write_text(json.dumps(wrapper, ensure_ascii=False, indent=2), encoding='utf-8') + print(f'wrote {OUTPUT}') + + +if __name__ == '__main__': + main() diff --git a/src/langbot/libs/dingtalk_api/api.py b/src/langbot/libs/dingtalk_api/api.py index f453d0bf..c5154530 100644 --- a/src/langbot/libs/dingtalk_api/api.py +++ b/src/langbot/libs/dingtalk_api/api.py @@ -1,17 +1,26 @@ import asyncio import base64 import json +import logging import time +import uuid import urllib.parse -from typing import Callable +from typing import Awaitable, Callable, Optional import dingtalk_stream # type: ignore import websockets from .EchoHandler import EchoTextHandler +from .card_callback import DingTalkCardActionHandler from .dingtalkevent import DingTalkEvent import httpx import traceback +_stdout_logger = logging.getLogger('langbot.dingtalk_api') + + +DINGTALK_OPENAPI_BASE = 'https://api.dingtalk.com' + + class DingTalkClient: def __init__( self, @@ -21,6 +30,7 @@ class DingTalkClient: robot_code: str, markdown_card: bool, logger: None, + card_action_callback: Optional[Callable[[dict], Awaitable[None]]] = None, ): """初始化 WebSocket 连接并自动启动""" self.credential = dingtalk_stream.Credential(client_id, client_secret) @@ -30,6 +40,14 @@ class DingTalkClient: # 在 DingTalkClient 中传入自己作为参数,避免循环导入 self.EchoTextHandler = EchoTextHandler(self) self.client.register_callback_handler(dingtalk_stream.chatbot.ChatbotMessage.TOPIC, self.EchoTextHandler) + # STREAM-mode card action button click handler. Forwards parsed payload + # to the adapter so it can resume paused Dify workflows. + self.card_action_callback = card_action_callback + self.card_action_handler = DingTalkCardActionHandler(self.client, self._on_card_action) + self.client.register_callback_handler( + dingtalk_stream.handlers.CallbackHandler.TOPIC_CARD_CALLBACK, + self.card_action_handler, + ) self._message_handlers = { 'example': [], } @@ -41,6 +59,16 @@ class DingTalkClient: self.logger = logger self._stopped = False # Flag to control the event loop + async def _on_card_action(self, payload: dict) -> None: + """Dispatch a parsed card-action payload to the adapter callback.""" + if self.card_action_callback is None: + return + try: + await self.card_action_callback(payload) + except Exception: + if self.logger: + await self.logger.error(f'DingTalk card action callback error: {traceback.format_exc()}') + async def get_access_token(self): url = 'https://api.dingtalk.com/v1.0/oauth2/accessToken' headers = {'Content-Type': 'application/json'} @@ -429,18 +457,35 @@ class DingTalkClient: 'Content-Type': 'application/json', } + # For enterprise-internal robots, robotCode == AppKey (client_id). + # The dedicated robot_code field is only required for scenario-group + # robots or third-party robots; fall back to client_id when empty so + # the common single-bot setup keeps working without manual config. + robot_code = self.robot_code or self.key data = { - 'robotCode': self.robot_code, + 'robotCode': robot_code, 'userIds': [target_id], 'msgKey': 'sampleText', 'msgParam': json.dumps({'content': content}), } + _stdout_logger.info( + 'DingTalk send_proactive_message_to_one request: robotCode=%s target_id=%s content_len=%d', + robot_code, + target_id, + len(content), + ) try: async with httpx.AsyncClient() as client: response = await client.post(url, headers=headers, json=data) + _stdout_logger.info( + 'DingTalk send_proactive_message_to_one response: status=%d body=%s', + response.status_code, + response.text[:500], + ) if response.status_code == 200: return except Exception: + _stdout_logger.exception('DingTalk send_proactive_message_to_one error') await self.logger.error(f'failed to send proactive massage to person: {traceback.format_exc()}') raise Exception(f'failed to send proactive massage to person: {traceback.format_exc()}') @@ -456,7 +501,7 @@ class DingTalkClient: } data = { - 'robotCode': self.robot_code, + 'robotCode': self.robot_code or self.key, 'openConversationId': target_id, 'msgKey': 'sampleText', 'msgParam': json.dumps({'content': content}), @@ -477,47 +522,274 @@ class DingTalkClient: quote_origin: bool = False, card_auto_layout: bool = False, ): - card_data = {} - card_data['config'] = json.dumps({'autoLayout': card_auto_layout}) - card_data['content'] = '' + """Create + deliver the streaming chat card for a chatbot reply. - # 将用户的消息内容作为卡片的查询参数,方便后续处理 - if incoming_message.message_type == 'text': - card_data['query'] = incoming_message.get_text_list()[0] + Replaces the old `dingtalk_stream.AICardReplier`-based path. Returns + `(None, out_track_id)` to keep call sites compatible with the + previous `(card_instance, card_instance_id)` shape — the first slot + is unused now that everything is driven by out_track_id. + """ + out_track_id = uuid.uuid4().hex + is_group = str(incoming_message.conversation_type) == '2' + if is_group: + open_space_id = f'dtv1.card//IM_GROUP.{incoming_message.conversation_id}' else: - card_data['query'] = '...' + open_space_id = f'dtv1.card//IM_ROBOT.{incoming_message.sender_staff_id}' - card_instance = dingtalk_stream.AICardReplier(self.client, incoming_message) - # print(card_instance) - # 先投放卡片: https://open.dingtalk.com/document/orgapp/create-and-deliver-cards - card_instance_id = await card_instance.async_create_and_deliver_card( - temp_card_id, - card_data, + card_param_map = {'content': ''} + if incoming_message.message_type == 'text': + card_param_map['query'] = incoming_message.get_text_list()[0] + else: + card_param_map['query'] = '...' + + await self.create_and_deliver_card( + card_template_id=temp_card_id, + out_track_id=out_track_id, + open_space_id=open_space_id, + is_group=is_group, + card_param_map=card_param_map, + card_data_config={'autoLayout': card_auto_layout}, ) - return card_instance, card_instance_id + return None, out_track_id async def send_card_message(self, card_instance, card_instance_id: str, content: str, is_final: bool): - content_key = 'content' + """Stream a single chunk into an existing card's `content` field.""" try: - await card_instance.async_streaming( - card_instance_id, - content_key=content_key, + await self.streaming_update_card( + out_track_id=card_instance_id, + content_key='content', content_value=content, append=False, finished=is_final, failed=False, ) except Exception as e: - self.logger.exception(e) - await card_instance.async_streaming( - card_instance_id, - content_key=content_key, + if self.logger: + self.logger.exception(e) + await self.streaming_update_card( + out_track_id=card_instance_id, + content_key='content', content_value='', append=False, finished=is_final, failed=True, ) + async def create_and_deliver_card( + self, + *, + card_template_id: str, + out_track_id: str, + open_space_id: str, + is_group: bool, + card_param_map: Optional[dict] = None, + callback_type: str = 'STREAM', + callback_route_key: Optional[str] = None, + support_forward: bool = True, + dynamic_data_source_configs: Optional[list] = None, + card_data_config: Optional[dict] = None, + at_user_ids: Optional[dict] = None, + recipients: Optional[list] = None, + ) -> bool: + """POST /v1.0/card/instances/createAndDeliver. + + Mirrors the SDK's `async_create_and_deliver_card` shape but exposes + the dynamic-data-source config slot so we can register a pull URL + for variable-length button lists. + """ + if not await self.check_access_token(): + await self.get_access_token() + + cardData: dict = {'cardParamMap': card_param_map or {}} + if card_data_config is not None: + cardData['config'] = json.dumps(card_data_config) + + body: dict = { + 'cardTemplateId': card_template_id, + 'outTrackId': out_track_id, + 'cardData': cardData, + 'callbackType': callback_type, + 'openSpaceId': open_space_id, + 'imGroupOpenSpaceModel': {'supportForward': support_forward}, + 'imRobotOpenSpaceModel': {'supportForward': support_forward}, + } + if callback_type == 'HTTP' and callback_route_key: + body['callbackRouteKey'] = callback_route_key + + if is_group: + deliver: dict = {'robotCode': self.robot_code or self.key} + if at_user_ids: + deliver['atUserIds'] = at_user_ids + if recipients is not None: + deliver['recipients'] = recipients + body['imGroupOpenDeliverModel'] = deliver + else: + body['imRobotOpenDeliverModel'] = {'spaceType': 'IM_ROBOT'} + + if dynamic_data_source_configs: + body['openDynamicDataConfig'] = {'dynamicDataSourceConfigs': dynamic_data_source_configs} + + url = f'{DINGTALK_OPENAPI_BASE}/v1.0/card/instances/createAndDeliver' + headers = { + 'x-acs-dingtalk-access-token': self.access_token, + 'Content-Type': 'application/json', + } + try: + _stdout_logger.info( + 'DingTalk createAndDeliver request body: %s', + json.dumps(body, ensure_ascii=False)[:1500], + ) + async with httpx.AsyncClient() as client: + response = await client.post(url, headers=headers, json=body, timeout=30.0) + if response.status_code == 200: + _stdout_logger.info( + 'DingTalk createAndDeliver response: %s', + response.text[:500], + ) + return True + _stdout_logger.error( + 'DingTalk createAndDeliver failed: status=%s body=%s', + response.status_code, + response.text, + ) + if self.logger: + await self.logger.error( + f'DingTalk createAndDeliver failed: status={response.status_code} body={response.text}' + ) + return False + except Exception: + _stdout_logger.exception('DingTalk createAndDeliver error') + if self.logger: + await self.logger.error(f'DingTalk createAndDeliver error: {traceback.format_exc()}') + return False + + async def streaming_update_card( + self, + *, + out_track_id: str, + content_key: str, + content_value: str, + append: bool, + finished: bool, + failed: bool = False, + ) -> bool: + """PUT /v1.0/card/streaming. + + Replaces `dingtalk_stream.AICardReplier.async_streaming` — same body + shape (outTrackId / guid / key / content / isFull / isFinalize / + isError) per the SDK source. + """ + if not await self.check_access_token(): + await self.get_access_token() + + body = { + 'outTrackId': out_track_id, + 'guid': uuid.uuid4().hex, + 'key': content_key, + 'content': content_value, + 'isFull': not append, + 'isFinalize': finished, + 'isError': failed, + } + url = f'{DINGTALK_OPENAPI_BASE}/v1.0/card/streaming' + headers = { + 'x-acs-dingtalk-access-token': self.access_token, + 'Content-Type': 'application/json', + } + try: + async with httpx.AsyncClient() as client: + response = await client.put(url, headers=headers, json=body, timeout=30.0) + if response.status_code == 200: + return True + if self.logger: + await self.logger.error( + f'DingTalk card streaming failed: status={response.status_code} body={response.text}' + ) + return False + except Exception: + if self.logger: + await self.logger.error(f'DingTalk card streaming error: {traceback.format_exc()}') + return False + + async def update_card_data( + self, + *, + out_track_id: str, + card_param_map: Optional[dict] = None, + private_data: Optional[dict] = None, + ) -> bool: + """PUT /v1.0/card/instances — non-streaming card content update.""" + if not await self.check_access_token(): + await self.get_access_token() + + body: dict = { + 'outTrackId': out_track_id, + 'cardData': {'cardParamMap': card_param_map or {}}, + } + if private_data: + body['privateData'] = private_data + + url = f'{DINGTALK_OPENAPI_BASE}/v1.0/card/instances' + headers = { + 'x-acs-dingtalk-access-token': self.access_token, + 'Content-Type': 'application/json', + } + try: + _stdout_logger.info( + 'DingTalk update_card_data request: out_track_id=%s body=%s', + out_track_id, + json.dumps(body, ensure_ascii=False)[:500], + ) + async with httpx.AsyncClient() as client: + response = await client.put(url, headers=headers, json=body, timeout=30.0) + _stdout_logger.info( + 'DingTalk update_card_data response: status=%d body=%s', + response.status_code, + response.text[:300], + ) + if response.status_code == 200: + return True + if self.logger: + await self.logger.error( + f'DingTalk update card failed: status={response.status_code} body={response.text}' + ) + return False + except Exception: + _stdout_logger.exception('DingTalk update_card_data error') + if self.logger: + await self.logger.error(f'DingTalk update card error: {traceback.format_exc()}') + return False + + async def delete_card(self, *, out_track_id: str) -> bool: + """POST /v1.0/card/instances/delete — recall a delivered card. + + Used to retroactively remove the initial streaming chat card when + the workflow turns out to be paused for human input — the prompt + and buttons then live entirely on the dedicated form card. + """ + if not await self.check_access_token(): + await self.get_access_token() + + url = f'{DINGTALK_OPENAPI_BASE}/v1.0/card/instances/delete' + headers = { + 'x-acs-dingtalk-access-token': self.access_token, + 'Content-Type': 'application/json', + } + body = {'outTrackId': out_track_id, 'userIdType': 1} + try: + _stdout_logger.info('DingTalk delete_card request: out_track_id=%s', out_track_id) + async with httpx.AsyncClient() as client: + response = await client.post(url, headers=headers, json=body, timeout=30.0) + _stdout_logger.info( + 'DingTalk delete_card response: status=%d body=%s', + response.status_code, + response.text[:300], + ) + return response.status_code == 200 + except Exception: + _stdout_logger.exception('DingTalk delete_card error') + return False + async def start(self): """启动 WebSocket 连接,监听消息""" self._stopped = False diff --git a/src/langbot/libs/dingtalk_api/card_callback.py b/src/langbot/libs/dingtalk_api/card_callback.py new file mode 100644 index 00000000..1bbe93cc --- /dev/null +++ b/src/langbot/libs/dingtalk_api/card_callback.py @@ -0,0 +1,96 @@ +"""STREAM-mode handler for DingTalk card action button clicks. + +DingTalk delivers card-action callbacks over the same WebSocket stream used +for chatbot messages, under the topic `/v1.0/card/instances/callback`. This +module subclasses `dingtalk_stream.CallbackHandler` and forwards the parsed +payload to a coroutine the adapter registers, so the resume-paused-workflow +logic stays in the platform adapter where it belongs. + +The `CardCallbackMessage` returned by `from_dict` exposes: + +* `card_instance_id` (from `outTrackId`) — the card whose button was clicked +* `user_id` — the clicker's userId +* `content` — parsed JSON; the click params live here. Where exactly inside + `content` they sit depends on the template binding. We probe + the common paths. +* `extension` — parsed JSON; any extra data we set when delivering the card. +""" + +from __future__ import annotations + +from typing import Awaitable, Callable, Optional + +import dingtalk_stream # type: ignore +from dingtalk_stream import AckMessage +from dingtalk_stream.card_callback import CardCallbackMessage + + +_PARAM_PATHS = ( + ('params',), + ('cardPrivateData', 'params'), + ('userPrivateData', 'params'), +) + + +def _extract_params(content: dict) -> dict: + """Return the action params dict regardless of where the template put it.""" + for path in _PARAM_PATHS: + node = content + for key in path: + if not isinstance(node, dict): + node = None + break + node = node.get(key) + if node is None: + break + if isinstance(node, dict) and node: + return node + return {} + + +class DingTalkCardActionHandler(dingtalk_stream.CallbackHandler): + def __init__( + self, + dingtalk_stream_client, + on_action: Optional[Callable[[dict], Awaitable[None]]] = None, + ): + super().__init__() + self.dingtalk_client = dingtalk_stream_client + self.on_action = on_action + + 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 {}) + + # `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 {} + action_id = raw.get('actionId') or '' + if not action_id: + # Some templates nest it under actionData / cardPrivateData. + action_data = raw.get('actionData') or {} + if isinstance(action_data, dict): + action_id = action_data.get('actionId') or action_id + if not action_id: + cpd = action_data.get('cardPrivateData') or {} + if isinstance(cpd, dict): + ids = cpd.get('actionIds') + if isinstance(ids, list) and ids: + action_id = str(ids[0]) + + payload = { + 'out_track_id': message.card_instance_id, + 'user_id': message.user_id, + 'corp_id': message.corp_id, + 'action_id': action_id, + 'params': params, + 'raw_content': message.content, + 'extension': message.extension if isinstance(message.extension, dict) else {}, + } + if self.on_action is not None: + await self.on_action(payload) + except Exception as e: + self.logger.error(f'DingTalkCardActionHandler.process error: {e}') + return AckMessage.STATUS_OK, 'OK' diff --git a/src/langbot/pkg/platform/sources/dingtalk.py b/src/langbot/pkg/platform/sources/dingtalk.py index f47f995d..4288fd0a 100644 --- a/src/langbot/pkg/platform/sources/dingtalk.py +++ b/src/langbot/pkg/platform/sources/dingtalk.py @@ -1,13 +1,19 @@ +import asyncio +import json import traceback import typing +import uuid + from langbot.libs.dingtalk_api.dingtalkevent import DingTalkEvent import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter import langbot_plugin.api.entities.builtin.platform.events as platform_events import langbot_plugin.api.entities.builtin.platform.entities as platform_entities +import langbot_plugin.api.entities.builtin.provider.session as provider_session from langbot.libs.dingtalk_api.api import DingTalkClient import datetime from langbot.pkg.platform.logger import EventLogger +from langbot.pkg.provider.runners.difysvapi import _format_human_input_text class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverter): @@ -170,6 +176,13 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): card_instance_id_dict: ( dict # 回复卡片消息字典,key为消息id,value为回复卡片实例id,用于在流式消息时判断是否发送到指定卡片 ) + # outTrackId → form snapshot {session_key, launcher_type, launcher_id, form_token, + # workflow_run_id, actions, node_title, form_content, expires_at, open_space_id, + # user_id_hint, current_text}. Lookup keys for the data-source pull endpoint and + # the STREAM card-action callback. + card_state: dict + ap: typing.Any = None + bot_uuid: str = '' def __init__(self, config: dict, logger: EventLogger): required_keys = [ @@ -194,10 +207,15 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): config=config, logger=logger, card_instance_id_dict={}, + card_state={}, bot_account_id=bot_account_id, bot=bot, listeners={}, ) + # Wire the card-action callback after super().__init__ so we can reference + # self.* — the client's handler stores this as a soft reference and reads + # it at fire time. + self.bot.card_action_callback = self._on_card_action async def reply_message( self, @@ -222,28 +240,79 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): quote_origin: bool = False, is_final: bool = False, ): - # event = await DingTalkEventConverter.yiri2target( - # message_source, - # ) - # incoming_message = event.incoming_message - - # msg_id = incoming_message.message_id message_id = bot_message.resp_message_id msg_seq = bot_message.msg_sequence + form_template_id = (self.config.get('human_input_card_template_id') or '').strip() + form_data = getattr(bot_message, '_form_data', None) + if is_final and self.ap is not None: + self.ap.logger.info( + f'DingTalk reply_message_chunk final: form_data_present={form_data is not None}, ' + f'form_template_configured={bool(form_template_id)}' + ) + + if form_data and is_final: + await self._handle_form_chunk(message_source, bot_message, message, form_data) + return + if (msg_seq - 1) % 8 == 0 or is_final: markdown_enabled = self.config.get('markdown_card', False) content, at = await DingTalkMessageConverter.yiri2target(message, markdown_enabled) - - card_instance, card_instance_id = self.card_instance_id_dict[message_id] if not content and bot_message.content: content = bot_message.content # 兼容直接传入content的情况 - # print(card_instance_id) + + chat_card_entry = self.card_instance_id_dict.get(message_id) + if chat_card_entry is None: + # No streaming chat card was created for this query — common + # path for synthetic events (e.g. resumed workflow after a + # button click). Lazy-create one so the resumed output streams + # into a card just like a normal conversation, instead of + # being deferred and sent in one shot on is_final. + if not content: + return # nothing to stream yet + chat_card_entry = await self._lazy_create_resume_chat_card(message_source, message_id) + if chat_card_entry is None: + # Lazy-create failed (no template configured); fall back + # to a one-shot proactive message on the final chunk. + if is_final: + await self._send_proactive_to_event(message_source, content) + return + + card_instance, card_instance_id = chat_card_entry if content: - await self.bot.send_card_message(card_instance, card_instance_id, content, is_final) - if is_final and bot_message.tool_calls is None: - # self.seq = 1 # 消息回复结束之后重置seq - self.card_instance_id_dict.pop(message_id) # 消息回复结束之后删除卡片实例id + if form_template_id: + # The form template's MarkdownBlock has `isStreaming: false` + # — the streaming endpoint (PUT /v1.0/card/streaming) does + # not propagate to non-streaming components. Use the full + # update_card_data PUT instead so the content actually + # appears in the card body. + try: + await self.bot.update_card_data( + out_track_id=card_instance_id, + card_param_map={ + 'content': content, + 'btns': '[]', + 'flowStatus': '3' if is_final else '1', + }, + ) + except Exception: + if self.ap is not None: + self.ap.logger.exception('DingTalk: update card content failed') + else: + await self.bot.send_card_message(card_instance, card_instance_id, content, is_final) + if is_final: + if form_template_id and not content: + # Empty final chunk still needs to leave the card with + # flowStatus=3 so the spinner stops. + try: + await self.bot.update_card_data( + out_track_id=card_instance_id, + card_param_map={'flowStatus': '3'}, + ) + except Exception: + pass + if bot_message.tool_calls is None: + self.card_instance_id_dict.pop(message_id, None) async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): markdown_enabled = self.config.get('markdown_card', False) @@ -260,16 +329,56 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): return is_stream async def create_message_card(self, message_id, event): - card_template_id = self.config['card_template_id'] + # When a form template is configured, every card in the conversation + # uses it (chat output, form prompts, post-click states). The chat + # template fallback only kicks in if no form template is configured. + form_template_id = (self.config.get('human_input_card_template_id') or '').strip() + legacy_template_id = self.config.get('card_template_id', '') + + # Synthetic events (e.g. card button clicks) have no inbound chatbot + # message — skip card creation. The lazy-create path in + # reply_message_chunk will spawn a fresh card when the first + # non-empty resume chunk arrives. + if event is None or event.source_platform_object is None: + return False + + if form_template_id: + # Defer card creation to the first non-empty chunk. If the Dify + # workflow pauses immediately for human input without producing + # any LLM text first, no chat card is created at all — only the + # form card gets delivered. Lazy-create lives in + # reply_message_chunk → _lazy_create_resume_chat_card. + return False + + # Legacy chat-card path (no form template configured). incoming_message = event.source_platform_object.incoming_message - # message_id = incoming_message.message_id card_auto_layout = self.config.get('card_ auto_layout', False) card_instance, card_instance_id = await self.bot.create_and_card( - card_template_id, incoming_message, card_auto_layout=card_auto_layout + legacy_template_id, incoming_message, card_auto_layout=card_auto_layout ) self.card_instance_id_dict[message_id] = (card_instance, card_instance_id) return True + def _session_key_from_event(self, event) -> str: + """Return launcher_type_launcher_id for an event, '' if unrecoverable.""" + if event is None: + return '' + spo = event.source_platform_object + if spo is None: + try: + if isinstance(event, platform_events.GroupMessage): + return f'group_{event.group.id}' + return f'person_{event.sender.id}' + except Exception: + return '' + try: + inc = spo.incoming_message + if str(inc.conversation_type) == '2': + return f'group_{inc.conversation_id}' + return f'person_{inc.sender_staff_id}' + except Exception: + return '' + def register_listener( self, event_type: typing.Type[platform_events.Event], @@ -309,3 +418,449 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): ], ): return super().unregister_listener(event_type, callback) + + # ------------------------------------------------------------------ + # Dify human-input form support + # ------------------------------------------------------------------ + + def set_bot_uuid(self, bot_uuid: str): + """Receive the bot uuid from the platform manager. + + Used to compose the public-facing unified-webhook URL for the card + dynamic-data-source pull endpoint. + """ + self.bot_uuid = bot_uuid + + def _derive_open_space(self, message_source: platform_events.MessageEvent) -> tuple[str, bool]: + """Return (openSpaceId, is_group) for the given inbound event.""" + if isinstance(message_source, platform_events.GroupMessage): + return f'dtv1.card//IM_GROUP.{message_source.group.id}', True + return f'dtv1.card//IM_ROBOT.{message_source.sender.id}', False + + def _derive_session_descriptor( + self, message_source: platform_events.MessageEvent + ) -> tuple[provider_session.LauncherTypes, str, str]: + """Return (launcher_type, launcher_id, sender_user_id) for routing.""" + if isinstance(message_source, platform_events.GroupMessage): + return ( + provider_session.LauncherTypes.GROUP, + str(message_source.group.id), + str(message_source.sender.id), + ) + return ( + provider_session.LauncherTypes.PERSON, + str(message_source.sender.id), + str(message_source.sender.id), + ) + + async def _handle_form_chunk( + self, + message_source: platform_events.MessageEvent, + bot_message, + message: platform_message.MessageChain, + form_data: dict, + ) -> None: + """Finalize the current chat card and deliver a new form card. + + Multi-card flow: every Dify pause spawns its own card. The card the + chat was streaming into (if any) is closed out via streaming_update + with finished=True so its spinner stops; a fresh card is then + delivered carrying the prompt + buttons. + """ + if self.ap is not None: + self.ap.logger.info( + f'DingTalk _handle_form_chunk: actions={len(form_data.get("actions") or [])}, ' + f'node_title={form_data.get("node_title", "")!r}' + ) + message_id = bot_message.resp_message_id + template_id = (self.config.get('human_input_card_template_id') or '').strip() + + # Finalize the previous chat card so its spinner stops. Use the + # already-streamed text as the final content (or zero-width space + # when nothing streamed, to satisfy any non-empty-content guards). + chat_card_entry = self.card_instance_id_dict.pop(message_id, None) + if chat_card_entry is not None: + _, chat_out_track_id = chat_card_entry + markdown_enabled = self.config.get('markdown_card', False) + text_content, _ = await DingTalkMessageConverter.yiri2target(message, markdown_enabled) + if not text_content and bot_message.content: + text_content = bot_message.content + try: + await self.bot.send_card_message(None, chat_out_track_id, text_content or '​', True) + except Exception: + await self.logger.error(f'DingTalk: finalize chat card before form failed: {traceback.format_exc()}') + # When the chat card uses the form template, also flip flowStatus + # to 3 so it leaves the pending state visibly. + if template_id: + try: + await self.bot.update_card_data( + out_track_id=chat_out_track_id, + card_param_map={'flowStatus': '3'}, + ) + except Exception: + pass + + if not template_id: + # No form template configured — fall back to plain text so users + # can still reply with the option number or title. + await self.send_message_text_form(message_source, form_data) + return + + await self._send_form_card(message_source, form_data, template_id) + + async def _send_form_card( + self, + message_source: platform_events.MessageEvent, + form_data: dict, + template_id: str, + ) -> None: + """Deliver a new card pre-loaded with the human-input prompt + buttons.""" + out_track_id = uuid.uuid4().hex + open_space_id, is_group = self._derive_open_space(message_source) + launcher_type, launcher_id, sender_user_id = self._derive_session_descriptor(message_source) + session_key = f'{launcher_type.value}_{launcher_id}' + + 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 '' + + self.card_state[out_track_id] = { + 'session_key': session_key, + 'launcher_type': launcher_type.value, + 'launcher_id': launcher_id, + 'sender_user_id': sender_user_id, + 'form_token': form_data.get('form_token', ''), + 'workflow_run_id': form_data.get('workflow_run_id', ''), + 'actions': actions, + 'node_title': node_title, + 'form_content': form_content, + 'open_space_id': open_space_id, + 'is_group': is_group, + } + + parts = [] + if node_title: + parts.append(f'**{node_title}**') + if form_content: + parts.append(form_content) + display_content = '\n\n'.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}, + }, + }, + } + ) + + try: + if self.ap is not None: + self.ap.logger.info( + f'DingTalk _send_form_card: out_track_id={out_track_id} template_id={template_id} ' + f'open_space_id={open_space_id} is_group={is_group} btns={len(btns)}' + ) + await self.bot.create_and_deliver_card( + card_template_id=template_id, + out_track_id=out_track_id, + open_space_id=open_space_id, + is_group=is_group, + card_param_map={ + 'content': display_content, + 'btns': json.dumps(btns, ensure_ascii=False), + 'flowStatus': '3', + }, + callback_type='STREAM', + ) + except Exception: + await self.logger.error(f'DingTalk: deliver form card failed: {traceback.format_exc()}') + await self.send_message_text_form(message_source, form_data) + self.card_state.pop(out_track_id, None) + + async def _lazy_create_resume_chat_card( + self, + message_source: platform_events.MessageEvent, + message_id: str, + ) -> typing.Optional[tuple]: + """Create a new card for resumed-workflow streaming output. + + Used after a button click triggers a synthetic event — no inbound + chatbot message means no card was created upstream, so we spin one + up here when the first non-empty chunk arrives. Prefers the form + template (so empty `btns` keep the layout consistent across the + whole conversation); falls back to the legacy chat template. + """ + form_template_id = (self.config.get('human_input_card_template_id') or '').strip() + legacy_template_id = (self.config.get('card_template_id') or '').strip() + template_id = form_template_id or legacy_template_id + if not template_id: + return None + out_track_id = uuid.uuid4().hex + open_space_id, is_group = self._derive_open_space(message_source) + if self.ap is not None: + self.ap.logger.info( + f'DingTalk _lazy_create_resume_chat_card: out_track_id={out_track_id} ' + f'open_space_id={open_space_id} is_group={is_group} ' + f'using_form_template={bool(form_template_id)}' + ) + if form_template_id: + card_param_map = {'content': '', 'btns': '[]', 'flowStatus': '1'} + card_data_config = None + else: + card_param_map = {'content': '', 'query': '...'} + card_data_config = {'autoLayout': self.config.get('card_auto_layout', False)} + try: + success = await self.bot.create_and_deliver_card( + card_template_id=template_id, + out_track_id=out_track_id, + open_space_id=open_space_id, + is_group=is_group, + card_param_map=card_param_map, + card_data_config=card_data_config, + callback_type='STREAM', + ) + except Exception: + if self.ap is not None: + self.ap.logger.exception('DingTalk: lazy create resume chat card failed') + return None + if not success: + return None + entry = (None, out_track_id) + self.card_instance_id_dict[message_id] = entry + return entry + + async def send_message_text_form( + self, + message_source: platform_events.MessageEvent, + 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 [], + ) + await self._send_proactive_to_event(message_source, display_text) + + async def _send_proactive_to_event( + self, + message_source: platform_events.MessageEvent, + content: str, + ) -> None: + """Send `content` as a proactive message to the conversation behind + `message_source`. Used when no inbound chatbot message exists to + anchor a card on (e.g. resumed flows triggered by card actions). + """ + if not content: + return + if self.ap is not None: + target = ( + str(message_source.group.id) + if isinstance(message_source, platform_events.GroupMessage) + else str(message_source.sender.id) + ) + self.ap.logger.info( + f'DingTalk _send_proactive_to_event: target={target} ' + f'is_group={isinstance(message_source, platform_events.GroupMessage)} content_len={len(content)}' + ) + try: + if isinstance(message_source, platform_events.GroupMessage): + await self.bot.send_proactive_message_to_group(str(message_source.group.id), content) + else: + await self.bot.send_proactive_message_to_one(str(message_source.sender.id), content) + except Exception: + if self.ap is not None: + self.ap.logger.exception('DingTalk: send proactive message failed') + await self.logger.error(f'DingTalk: send proactive message failed: {traceback.format_exc()}') + + async def _on_card_action(self, payload: dict) -> None: + """Translate a card button click into a synthetic query. + + Reads the clicked button's ``actionId`` (the real Dify action id — + the ButtonGroup template sends it back via `event.params.actionId`), + recovers the action title from ``card_state``, and enqueues a + synthetic `_dify_form_action` query the same way Lark / Telegram do. + """ + if self.ap is not None: + self.ap.logger.info( + f'DingTalk _on_card_action received: out_track_id={payload.get("out_track_id")} ' + f'payload_action_id={payload.get("action_id")!r} params={payload.get("params")!r}' + ) + out_track_id = payload.get('out_track_id') or '' + params = payload.get('params') or {} + # ButtonGroup `sendCardRequest` events surface the click id at the + # callback top level as `actionId`; fall back to `params.action_id` + # (alternate template wiring) and `params.actionId`. + raw_action_id = ( + (payload.get('action_id') or '').strip() + or (params.get('action_id') or '').strip() + or (params.get('actionId') or '').strip() + or (params.get('id') or '').strip() + ) + 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 + if not raw_action_id: + await self.logger.warning(f'DingTalk: card action with no action_id, payload={payload}') + return + + actions = state.get('actions', []) or [] + action_id = raw_action_id + action_title = raw_action_id + for action in actions: + if str(action.get('id', '')) == raw_action_id: + action_title = action.get('title') or raw_action_id + break + + 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_id, + 'action_title': action_title, + 'node_title': state.get('node_title', ''), + 'user': f'{launcher_type.value}_{launcher_id}', + 'inputs': {}, + } + + message_chain = platform_message.MessageChain([platform_message.Plain(text=f'[Form Action: {action_title}]')]) + + 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, + ) + + bot_uuid = '' + pipeline_uuid = None + if self.ap is not 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: + self.ap.logger.info( + f'DingTalk _on_card_action enqueuing form action: action_id={action_id!r} ' + f'action_title={action_title!r} launcher_type={launcher_type.value} ' + f'launcher_id={launcher_id} bot_uuid={bot_uuid} pipeline_uuid={pipeline_uuid}' + ) + 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('DingTalk _on_card_action: query enqueued OK') + except Exception: + self.ap.logger.exception('DingTalk: enqueue form action query failed') + return + + # Visual feedback: collapse the form card to a "已选择" notice so + # the user knows the click registered while the workflow resumes. + asyncio.create_task( + self._mark_card_resolved( + out_track_id, + action_title, + node_title=state.get('node_title', ''), + form_content=state.get('form_content', ''), + ) + ) + + # Once consumed, drop the state — the runner clears _PENDING_FORMS too. + self.card_state.pop(out_track_id, None) + + async def _mark_card_resolved( + self, + out_track_id: str, + action_title: str, + *, + node_title: str = '', + form_content: str = '', + ) -> None: + """Update the form card to acknowledge the user's selection. + + We rewrite the card content with the original prompt + a green tick + marker, and explicitly clear ``btns`` so the buttons are removed + once chosen. ``flowStatus`` is re-sent because some DingTalk clients + treat the PUT update as a partial *replace* of cardParamMap rather + than a merge — without it, the AICardContainer status containers + would all gate to ``gone`` and the whole card would blank out. + """ + parts: list[str] = [] + if node_title: + parts.append(f'**{node_title}**') + if form_content: + parts.append(form_content) + parts.append(f'---\n✅ 已选择:**{action_title}**') + content = '\n\n'.join(parts) + if self.ap is not None: + self.ap.logger.info(f'DingTalk _mark_card_resolved: out_track_id={out_track_id} action={action_title!r}') + try: + await self.bot.update_card_data( + out_track_id=out_track_id, + card_param_map={ + 'content': content, + 'btns': '[]', + 'flowStatus': '3', + }, + ) + except Exception: + await self.logger.error(f'DingTalk: update form card after click failed: {traceback.format_exc()}') diff --git a/src/langbot/pkg/platform/sources/dingtalk.yaml b/src/langbot/pkg/platform/sources/dingtalk.yaml index c7c25e67..dff76216 100644 --- a/src/langbot/pkg/platform/sources/dingtalk.yaml +++ b/src/langbot/pkg/platform/sources/dingtalk.yaml @@ -103,6 +103,18 @@ spec: type: string required: true default: "填写你的卡片template_id" + - name: human_input_card_template_id + label: + en_US: Human Input Card Template ID + zh_Hans: 人工输入卡片模板ID + zh_Hant: 人工輸入卡片範本ID + description: + en_US: "Template ID used as the SINGLE card for the whole conversation turn. Streamed LLM text fills the `content` markdown variable; on a Dify human-input pause the `btns` buttonGroup variable is populated so action buttons appear on the SAME card; after the user clicks a button the buttons disappear and resumed streaming continues into the same card. Use the bundled `src/langbot/templates/dingtalk_human_input_card.json` — it ships with `content` (MarkdownBlock) and `btns` (ButtonGroup) already wired. Leave empty to fall back to the legacy two-card behaviour (chat card streaming text + plain-text human-input prompts)." + zh_Hans: "用作整个对话回合**唯一**卡片的模板ID。流式 LLM 文本写入 `content` markdown 变量;Dify 人工输入暂停时同一张卡的 `btns` buttonGroup 变量被填上、按钮浮现;用户点击后按钮消失、恢复的流式内容继续追加到同一张卡。可使用项目附带的 `src/langbot/templates/dingtalk_human_input_card.json`——已经预先连好 `content` (MarkdownBlock) 与 `btns` (ButtonGroup)。留空则降级为旧的双卡行为(聊天卡走流式 + 人工输入走纯文本)。" + zh_Hant: "用作整個對話回合**唯一**卡片的範本ID。流式 LLM 文字寫入 `content` markdown 變數;Dify 人工輸入暫停時同一張卡的 `btns` buttonGroup 變數被填上、按鈕浮現;使用者點擊後按鈕消失、恢復的流式內容繼續追加到同一張卡。可使用專案附帶的 `src/langbot/templates/dingtalk_human_input_card.json`——已經預先連好 `content` (MarkdownBlock) 與 `btns` (ButtonGroup)。留空則降級為舊的雙卡行為(聊天卡走流式 + 人工輸入走純文字)。" + type: string + required: false + default: "" execution: python: path: ./dingtalk.py diff --git a/src/langbot/templates/dingtalk_human_input_card.json b/src/langbot/templates/dingtalk_human_input_card.json new file mode 100644 index 00000000..4c8e1f1a --- /dev/null +++ b/src/langbot/templates/dingtalk_human_input_card.json @@ -0,0 +1,6 @@ +{ + "editorData": "{\"schemaVersion\":\"3.0.0\",\"schema\":{\"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\"}],\"componentsTree\":[{\"componentName\":\"AICardContainer\",\"id\":\"node_root\",\"props\":{\"marginLeft\":0,\"marginRight\":0,\"marginTop\":0,\"marginBottom\":0,\"enablePending\":true,\"enableWriting\":false,\"enableDoing\":false,\"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_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\":\"MarkdownBlock\",\"id\":\"node_text_content\",\"props\":{\"mdVer\":0,\"icon\":{\"type\":\"icon\",\"icon\":\"\",\"iconType\":\"emoji\"},\"content\":{\"variable\":\"content\",\"variableType\":\"global\",\"type\":\"variableValue\"},\"visible\":{\"type\":\"dynamicVisible\",\"value\":true,\"valueType\":\"fixed\",\"condition\":{\"op\":\"and\",\"conditions\":[]}},\"isStreaming\":false,\"enableLinkStatPoint\":false,\"linkStatPoint\":{\"type\":\"dynamicString\",\"content\":\"\",\"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},{\"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],\"fileTypeList\":[]}}", + "widgetInfo": "", + "type": "im", + "mode": "card" +} \ No newline at end of file