From d68b371734aff9fa398378d4d27d408d52a9300c Mon Sep 17 00:00:00 2001
From: fdc310 <2213070223@qq.com>
Date: Wed, 28 Oct 2026 00:14:59 +0800
Subject: [PATCH] feat(dingtalk): deliver A2UI cards and restore agent replies
---
src/langbot/libs/dingtalk_api/a2ui.py | 127 ++++
src/langbot/libs/dingtalk_api/api.py | 358 +++--------
.../libs/dingtalk_api/card_callback.py | 6 +-
src/langbot/pkg/agent/runner/orchestrator.py | 40 +-
.../pkg/agent/runner/platform_tools.py | 21 +
.../controller/groups/platform/adapters.py | 18 -
.../pkg/platform/adapters/dingtalk/adapter.py | 10 +-
.../platform/adapters/dingtalk/interaction.py | 335 +++++-----
.../platform/adapters/dingtalk/manifest.yaml | 91 +--
src/langbot/pkg/platform/botmgr.py | 6 +-
src/langbot/pkg/platform/sources/dingtalk.py | 595 ++++++------------
.../pkg/platform/sources/dingtalk.yaml | 91 +--
.../templates/dingtalk_human_input_card.json | 6 -
.../templates/dingtalk_stream_card.json | 6 -
.../agent/test_orchestrator_integration.py | 97 +++
tests/unit_tests/agent/test_platform_tools.py | 30 +
.../platform/test_dingtalk_adapter.py | 33 +-
.../unit_tests/platform/test_dingtalk_api.py | 162 +++--
.../platform/test_dingtalk_eba_adapter.py | 166 +++--
.../unit_tests/platform/test_routing_rules.py | 10 +-
20 files changed, 1048 insertions(+), 1160 deletions(-)
create mode 100644 src/langbot/libs/dingtalk_api/a2ui.py
delete mode 100644 src/langbot/templates/dingtalk_human_input_card.json
delete mode 100644 src/langbot/templates/dingtalk_stream_card.json
diff --git a/src/langbot/libs/dingtalk_api/a2ui.py b/src/langbot/libs/dingtalk_api/a2ui.py
new file mode 100644
index 000000000..d352d85bf
--- /dev/null
+++ b/src/langbot/libs/dingtalk_api/a2ui.py
@@ -0,0 +1,127 @@
+"""DingTalk A2UI message builders shared by both DingTalk adapters."""
+
+from __future__ import annotations
+
+import json
+import typing
+
+
+A2UI_CATALOG_ID = 'https://dingtalk.com/card/a2ui/catalogs/public/catalog.json'
+A2UI_MESSAGE_VERSION = 'v1.0'
+A2UI_PROTOCOL_VERSION = '1.0'
+
+
+def binding(path: str) -> dict[str, str]:
+ return {'path': path}
+
+
+def serialize_messages(messages: typing.Iterable[dict | str]) -> list[str]:
+ serialized = [
+ message if isinstance(message, str) else json.dumps(message, ensure_ascii=False, separators=(',', ':'))
+ for message in messages
+ ]
+ if not serialized:
+ raise ValueError('DingTalk A2UI messages must not be empty')
+ return serialized
+
+
+def create_surface_messages(
+ surface_id: str,
+ *,
+ data: dict[str, typing.Any],
+ components: list[dict[str, typing.Any]],
+) -> list[dict[str, typing.Any]]:
+ return [
+ {
+ 'version': A2UI_MESSAGE_VERSION,
+ 'createSurface': {
+ 'surfaceId': surface_id,
+ 'catalogId': A2UI_CATALOG_ID,
+ },
+ },
+ {
+ 'version': A2UI_MESSAGE_VERSION,
+ 'updateDataModel': {
+ 'surfaceId': surface_id,
+ 'path': '/',
+ 'value': data,
+ },
+ },
+ {
+ 'version': A2UI_MESSAGE_VERSION,
+ 'updateComponents': {
+ 'surfaceId': surface_id,
+ 'components': components,
+ },
+ },
+ ]
+
+
+def update_surface_messages(
+ surface_id: str,
+ *,
+ data: dict[str, typing.Any] | None = None,
+ components: list[dict[str, typing.Any]] | None = None,
+) -> list[dict[str, typing.Any]]:
+ messages: list[dict[str, typing.Any]] = []
+ if data is not None:
+ messages.append(
+ {
+ 'version': A2UI_MESSAGE_VERSION,
+ 'updateDataModel': {
+ 'surfaceId': surface_id,
+ 'path': '/',
+ 'value': data,
+ },
+ }
+ )
+ if components is not None:
+ messages.append(
+ {
+ 'version': A2UI_MESSAGE_VERSION,
+ 'updateComponents': {
+ 'surfaceId': surface_id,
+ 'components': components,
+ },
+ }
+ )
+ if not messages:
+ raise ValueError('DingTalk A2UI update requires data or components')
+ return messages
+
+
+def text_card_components() -> list[dict[str, typing.Any]]:
+ return [
+ {
+ 'id': 'root',
+ 'component': 'Column',
+ 'children': ['content'],
+ 'gap': 8,
+ },
+ {
+ 'id': 'content',
+ 'component': 'Markdown',
+ 'content': binding('/content'),
+ },
+ ]
+
+
+def create_text_card_messages(surface_id: str, content: str = '') -> list[dict[str, typing.Any]]:
+ return create_surface_messages(
+ surface_id,
+ data={'content': content},
+ components=text_card_components(),
+ )
+
+
+def update_text_card_messages(surface_id: str, content: str) -> list[dict[str, typing.Any]]:
+ return [
+ {
+ 'version': A2UI_MESSAGE_VERSION,
+ 'updateDataModel': {
+ 'surfaceId': surface_id,
+ 'path': '/content',
+ 'value': content,
+ },
+ }
+ ]
diff --git a/src/langbot/libs/dingtalk_api/api.py b/src/langbot/libs/dingtalk_api/api.py
index 2eaff2447..9a7c2314f 100644
--- a/src/langbot/libs/dingtalk_api/api.py
+++ b/src/langbot/libs/dingtalk_api/api.py
@@ -14,15 +14,17 @@ import websockets
from .EchoHandler import EchoTextHandler
from .card_callback import DingTalkCardActionHandler
from .dingtalkevent import DingTalkEvent
-from .thinking import card_text_fields, visible_think_markers
+from .a2ui import (
+ A2UI_PROTOCOL_VERSION,
+ create_text_card_messages,
+ serialize_messages,
+ update_text_card_messages,
+)
+from .thinking import visible_think_markers
import httpx
import traceback
from langbot.pkg.utils import httpclient
-from langbot.pkg.utils.outbound_url import (
- UnsafeOutboundUrlError,
- assert_safe_local_media_path,
- assert_safe_outbound_url,
-)
+from langbot.pkg.utils.outbound_url import assert_safe_outbound_url
_stdout_logger = logging.getLogger('langbot.dingtalk_api')
@@ -64,34 +66,8 @@ async def _read_httpx_media_limited(response: httpx.Response) -> bytes:
return bytes(body)
-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
-
-
def is_stream_card_configured(config: dict) -> bool:
- if not config.get('markdown_card', True):
- return False
- template_id = config.get('card_template_id')
- # Older manifests stored instructional placeholder text as the default.
- return (
- isinstance(template_id, str) and bool(template_id.strip()) and template_id.strip() != '填写你的卡片template_id'
- )
+ return bool(config.get('markdown_card', True))
class DingTalkClient:
@@ -132,10 +108,8 @@ class DingTalkClient:
self.access_token_expiry_time = ''
self.markdown_card = markdown_card
self.logger = logger
- # Legacy access_token used by the OLD oapi.dingtalk.com endpoints
- # (e.g. /media/upload, which is the only documented way to get an
- # `@xxx` media_id usable in card Avatar.imageUrl). The new v1.0
- # token doesn't work there — different auth domain.
+ # Attachment uploads still use the legacy oapi.dingtalk.com token.
+ # The v1.0 application token does not work for that auth domain.
self.legacy_access_token = ''
self.legacy_access_token_expiry_time: typing.Optional[float] = None
self._stopped = False # Flag to control the event loop
@@ -731,118 +705,107 @@ class DingTalkClient:
async def create_and_card(
self,
- temp_card_id: str,
incoming_message: dingtalk_stream.ChatbotMessage,
quote_origin: bool = False,
- card_auto_layout: bool = True,
):
"""Create + deliver the streaming chat card for a chatbot reply.
- Replaces the old `dingtalk_stream.AICardReplier`-based path. Returns
- `(layout_config, out_track_id)` keeps the adapter's tuple contract
- while preserving the layout setting on subsequent data updates.
+ Returns ``(card state, out_track_id)`` for subsequent A2UI updates.
"""
out_track_id = uuid.uuid4().hex
+ surface_id = f'langbot-{out_track_id}'
is_group = str(incoming_message.conversation_type) == '2'
if is_group:
- open_space_id = f'dtv1.card//IM_GROUP.{incoming_message.conversation_id}'
+ open_space_id = f'dtv1.card//im_group.{incoming_message.conversation_id}'
else:
- open_space_id = f'dtv1.card//IM_ROBOT.{incoming_message.sender_staff_id}'
+ open_space_id = f'dtv1.card//im_robot.{incoming_message.sender_staff_id}'
- card_param_map = {
- 'content': '',
- 'answer': '',
- 'reasoning': '',
- 'flowStatus': '1',
- 'brand': '',
- 'hasReasoning': '',
- }
- 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,
+ await self.create_and_deliver_ai_card(
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},
+ a2ui_messages=create_text_card_messages(surface_id),
+ flow_status='PROCESSING',
+ summary='LangBot is replying',
)
- return {'autoLayout': card_auto_layout}, out_track_id
+ return {'surface_id': surface_id}, out_track_id
async def send_card_message(self, card_instance, card_instance_id: str, content: str, is_final: bool):
- """Update separate reasoning/answer fields and the legacy content field."""
- fields = card_text_fields(content)
- # Old templates continue to render content with explicit think markers;
- # the supplied split template binds only reasoning and answer.
- fields['content'] = visible_think_markers(content)
- fields['flowStatus'] = '3' if is_final else '2'
- fields['brand'] = ''
- if isinstance(card_instance, dict):
- fields['config'] = card_instance
- fields['autoLayout'] = str(card_instance.get('autoLayout', True)).lower()
- if not await self.update_card_data(out_track_id=card_instance_id, card_param_map=fields):
- raise RuntimeError('DingTalk card content update failed; see adapter logs for the API response')
+ """Replace the streamed Markdown content on an A2UI card."""
+ surface_id = (
+ str(card_instance.get('surface_id') or '') if isinstance(card_instance, dict) else ''
+ ) or f'langbot-{card_instance_id}'
+ await self.update_ai_card(
+ out_track_id=card_instance_id,
+ a2ui_messages=update_text_card_messages(surface_id, visible_think_markers(content)),
+ flow_status='FINISH' if is_final else 'PROCESSING',
+ )
- async def create_and_deliver_card(
+ @staticmethod
+ def _check_ai_card_result(response) -> dict:
+ try:
+ data = response.json()
+ except Exception as exc:
+ raise RuntimeError(f'Invalid DingTalk AI card response (HTTP {response.status_code})') from exc
+ if response.status_code != 200 or data.get('success') is not True:
+ raise RuntimeError(
+ f'DingTalk AI card request failed (HTTP {response.status_code}): '
+ f'{data.get("message", data.get("errorMsg", data))}'
+ )
+ result = data.get('result') if isinstance(data.get('result'), dict) else {}
+ failures = [
+ item
+ for item in result.get('deliverResults') or []
+ if isinstance(item, dict) and item.get('success') is not True
+ ]
+ if failures:
+ raise RuntimeError(f'DingTalk AI card delivery failed: {failures}')
+ return result
+
+ async def create_and_deliver_ai_card(
self,
*,
- card_template_id: str,
out_track_id: str,
open_space_id: str,
is_group: bool,
- card_param_map: Optional[dict] = None,
+ a2ui_messages: list[dict | str],
+ flow_status: str = 'PROCESSING',
+ summary: str = '',
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.
- """
+ biz_context: Optional[dict] = None,
+ ) -> dict:
+ """Create and deliver an A2UI card as the configured robot application."""
if not await self.check_access_token():
await self.get_access_token()
- template_params = dict(card_param_map or {})
- if card_data_config is not None:
- template_params['config'] = card_data_config
- template_params['autoLayout'] = str(card_data_config.get('autoLayout', True)).lower()
- cardData: dict = {'cardParamMap': _stringify_card_param_map(template_params)}
-
+ robot_code = self.robot_code or self.key
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},
+ 'protocolVersion': A2UI_PROTOCOL_VERSION,
+ 'flowStatus': flow_status,
+ 'a2uiMessages': serialize_messages(a2ui_messages),
+ 'summary': summary,
+ 'callbackType': callback_type,
}
- 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['imGroupOpenSpaceModel'] = {'supportForward': False}
+ deliver: dict = {'robotCode': robot_code}
body['imGroupOpenDeliverModel'] = deliver
else:
- body['imRobotOpenDeliverModel'] = {'spaceType': 'IM_ROBOT'}
+ body['imRobotOpenSpaceModel'] = {'supportForward': False}
+ deliver = {'robotCode': robot_code}
+ body['imRobotOpenDeliverModel'] = deliver
+ if at_user_ids:
+ deliver['atUserIds'] = at_user_ids
+ if recipients is not None:
+ deliver['recipients'] = recipients
+ if biz_context:
+ body['bizContext'] = biz_context
- if dynamic_data_source_configs:
- body['openDynamicDataConfig'] = {'dynamicDataSourceConfigs': dynamic_data_source_configs}
-
- url = f'{DINGTALK_OPENAPI_BASE}/v1.0/card/instances/createAndDeliver'
+ url = f'{DINGTALK_OPENAPI_BASE}/v1.0/card/ai/instances/createAndDeliver'
headers = {
'x-acs-dingtalk-access-token': self.access_token,
'Content-Type': 'application/json',
@@ -850,45 +813,33 @@ class DingTalkClient:
try:
async with self._http_client_context() as client:
response = await client.post(url, headers=headers, json=body, timeout=30.0)
- # Propagate business failures as well as transport failures. Callers
- # must not register or update a card that was never delivered.
- self._check_send_result(response)
+ result = self._check_ai_card_result(response)
except Exception as exc:
- error = RuntimeError(f'DingTalk card creation failed: {exc}')
+ error = RuntimeError(f'DingTalk AI card creation failed: {exc}')
_stdout_logger.error('%s', error)
raise error from exc
- _stdout_logger.info('DingTalk card created: outTrackId=%s', out_track_id)
- return True
+ _stdout_logger.info('DingTalk AI card created: outTrackId=%s', out_track_id)
+ return result
- async def streaming_update_card(
+ async def update_ai_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.
- """
+ a2ui_messages: list[dict | str],
+ flow_status: str,
+ out_update_id: Optional[str] = None,
+ ) -> dict:
+ """Apply an idempotent A2UI delta to an existing AI card."""
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,
+ 'outUpdateId': out_update_id or uuid.uuid4().hex,
+ 'flowStatus': flow_status,
+ 'a2uiMessages': serialize_messages(a2ui_messages),
}
- url = f'{DINGTALK_OPENAPI_BASE}/v1.0/card/streaming'
+ url = f'{DINGTALK_OPENAPI_BASE}/v1.0/card/ai/instances/update'
headers = {
'x-acs-dingtalk-access-token': self.access_token,
'Content-Type': 'application/json',
@@ -896,75 +847,17 @@ class DingTalkClient:
try:
async with self._http_client_context() as client:
response = await client.put(url, headers=headers, json=body, timeout=30.0)
- if response.status_code == 200:
- return True
- if self.logger:
- response_body = await httpclient.response_text(response)
- await self.logger.error(
- f'DingTalk card streaming failed: status={response.status_code} body={response_body}'
- )
- 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': _stringify_card_param_map(card_param_map)},
- }
- 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)[:1500],
- )
- async with self._http_client_context() as client:
- response = await client.put(url, headers=headers, json=body, timeout=30.0)
- response_body = await httpclient.response_text(response, max_chars=300)
- _stdout_logger.info(
- 'DingTalk update_card_data response: status=%d body=%s',
- response.status_code,
- response_body,
- )
- 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_body}'
- )
- 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
+ return self._check_ai_card_result(response)
+ except Exception as exc:
+ error = RuntimeError(f'DingTalk AI card update failed: {exc}')
+ _stdout_logger.error('%s', error)
+ raise error from exc
async def get_legacy_access_token(self) -> Optional[str]:
"""Fetch the LEGACY (oapi.dingtalk.com) access_token. This is a
different auth domain from the v1.0 token cached in
``self.access_token`` — only the legacy token authorises the
- ``/media/upload`` endpoint that returns an ``@xxx`` media_id
- consumable by card components like Avatar.imageUrl.
+ ``/media/upload`` endpoint used by attachment delivery.
Returns the token string on success, None on failure. Caches
with a 60s safety margin before the documented 7200s expiry.
@@ -998,67 +891,6 @@ class DingTalkClient:
await self.logger.error(f'DingTalk legacy gettoken error: {traceback.format_exc()}')
return None
- async def upload_image_media(self, file_path: str) -> Optional[str]:
- """Upload an image file to DingTalk media storage and return the
- ``@xxx`` media_id, which can be passed straight into card variables
- like Avatar.imageUrl. Endpoint:
-
- POST https://oapi.dingtalk.com/media/upload?access_token=…&type=image
-
- Returns the media_id on success, None on any failure (caller
- should handle a None gracefully — DingTalk falls back to a
- default avatar when imageUrl is empty/unknown).
- """
- if not os.path.exists(file_path):
- if self.logger:
- await self.logger.error(f'DingTalk upload_image_media: file not found {file_path}')
- return None
-
- try:
- # Caller-supplied path: confine it to the media roots so a plugin or
- # external input cannot make the adapter upload arbitrary files.
- safe_file_path = assert_safe_local_media_path(file_path)
- except UnsafeOutboundUrlError as exc:
- if self.logger:
- await self.logger.error(f'DingTalk upload_image_media: rejected path: {exc}')
- return None
-
- token = await self.get_legacy_access_token()
- if not token:
- return None
-
- url = 'https://oapi.dingtalk.com/media/upload'
- try:
- file_bytes = await asyncio.to_thread(_read_local_media_limited, safe_file_path)
- file_name = os.path.basename(safe_file_path)
- # Best-effort content-type guess; DingTalk accepts the major image
- # mime types and otherwise infers from the bytes.
- ext = os.path.splitext(file_name)[1].lower().lstrip('.')
- mime = {'png': 'image/png', 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'gif': 'image/gif'}.get(
- ext, 'application/octet-stream'
- )
- async with self._http_client_context() as client:
- response = await client.post(
- url,
- params={'access_token': token, 'type': 'image'},
- files={'media': (file_name, file_bytes, mime)},
- timeout=30.0,
- )
- data = await httpclient.parse_json_response(response) if response.status_code == 200 else {}
- if data.get('errcode') == 0 and data.get('media_id'):
- _stdout_logger.info('DingTalk upload_image_media OK: media_id=%s', data['media_id'])
- return data['media_id']
- if self.logger:
- response_body = await httpclient.response_text(response, max_chars=300)
- await self.logger.error(
- f'DingTalk upload_image_media failed: status={response.status_code} body={response_body}'
- )
- except Exception:
- _stdout_logger.exception('DingTalk upload_image_media error')
- if self.logger:
- await self.logger.error(f'DingTalk upload_image_media error: {traceback.format_exc()}')
- return None
-
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
index 4634a696b..e18d03c59 100644
--- a/src/langbot/libs/dingtalk_api/card_callback.py
+++ b/src/langbot/libs/dingtalk_api/card_callback.py
@@ -26,6 +26,10 @@ from dingtalk_stream.card_callback import CardCallbackMessage
_PARAM_PATHS = (
+ ('actionData', 'a2uiAction', 'context'),
+ ('a2uiAction', 'context'),
+ ('action', 'context'),
+ ('context',),
('params',),
('cardPrivateData', 'params'),
('userPrivateData', 'params'),
@@ -77,7 +81,7 @@ class DingTalkCardActionHandler(dingtalk_stream.CallbackHandler):
# 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 ''
+ action_id = raw.get('actionId') or params.get('actionId') or params.get('action_id') or ''
if not action_id:
# Some templates nest it under actionData / cardPrivateData.
action_data = raw.get('actionData') or {}
diff --git a/src/langbot/pkg/agent/runner/orchestrator.py b/src/langbot/pkg/agent/runner/orchestrator.py
index d96b8ca44..7400c1326 100644
--- a/src/langbot/pkg/agent/runner/orchestrator.py
+++ b/src/langbot/pkg/agent/runner/orchestrator.py
@@ -7,13 +7,14 @@ import asyncio
import contextlib
import copy
import typing
+from uuid import uuid4
from langbot_plugin.api.entities.builtin.provider import message as provider_message
from langbot_plugin.api.entities.builtin.provider import prompt as provider_prompt
from langbot_plugin.api.entities.builtin.pipeline import query as pipeline_query
from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction
-from .reply_stream import ReplyStreamSession
+from .reply_stream import ReplyStreamRequest, ReplyStreamSession
from ...core import app
from ...i18n import t
from ...i18n.workspace import get_workspace_language
@@ -36,7 +37,7 @@ from .interaction_manager import InteractionManager
from .query_bridge import QueryRunBridge
from .registry import RunnerRegistry
from .resource_builder import AgentResourceBuilder
-from .platform_tools import freeze_platform_context
+from .platform_tools import PLATFORM_TOOLS_BY_NAME, freeze_platform_context
from .result_normalizer import AgentResultNormalizer
from .run_journal import AgentRunJournal
from .session_registry import AgentRunSessionRegistry, get_session_registry
@@ -253,6 +254,14 @@ class AgentRunOrchestrator:
terminal_usage: dict[str, typing.Any] | None = None
execution_outcome = 'unknown'
execution_error = ''
+ auto_reply_enabled = (
+ binding.processor_type == 'agent'
+ and binding.delivery_policy.enable_reply
+ and event.event_type == 'message.received'
+ and reply_streams.adapter is not None
+ )
+ auto_reply_stream_id = uuid4()
+ explicit_delivery_completed = False
# A run reached without a platform ingress (WebUI debug, service API)
# owns its own chain; a run inside an ingress reuses that chain.
trace_binding: trace_mod.TraceBinding | None = None
@@ -361,6 +370,15 @@ class AgentRunOrchestrator:
):
continue
+ if result_type == 'tool.call.completed':
+ result_data = result_dict.get('data') or {}
+ tool_definition = PLATFORM_TOOLS_BY_NAME.get(result_data.get('tool_name'))
+ if tool_definition is not None and tool_definition.api in {
+ 'send_message',
+ 'interaction.request',
+ }:
+ explicit_delivery_completed = True
+
journal_started = time.monotonic()
await self.journal.append_run_result(
result_dict=result_dict,
@@ -447,6 +465,24 @@ class AgentRunOrchestrator:
async with binding_for(execution_query).lock:
result.attachments = exported_message(execution_query, file_ids, consume=True)
+ if auto_reply_enabled and not explicit_delivery_completed and result is not None:
+ reply_text = None
+ operation = None
+ if result_type == 'message.delta' and isinstance(result, provider_message.MessageChunk):
+ reply_text = result.all_content or result.content
+ operation = 'update'
+ elif has_completed_message:
+ reply_text = result.content
+ operation = 'finish'
+ if isinstance(reply_text, str) and reply_text.strip() and operation is not None:
+ await reply_streams.apply(
+ ReplyStreamRequest(
+ stream_id=auto_reply_stream_id,
+ operation=operation,
+ text=reply_text,
+ )
+ )
+
if result is not None:
yield result
diff --git a/src/langbot/pkg/agent/runner/platform_tools.py b/src/langbot/pkg/agent/runner/platform_tools.py
index b75b4009b..b28065df7 100644
--- a/src/langbot/pkg/agent/runner/platform_tools.py
+++ b/src/langbot/pkg/agent/runner/platform_tools.py
@@ -899,6 +899,27 @@ async def execute_platform_tool(
context=context, target_type=target_type, target_id=target_id, message=message
)
+ reply_streams = session.get('reply_streams')
+ if (
+ definition.name == 'event_reply'
+ and message_chain is None
+ and reply_streams is not None
+ and getattr(reply_streams, 'adapter', None) is bot.adapter
+ ):
+ from uuid import uuid4
+
+ from .reply_stream import ReplyStreamRequest
+
+ async def api_func(target_type, target_id, message):
+ text = ''.join(part.text for part in message if isinstance(part, platform_message.Plain))
+ return await reply_streams.apply(
+ ReplyStreamRequest(
+ stream_id=uuid4(),
+ operation='finish',
+ text=text,
+ )
+ )
+
if not callable(api_func):
raise ValueError(f'Platform API {definition.api} is declared but not implemented')
if definition.api == 'send_message':
diff --git a/src/langbot/pkg/api/http/controller/groups/platform/adapters.py b/src/langbot/pkg/api/http/controller/groups/platform/adapters.py
index 6b7f41d16..a72c6dcd2 100644
--- a/src/langbot/pkg/api/http/controller/groups/platform/adapters.py
+++ b/src/langbot/pkg/api/http/controller/groups/platform/adapters.py
@@ -309,24 +309,6 @@ class AdaptersRouterGroup(group.RouterGroup):
importutil.read_resource_file_bytes(icon_path), mimetype=mimetypes.guess_type(icon_path)[0]
)
- @self.route('/dingtalk/stream-card-template', methods=['GET'], auth_type=group.AuthType.NONE)
- async def _() -> quart.Response:
- filename = 'dingtalk_stream_card.json'
- response = quart.Response(
- importutil.read_resource_file_bytes(f'templates/{filename}'), mimetype='application/json'
- )
- response.headers['Content-Disposition'] = f'attachment; filename={filename}'
- return response
-
- @self.route('/dingtalk/human-input-card-template', methods=['GET'], auth_type=group.AuthType.NONE)
- async def _() -> quart.Response:
- filename = 'dingtalk_human_input_card.json'
- response = quart.Response(
- importutil.read_resource_file_bytes(f'templates/{filename}'), mimetype='application/json'
- )
- response.headers['Content-Disposition'] = f'attachment; filename={filename}'
- return response
-
# In-memory session store for active registrations
_create_app_sessions: dict = {}
_SESSION_TTL = 900 # 15 minutes
diff --git a/src/langbot/pkg/platform/adapters/dingtalk/adapter.py b/src/langbot/pkg/platform/adapters/dingtalk/adapter.py
index 60a14087d..56048e079 100644
--- a/src/langbot/pkg/platform/adapters/dingtalk/adapter.py
+++ b/src/langbot/pkg/platform/adapters/dingtalk/adapter.py
@@ -130,7 +130,7 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
'get_file_url',
'call_platform_api',
]
- if self.config.get('human_input_card_template_id') and hasattr(self.bot, 'create_and_deliver_card'):
+ if hasattr(self.bot, 'create_and_deliver_ai_card'):
apis.append('interaction.request')
return apis
@@ -225,14 +225,8 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
oldest_id = next(iter(self.card_instance_id_dict))
self.card_instance_id_dict.pop(oldest_id, None)
getattr(self, '_card_last_updates', {}).pop(oldest_id, None)
- card_template_id = self.config['card_template_id'].strip()
incoming_message = event.source_platform_object.incoming_message
- card_auto_layout = self.config.get('card_auto_layout', True)
- card_instance, card_instance_id = await self.bot.create_and_card(
- card_template_id,
- incoming_message,
- card_auto_layout=card_auto_layout,
- )
+ card_instance, card_instance_id = await self.bot.create_and_card(incoming_message)
self.card_instance_id_dict[message_id] = (card_instance, card_instance_id)
return True
diff --git a/src/langbot/pkg/platform/adapters/dingtalk/interaction.py b/src/langbot/pkg/platform/adapters/dingtalk/interaction.py
index db9185292..d99d9ab3a 100644
--- a/src/langbot/pkg/platform/adapters/dingtalk/interaction.py
+++ b/src/langbot/pkg/platform/adapters/dingtalk/interaction.py
@@ -7,6 +7,7 @@ import time
import typing
import uuid
+from langbot.libs.dingtalk_api.a2ui import binding, create_surface_messages, update_surface_messages
from langbot.libs.dingtalk_api.dingtalkevent import DingTalkEvent
from langbot_plugin.api.entities.builtin.platform import events as platform_events
@@ -16,69 +17,141 @@ def interaction_delivery_capabilities() -> dict[str, typing.Any]:
'field_types': ['text', 'textarea', 'number', 'select'],
'action_styles': ['default', 'primary', 'danger'],
'supports_updates': True,
- 'max_fields': 1,
+ 'max_fields': 8,
}
-def _select_options(options: list[dict[str, typing.Any]]) -> list[dict[str, typing.Any]]:
- locales = ['zh_CN', 'zh_TW', 'en_US', 'ja_JP', 'vi_VN', 'th_TH', 'id_ID', 'ms_MY', 'ko_KR']
- result = []
- for option in options:
- value = str(option.get('value') or '')
- label = str(option.get('label') or value)
- if value:
- result.append({'value': value, 'text': {locale: label for locale in locales}})
- return result
+def _json_pointer_segment(value: str) -> str:
+ return value.replace('~', '~0').replace('/', '~1')
-def _field_card_params(field: dict[str, typing.Any]) -> dict[str, typing.Any] | None:
- field_type = str(field.get('type') or '')
- field_id = str(field.get('id') or '')
- if field_type not in {'text', 'textarea', 'number', 'select'} or not field_id:
- return None
- label = str(field.get('label') or field_id)
- placeholder = str(field.get('placeholder') or label)
- default = field.get('default')
- params: dict[str, typing.Any] = {
- 'input_visible': '',
- 'input_title': '',
- 'input_placeholder': '',
- 'input_value': '',
- 'select_visible': '',
- 'select_placeholder': '',
- 'select_options': [],
- 'index_o': [],
- 'select_index': -1,
- }
- if field_type == 'select':
- raw_options = field.get('options') if isinstance(field.get('options'), list) else []
- options = [option for option in raw_options if isinstance(option, dict)]
- encoded_options = _select_options(options)
- if not encoded_options:
- return None
- selected_index = next(
- (index for index, option in enumerate(options) if str(option.get('value')) == str(default)),
- -1,
- )
- params.update(
- {
- 'select_visible': 'true',
- 'select_placeholder': placeholder,
- 'select_options': [str(option.get('value') or '') for option in options],
- 'index_o': encoded_options,
- 'select_index': selected_index,
+def _build_interaction_messages(
+ surface_id: str,
+ request: dict[str, typing.Any],
+ callback_token: str,
+) -> tuple[dict[str, typing.Any], list[dict[str, typing.Any]], dict[str, str]]:
+ raw_fields = request.get('fields') if isinstance(request.get('fields'), list) else []
+ raw_actions = request.get('actions') if isinstance(request.get('actions'), list) else []
+ fields = [field for field in raw_fields if isinstance(field, dict)][:8]
+ actions = [action for action in raw_actions if isinstance(action, dict)]
+
+ form: dict[str, typing.Any] = {}
+ field_types: dict[str, str] = {}
+ required_fields: list[tuple[str, str]] = []
+ components: list[dict[str, typing.Any]] = []
+ children: list[str] = []
+
+ title = str(request.get('title') or '').strip()
+ description = str(request.get('description') or '').strip()
+ if title:
+ components.append({'id': 'title', 'component': 'Text', 'text': title})
+ children.append('title')
+ if description:
+ components.append({'id': 'description', 'component': 'Markdown', 'content': description})
+ children.append('description')
+
+ for index, field in enumerate(fields):
+ field_id = str(field.get('id') or f'field_{index}')
+ field_type = str(field.get('type') or 'text')
+ if field_type not in {'text', 'textarea', 'number', 'select'}:
+ continue
+ field_types[field_id] = field_type
+ path = f'/form/{_json_pointer_segment(field_id)}'
+ component_id = f'field-{index}'
+ label = str(field.get('label') or field_id)
+ placeholder = str(field.get('placeholder') or '')
+ default = field.get('default')
+
+ if field_type == 'select':
+ options = [
+ {
+ 'label': str(option.get('label') or option.get('value') or ''),
+ 'value': str(option.get('value') or ''),
+ }
+ for option in field.get('options') or []
+ if isinstance(option, dict) and str(option.get('value') or '')
+ ]
+ if not options:
+ continue
+ selected = default if isinstance(default, list) else ([] if default in (None, '') else [str(default)])
+ form[field_id] = [str(value) for value in selected]
+ component = {
+ 'id': component_id,
+ 'component': 'ChoicePicker',
+ 'label': placeholder or label,
+ 'variant': 'mutuallyExclusive',
+ 'displayStyle': 'dropdown',
+ 'options': options,
+ 'value': binding(path),
}
- )
- else:
- params.update(
- {
- 'input_visible': 'true',
- 'input_title': label,
- 'input_placeholder': placeholder,
- 'input_value': '' if default is None else str(default),
+ elif field_type == 'number':
+ numeric_default = default if isinstance(default, (int, float)) and not isinstance(default, bool) else 0
+ form[field_id] = numeric_default
+ component = {
+ 'id': component_id,
+ 'component': 'NumberInput',
+ 'label': label,
+ 'placeholder': placeholder,
+ 'value': binding(path),
}
+ else:
+ form[field_id] = '' if default is None else str(default)
+ component = {
+ 'id': component_id,
+ 'component': 'TextField',
+ 'label': label,
+ 'placeholder': placeholder,
+ 'variant': 'longText' if field_type == 'textarea' else 'shortText',
+ 'value': binding(path),
+ }
+ components.append(component)
+ children.append(component_id)
+ if field.get('required'):
+ required_fields.append((path, label))
+
+ rendered_actions = actions or ([{'label': 'Submit', 'style': 'primary'}] if field_types else [])
+ for index, action in enumerate(rendered_actions):
+ label_id = f'action-label-{index}'
+ button_id = f'action-{index}'
+ callback_data = f'lbi:{callback_token}:a:{index}' if actions else f'lbi:{callback_token}:f:0'
+ style = str(action.get('style') or 'default')
+ button: dict[str, typing.Any] = {
+ 'id': button_id,
+ 'component': 'Button',
+ 'child': label_id,
+ 'variant': 'primary' if style == 'primary' else 'default',
+ 'action': {
+ 'event': {
+ 'name': 'langbot_interaction_submit',
+ 'context': {
+ 'actionId': callback_data,
+ 'values': binding('/form'),
+ },
+ }
+ },
+ }
+ if required_fields:
+ button['checks'] = [
+ {
+ 'condition': {'call': 'required', 'args': {'value': binding(path)}},
+ 'message': f'{label} is required',
+ }
+ for path, label in required_fields
+ ]
+ components.extend(
+ [
+ button,
+ {
+ 'id': label_id,
+ 'component': 'Text',
+ 'text': str(action.get('label') or action.get('id') or 'Submit'),
+ },
+ ]
)
- return params
+ children.append(button_id)
+
+ components.insert(0, {'id': 'root', 'component': 'Column', 'children': children, 'gap': 12})
+ return {'form': form}, components, field_types
def _prune_callback_contexts(adapter: typing.Any, now: float | None = None) -> None:
@@ -88,48 +161,6 @@ def _prune_callback_contexts(adapter: typing.Any, now: float | None = None) -> N
contexts.pop(key, None)
-def _buttons(request: dict[str, typing.Any], callback_token: str) -> list[dict[str, typing.Any]]:
- fields = request.get('fields') if isinstance(request.get('fields'), list) else []
- actions = request.get('actions') if isinstance(request.get('actions'), list) else []
- items: list[tuple[str, str, str]] = []
- if actions and not fields:
- items = [
- (
- str(action.get('label') or action.get('id') or index + 1),
- f'lbi:{callback_token}:a:{index}',
- str(action.get('style') or 'default'),
- )
- for index, action in enumerate(actions)
- if isinstance(action, dict)
- ]
- elif len(fields) == 1 and not actions and isinstance(fields[0], dict):
- field = fields[0]
- if field.get('type') != 'select':
- return []
- options = field.get('options') if isinstance(field.get('options'), list) else []
- items = [
- (
- str(option.get('label') or option.get('value') or index + 1),
- f'lbi:{callback_token}:f:0:{index}',
- 'default',
- )
- for index, option in enumerate(options)
- if isinstance(option, dict)
- ]
- return [
- {
- 'text': label,
- 'color': 'blue' if style == 'primary' else 'red' if style == 'danger' else 'gray',
- 'status': 'normal',
- 'event': {
- 'type': 'sendCardRequest',
- 'params': {'actionId': callback_data, 'params': {'action_id': callback_data}},
- },
- }
- for label, callback_data, style in items
- ]
-
-
async def send_interaction(adapter: typing.Any, params: dict[str, typing.Any]) -> dict[str, typing.Any]:
request = params.get('request')
reply_target = params.get('reply_target')
@@ -140,11 +171,7 @@ async def send_interaction(adapter: typing.Any, params: dict[str, typing.Any]) -
target_id = str(reply_target.get('target_id') or '')
fields = request.get('fields') if isinstance(request.get('fields'), list) else []
actions = request.get('actions') if isinstance(request.get('actions'), list) else []
- buttons = _buttons(request, callback_token)
- field_params = (
- _field_card_params(fields[0]) if len(fields) == 1 and not actions and isinstance(fields[0], dict) else None
- )
- if not buttons and field_params is None:
+ if not fields and not actions:
fallback = '\n\n'.join(
part
for part in (
@@ -158,48 +185,53 @@ async def send_interaction(adapter: typing.Any, params: dict[str, typing.Any]) -
return {'ok': True, 'message_id': result.message_id, 'rich': False}
if target_type not in {'group', 'person'} or not target_id:
raise ValueError('interaction.request has an invalid DingTalk target')
- field_label = str(fields[0].get('label') or '').strip() if fields and isinstance(fields[0], dict) else ''
- content = '\n\n'.join(
- part
- for part in (
- f'## {str(request.get("title") or "").strip()}',
- str(request.get('description') or '').strip(),
- field_label,
+ update_target = params.get('update_target')
+ if isinstance(update_target, dict):
+ if update_target.get('rich') is not True or not update_target.get('message_id'):
+ raise ValueError('DingTalk interaction update target is not an A2UI card')
+ out_track_id = str(update_target['message_id'])
+ else:
+ out_track_id = uuid.uuid4().hex
+ surface_id = f'langbot-interaction-{out_track_id}'
+ data, components, field_types = _build_interaction_messages(surface_id, request, callback_token)
+ if isinstance(update_target, dict):
+ await adapter.bot.update_ai_card(
+ out_track_id=out_track_id,
+ a2ui_messages=update_surface_messages(surface_id, data=data, components=components),
+ flow_status='CONFIRMING',
)
- if part
- )
- out_track_id = uuid.uuid4().hex
- card_param_map = {'content': content, 'btns': buttons}
- if field_params is not None:
- card_param_map.update(field_params)
- await adapter.bot.create_and_deliver_card(
- card_template_id=adapter.config['human_input_card_template_id'],
- out_track_id=out_track_id,
- open_space_id=(
- f'dtv1.card//IM_GROUP.{target_id}' if target_type == 'group' else f'dtv1.card//IM_ROBOT.{target_id}'
- ),
- is_group=target_type == 'group',
- card_param_map=card_param_map,
- )
- if field_params is not None:
- _prune_callback_contexts(adapter)
- field = fields[0]
- adapter.interaction_callback_contexts[out_track_id] = {
- 'callback_token': callback_token,
- 'field_id': str(field.get('id') or ''),
- 'field_type': str(field.get('type') or ''),
- 'options': [
- str(option.get('value') or '') for option in field.get('options') or [] if isinstance(option, dict)
- ],
- 'expires_at': time.monotonic() + 30 * 60,
- }
- return {'ok': True, 'message_id': out_track_id, 'rich': True}
+ else:
+ await adapter.bot.create_and_deliver_ai_card(
+ out_track_id=out_track_id,
+ open_space_id=(
+ f'dtv1.card//im_group.{target_id}' if target_type == 'group' else f'dtv1.card//im_robot.{target_id}'
+ ),
+ is_group=target_type == 'group',
+ a2ui_messages=create_surface_messages(surface_id, data=data, components=components),
+ flow_status='CONFIRMING',
+ summary=str(request.get('title') or request.get('fallback_text') or 'Interaction required'),
+ biz_context={'callbackToken': callback_token},
+ )
+ _prune_callback_contexts(adapter)
+ adapter.interaction_callback_contexts[out_track_id] = {
+ 'callback_token': callback_token,
+ 'field_types': field_types,
+ 'expires_at': time.monotonic() + 30 * 60,
+ }
+ return {
+ 'ok': True,
+ 'message_id': out_track_id,
+ 'rich': True,
+ 'updated': isinstance(update_target, dict),
+ }
def parse_callback_data(value: str) -> dict[str, typing.Any]:
parts = value.split(':')
if len(parts) == 4 and parts[0] == 'lbi' and parts[1] and parts[2] == 'a' and parts[3].isdigit():
return {'callback_token': parts[1], 'action_ref': int(parts[3])}
+ if len(parts) == 4 and parts[0] == 'lbi' and parts[1] and parts[2] == 'f' and parts[3].isdigit():
+ return {'callback_token': parts[1], 'field_ref': int(parts[3])}
if (
len(parts) == 5
and parts[0] == 'lbi'
@@ -254,7 +286,7 @@ def _find_named_value(value: typing.Any, names: set[str]) -> typing.Any:
def _callback_track_id(callback: dict[str, typing.Any]) -> str:
- value = _find_named_value(callback, {'outTrackId', 'out_track_id', 'outtrackid'})
+ value = _find_named_value(callback, {'outTrackId', 'out_track_id', 'outtrackid', 'card_instance_id'})
return str(value or '')
@@ -307,6 +339,24 @@ def interaction_event_from_native(
callback_data = _find_callback_data(callback)
if callback_data:
parsed = parse_callback_data(callback_data)
+ raw_values = _mapping(_find_named_value(callback, {'values'}))
+ context: dict[str, typing.Any] = {}
+ if callback_contexts is not None:
+ track_id = _callback_track_id(callback)
+ context = callback_contexts.get(track_id)
+ if context is None:
+ return None
+ if parsed.get('callback_token') != context.get('callback_token'):
+ return None
+ callback_contexts.pop(track_id, None)
+ if raw_values:
+ field_types = context.get('field_types') if isinstance(context.get('field_types'), dict) else {}
+ values = {}
+ for field_id, value in raw_values.items():
+ if field_types.get(field_id) == 'select' and isinstance(value, list):
+ value = value[0] if value else ''
+ values[str(field_id)] = value
+ parsed['values'] = values
else:
contexts = callback_contexts if callback_contexts is not None else {}
track_id = _callback_track_id(callback)
@@ -322,12 +372,13 @@ def interaction_event_from_native(
}
contexts.pop(track_id, None)
space_id = str(callback.get('space_id') or '')
- if 'IM_GROUP.' in space_id:
+ normalized_space_id = space_id.lower()
+ if 'im_group.' in normalized_space_id:
target_type = 'group'
- target_id = space_id.split('IM_GROUP.', 1)[1]
- elif 'IM_ROBOT.' in space_id:
+ target_id = space_id[normalized_space_id.index('im_group.') + len('im_group.') :]
+ elif 'im_robot.' in normalized_space_id:
target_type = 'person'
- target_id = space_id.split('IM_ROBOT.', 1)[1]
+ target_id = space_id[normalized_space_id.index('im_robot.') + len('im_robot.') :]
else:
raise ValueError('DingTalk interaction callback has no delivery space')
return platform_events.PlatformSpecificEvent(
diff --git a/src/langbot/pkg/platform/adapters/dingtalk/manifest.yaml b/src/langbot/pkg/platform/adapters/dingtalk/manifest.yaml
index 11a0b0e1f..4efbf2fb7 100644
--- a/src/langbot/pkg/platform/adapters/dingtalk/manifest.yaml
+++ b/src/langbot/pkg/platform/adapters/dingtalk/manifest.yaml
@@ -56,97 +56,12 @@ spec:
zh_Hans: 使用卡片回复
zh_Hant: 使用卡片回覆
description:
- en_US: Controls Markdown and streaming card replies. Disable to use plain text while keeping your template settings. When enabled, a configured template automatically enables streaming.
- zh_Hans: 控制 Markdown 和流式卡片回复。关闭后使用纯文本,保留模板配置;开启且已配置模板时自动使用流式回复。
- zh_Hant: 控制 Markdown 和串流卡片回覆。關閉後使用純文字,保留範本設定;開啟且已設定範本時自動使用串流回覆。
+ en_US: Uses built-in A2UI cards for Markdown, streaming replies, and interactions. No card template is required. The app must have AICard.Instance.Write permission.
+ zh_Hans: 使用内置 A2UI 卡片提供 Markdown、流式回复和交互能力,无需配置卡片模板。应用必须开通 AICard.Instance.Write 权限。
+ zh_Hant: 使用內建 A2UI 卡片提供 Markdown、串流回覆和互動能力,無需設定卡片範本。應用必須開通 AICard.Instance.Write 權限。
type: boolean
required: false
default: true
- - name: card_template_id
- show_if:
- field: markdown_card
- operator: eq
- value: true
- label:
- en_US: Card Template ID
- zh_Hans: 卡片模板ID
- zh_Hant: 卡片範本ID
- description:
- en_US: Template for streaming text replies. Save and publish it in DingTalk Card Platform, then paste its ID here to automatically enable streaming while card replies are enabled. Leave empty for ordinary replies. Separate from the human input template.
- zh_Hans: 用于流式文字回复。在钉钉卡片平台保存并发布模板后,开启卡片回复后,填写 ID 即自动启用流式回复,留空使用普通回复;与人工输入卡片模板分开配置。
- zh_Hant: 用於串流文字回覆。在釘釘卡片平台儲存並發布範本後,開啟卡片回覆後,填寫 ID 即自動啟用串流回覆,留空使用一般回覆;與人工輸入卡片範本分開設定。
- type: string
- required: false
- default: ""
- - name: stream_card_template_download
- show_if:
- field: markdown_card
- operator: eq
- value: true
- label:
- en_US: Download Streaming Reply Card Template
- zh_Hans: 下载流式回复卡片模板
- zh_Hant: 下載串流回覆卡片範本
- description:
- en_US: Displays reasoning and the answer separately. Import through More > Import Card Template in the DingTalk AI card editor, save and publish, then paste its ID into Card Template ID above. Respects the pipeline thinking removal setting.
- zh_Hans: 分开展示思考与正文。在钉钉 AI 卡片编辑器中选择「更多 → 导入卡片模板」,保存并发布后,将 ID 填入上方「卡片模板 ID」。是否保留思考仍由流水线设置控制。
- zh_Hant: 分開展示思考與正文。在釘釘 AI 卡片編輯器中選擇「更多 → 匯入卡片範本」,儲存並發布後,將 ID 填入上方「卡片範本 ID」。是否保留思考仍由流水線設定控制。
- type: download-link
- required: false
- default: ''
- url: /api/v1/platform/adapters/dingtalk/stream-card-template
- download_filename: dingtalk_stream_card.json
- - name: card_auto_layout
- show_if:
- field: markdown_card
- operator: eq
- value: true
- label:
- en_US: Card Auto Layout
- zh_Hans: 卡片宽屏自动布局
- zh_Hant: 卡片寬螢幕自動佈局
- description:
- en_US: Enabled by default. Allows supported cards to adapt to wider windows. The LangBot streaming reply template includes the required variables; you can disable this for a narrower layout.
- zh_Hans: 默认开启,让支持的卡片适应较宽的聊天窗口。LangBot 流式回复模板已包含所需变量;如需较窄布局,可关闭此项。
- zh_Hant: 預設開啟,讓支援的卡片適應較寬的聊天視窗。LangBot 串流回覆範本已包含所需變數;如需較窄版面,可關閉此項。
- type: boolean
- required: false
- default: true
- - name: human_input_card_template_id
- label:
- en_US: Human Input Card Template ID
- zh_Hans: 人工输入卡片模板 ID
- zh_Hant: 人工輸入卡片範本 ID
- description:
- en_US: Optional. Used when a workflow or plugin requests user input, a choice, or confirmation before continuing (for example, a Dify Human Input node). Ordinary chat does not require it. Import the template below and enter its published ID here; streaming text replies use the separate Card Template ID.
- zh_Hans: 可选。当工作流或插件需要用户填写信息、选择选项或确认后再继续执行时使用,例如 Dify 的人工输入节点。普通聊天无需配置。请导入下方模板,将发布后的 ID 填在这里;流式文字回复使用单独的「卡片模板 ID」。
- zh_Hant: 可選。當工作流或外掛需要使用者填寫資訊、選擇選項或確認後再繼續執行時使用,例如 Dify 的人工輸入節點。一般聊天無需設定。請匯入下方範本,將發布後的 ID 填在這裡;串流文字回覆使用獨立的「卡片範本 ID」。
- type: string
- required: false
- default: ""
- - name: human_input_card_template_download
- label:
- en_US: Download Human Input Card Template
- zh_Hans: 下载人工输入卡片模板
- zh_Hant: 下載人工輸入卡片範本
- description:
- en_US: Download the human input template. Create an AI card in DingTalk Card Platform, choose More > Import Card Template in the editor, then save and publish. Paste its ID into the field above.
- zh_Hans: 下载人工输入模板后,在钉钉卡片平台新建 AI 卡片,在编辑器中选择「更多 → 导入卡片模板」,保存并发布,将 ID 填入上方字段。
- zh_Hant: 下載人工輸入範本後,在釘釘卡片平台新建 AI 卡片,在編輯器中選擇「更多 → 匯入卡片範本」,儲存並發布,將 ID 填入上方欄位。
- type: download-link
- required: false
- default: ''
- url: /api/v1/platform/adapters/dingtalk/human-input-card-template
- download_filename: dingtalk_human_input_card.json
- help_links:
- zh: https://langbot.app/docs/zh/usage/platforms/dingtalk#导入人工输入卡片模板
- en: https://open-dev.dingtalk.com/fe/card
- ja: https://open-dev.dingtalk.com/fe/card
- help_label:
- en_US: Import Guide
- zh_Hans: 导入指引
- zh_Hant: 匯入指引
- ja_JP: インポート手順
supported_events:
- message.received
diff --git a/src/langbot/pkg/platform/botmgr.py b/src/langbot/pkg/platform/botmgr.py
index f7ae30188..59423ebe6 100644
--- a/src/langbot/pkg/platform/botmgr.py
+++ b/src/langbot/pkg/platform/botmgr.py
@@ -898,8 +898,8 @@ class RuntimeBot:
),
state_policy=StatePolicy(state_scopes=['conversation', 'actor', 'subject', 'runner']),
delivery_policy=DeliveryPolicy(
- enable_streaming=False,
- enable_reply=False,
+ enable_streaming=agent.get('kind') == 'agent' and event_type == 'message.received',
+ enable_reply=agent.get('kind') == 'agent' and event_type == 'message.received',
enable_interactions=agent.get('kind') != 'event_processor',
),
agent_id=agent.get('uuid'),
@@ -1291,6 +1291,8 @@ class RuntimeBot:
)
envelope = self._eba_event_to_agent_envelope(event, adapter, execution_id)
+ if target_type == 'agent' and isinstance(event, platform_events.MessageReceivedEvent):
+ envelope.delivery.supports_streaming = True
if target_type == 'event_processor':
envelope.data = event.model_dump(mode='json', exclude={'source_platform_object', 'legacy_event'})
if isinstance(event, platform_events.MessageReceivedEvent):
diff --git a/src/langbot/pkg/platform/sources/dingtalk.py b/src/langbot/pkg/platform/sources/dingtalk.py
index a8fd2349d..e70ef5b8f 100644
--- a/src/langbot/pkg/platform/sources/dingtalk.py
+++ b/src/langbot/pkg/platform/sources/dingtalk.py
@@ -1,7 +1,10 @@
-from langbot.libs.dingtalk_api.thinking import visible_think_markers
+from langbot.libs.dingtalk_api.a2ui import (
+ binding,
+ create_surface_messages,
+ create_text_card_messages,
+ update_surface_messages,
+)
import asyncio
-import json
-import pathlib
import re
import traceback
import typing
@@ -317,17 +320,6 @@ def _dingtalk_missing_completed_input_lines(form_data: dict, form_content: str)
return [line for line in _dingtalk_completed_input_lines(form_data) if line.strip() not in rendered_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()
@@ -355,28 +347,6 @@ def _dingtalk_select_options(field: dict) -> list[str]:
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'):
@@ -387,62 +357,8 @@ def _dingtalk_current_input_field(form_data: dict) -> dict | None:
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."""
+ """Normalize legacy template and A2UI input callback payloads."""
if not isinstance(params, dict):
return {}
@@ -468,6 +384,8 @@ def _dingtalk_extract_component_inputs(params: dict) -> dict:
break
if isinstance(select_value, dict):
select_value = select_value.get('value') or select_value.get('label') or select_value.get('index')
+ if isinstance(select_value, list):
+ select_value = select_value[0] if select_value else ''
if select_value not in (None, ''):
if isinstance(select_value, str):
select_value = select_value.strip()
@@ -476,6 +394,134 @@ def _dingtalk_extract_component_inputs(params: dict) -> dict:
return result
+def _dingtalk_a2ui_form(
+ form_data: dict,
+ out_track_id: str,
+ *,
+ prior_content: str = '',
+) -> tuple[dict, list[dict]]:
+ node_title = str(form_data.get('node_title') or 'Human Input Required')
+ form_content = _dingtalk_clean_form_content(form_data)
+ content_parts = [part for part in (prior_content.strip(), f'## {node_title}', form_content) if part]
+ data: dict[str, typing.Any] = {'content': '\n\n'.join(content_parts), 'form': {}}
+ components: list[dict] = [
+ {'id': 'root', 'component': 'Column', 'children': ['content'], 'gap': 12},
+ {'id': 'content', 'component': 'Markdown', 'content': binding('/content')},
+ ]
+ children = components[0]['children']
+
+ field = _dingtalk_current_input_field(form_data)
+ if field is not None:
+ field_name = _dingtalk_field_name(field) or 'value'
+ field_type = _dingtalk_field_type(field)
+ default = (form_data.get('inputs') or {}).get(field_name, field.get('default'))
+ label = str(field.get('label') or field_name)
+ placeholder = str(field.get('placeholder') or '')
+ if field_type == 'select':
+ options = [{'label': option, 'value': option} for option in _dingtalk_select_options(field)]
+ data['form']['value'] = [] if default in (None, '') else [str(default)]
+ components.append(
+ {
+ 'id': 'field',
+ 'component': 'ChoicePicker',
+ 'label': placeholder or label,
+ 'variant': 'mutuallyExclusive',
+ 'displayStyle': 'dropdown',
+ 'options': options,
+ 'value': binding('/form/value'),
+ }
+ )
+ value_key = 'select'
+ elif field_type == 'number':
+ data['form']['value'] = default if isinstance(default, (int, float)) else 0
+ components.append(
+ {
+ 'id': 'field',
+ 'component': 'NumberInput',
+ 'label': label,
+ 'placeholder': placeholder,
+ 'value': binding('/form/value'),
+ }
+ )
+ value_key = 'input'
+ else:
+ data['form']['value'] = '' if default is None else str(default)
+ components.append(
+ {
+ 'id': 'field',
+ 'component': 'TextField',
+ 'label': label,
+ 'placeholder': placeholder,
+ 'variant': 'longText' if field_type == 'textarea' else 'shortText',
+ 'value': binding('/form/value'),
+ }
+ )
+ value_key = 'input'
+ components.extend(
+ [
+ {
+ 'id': 'submit-input',
+ 'component': 'Button',
+ 'child': 'submit-input-label',
+ 'variant': 'primary',
+ 'action': {
+ 'event': {
+ 'name': 'langbot_form_input',
+ 'context': {
+ 'actionId': '__langbot_input__',
+ 'out_track_id': out_track_id,
+ value_key: binding('/form/value'),
+ },
+ }
+ },
+ 'checks': [
+ {
+ 'condition': {
+ 'call': 'required',
+ 'args': {'value': binding('/form/value')},
+ },
+ 'message': f'{label} is required',
+ }
+ ],
+ },
+ {'id': 'submit-input-label', 'component': 'Text', 'text': 'Submit'},
+ ]
+ )
+ children.extend(['field', 'submit-input'])
+ else:
+ for index, action in enumerate(form_data.get('actions') or []):
+ action_id = str(action.get('id') or '')
+ label_id = f'action-label-{index}'
+ button_id = f'action-{index}'
+ style = str(action.get('button_style') or '')
+ components.extend(
+ [
+ {
+ 'id': button_id,
+ 'component': 'Button',
+ 'child': label_id,
+ 'variant': 'primary' if style == 'primary' or (not style and index == 0) else 'default',
+ 'action': {
+ 'event': {
+ 'name': 'langbot_form_action',
+ 'context': {
+ 'actionId': action_id,
+ 'out_track_id': out_track_id,
+ },
+ }
+ },
+ },
+ {
+ 'id': label_id,
+ 'component': 'Text',
+ 'text': str(action.get('title') or action_id or f'Option {index + 1}'),
+ },
+ ]
+ )
+ children.append(button_id)
+ return data, components
+
+
class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
bot: DingTalkClient
bot_account_id: str
@@ -508,17 +554,6 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
]
ap: typing.Any = None
bot_uuid: str = ''
- # DingTalk media_id (`@xxx` format) for the bot avatar image, fetched
- # on adapter startup by uploading the bundled LangBot logo via the
- # legacy /media/upload endpoint. Empty string when the upload hasn't
- # run yet or failed — the template's Avatar then falls back to its
- # default (initials of `name`).
- bot_avatar_media_id: str = ''
-
- # Path to the LangBot logo bundled in the repo (`res/logo-blue.png`),
- # resolved relative to this file. Updated to find the file even when
- # LangBot is installed as a package or run from a different cwd.
- _LOGO_PATH: typing.ClassVar[pathlib.Path] = pathlib.Path(__file__).resolve().parents[5] / 'res' / 'logo-blue.png'
def __init__(self, config: dict, logger: EventLogger):
required_keys = [
@@ -633,12 +668,10 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
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)}'
+ f'DingTalk reply_message_chunk final: form_data_present={form_data is not None}, a2ui_enabled=True'
)
if form_data and is_final:
@@ -672,51 +705,16 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
return
card_instance, card_instance_id = chat_card_entry
- # btns is reserved exclusively for Dify form-action buttons.
- # The template renders an Avatar header above the markdown
- # content; no feedback buttons get injected here.
if content:
- if form_template_id:
- # The card content has already been written via
- # update_card_data (in _paint_form_on_card and the
- # initial card creation). The streaming endpoint
- # (PUT /v1.0/card/streaming) does not propagate
- # updates on cards whose content was last set via
- # update_card_data — they take different code paths
- # on the DingTalk client. Stick with update_card_data
- # for the whole turn for consistency.
- try:
- await self.bot.update_card_data(
- out_track_id=card_instance_id,
- card_param_map=self._card_params(
- 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')
- else:
- await self.bot.send_card_message(card_instance, card_instance_id, content, is_final)
+ await self.bot.send_card_message(card_instance, card_instance_id, content, is_final)
+ session_key = self._session_key_from_event(message_source)
+ if session_key:
+ self.active_turn_text[session_key] = content
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=self._card_params(
- flowStatus='3',
- **_dingtalk_empty_form_component_params(),
- ),
- )
- except Exception:
- pass
+ if not content:
+ session_key = self._session_key_from_event(message_source)
+ final_content = self.active_turn_text.get(session_key, '') if session_key else ''
+ await self.bot.send_card_message(card_instance, card_instance_id, final_content, True)
if bot_message.tool_calls is None:
self.card_instance_id_dict.pop(message_id, None)
event = await DingTalkEventConverter.yiri2target(message_source)
@@ -737,63 +735,24 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
async def create_message_card(self, message_id, event):
self._prune_card_state()
- form_template_id = (self.config.get('human_input_card_template_id') or '').strip()
- legacy_template_id = (self.config.get('card_template_id') or '').strip()
# Synthetic events (button clicks): look up the card already in
# active_turn_card so reply_message_chunk can stream to it.
if event is None or event.source_platform_object is None:
- if form_template_id:
- session_key = self._session_key_from_event(event) if event is not None else ''
- carry = self.active_turn_card.get(session_key, '') if session_key else ''
- if carry:
- self.card_instance_id_dict[message_id] = (None, carry)
- return True
+ session_key = self._session_key_from_event(event) if event is not None else ''
+ carry = self.active_turn_card.get(session_key, '') if session_key else ''
+ if carry:
+ self.card_instance_id_dict[message_id] = ({'surface_id': f'langbot-{carry}'}, carry)
+ return True
return False
- if form_template_id:
- # Create one card with the form template, empty buttons,
- # pending state. Streaming writes content to it; form pause
- # paints buttons onto it. One card per turn, no duplication.
- incoming_message = event.source_platform_object.incoming_message
- 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:
- open_space_id = f'dtv1.card//IM_ROBOT.{incoming_message.sender_staff_id}'
- try:
- await self.bot.create_and_deliver_card(
- card_template_id=form_template_id,
- 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',
- **_dingtalk_empty_form_component_params(),
- ),
- callback_type='STREAM',
- )
- except Exception:
- if self.ap is not None:
- self.ap.logger.exception('DingTalk: create form-template card failed')
- return False
- self.card_instance_id_dict[message_id] = (None, out_track_id)
- session_key = self._session_key_from_event(event)
- if session_key:
- self.active_turn_card[session_key] = out_track_id
- self.active_turn_text[session_key] = ''
- return True
-
- # Legacy chat-card path (no form template).
incoming_message = event.source_platform_object.incoming_message
- card_auto_layout = self.config.get('card_auto_layout', True)
- card_instance, card_instance_id = await self.bot.create_and_card(
- legacy_template_id, incoming_message, card_auto_layout=card_auto_layout
- )
+ card_instance, card_instance_id = await self.bot.create_and_card(incoming_message)
self.card_instance_id_dict[message_id] = (card_instance, card_instance_id)
+ session_key = self._session_key_from_event(event)
+ if session_key:
+ self.active_turn_card[session_key] = card_instance_id
+ self.active_turn_text[session_key] = ''
return True
def _session_key_from_event(self, event) -> str:
@@ -839,38 +798,8 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
self.bot.on_message('GroupMessage')(on_message)
async def run_async(self):
- # Upload the bundled LangBot logo so the card Avatar can render
- # via DingTalk's media CDN — external URLs (e.g. raw.githubusercontent)
- # are blocked by DingTalk's Avatar.imageUrl resolver. Non-fatal if
- # the upload fails: cards still render without an avatar image.
- if self._LOGO_PATH.exists():
- media_id = await self.bot.upload_image_media(str(self._LOGO_PATH))
- if media_id:
- self.bot_avatar_media_id = media_id
- if self.ap is not None:
- self.ap.logger.info(f'DingTalk bot avatar uploaded: media_id={media_id}')
- else:
- if self.ap is not None:
- self.ap.logger.warning('DingTalk bot avatar upload failed; card will use default')
- else:
- if self.ap is not None:
- self.ap.logger.warning(f'DingTalk bot avatar source not found: {self._LOGO_PATH}')
await self.bot.start()
- def _card_params(self, **extra) -> dict:
- """Build a cardParamMap dict that always carries `bot_avatar`
- (when uploaded) alongside whatever caller-specific params. The
- bot_avatar key gets dropped on every update_card_data call —
- DingTalk wipes unspecified template variables, so re-sending it
- on each update is mandatory."""
- params = {}
- if self.bot_avatar_media_id:
- params['bot_avatar'] = self.bot_avatar_media_id
- params.update(extra)
- if isinstance(params.get('content'), str):
- params['content'] = visible_think_markers(params['content'])
- return params
-
async def kill(self) -> bool:
task_set = getattr(self, '_background_tasks', set())
background_tasks = list(task_set)
@@ -916,8 +845,8 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
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
+ 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
@@ -942,53 +871,23 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
message: platform_message.MessageChain,
form_data: dict,
) -> None:
- """Surface human-input prompt + buttons on the active card.
-
- In single-card mode (form_template_id configured): update the
- EXISTING card with form buttons so it transitions from streaming
- output to prompt+buttons on the same card. In legacy mode:
- finalize the chat card and deliver a separate form card.
- """
+ """Surface a human-input prompt on the active A2UI card."""
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()
-
- if template_id:
- # Single-card mode: paint prompt + buttons onto the existing card.
- session_key = self._session_key_from_event(message_source)
- entry = self.card_instance_id_dict.get(message_id)
- out_track_id = entry[1] if entry else None
- if not out_track_id and session_key:
- out_track_id = self.active_turn_card.get(session_key, '')
- if out_track_id:
- await self._paint_form_on_card(message_source, out_track_id, form_data, session_key)
- self.card_instance_id_dict.pop(message_id, None)
- return
-
- # No existing card (e.g. Dify paused immediately with no LLM
- # output before the pause). Create a form card directly.
- await self._send_form_card(message_source, form_data, template_id)
- self.card_instance_id_dict.pop(message_id, None)
- return
-
- # Legacy mode: finalize the streaming card with text fallback.
- 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()}')
-
- await self.send_message_text_form(message_source, form_data)
+ session_key = self._session_key_from_event(message_source)
+ entry = self.card_instance_id_dict.get(message_id)
+ out_track_id = entry[1] if entry else None
+ if not out_track_id and session_key:
+ out_track_id = self.active_turn_card.get(session_key, '')
+ if out_track_id:
+ await self._paint_form_on_card(message_source, out_track_id, form_data, session_key)
+ else:
+ await self._send_form_card(message_source, form_data)
+ self.card_instance_id_dict.pop(message_id, None)
async def _paint_form_on_card(
self,
@@ -1002,13 +901,6 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
node_title = form_data.get('node_title', '') or 'Human Input Required'
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.
self._prune_card_state()
@@ -1031,37 +923,18 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
'created_at': time.monotonic(),
}
- 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():
- parts.append(prior.rstrip())
- parts.append('
')
- # DingTalk's card markdown widget strips `\n\n` paragraph breaks in
- # template content slots, fusing inline siblings into a single line.
- # Force visual line breaks with explicit HTML `
` tags so the
- # title sits on its own line above form_content.
- if node_title:
- parts.append(f'**{node_title}**')
- if form_content:
- parts.append(_dingtalk_card_markdown(form_content))
- missing_completed_lines = _dingtalk_missing_completed_input_lines(form_data, form_content)
- if missing_completed_lines:
- parts.append('
' + '
'.join(missing_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))
- display_content = '
'.join(parts)
+ data, components = _dingtalk_a2ui_form(form_data, out_track_id, prior_content=prior)
try:
- await self.bot.update_card_data(
+ await self.bot.update_ai_card(
out_track_id=out_track_id,
- card_param_map=self._card_params(
- content=display_content,
- btns=json.dumps(btns, ensure_ascii=False),
- flowStatus='3',
- **component_params,
+ a2ui_messages=update_surface_messages(
+ f'langbot-{out_track_id}',
+ data=data,
+ components=components,
),
+ flow_status='CONFIRMING',
)
except Exception:
if self.ap is not None:
@@ -1069,40 +942,10 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
await self.send_message_text_form(message_source, form_data)
return
- @staticmethod
- def _build_btns(actions: list, out_track_id: str) -> list:
- 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},
- },
- },
- }
- )
- return btns
-
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
@@ -1114,13 +957,6 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
node_title = form_data.get('node_title', '') or 'Human Input Required'
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._prune_card_state()
self.card_state[out_track_id] = {
@@ -1143,38 +979,25 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
'created_at': time.monotonic(),
}
- parts = []
- if node_title:
- parts.append(f'**{node_title}**')
- if form_content:
- parts.append(_dingtalk_card_markdown(form_content))
- missing_completed_lines = _dingtalk_missing_completed_input_lines(form_data, form_content)
- if missing_completed_lines:
- parts.append('
' + '
'.join(missing_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))
- display_content = '
'.join(parts)
-
- btns = self._build_btns(actions if should_show_actions else [], out_track_id)
+ data, components = _dingtalk_a2ui_form(form_data, 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)}'
+ f'DingTalk _send_form_card: out_track_id={out_track_id} '
+ f'open_space_id={open_space_id} is_group={is_group} actions={len(actions)}'
)
- await self.bot.create_and_deliver_card(
- card_template_id=template_id,
+ await self.bot.create_and_deliver_ai_card(
out_track_id=out_track_id,
open_space_id=open_space_id,
is_group=is_group,
- card_param_map=self._card_params(
- content=display_content,
- btns=json.dumps(btns, ensure_ascii=False),
- flowStatus='3',
- **component_params,
+ a2ui_messages=create_surface_messages(
+ f'langbot-{out_track_id}',
+ data=data,
+ components=components,
),
+ flow_status='CONFIRMING',
+ summary=node_title,
callback_type='STREAM',
)
except Exception:
@@ -1193,43 +1016,24 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
card stays put with the selection notice, and a fresh card is
spawned here for the LLM reply to stream into.
"""
- form_template_id = (self.config.get('human_input_card_template_id') or '').strip()
- 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 form_template_id:
- 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`
- # variable, so don't decorate the param map here.
- card_param_map = {'content': '', 'query': '...'}
- card_data_config = {'autoLayout': self.config.get('card_auto_layout', True)}
+ surface_id = f'langbot-{out_track_id}'
try:
- success = await self.bot.create_and_deliver_card(
- card_template_id=template_id,
+ await self.bot.create_and_deliver_ai_card(
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,
+ a2ui_messages=create_text_card_messages(surface_id),
+ flow_status='PROCESSING',
+ summary='LangBot is replying',
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)
+ entry = ({'surface_id': surface_id}, out_track_id)
self.card_instance_id_dict[message_id] = entry
# Register as the active card so any further chunks on this turn
# (and a subsequent re-pause) land on the same new card.
@@ -1601,14 +1405,17 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
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(
+ await self.bot.update_ai_card(
out_track_id=out_track_id,
- card_param_map=self._card_params(
- content=content,
- btns='[]',
- flowStatus='3',
- **_dingtalk_empty_form_component_params(),
+ a2ui_messages=update_surface_messages(
+ f'langbot-{out_track_id}',
+ data={'content': content},
+ components=[
+ {'id': 'root', 'component': 'Column', 'children': ['content'], 'gap': 8},
+ {'id': 'content', 'component': 'Markdown', 'content': binding('/content')},
+ ],
),
+ flow_status='CONFIRMED',
)
except Exception:
if self.ap is not None:
diff --git a/src/langbot/pkg/platform/sources/dingtalk.yaml b/src/langbot/pkg/platform/sources/dingtalk.yaml
index 43392cf0b..a9ef513b8 100644
--- a/src/langbot/pkg/platform/sources/dingtalk.yaml
+++ b/src/langbot/pkg/platform/sources/dingtalk.yaml
@@ -55,97 +55,12 @@ spec:
zh_Hans: 使用卡片回复
zh_Hant: 使用卡片回覆
description:
- en_US: Controls Markdown and streaming card replies. Disable to use plain text while keeping your template settings. When enabled, a configured template automatically enables streaming.
- zh_Hans: 控制 Markdown 和流式卡片回复。关闭后使用纯文本,保留模板配置;开启且已配置模板时自动使用流式回复。
- zh_Hant: 控制 Markdown 和串流卡片回覆。關閉後使用純文字,保留範本設定;開啟且已設定範本時自動使用串流回覆。
+ en_US: Uses built-in A2UI cards for Markdown, streaming replies, and interactions. No card template is required. The app must have AICard.Instance.Write permission.
+ zh_Hans: 使用内置 A2UI 卡片提供 Markdown、流式回复和交互能力,无需配置卡片模板。应用必须开通 AICard.Instance.Write 权限。
+ zh_Hant: 使用內建 A2UI 卡片提供 Markdown、串流回覆和互動能力,無需設定卡片範本。應用必須開通 AICard.Instance.Write 權限。
type: boolean
required: false
default: true
- - name: card_template_id
- show_if:
- field: markdown_card
- operator: eq
- value: true
- label:
- en_US: card template id
- zh_Hans: 卡片模板ID
- zh_Hant: 卡片範本ID
- description:
- en_US: Template for streaming text replies. Save and publish it in DingTalk Card Platform, then paste its ID here to automatically enable streaming while card replies are enabled. Leave empty for ordinary replies. Separate from the human input template.
- zh_Hans: 用于流式文字回复。在钉钉卡片平台保存并发布模板后,开启卡片回复后,填写 ID 即自动启用流式回复,留空使用普通回复;与人工输入卡片模板分开配置。
- zh_Hant: 用於串流文字回覆。在釘釘卡片平台儲存並發布範本後,開啟卡片回覆後,填寫 ID 即自動啟用串流回覆,留空使用一般回覆;與人工輸入卡片範本分開設定。
- type: string
- required: false
- default: ""
- - name: stream_card_template_download
- show_if:
- field: markdown_card
- operator: eq
- value: true
- label:
- en_US: Download Streaming Reply Card Template
- zh_Hans: 下载流式回复卡片模板
- zh_Hant: 下載串流回覆卡片範本
- description:
- en_US: Displays reasoning and the answer separately. Import through More > Import Card Template in the DingTalk AI card editor, save and publish, then paste its ID into Card Template ID above. Respects the pipeline thinking removal setting.
- zh_Hans: 分开展示思考与正文。在钉钉 AI 卡片编辑器中选择「更多 → 导入卡片模板」,保存并发布后,将 ID 填入上方「卡片模板 ID」。是否保留思考仍由流水线设置控制。
- zh_Hant: 分開展示思考與正文。在釘釘 AI 卡片編輯器中選擇「更多 → 匯入卡片範本」,儲存並發布後,將 ID 填入上方「卡片範本 ID」。是否保留思考仍由流水線設定控制。
- type: download-link
- required: false
- default: ''
- url: /api/v1/platform/adapters/dingtalk/stream-card-template
- download_filename: dingtalk_stream_card.json
- - name: card_auto_layout
- show_if:
- field: markdown_card
- operator: eq
- value: true
- label:
- en_US: Card Auto Layout
- zh_Hans: 卡片宽屏自动布局
- zh_Hant: 卡片寬螢幕自動佈局
- description:
- en_US: Enabled by default. Allows supported cards to adapt to wider windows. The LangBot streaming reply template includes the required variables; you can disable this for a narrower layout.
- zh_Hans: 默认开启,让支持的卡片适应较宽的聊天窗口。LangBot 流式回复模板已包含所需变量;如需较窄布局,可关闭此项。
- zh_Hant: 預設開啟,讓支援的卡片適應較寬的聊天視窗。LangBot 串流回覆範本已包含所需變數;如需較窄版面,可關閉此項。
- type: boolean
- required: false
- default: true
- - name: human_input_card_template_id
- label:
- en_US: Human Input Card Template ID
- zh_Hans: 人工输入卡片模板ID
- zh_Hant: 人工輸入卡片範本ID
- description:
- en_US: Optional. Used when a workflow or plugin requests user input, a choice, or confirmation before continuing (for example, a Dify Human Input node). Ordinary chat does not require it. Import the template below and enter its published ID here; streaming text replies use the separate Card Template ID.
- zh_Hans: 可选。当工作流或插件需要用户填写信息、选择选项或确认后再继续执行时使用,例如 Dify 的人工输入节点。普通聊天无需配置。请导入下方模板,将发布后的 ID 填在这里;流式文字回复使用单独的「卡片模板 ID」。
- zh_Hant: 可選。當工作流或外掛需要使用者填寫資訊、選擇選項或確認後再繼續執行時使用,例如 Dify 的人工輸入節點。一般聊天無需設定。請匯入下方範本,將發布後的 ID 填在這裡;串流文字回覆使用獨立的「卡片範本 ID」。
- type: string
- required: false
- default: ""
- - name: human_input_card_template_download
- label:
- en_US: Download Human Input Card Template
- zh_Hans: 下载人工输入卡片模板
- zh_Hant: 下載人工輸入卡片範本
- description:
- en_US: Download the human input template. Create an AI card in DingTalk Card Platform, choose More > Import Card Template in the editor, then save and publish. Paste its ID into the field above.
- zh_Hans: 下载人工输入模板后,在钉钉卡片平台新建 AI 卡片,在编辑器中选择「更多 → 导入卡片模板」,保存并发布,将 ID 填入上方字段。
- zh_Hant: 下載人工輸入範本後,在釘釘卡片平台新建 AI 卡片,在編輯器中選擇「更多 → 匯入卡片範本」,儲存並發布,將 ID 填入上方欄位。
- type: download-link
- required: false
- default: ""
- url: /api/v1/platform/adapters/dingtalk/human-input-card-template
- download_filename: dingtalk_human_input_card.json
- help_links:
- zh: https://langbot.app/docs/zh/usage/platforms/dingtalk#导入人工输入卡片模板
- en: https://open-dev.dingtalk.com/fe/card
- ja: https://open-dev.dingtalk.com/fe/card
- help_label:
- en_US: Import Guide
- zh_Hans: 导入指引
- zh_Hant: 匯入指引
- ja_JP: インポート手順
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
deleted file mode 100644
index 81e891ac6..000000000
--- a/src/langbot/templates/dingtalk_human_input_card.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "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\":\"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\":\"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\":\"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"
-}
\ No newline at end of file
diff --git a/src/langbot/templates/dingtalk_stream_card.json b/src/langbot/templates/dingtalk_stream_card.json
deleted file mode 100644
index e11cb3154..000000000
--- a/src/langbot/templates/dingtalk_stream_card.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "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\",\"children\":[]},{\"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\":\"MarkdownBlock\",\"main\":\"./src/index.tsx\",\"destructuring\":false,\"subName\":\"\",\"componentName\":\"MarkdownBlock\"},{\"package\":\"@ali/dxComponent\",\"version\":\"1.0.0\",\"exportName\":\"CollapsePanel\",\"main\":\"./src/index.tsx\",\"destructuring\":false,\"subName\":\"\",\"componentName\":\"CollapsePanel\"},{\"package\":\"@ali/dxComponent\",\"version\":\"1.0.0\",\"exportName\":\"AIGenerationProcessing\",\"main\":\"./src/index.tsx\",\"destructuring\":false,\"subName\":\"\",\"componentName\":\"AIGenerationProcessing\"},{\"package\":\"@ali/dxComponent\",\"version\":\"1.0.0\",\"exportName\":\"Feedback\",\"main\":\"./src/index.tsx\",\"destructuring\":false,\"subName\":\"\",\"componentName\":\"Feedback\"},{\"package\":\"@ali/dxComponent\",\"version\":\"1.0.0\",\"exportName\":\"Grid\",\"main\":\"./src/index.tsx\",\"destructuring\":false,\"subName\":\"\",\"componentName\":\"Grid\"},{\"package\":\"@ali/dxComponent\",\"version\":\"1.0.0\",\"exportName\":\"Image\",\"main\":\"./src/index.tsx\",\"destructuring\":false,\"subName\":\"\",\"componentName\":\"Image\"}],\"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\":\"prompt\",\"enableFlowAbort\":false,\"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\":false,\"heightLimit\":480,\"foldStatusLocalDataKey\":\"_cardFoldStatusLocalDataKey\"},\"innerOffset\":0,\"enableCollapse\":false,\"margin\":-2},\"title\":\"回复中\",\"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\":\"CollapsePanel\",\"id\":\"langbot_reasoning_2\",\"props\":{\"id\":{\"i18n\":false,\"type\":\"dynamicString\",\"content\":\"reasoning_2\"},\"title\":{\"i18n\":false,\"type\":\"dynamicString\",\"content\":\"思考过程 · 输出中\"},\"contentVisible\":true,\"marginLeft\":12,\"marginRight\":12,\"marginBottom\":8,\"marginTop\":4,\"visible\":{\"type\":\"dynamicVisible\",\"value\":true,\"valueType\":\"variable\",\"variable\":\"hasReasoning\",\"variableType\":\"global\",\"condition\":{\"op\":\"and\",\"conditions\":[]}},\"margin\":-2,\"innerOffset\":0},\"title\":\"思考过程\",\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\",\"children\":[{\"componentName\":\"MarkdownBlock\",\"id\":\"node_2_reasoning\",\"props\":{\"mdVer\":0,\"icon\":{\"type\":\"icon\",\"icon\":\"\",\"iconType\":\"emoji\"},\"content\":{\"variable\":\"reasoning\",\"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\":8,\"marginBottom\":8,\"marginLeft\":12,\"marginRight\":12},\"title\":\"reasoning\",\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\"}]},{\"componentName\":\"MarkdownBlock\",\"id\":\"node_2_answer\",\"props\":{\"mdVer\":0,\"icon\":{\"type\":\"icon\",\"icon\":\"\",\"iconType\":\"emoji\"},\"content\":{\"variable\":\"answer\",\"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\":8,\"marginBottom\":8,\"marginLeft\":12,\"marginRight\":12},\"title\":\"answer\",\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\"}]}]},{\"componentName\":\"AICardStatusContainer\",\"id\":\"node_status_doing\",\"props\":{\"status\":4,\"marginLeft\":0,\"marginRight\":0,\"marginTop\":0,\"marginBottom\":0,\"enableExtend\":false,\"autoFoldConfig\":{\"needFold\":false,\"heightLimit\":480,\"foldStatusLocalDataKey\":\"_cardFoldStatusLocalDataKey\"},\"innerOffset\":0,\"enableCollapse\":false,\"margin\":-2},\"title\":\"执行中\",\"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\":\"CollapsePanel\",\"id\":\"langbot_reasoning_4\",\"props\":{\"id\":{\"i18n\":false,\"type\":\"dynamicString\",\"content\":\"reasoning_4\"},\"title\":{\"i18n\":false,\"type\":\"dynamicString\",\"content\":\"思考过程 · 输出中\"},\"contentVisible\":true,\"marginLeft\":12,\"marginRight\":12,\"marginBottom\":8,\"marginTop\":4,\"visible\":{\"type\":\"dynamicVisible\",\"value\":true,\"valueType\":\"variable\",\"variable\":\"hasReasoning\",\"variableType\":\"global\",\"condition\":{\"op\":\"and\",\"conditions\":[]}},\"margin\":-2,\"innerOffset\":0},\"title\":\"思考过程\",\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\",\"children\":[{\"componentName\":\"MarkdownBlock\",\"id\":\"node_4_reasoning\",\"props\":{\"mdVer\":0,\"icon\":{\"type\":\"icon\",\"icon\":\"\",\"iconType\":\"emoji\"},\"content\":{\"variable\":\"reasoning\",\"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\":8,\"marginBottom\":8,\"marginLeft\":12,\"marginRight\":12},\"title\":\"reasoning\",\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\"}]},{\"componentName\":\"MarkdownBlock\",\"id\":\"node_4_answer\",\"props\":{\"mdVer\":0,\"icon\":{\"type\":\"icon\",\"icon\":\"\",\"iconType\":\"emoji\"},\"content\":{\"variable\":\"answer\",\"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\":8,\"marginBottom\":8,\"marginLeft\":12,\"marginRight\":12},\"title\":\"answer\",\"hidden\":false,\"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\":false,\"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\":\"CollapsePanel\",\"id\":\"langbot_reasoning_3\",\"props\":{\"id\":{\"i18n\":false,\"type\":\"dynamicString\",\"content\":\"reasoning_3\"},\"title\":{\"i18n\":false,\"type\":\"dynamicString\",\"content\":\"思考过程\"},\"contentVisible\":false,\"marginLeft\":12,\"marginRight\":12,\"marginBottom\":8,\"marginTop\":4,\"visible\":{\"type\":\"dynamicVisible\",\"value\":true,\"valueType\":\"variable\",\"variable\":\"hasReasoning\",\"variableType\":\"global\",\"condition\":{\"op\":\"and\",\"conditions\":[]}},\"margin\":-2,\"innerOffset\":0},\"title\":\"思考过程\",\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\",\"children\":[{\"componentName\":\"MarkdownBlock\",\"id\":\"node_3_reasoning\",\"props\":{\"mdVer\":0,\"icon\":{\"type\":\"icon\",\"icon\":\"\",\"iconType\":\"emoji\"},\"content\":{\"variable\":\"reasoning\",\"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\":8,\"marginBottom\":8,\"marginLeft\":12,\"marginRight\":12},\"title\":\"reasoning\",\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\"}]},{\"componentName\":\"MarkdownBlock\",\"id\":\"node_3_answer\",\"props\":{\"mdVer\":0,\"icon\":{\"type\":\"icon\",\"icon\":\"\",\"iconType\":\"emoji\"},\"content\":{\"variable\":\"answer\",\"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\":8,\"marginBottom\":8,\"marginLeft\":12,\"marginRight\":12},\"title\":\"answer\",\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\"}]},{\"componentName\":\"Grid\",\"id\":\"langbot_operation_row\",\"props\":{\"direction\":\"horizontal\",\"isAutoHeight\":true,\"isAutoWidth\":false,\"autoWidth\":false,\"isFixedWidth\":false,\"fixWidth\":false,\"width\":100,\"childGravity\":\"leftTop\",\"hasBackground\":false,\"hasGradientBackground\":false,\"hasBorder\":false,\"marginLeft\":12,\"marginRight\":12,\"marginTop\":0,\"marginBottom\":0,\"actionType\":\"none\"},\"children\":[{\"componentName\":\"Grid\",\"id\":\"langbot_link\",\"props\":{\"direction\":\"horizontal\",\"isAutoHeight\":false,\"isAutoWidth\":true,\"autoWidth\":true,\"isFixedWidth\":false,\"fixWidth\":false,\"childGravity\":\"leftCenter\",\"hasBackground\":false,\"hasGradientBackground\":false,\"hasBorder\":false,\"marginLeft\":0,\"marginRight\":8,\"marginTop\":0,\"marginBottom\":0,\"actionType\":\"url\",\"urlType\":\"all\",\"url\":{\"type\":\"dynamicLink\",\"valueType\":\"fixed\",\"value\":\"https://langbot.app?utm_source=dingtalk&utm_medium=bot_card&utm_campaign=langbot\",\"variable\":\"\",\"variableType\":\"global\"},\"height\":28},\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\",\"children\":[{\"componentName\":\"Image\",\"id\":\"langbot_logo\",\"props\":{\"images\":{\"defaultLang\":\"zh_Hans\",\"content\":{\"zh_Hans\":{\"value\":\"https://langbot.app/logo-blue.png\",\"valueType\":\"fixed\",\"type\":\"dynamicImage\"}},\"i18n\":false},\"darkModeImages\":{\"defaultLang\":\"zh_Hans\",\"content\":{\"zh_Hans\":{\"value\":\"\",\"valueType\":\"fixed\",\"type\":\"dynamicImage\"}},\"i18n\":false},\"single\":true,\"height\":{\"type\":\"dynamicWidth\",\"valueType\":\"fixed\",\"value\":12,\"variableType\":\"global\",\"variable\":\"\",\"full\":false,\"adaptive\":false},\"width\":{\"type\":\"dynamicWidth\",\"valueType\":\"fixed\",\"value\":12,\"variableType\":\"global\",\"variable\":\"\",\"full\":false,\"adaptive\":false},\"cornerRadius\":{\"type\":\"dynamicNumber\",\"valueType\":\"fixed\",\"value\":0,\"variable\":\"\",\"variableType\":\"global\"},\"scaleType\":\"fitCenter\",\"enablePreview\":false,\"marginRight\":4,\"marginTop\":0,\"marginBottom\":0,\"visible\":{\"type\":\"dynamicVisible\",\"value\":true,\"valueType\":\"fixed\",\"condition\":{\"op\":\"and\",\"conditions\":[]}},\"adaptiveSize\":false,\"fixedRatio\":false,\"hoverText\":{\"type\":\"dynamicString\",\"content\":\"\",\"i18n\":false},\"enableBorder\":false,\"borderColor\":\"#297f8790\",\"statPoint\":{\"type\":\"dynamicString\",\"content\":\"\",\"i18n\":false},\"statPointParams\":[{\"type\":\"fixed\",\"variable\":\"\",\"value\":\"\",\"name\":\"\",\"variableType\":\"global\",\"id\":\"1\"}],\"aspectRatio\":{\"type\":\"dynamicNumber\",\"valueType\":\"fixed\",\"value\":2,\"variable\":\"\",\"variableType\":\"global\"},\"offsetLeft\":0,\"offsetRight\":0,\"marginLeft\":0,\"innerOffset\":0,\"enableStatPoint\":false,\"margin\":-2},\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\"},{\"componentName\":\"BaseText\",\"id\":\"langbot_name\",\"props\":{\"text\":{\"type\":\"dynamicString\",\"content\":\"LangBot\",\"i18n\":false},\"hoverText\":{\"type\":\"dynamicString\",\"content\":\"\",\"i18n\":false},\"iconType\":\"iconCode\",\"iconFont\":{\"type\":\"icon\",\"iconType\":\"ddIcon\",\"icon\":\"icon_question_fill\"},\"icon\":{\"type\":\"dynamicLink\",\"value\":\"\",\"valueType\":\"fixed\",\"variable\":\"\",\"variableType\":\"global\"},\"darkIcon\":{\"type\":\"dynamicLink\",\"value\":\"\",\"valueType\":\"fixed\",\"variable\":\"\",\"variableType\":\"global\"},\"autoWidth\":true,\"maxWidth\":0,\"fixedWidth\":0,\"marginLeft\":0,\"marginRight\":0,\"marginTop\":0,\"marginBottom\":0,\"fontColorType\":\"Standard\",\"enableHighlight\":false,\"maxLine\":{\"type\":\"dynamicNumber\",\"valueType\":\"fixed\",\"value\":1},\"color\":{\"type\":\"dynamicColor\",\"valueType\":\"fixed\",\"value\":\"common_level3_base_color\"},\"customLightColor\":{\"type\":\"dynamicColor\",\"valueType\":\"fixed\",\"value\":\"#35404b\",\"variable\":\"\",\"variableType\":\"global\"},\"customDarkColor\":{\"type\":\"dynamicColor\",\"valueType\":\"fixed\",\"value\":\"#f6f6f6\",\"variable\":\"\",\"variableType\":\"global\"},\"gravity\":\"left\",\"fontSizeType\":\"Standard\",\"styleType\":\"custom\",\"styleToken\":\"common_body_text_style\",\"size\":\"small\",\"customFontSize\":12,\"customFontLineHeight\":16,\"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\":\"wrap_content\"},\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\"}]},{\"componentName\":\"Grid\",\"id\":\"langbot_native_operations\",\"props\":{\"direction\":\"horizontal\",\"isAutoHeight\":true,\"isAutoWidth\":false,\"autoWidth\":false,\"isFixedWidth\":false,\"fixWidth\":false,\"width\":100,\"childGravity\":\"leftTop\",\"hasBackground\":false,\"hasGradientBackground\":false,\"hasBorder\":false,\"marginLeft\":0,\"marginRight\":0,\"marginTop\":0,\"marginBottom\":0,\"actionType\":\"none\"},\"children\":[{\"componentName\":\"Feedback\",\"id\":\"node_ocmv2bczc8a\",\"props\":{\"params\":[{\"type\":\"fixed\",\"variable\":\"\",\"value\":\"\",\"name\":\"\",\"variableType\":\"global\",\"id\":\"1\"}],\"visible\":{\"type\":\"dynamicVisible\",\"value\":true,\"valueType\":\"fixed\",\"condition\":{\"op\":\"and\",\"conditions\":[]}},\"marginConfigurable\":false,\"enableLikeDislike\":true,\"enableCopy\":true,\"copyType\":\"common\",\"copyVariable\":{\"type\":\"variableValue\",\"variable\":\"answer\",\"variableType\":\"global\",\"varType\":\"markdown\"},\"enableDivider\":false,\"margin\":-2,\"marginLeft\":0,\"marginRight\":0,\"marginTop\":0,\"marginBottom\":0,\"innerOffset\":0,\"enableRefresh\":false,\"actionType\":\"none\",\"actionId\":{\"type\":\"dynamicString\",\"content\":\"sys_action_like_component\",\"i18n\":false},\"refreshParams\":[{\"type\":\"fixed\",\"variable\":\"\",\"value\":\"\",\"name\":\"\",\"variableType\":\"global\",\"id\":\"1\"}],\"refreshSuccessCondition\":{\"op\":\"and\",\"conditions\":[]},\"refreshSuccessToast\":{\"type\":\"dynamicString\",\"content\":\"\",\"i18n\":false},\"refreshFailureToast\":{\"type\":\"dynamicString\",\"content\":\"\",\"i18n\":false},\"refreshVisible\":{\"type\":\"dynamicVisible\",\"value\":true,\"valueType\":\"fixed\",\"condition\":{\"op\":\"and\",\"conditions\":[]}}},\"title\":\"操作区\",\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\",\"children\":[]}],\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\"}],\"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\":\"\",\"reasoning\":\"> 这里展示模型返回的思考内容。\",\"answer\":\"这里展示正式回答。\",\"query\":\"\",\"config\":{\"autoLayout\":true},\"brand\":\"\",\"hasReasoning\":\"true\",\"autoLayout\":\"true\"},\"cardPrivateData\":{},\"localData\":{\"flowStatus\":\"\",\"_cardFoldStatusLocalDataKey\":\"\"},\"richTextData\":{}},\"renderContext\":{\"regenerateEnabled\":\"1\",\"regenerateIndex\":\"2\",\"regenerateTotal\":\"5\"},\"editVersion\":0,\"customWidgetInfo\":\"\",\"useCustomWidgetInfo\":false,\"variableList\":[{\"id\":\"content\",\"name\":\"content\",\"type\":\"markdown\",\"description\":\"content\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":false},{\"id\":\"reasoning\",\"name\":\"reasoning\",\"type\":\"markdown\",\"description\":\"reasoning\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":false},{\"id\":\"answer\",\"name\":\"answer\",\"type\":\"markdown\",\"description\":\"answer\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":false},{\"id\":\"flowStatus\",\"name\":\"flowStatus\",\"type\":\"string\",\"description\":\"flowStatus\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":false},{\"id\":\"query\",\"name\":\"query\",\"type\":\"string\",\"description\":\"query\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":false},{\"id\":\"config\",\"name\":\"config\",\"type\":\"object\",\"description\":\"config\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":false,\"schema\":[{\"id\":\"config.autoLayout\",\"name\":\"autoLayout\",\"type\":\"boolean\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":false,\"description\":\"Wide card layout\"}]},{\"id\":\"brand\",\"name\":\"brand\",\"type\":\"markdown\",\"description\":\"brand\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":false},{\"id\":\"hasReasoning\",\"name\":\"hasReasoning\",\"type\":\"string\",\"description\":\"hasReasoning\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":false},{\"id\":\"autoLayout\",\"name\":\"autoLayout\",\"type\":\"string\",\"description\":\"Card auto layout\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":false}],\"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/agent/test_orchestrator_integration.py b/tests/unit_tests/agent/test_orchestrator_integration.py
index 602b7a93c..09f82f7fe 100644
--- a/tests/unit_tests/agent/test_orchestrator_integration.py
+++ b/tests/unit_tests/agent/test_orchestrator_integration.py
@@ -1269,6 +1269,103 @@ class TestQueryEntrySessionQueryId:
mcp_loader.build_resource_context_for_query.assert_awaited_once_with(execution_query)
+@pytest.mark.asyncio
+async def test_direct_agent_streams_output_to_the_originating_platform(clean_agent_state):
+ from langbot.pkg.agent.runner.host_models import (
+ AgentBinding,
+ AgentEventEnvelope,
+ BindingScope,
+ DeliveryPolicy,
+ ResourcePolicy,
+ StatePolicy,
+ )
+ from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext
+ from langbot_plugin.api.entities.builtin.runner.input import AgentInput
+
+ connector = FakePluginConnector(
+ results=[
+ {
+ 'type': 'message.delta',
+ 'data': {
+ 'chunk': {
+ 'role': 'assistant',
+ 'content': 'hello',
+ 'all_content': 'hello',
+ }
+ },
+ },
+ {
+ 'type': 'message.completed',
+ 'data': {'message': {'role': 'assistant', 'content': 'hello world'}},
+ },
+ {'type': 'run.completed', 'data': {'finish_reason': 'stop'}},
+ ]
+ )
+ ap = FakeApplication(connector, clean_agent_state)
+ orchestrator = AgentRunOrchestrator(ap, FakeRegistry(make_descriptor()))
+ adapter = types.SimpleNamespace(
+ is_stream_output_supported=AsyncMock(return_value=True),
+ create_message_card=AsyncMock(return_value=True),
+ reply_message_chunk=AsyncMock(),
+ reply_message=AsyncMock(),
+ send_message=AsyncMock(),
+ )
+ incoming = platform_events.MessageReceivedEvent(
+ message_id='message-1',
+ sender=platform_entities.User(id='user-1'),
+ chat_type=platform_entities.ChatType.PRIVATE,
+ chat_id='user-1',
+ message_chain=platform_message.MessageChain([platform_message.Plain(text='hi')]),
+ source_platform_object=object(),
+ )
+ event = AgentEventEnvelope(
+ event_id='event-1',
+ event_type='message.received',
+ source='platform',
+ bot_id='bot_001',
+ workspace_id=TEST_CONTEXT.workspace_uuid,
+ conversation_id='person_user-1',
+ input=AgentInput(text='hi'),
+ delivery=DeliveryContext(
+ surface='platform',
+ reply_target={'target_type': 'person', 'target_id': 'user-1', 'message_id': 'message-1'},
+ supports_streaming=True,
+ ),
+ )
+ binding = AgentBinding(
+ binding_id='binding-1',
+ scope=BindingScope(scope_type='bot', scope_id='bot_001'),
+ event_types=['message.received'],
+ runner_id=RUNNER_ID,
+ resource_policy=ResourcePolicy(),
+ state_policy=StatePolicy(),
+ delivery_policy=DeliveryPolicy(enable_streaming=True, enable_reply=True),
+ processor_type='agent',
+ processor_id='agent-1',
+ )
+
+ results = [
+ item
+ async for item in orchestrator.run(
+ event,
+ binding,
+ adapter_context={
+ '_delivery_adapter': adapter,
+ '_platform_event': incoming,
+ '_execution_context': TEST_CONTEXT,
+ },
+ )
+ ]
+
+ assert [item.content for item in results] == ['hello', 'hello world']
+ adapter.create_message_card.assert_awaited_once()
+ chunks = adapter.reply_message_chunk.await_args_list
+ assert [call.kwargs['bot_message'].all_content for call in chunks] == ['hello', 'hello world']
+ assert [call.kwargs['is_final'] for call in chunks] == [False, True]
+ adapter.reply_message.assert_not_awaited()
+ adapter.send_message.assert_not_awaited()
+
+
class TestQueryEntryAdapterParams:
"""Tests for params handling in Query entry adapter."""
diff --git a/tests/unit_tests/agent/test_platform_tools.py b/tests/unit_tests/agent/test_platform_tools.py
index c9af18152..d2dad499a 100644
--- a/tests/unit_tests/agent/test_platform_tools.py
+++ b/tests/unit_tests/agent/test_platform_tools.py
@@ -364,6 +364,36 @@ async def test_event_reply_builds_message_chain_for_the_frozen_target() -> None:
assert call.kwargs['message'][0].text == 'hello'
+@pytest.mark.asyncio
+async def test_event_reply_uses_run_reply_stream_when_available() -> None:
+ adapter = SimpleNamespace(
+ get_supported_apis=lambda: ['send_message'],
+ send_message=AsyncMock(return_value=None),
+ )
+ reply_streams = SimpleNamespace(
+ adapter=adapter,
+ apply=AsyncMock(return_value={'status': 'completed', 'delivery': 'streaming'}),
+ )
+ ap = SimpleNamespace(
+ platform_mgr=SimpleNamespace(get_bot_by_uuid=AsyncMock(return_value=SimpleNamespace(adapter=adapter)))
+ )
+ session = {
+ 'reply_streams': reply_streams,
+ 'authorization': {
+ 'bot_id': 'bot-1',
+ 'platform_context': freeze_platform_context(_event()),
+ },
+ }
+
+ result = await execute_platform_tool(ap, object(), session, 'event_reply', {'text': 'hello'})
+
+ request = reply_streams.apply.await_args.args[0]
+ assert request.operation == 'finish'
+ assert request.text == 'hello'
+ assert result == {'status': 'completed', 'delivery': 'streaming'}
+ adapter.send_message.assert_not_awaited()
+
+
@pytest.mark.asyncio
async def test_present_interactive_card_uses_host_interaction_manager() -> None:
adapter = SimpleNamespace(get_supported_apis=lambda: ['interaction.request'])
diff --git a/tests/unit_tests/platform/test_dingtalk_adapter.py b/tests/unit_tests/platform/test_dingtalk_adapter.py
index b2974837a..af7de7364 100644
--- a/tests/unit_tests/platform/test_dingtalk_adapter.py
+++ b/tests/unit_tests/platform/test_dingtalk_adapter.py
@@ -8,11 +8,11 @@ import pytest
from langbot.pkg.platform import botmgr as _botmgr # noqa: F401
from langbot.pkg.platform.sources.dingtalk import (
DingTalkAdapter,
+ _dingtalk_a2ui_form,
_dingtalk_card_markdown,
_dingtalk_clean_form_content,
_dingtalk_completed_input_lines,
_dingtalk_extract_component_inputs,
- _dingtalk_form_component_params,
_dingtalk_missing_completed_input_lines,
_dingtalk_pending_input_defs,
)
@@ -29,29 +29,30 @@ def test_dingtalk_auxiliary_tasks_are_bounded():
assert len(adapter._background_tasks) == 100
-def test_dingtalk_select_component_params_expose_options():
- params = _dingtalk_form_component_params(
+def test_dingtalk_dify_input_card_uses_a2ui_binding_and_required_check():
+ data, components = _dingtalk_a2ui_form(
{
- '_current_input_field': 'choice',
+ 'node_title': 'Review',
+ 'form_content': 'Add a comment',
+ '_current_input_field': 'comment',
'input_defs': [
{
- 'output_variable_name': 'choice',
- 'type': 'select',
- 'option_source': {'type': 'constant', 'value': ['A', 'B']},
+ 'output_variable_name': 'comment',
+ 'type': 'textarea',
+ 'label': 'Comment',
}
],
'inputs': {},
- }
+ },
+ 'track-id',
)
- 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
+ field = next(component for component in components if component['component'] == 'TextField')
+ button = next(component for component in components if component['component'] == 'Button')
+ assert data['form']['value'] == ''
+ assert field['value'] == {'path': '/form/value'}
+ assert button['action']['event']['context']['input'] == {'path': '/form/value'}
+ assert button['checks'][0]['condition']['call'] == 'required'
def test_dingtalk_extract_select_from_builtin_result_dict():
diff --git a/tests/unit_tests/platform/test_dingtalk_api.py b/tests/unit_tests/platform/test_dingtalk_api.py
index 91803baac..8d467bc59 100644
--- a/tests/unit_tests/platform/test_dingtalk_api.py
+++ b/tests/unit_tests/platform/test_dingtalk_api.py
@@ -1,57 +1,19 @@
-"""Tests for DingTalk API payload helpers."""
+"""Tests for DingTalk A2UI API payloads."""
import json
from contextlib import asynccontextmanager
from unittest.mock import AsyncMock
-from langbot.libs.dingtalk_api.api import DingTalkClient, _stringify_card_param_map
-from langbot.pkg.utils import httpclient
+import pytest
+
+from langbot.libs.dingtalk_api.a2ui import create_text_card_messages, update_text_card_messages
+from langbot.libs.dingtalk_api.api import DingTalkClient
-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'] == ''
-
-
-async def test_create_card_embeds_layout_config_as_template_parameter(monkeypatch):
- response = type('Response', (), {'status_code': 200, 'json': lambda self: {}})()
- post = AsyncMock(return_value=response)
-
+def _client(http_client):
@asynccontextmanager
async def client_context():
- yield type('HttpClient', (), {'post': post})()
+ yield http_client
client = object.__new__(DingTalkClient)
client.access_token = 'access-token'
@@ -59,26 +21,100 @@ async def test_create_card_embeds_layout_config_as_template_parameter(monkeypatc
client.key = 'client-id'
client.logger = None
client.check_access_token = AsyncMock(return_value=True)
+ client.get_access_token = AsyncMock()
client._http_client_context = client_context
- monkeypatch.setattr(httpclient, 'response_text', AsyncMock(return_value='{}'))
+ return client
- original_params = {'content': 'hello'}
- delivered = await client.create_and_deliver_card(
- card_template_id='template-id',
+
+def _response(result=None):
+ payload = {'success': True, 'result': result or {}}
+ return type('Response', (), {'status_code': 200, 'json': lambda self: payload})()
+
+
+@pytest.mark.asyncio
+async def test_create_ai_card_uses_group_robot_delivery_model():
+ post = AsyncMock(
+ return_value=_response(
+ {
+ 'outTrackId': 'track-id',
+ 'deliverResults': [{'spaceType': 'IM_GROUP', 'success': True}],
+ }
+ )
+ )
+ client = _client(type('HttpClient', (), {'post': post})())
+
+ result = await client.create_and_deliver_ai_card(
out_track_id='track-id',
- open_space_id='dtv1.card//IM_ROBOT.user-id',
- is_group=False,
- card_param_map=original_params,
- card_data_config={'autoLayout': True},
+ open_space_id='dtv1.card//im_group.group-id',
+ is_group=True,
+ a2ui_messages=create_text_card_messages('surface-id', 'hello'),
+ summary='hello',
)
- request_body = post.await_args.kwargs['json']
- assert delivered is True
- assert request_body['cardData'] == {
- 'cardParamMap': {
- 'content': 'hello',
- 'config': '{"autoLayout": true}',
- 'autoLayout': 'true',
- }
- }
- assert original_params == {'content': 'hello'}
+ request = post.await_args
+ body = request.kwargs['json']
+ assert request.args[0].endswith('/v1.0/card/ai/instances/createAndDeliver')
+ assert result['outTrackId'] == 'track-id'
+ assert body['imGroupOpenDeliverModel'] == {'robotCode': 'robot-code'}
+ assert body['imGroupOpenSpaceModel'] == {'supportForward': False}
+ assert 'imRobotOpenDeliverModel' not in body
+ assert 'cardTemplateId' not in body
+ assert body['protocolVersion'] == '1.0'
+ assert body['flowStatus'] == 'PROCESSING'
+ assert all(isinstance(message, str) for message in body['a2uiMessages'])
+ assert json.loads(body['a2uiMessages'][0])['createSurface']['surfaceId'] == 'surface-id'
+
+
+@pytest.mark.asyncio
+async def test_create_ai_card_uses_single_chat_robot_delivery_model():
+ post = AsyncMock(return_value=_response({'deliverResults': [{'spaceType': 'IM_ROBOT', 'success': True}]}))
+ client = _client(type('HttpClient', (), {'post': post})())
+
+ await client.create_and_deliver_ai_card(
+ out_track_id='track-id',
+ open_space_id='dtv1.card//im_robot.user-id',
+ is_group=False,
+ a2ui_messages=create_text_card_messages('surface-id'),
+ )
+
+ body = post.await_args.kwargs['json']
+ assert body['imRobotOpenDeliverModel'] == {'robotCode': 'robot-code'}
+ assert body['imRobotOpenSpaceModel'] == {'supportForward': False}
+ assert 'imGroupOpenDeliverModel' not in body
+
+
+@pytest.mark.asyncio
+async def test_update_ai_card_uses_a2ui_delta_and_idempotency_key():
+ put = AsyncMock(return_value=_response({'outTrackId': 'track-id', 'flowStatus': 'FINISH'}))
+ client = _client(type('HttpClient', (), {'put': put})())
+
+ await client.update_ai_card(
+ out_track_id='track-id',
+ out_update_id='update-id',
+ flow_status='FINISH',
+ a2ui_messages=update_text_card_messages('surface-id', 'done'),
+ )
+
+ request = put.await_args
+ body = request.kwargs['json']
+ assert request.args[0].endswith('/v1.0/card/ai/instances/update')
+ assert body['outTrackId'] == 'track-id'
+ assert body['outUpdateId'] == 'update-id'
+ assert body['flowStatus'] == 'FINISH'
+ assert json.loads(body['a2uiMessages'][0])['updateDataModel']['path'] == '/content'
+
+
+@pytest.mark.asyncio
+async def test_create_ai_card_rejects_failed_delivery_result():
+ post = AsyncMock(
+ return_value=_response({'deliverResults': [{'spaceType': 'IM_GROUP', 'success': False, 'errorMsg': 'denied'}]})
+ )
+ client = _client(type('HttpClient', (), {'post': post})())
+
+ with pytest.raises(RuntimeError, match='delivery failed'):
+ await client.create_and_deliver_ai_card(
+ out_track_id='track-id',
+ open_space_id='dtv1.card//im_group.group-id',
+ is_group=True,
+ a2ui_messages=create_text_card_messages('surface-id'),
+ )
diff --git a/tests/unit_tests/platform/test_dingtalk_eba_adapter.py b/tests/unit_tests/platform/test_dingtalk_eba_adapter.py
index 74d5a1c04..83956c45f 100644
--- a/tests/unit_tests/platform/test_dingtalk_eba_adapter.py
+++ b/tests/unit_tests/platform/test_dingtalk_eba_adapter.py
@@ -1,6 +1,5 @@
from __future__ import annotations
-import json
import pathlib
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock, patch
@@ -51,7 +50,8 @@ class DummyDingTalkClient(DingTalkClient):
self.download_image = AsyncMock(return_value='data:image/png;base64,BBBB')
self.create_and_card = AsyncMock(return_value=('card', 'card-id'))
self.send_card_message = AsyncMock()
- self.create_and_deliver_card = AsyncMock(return_value=True)
+ self.create_and_deliver_ai_card = AsyncMock(return_value={})
+ self.update_ai_card = AsyncMock(return_value={})
self.start = AsyncMock()
self.stop = AsyncMock()
@@ -85,9 +85,6 @@ def make_adapter() -> DingTalkAdapter:
'robot_code': 'robot-code',
'markdown_card': True,
'enable-stream-reply': False,
- 'card_auto_layout': False,
- 'card_template_id': 'template-id',
- 'human_input_card_template_id': 'human-input-template-id',
}
with patch('langbot.pkg.platform.adapters.dingtalk.adapter.DingTalkClient', DummyDingTalkClient):
return DingTalkAdapter(config, DummyLogger())
@@ -161,36 +158,6 @@ def test_dingtalk_platform_api_map_matches_manifest():
assert set(PLATFORM_API_MAP) == manifest_actions
-def test_dingtalk_human_input_card_template_matches_interaction_contract():
- template_path = (
- pathlib.Path(__file__).parents[3] / 'src' / 'langbot' / 'templates' / 'dingtalk_human_input_card.json'
- )
- exported_template = json.loads(template_path.read_text(encoding='utf-8'))
- editor_data = json.loads(exported_template['editorData'])
- variable_ids = {item['id'] for item in editor_data['variableList']}
- serialized_editor = json.dumps(editor_data, ensure_ascii=False)
-
- assert exported_template['type'] == 'im'
- assert exported_template['mode'] == 'card'
- assert {
- 'content',
- 'btns',
- 'input_visible',
- 'input_title',
- 'input_placeholder',
- 'input_value',
- 'select_visible',
- 'select_placeholder',
- 'select_options',
- 'select_index',
- 'index_o',
- } <= variable_ids
- assert '"componentName": "Input"' in serialized_editor
- assert '"componentName": "SelectBlock"' in serialized_editor
- assert '__built_in_inputResult__' in serialized_editor
- assert '__built_in_selectResult__' in serialized_editor
-
-
@pytest.mark.asyncio
async def test_dingtalk_message_converter_maps_outbound_components():
content, at = await DingTalkMessageConverter.yiri2target(
@@ -367,10 +334,12 @@ async def test_dingtalk_interaction_delivery_and_callback_event():
)
assert result['rich'] is True
- kwargs = adapter.bot.create_and_deliver_card.await_args.kwargs
- assert kwargs['card_template_id'] == 'human-input-template-id'
- assert kwargs['open_space_id'] == 'dtv1.card//IM_GROUP.group-1'
- assert kwargs['card_param_map']['btns'][0]['event']['params']['actionId'] == 'lbi:callback-token:a:0'
+ kwargs = adapter.bot.create_and_deliver_ai_card.await_args.kwargs
+ assert kwargs['open_space_id'] == 'dtv1.card//im_group.group-1'
+ assert kwargs['flow_status'] == 'CONFIRMING'
+ components = kwargs['a2ui_messages'][2]['updateComponents']['components']
+ button = next(component for component in components if component['component'] == 'Button')
+ assert button['action']['event']['context']['actionId'] == 'lbi:callback-token:a:0'
native = dingtalk_card_callback_event(
content={'actionId': 'lbi:callback-token:a:0'},
@@ -409,20 +378,34 @@ async def test_dingtalk_native_input_uses_template_variables_and_submits_value()
},
)
- params = adapter.bot.create_and_deliver_card.await_args.kwargs['card_param_map']
- assert params['input_visible'] == 'true'
- assert params['input_title'] == 'Comment'
- assert params['input_placeholder'] == 'Explain the decision'
- assert params['select_visible'] == ''
- assert params['btns'] == []
+ kwargs = adapter.bot.create_and_deliver_ai_card.await_args.kwargs
+ components = kwargs['a2ui_messages'][2]['updateComponents']['components']
+ field = next(component for component in components if component['component'] == 'TextField')
+ button = next(component for component in components if component['component'] == 'Button')
+ assert field['label'] == 'Comment'
+ assert field['placeholder'] == 'Explain the decision'
+ assert field['variant'] == 'longText'
+ assert button['checks'] == [
+ {
+ 'condition': {'call': 'required', 'args': {'value': {'path': '/form/comment'}}},
+ 'message': 'Comment is required',
+ }
+ ]
assert result['message_id'] in adapter.interaction_callback_contexts
native = dingtalk_card_callback_event(
content={
- 'outTrackId': result['message_id'],
- 'params': {'input': 'Looks good'},
+ 'actionData': {
+ 'a2uiAction': {
+ 'context': {
+ 'actionId': 'lbi:callback-token:f:0',
+ 'values': {'comment': 'Looks good'},
+ }
+ }
+ }
},
- space_id='dtv1.card//IM_GROUP.group-1',
+ card_instance_id=result['message_id'],
+ space_id='dtv1.card//im_group.group-1',
)
event = interaction_event_from_native(native, adapter.interaction_callback_contexts)
@@ -459,17 +442,25 @@ async def test_dingtalk_native_select_uses_select_block_and_normalizes_callback(
},
)
- params = adapter.bot.create_and_deliver_card.await_args.kwargs['card_param_map']
- assert params['select_visible'] == 'true'
- assert [option['value'] for option in params['index_o']] == ['normal', 'urgent']
- assert params['input_visible'] == ''
+ kwargs = adapter.bot.create_and_deliver_ai_card.await_args.kwargs
+ components = kwargs['a2ui_messages'][2]['updateComponents']['components']
+ field = next(component for component in components if component['component'] == 'ChoicePicker')
+ assert field['displayStyle'] == 'dropdown'
+ assert [option['value'] for option in field['options']] == ['normal', 'urgent']
native = dingtalk_card_callback_event(
content={
- 'out_track_id': result['message_id'],
- 'params': {'select': '{"index": 1, "value": "urgent"}'},
+ 'actionData': {
+ 'a2uiAction': {
+ 'context': {
+ 'actionId': 'lbi:callback-token:f:0',
+ 'values': {'priority': ['urgent']},
+ }
+ }
+ }
},
- space_id='dtv1.card//IM_ROBOT.user-1',
+ card_instance_id=result['message_id'],
+ space_id='dtv1.card//im_robot.user-1',
)
event = interaction_event_from_native(native, adapter.interaction_callback_contexts)
@@ -478,6 +469,58 @@ async def test_dingtalk_native_select_uses_select_block_and_normalizes_callback(
assert event.data['values'] == {'priority': 'urgent'}
+@pytest.mark.asyncio
+async def test_dingtalk_interaction_update_reuses_existing_a2ui_card():
+ adapter = make_adapter()
+
+ result = await adapter.call_platform_api(
+ 'interaction.request',
+ {
+ 'callback_token': 'new-token',
+ 'reply_target': {'target_type': 'group', 'target_id': 'group-1'},
+ 'update_target': {'message_id': 'existing-track-id', 'rich': True},
+ 'request': {
+ 'interaction_id': 'form-2',
+ 'title': 'Updated approval',
+ 'actions': [{'id': 'approve', 'label': 'Approve', 'style': 'primary'}],
+ 'fallback_text': 'Reply approve.',
+ },
+ },
+ )
+
+ adapter.bot.create_and_deliver_ai_card.assert_not_awaited()
+ kwargs = adapter.bot.update_ai_card.await_args.kwargs
+ assert kwargs['out_track_id'] == 'existing-track-id'
+ assert kwargs['flow_status'] == 'CONFIRMING'
+ assert 'updateDataModel' in kwargs['a2ui_messages'][0]
+ assert 'updateComponents' in kwargs['a2ui_messages'][1]
+ assert result == {
+ 'ok': True,
+ 'message_id': 'existing-track-id',
+ 'rich': True,
+ 'updated': True,
+ }
+ assert adapter.interaction_callback_contexts['existing-track-id']['callback_token'] == 'new-token'
+
+
+def test_dingtalk_stale_callback_does_not_consume_replacement_context():
+ contexts = {
+ 'card-1': {
+ 'callback_token': 'new-token',
+ 'field_types': {},
+ 'expires_at': float('inf'),
+ }
+ }
+ native = dingtalk_card_callback_event(
+ content={'actionId': 'lbi:old-token:a:0'},
+ card_instance_id='card-1',
+ space_id='dtv1.card//im_group.group-1',
+ )
+
+ assert interaction_event_from_native(native, contexts) is None
+ assert contexts['card-1']['callback_token'] == 'new-token'
+
+
@pytest.mark.asyncio
async def test_dingtalk_adapter_dispatches_native_input_submission():
adapter = make_adapter()
@@ -509,10 +552,17 @@ async def test_dingtalk_adapter_dispatches_native_input_submission():
)
native = dingtalk_card_callback_event(
content={
- 'outTrackId': result['message_id'],
- 'params': {'inputResult': '42.5'},
+ 'actionData': {
+ 'a2uiAction': {
+ 'context': {
+ 'actionId': 'lbi:callback-token:f:0',
+ 'values': {'score': 42.5},
+ }
+ }
+ }
},
- space_id='dtv1.card//IM_GROUP.group-1',
+ card_instance_id=result['message_id'],
+ space_id='dtv1.card//im_group.group-1',
)
await adapter._handle_native_event(native)
diff --git a/tests/unit_tests/platform/test_routing_rules.py b/tests/unit_tests/platform/test_routing_rules.py
index 48951c08b..f9cacbd2b 100644
--- a/tests/unit_tests/platform/test_routing_rules.py
+++ b/tests/unit_tests/platform/test_routing_rules.py
@@ -787,8 +787,8 @@ async def test_bound_event_processor_receives_one_complete_typed_event():
@pytest.mark.parametrize('output_kind', ['message', 'chunks', 'tool_rounds'])
@pytest.mark.parametrize('explicit_reply', [False, True])
@pytest.mark.parametrize('runner_fails', [False, True])
-async def test_processor_outputs_require_explicit_platform_actions(kind, output_kind, explicit_reply, runner_fails):
- """Draining runner results must not send text, duplicate replies, or stream cards."""
+async def test_processor_output_delivery_policy(kind, output_kind, explicit_reply, runner_fails):
+ """Direct Agents may auto-deliver output; event processors remain explicit-only."""
from langbot_plugin.api.entities.builtin.platform import entities, events, message
from langbot_plugin.api.entities.builtin.provider import message as provider_message
from datetime import datetime
@@ -812,9 +812,9 @@ async def test_processor_outputs_require_explicit_platform_actions(kind, output_
restored = message.MessageChain.model_validate(envelope.data['message_chain'])
assert restored.source.id == 'source-1'
assert restored.source.time == datetime(2026, 10, 8, 12)
- assert envelope.delivery.supports_streaming is False
- assert binding.delivery_policy.enable_streaming is False
- assert binding.delivery_policy.enable_reply is False
+ assert envelope.delivery.supports_streaming is (kind == 'agent')
+ assert binding.delivery_policy.enable_streaming is (kind == 'agent')
+ assert binding.delivery_policy.enable_reply is (kind == 'agent')
assert adapter_context['_delivery_adapter'] is adapter
if explicit_reply:
session = {'authorization': {'bot_id': 'bot-1', 'platform_context': freeze_platform_context(envelope)}}