diff --git a/src/langbot/libs/dingtalk_api/api.py b/src/langbot/libs/dingtalk_api/api.py index d6a4914ce..f5f50933b 100644 --- a/src/langbot/libs/dingtalk_api/api.py +++ b/src/langbot/libs/dingtalk_api/api.py @@ -14,6 +14,7 @@ import websockets from .EchoHandler import EchoTextHandler from .card_callback import DingTalkCardActionHandler from .dingtalkevent import DingTalkEvent +from .thinking import card_text_fields, visible_think_markers import httpx import traceback from langbot.pkg.utils import httpclient @@ -74,6 +75,12 @@ def _stringify_card_param_map(card_param_map: Optional[dict]) -> dict: return result +def is_stream_card_configured(config: dict) -> bool: + 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' + + class DingTalkClient: _MAX_INBOUND_TASKS = 100 @@ -108,7 +115,7 @@ class DingTalkClient: } self.access_token = '' self.robot_name = robot_name - self.robot_code = robot_code + self.robot_code = (robot_code or '').strip() or client_id self.access_token_expiry_time = '' self.markdown_card = markdown_card self.logger = logger @@ -258,28 +265,101 @@ class DingTalkClient: async def update_incoming_message(self, message): """异步更新 DingTalkClient 中的 incoming_message""" + # Resolve identity before downloading any attachments from this event. + robot_code = getattr(message, 'robot_code', None) + if isinstance(robot_code, str) and robot_code.strip(): + self.robot_code = robot_code.strip() message_data = await self.get_message(message) if message_data: event = DingTalkEvent.from_payload(message_data) if event: await self._handle_message(event) + @staticmethod + def _check_send_result(response): + # HTTP 200 can still contain a rejected DingTalk message. + data = response.json() + if response.status_code != 200 or data.get('errcode', 0) != 0 or data.get('code'): + raise RuntimeError( + f"DingTalk delivery failed (HTTP {response.status_code}, " + f"code={data.get('errcode', data.get('code', 'unknown'))}): " + f"{data.get('errmsg', data.get('message', 'Unknown error'))}" + ) + return data + async def send_message(self, content: str, incoming_message, at: bool): + if not content.strip(): + return {} + content = visible_think_markers(content, self.markdown_card) if self.markdown_card: - if at: - self.EchoTextHandler.reply_markdown( - title='@' + incoming_message.sender_nick + ' ' + content, - text='@' + incoming_message.sender_nick + ' ' + content, - incoming_message=incoming_message, - ) - else: - self.EchoTextHandler.reply_markdown( - title=content, - text=content, - incoming_message=incoming_message, - ) + text = ('@' + incoming_message.sender_nick + ' ' if at else '') + content + payload = {'msgtype': 'markdown', 'markdown': {'title': text[:80], 'text': text}} else: - self.EchoTextHandler.reply_text(content, incoming_message) + payload = {'msgtype': 'text', 'text': {'content': content}} + payload['at'] = {'atUserIds': [incoming_message.sender_staff_id]} + async with self._http_client_context() as client: + response = await client.post(incoming_message.session_webhook, json=payload, timeout=30.0) + return self._check_send_result(response) + + async def send_attachment(self, component, incoming_message): + from langbot_plugin.api.entities.builtin.platform.message import Image + + is_image = isinstance(component, Image) + name = getattr(component, 'name', '') or ('image.png' if is_image else 'file') + name = os.path.basename(name) + encoded = getattr(component, 'base64', None) + if encoded: + encoded = encoded.split(',', 1)[-1] + if len(encoded) > 4 * ((_MAX_MEDIA_BYTES + 2) // 3): + raise ValueError('DingTalk media exceeds the size limit') + body = base64.b64decode(encoded, validate=True) + elif getattr(component, 'path', None): + body = await asyncio.to_thread(_read_local_media_limited, component.path) + elif getattr(component, 'url', None): + async with self._http_client_context() as client: + async with client.stream('GET', component.url, timeout=30.0) as response: + response.raise_for_status() + body = await _read_httpx_media_limited(response) + else: + raise ValueError('DingTalk attachment has no readable content') + if len(body) > _MAX_MEDIA_BYTES: + raise ValueError('DingTalk media exceeds the size limit') + token = await self.get_legacy_access_token() + if not token: + raise RuntimeError('DingTalk media authentication failed') + async with self._http_client_context() as client: + response = await client.post( + 'https://oapi.dingtalk.com/media/upload', + params={'access_token': token, 'type': 'image' if is_image else 'file'}, + files={'media': (name, body)}, timeout=30.0, + ) + uploaded = self._check_send_result(response) + media_id = uploaded.get('media_id') + if not media_id: + raise RuntimeError('DingTalk media upload returned no media ID') + if not await self.check_access_token(): + await self.get_access_token() + params = {'photoURL': media_id} if is_image else { + 'mediaId': media_id, 'fileName': name, 'fileType': os.path.splitext(name)[1].lstrip('.') + } + payload = { + 'robotCode': self.robot_code or self.key, + 'msgKey': 'sampleImageMsg' if is_image else 'sampleFile', + 'msgParam': json.dumps(params, ensure_ascii=False), + } + if str(incoming_message.conversation_type) == '2': + endpoint = 'groupMessages/send' + payload['openConversationId'] = incoming_message.conversation_id + else: + endpoint = 'oToMessages/batchSend' + payload['userIds'] = [incoming_message.sender_staff_id] + async with self._http_client_context() as client: + response = await client.post( + f'{DINGTALK_OPENAPI_BASE}/v1.0/robot/{endpoint}', + headers={'x-acs-dingtalk-access-token': self.access_token}, + json=payload, timeout=30.0, + ) + return self._check_send_result(response) async def get_incoming_message(self): """获取收到的消息""" @@ -562,7 +642,7 @@ class DingTalkClient: 'robotCode': robot_code, 'userIds': [target_id], 'msgKey': 'sampleText', - 'msgParam': json.dumps({'content': content}), + 'msgParam': json.dumps({'content': visible_think_markers(content, False)}), } _stdout_logger.info( 'DingTalk send_proactive_message_to_one request: robotCode=%s target_id=%s content_len=%d', @@ -606,7 +686,7 @@ class DingTalkClient: 'robotCode': self.robot_code or self.key, 'openConversationId': target_id, 'msgKey': 'sampleText', - 'msgParam': json.dumps({'content': content}), + 'msgParam': json.dumps({'content': visible_think_markers(content, False)}), } try: async with self._http_client_context() as client: @@ -627,14 +707,13 @@ class DingTalkClient: temp_card_id: str, incoming_message: dingtalk_stream.ChatbotMessage, quote_origin: bool = False, - card_auto_layout: 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 - `(None, out_track_id)` to keep call sites compatible with the - previous `(card_instance, card_instance_id)` shape — the first slot - is unused now that everything is driven by out_track_id. + `(layout_config, out_track_id)` keeps the adapter's tuple contract + while preserving the layout setting on subsequent data updates. """ out_track_id = uuid.uuid4().hex is_group = str(incoming_message.conversation_type) == '2' @@ -643,7 +722,7 @@ class DingTalkClient: else: open_space_id = f'dtv1.card//IM_ROBOT.{incoming_message.sender_staff_id}' - card_param_map = {'content': ''} + card_param_map = {'content': '', 'answer': '', 'reasoning': '', 'flowStatus': '1', 'brand': '**🤖 LangBot**', 'hasReasoning': ''} if incoming_message.message_type == 'text': card_param_map['query'] = incoming_message.get_text_list()[0] else: @@ -657,30 +736,21 @@ class DingTalkClient: card_param_map=card_param_map, card_data_config={'autoLayout': card_auto_layout}, ) - return None, out_track_id + return {'autoLayout': card_auto_layout}, out_track_id async def send_card_message(self, card_instance, card_instance_id: str, content: str, is_final: bool): - """Stream a single chunk into an existing card's `content` field.""" - try: - await self.streaming_update_card( - out_track_id=card_instance_id, - content_key='content', - content_value=content, - append=False, - finished=is_final, - failed=False, - ) - except Exception as e: - if self.logger: - self.logger.exception(e) - await self.streaming_update_card( - out_track_id=card_instance_id, - content_key='content', - content_value='', - append=False, - finished=is_final, - failed=True, - ) + """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'] = '**🤖 LangBot**' + 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') async def create_and_deliver_card( self, @@ -710,6 +780,7 @@ class DingTalkClient: 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)} body: dict = { @@ -743,34 +814,17 @@ class DingTalkClient: 'Content-Type': 'application/json', } try: - _stdout_logger.info( - 'DingTalk createAndDeliver request body: %s', - json.dumps(body, ensure_ascii=False)[:1500], - ) async with self._http_client_context() as client: response = await client.post(url, headers=headers, json=body, timeout=30.0) - response_body = await httpclient.response_text(response, max_chars=500) - if response.status_code == 200: - _stdout_logger.info( - 'DingTalk createAndDeliver response: %s', - response_body, - ) - return True - _stdout_logger.error( - 'DingTalk createAndDeliver failed: status=%s body=%s', - response.status_code, - response_body, - ) - if self.logger: - await self.logger.error( - f'DingTalk createAndDeliver failed: status={response.status_code} body={response_body}' - ) - return False - except Exception: - _stdout_logger.exception('DingTalk createAndDeliver error') - if self.logger: - await self.logger.error(f'DingTalk createAndDeliver error: {traceback.format_exc()}') - return False + # 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) + except Exception as exc: + error = RuntimeError(f'DingTalk 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 async def streaming_update_card( self, diff --git a/src/langbot/libs/dingtalk_api/thinking.py b/src/langbot/libs/dingtalk_api/thinking.py new file mode 100644 index 000000000..a69f85250 --- /dev/null +++ b/src/langbot/libs/dingtalk_api/thinking.py @@ -0,0 +1,37 @@ +"""Render reasoning markers without changing the pipeline output policy.""" + +import re + +_MARKER = re.compile(r"", re.IGNORECASE) + + +def visible_think_markers(content: str, markdown: bool = True) -> str: + def replace(match): + tag = match.group(0).lower() + return "\n\n" + (f"`{tag}`" if markdown else tag) + "\n\n" + + return _MARKER.sub(replace, content).strip() + + +def card_text_fields(content: str) -> dict[str, str]: + thinking, answer = [], [] + inside = False + offset = 0 + for match in _MARKER.finditer(content): + (thinking if inside else answer).append(content[offset:match.start()]) + inside = match.group(0).lower() == "" + offset = match.end() + tail = content[offset:] + # A stream can stop in the middle of an opening or closing marker. + for size in range(min(len(tail), 7), 0, -1): + if any(marker.startswith(tail[-size:].lower()) for marker in ("", "")): + tail = tail[:-size] + break + (thinking if inside else answer).append(tail) + reasoning = "\n".join(part.strip() for part in thinking if part.strip()) + response = "\n".join(part.strip() for part in answer if part.strip()) + return { + "reasoning": "> " + reasoning.replace("\n", "\n> ") if reasoning else "", + "answer": response, + "hasReasoning": "true" if reasoning else "", + } 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 75a046e8c..bcc81d855 100644 --- a/src/langbot/pkg/api/http/controller/groups/platform/adapters.py +++ b/src/langbot/pkg/api/http/controller/groups/platform/adapters.py @@ -187,6 +187,15 @@ 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' diff --git a/src/langbot/pkg/platform/adapters/dingtalk/adapter.py b/src/langbot/pkg/platform/adapters/dingtalk/adapter.py index 230959719..2a7720130 100644 --- a/src/langbot/pkg/platform/adapters/dingtalk/adapter.py +++ b/src/langbot/pkg/platform/adapters/dingtalk/adapter.py @@ -1,12 +1,13 @@ from __future__ import annotations +import time import traceback import typing import dingtalk_stream import pydantic -from langbot.libs.dingtalk_api.api import DingTalkClient +from langbot.libs.dingtalk_api.api import DingTalkClient, is_stream_card_configured from langbot.libs.dingtalk_api.dingtalkevent import DingTalkEvent import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger @@ -75,7 +76,7 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo arbitrary_types_allowed = True def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger): - required_keys = ['client_id', 'client_secret', 'robot_name', 'robot_code'] + required_keys = ['client_id', 'client_secret'] missing_keys = [key for key in required_keys if key not in config] if missing_keys: raise Exception('钉钉缺少相关配置项,请查看文档或联系管理员') @@ -83,8 +84,8 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo bot = DingTalkClient( client_id=config['client_id'], client_secret=config['client_secret'], - robot_name=config['robot_name'], - robot_code=config['robot_code'], + robot_name=config.get('robot_name', ''), + robot_code=config.get('robot_code', ''), markdown_card=config.get('markdown_card', True), logger=logger, ) @@ -92,7 +93,7 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo config=config, logger=logger, card_instance_id_dict={}, - bot_account_id=config['robot_name'], + bot_account_id=config['client_id'], bot=bot, listeners={}, _message_cache={}, @@ -158,13 +159,28 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo assert isinstance(message_source.source_platform_object, DingTalkEvent) incoming_message = message_source.source_platform_object.incoming_message markdown_enabled = self.config.get('markdown_card', False) - content, at = await DingTalkMessageConverter.yiri2target(message, markdown_enabled) + text_message = platform_message.MessageChain([ + part for part in message if not isinstance(part, (platform_message.Image, platform_message.File)) + ]) + content, at = await DingTalkMessageConverter.yiri2target(text_message, markdown_enabled) raw = await self.bot.send_message(content, incoming_message, at) + await self._send_attachments(message, incoming_message) return platform_events.MessageResult( message_id=getattr(incoming_message, 'message_id', None), raw=raw if isinstance(raw, dict) else {'result': raw}, ) + async def _send_attachments(self, message, incoming_message): + failures = [] + for part in message: + if isinstance(part, (platform_message.Image, platform_message.File)): + try: + await self.bot.send_attachment(part, incoming_message) + except Exception as exc: + failures.append(str(exc)) + if failures: + raise RuntimeError('; '.join(failures)) + async def reply_message_chunk( self, message_source: platform_events.MessageEvent, @@ -174,12 +190,19 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo is_final: bool = False, ): message_id = bot_message.resp_message_id - msg_seq = bot_message.msg_sequence - if (msg_seq - 1) % 8 != 0 and not is_final: + now = time.monotonic() + last_updates = getattr(self, '_card_last_updates', None) + if last_updates is None: + self._card_last_updates = last_updates = {} + if not is_final and now - last_updates.get(message_id, 0) < 0.35: return + last_updates[message_id] = now markdown_enabled = self.config.get('markdown_card', False) - content, _ = await DingTalkMessageConverter.yiri2target(message, markdown_enabled) + text_message = platform_message.MessageChain([ + part for part in message if not isinstance(part, (platform_message.Image, platform_message.File)) + ]) + content, _ = await DingTalkMessageConverter.yiri2target(text_message, markdown_enabled) card_instance, card_instance_id = self.card_instance_id_dict[message_id] if not content and bot_message.content: content = bot_message.content @@ -187,13 +210,17 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo await self.bot.send_card_message(card_instance, card_instance_id, content, is_final) if is_final and bot_message.tool_calls is None: self.card_instance_id_dict.pop(message_id) + last_updates.pop(message_id, None) + await self._send_attachments(message, message_source.source_platform_object.incoming_message) async def create_message_card(self, message_id, event): while len(self.card_instance_id_dict) >= 1000: - self.card_instance_id_dict.pop(next(iter(self.card_instance_id_dict)), None) - card_template_id = self.config['card_template_id'] + 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', False) + 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, @@ -203,7 +230,7 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo return True async def is_stream_output_supported(self) -> bool: - return bool(self.config.get('enable-stream-reply', False)) + return is_stream_card_configured(self.config) async def call_platform_api(self, action: str, params: dict = {}) -> dict: if action == 'interaction.request' and action in self.get_supported_apis(): @@ -262,6 +289,7 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo async def _handle_native_event(self, event: DingTalkEvent): try: + self.bot_account_id = getattr(event.incoming_message, 'chatbot_user_id', None) or self.bot_account_id interaction_event = interaction_event_from_native(event, self.interaction_callback_contexts) if interaction_event is not None: await self._dispatch_eba_event(interaction_event) @@ -271,13 +299,13 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo f'conversation={event.conversation}, message_id={getattr(event.incoming_message, "message_id", None)}' ) if platform_events.FriendMessage in self.listeners or platform_events.GroupMessage in self.listeners: - legacy_event = await self.event_converter.target2legacy(event, self.config['robot_name']) + legacy_event = await self.event_converter.target2legacy(event, self.bot_account_id) if legacy_event: callback = self.listeners.get(type(legacy_event)) if callback: await callback(legacy_event, self) - eba_event = await self.event_converter.target2yiri(event, self.config['robot_name']) + eba_event = await self.event_converter.target2yiri(event, self.bot_account_id) if eba_event: self._cache_event(eba_event) await self._dispatch_eba_event(eba_event) diff --git a/src/langbot/pkg/platform/adapters/dingtalk/event_converter.py b/src/langbot/pkg/platform/adapters/dingtalk/event_converter.py index 51e7db193..41a97ba6f 100644 --- a/src/langbot/pkg/platform/adapters/dingtalk/event_converter.py +++ b/src/langbot/pkg/platform/adapters/dingtalk/event_converter.py @@ -16,9 +16,9 @@ class DingTalkEventConverter(abstract_platform_adapter.AbstractEventConverter): return getattr(event, 'source_platform_object', None) @staticmethod - async def target2yiri(event: DingTalkEvent, bot_name: str) -> platform_events.Event | None: + async def target2yiri(event: DingTalkEvent, bot_id: str) -> platform_events.Event | None: if event.conversation in {'FriendMessage', 'GroupMessage'}: - return await DingTalkEventConverter.message_to_eba(event, bot_name) + return await DingTalkEventConverter.message_to_eba(event, bot_id) if event.conversation == 'CardCallback': feedback = DingTalkEventConverter.card_callback_to_feedback(event) if feedback is not None: @@ -29,17 +29,17 @@ class DingTalkEventConverter(abstract_platform_adapter.AbstractEventConverter): @staticmethod async def target2legacy( event: DingTalkEvent, - bot_name: str, + bot_id: str, ) -> platform_events.FriendMessage | platform_events.GroupMessage | None: - eba_event = await DingTalkEventConverter.message_to_eba(event, bot_name) + eba_event = await DingTalkEventConverter.message_to_eba(event, bot_id) if eba_event: return eba_event.to_legacy_event() return None @staticmethod - async def message_to_eba(event: DingTalkEvent, bot_name: str) -> platform_events.MessageReceivedEvent: + async def message_to_eba(event: DingTalkEvent, bot_id: str) -> platform_events.MessageReceivedEvent: incoming_message = event.incoming_message - message_chain = await DingTalkMessageConverter.target2yiri(event, bot_name) + message_chain = await DingTalkMessageConverter.target2yiri(event, bot_id) sender = DingTalkEventConverter.user_from_event(event) chat_type = platform_entities.ChatType.PRIVATE chat_id = getattr(incoming_message, 'sender_staff_id', '') diff --git a/src/langbot/pkg/platform/adapters/dingtalk/manifest.yaml b/src/langbot/pkg/platform/adapters/dingtalk/manifest.yaml index 93e6da6de..fd288ff14 100644 --- a/src/langbot/pkg/platform/adapters/dingtalk/manifest.yaml +++ b/src/langbot/pkg/platform/adapters/dingtalk/manifest.yaml @@ -28,36 +28,12 @@ spec: zh_Hans: 一键创建应用 zh_Hant: 一鍵建立應用 description: - en_US: 'Scan QR code with DingTalk to automatically create an app and fill in credentials. Note: Robot Code cannot be - obtained automatically, you need to copy it from the DingTalk Developer Backend manually.' - zh_Hans: 使用钉钉扫码自动创建应用并填写凭据。注意:机器人代码无法自动获取,需前往钉钉开发者后台手动复制。 - zh_Hant: 使用釘釘掃碼自動建立應用並填寫憑證。注意:機器人代碼無法自動取得,需前往釘釘開發者後台手動複製。 + en_US: Scan with DingTalk to create an app and automatically fill in credentials. The bot identity is resolved automatically. + zh_Hans: 使用钉钉扫码创建应用并自动填写凭据,机器人身份会自动识别。 + zh_Hant: 使用釘釘掃碼建立應用並自動填寫憑證,機器人身分會自動識別。 type: qr-code-login login_platform: dingtalk required: false - - name: human_input_card_template_download - label: - en_US: Download Human Input Card Template - zh_Hans: 下载人工输入卡片模板 - zh_Hant: 下載人工輸入卡片範本 - description: - en_US: Download the template, import it in DingTalk Card Platform, then enter its template ID below to enable native interactions. - zh_Hans: 下载模板并导入钉钉卡片平台,再将模板 ID 填入下方以启用原生交互。 - zh_Hant: 下載範本並匯入釘釘卡片平台,再將範本 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://open-dev.dingtalk.com/fe/card - 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: インポート手順 - name: client_id label: en_US: Client ID @@ -74,22 +50,32 @@ spec: type: string required: true default: "" - - name: robot_code + - name: card_template_id label: - en_US: Robot Code - zh_Hans: 机器人代码 - zh_Hant: 機器人代碼 + 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 replies. Leave empty for ordinary replies. Separate from the human input template. + zh_Hans: 用于流式文字回复。在钉钉卡片平台保存并发布模板后,填写 ID 即自动启用流式回复,留空使用普通回复;与人工输入卡片模板分开配置。 + zh_Hant: 用於串流文字回覆。在釘釘卡片平台儲存並發布範本後,填寫 ID 即自動啟用串流回覆,留空使用一般回覆;與人工輸入卡片範本分開設定。 type: string - required: true + required: false default: "" - - name: robot_name + - name: stream_card_template_download label: - en_US: Robot Name - zh_Hans: 机器人名称 - zh_Hant: 機器人名稱 - type: string - required: true - default: "" + 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: markdown_card label: en_US: Markdown Card @@ -98,47 +84,53 @@ spec: type: boolean required: false default: true - - name: enable-stream-reply - label: - en_US: Enable Stream Reply Mode - zh_Hans: 启用钉钉卡片流式回复模式 - zh_Hant: 啟用釘釘卡片串流回覆模式 - description: - en_US: Used for streaming replies from pipelines or plugins. If enabled, the bot will use DingTalk card streaming replies. - zh_Hans: 用于流水线和插件主动发起的流式回复。如果启用,将使用钉钉卡片流式方式来回复内容 - zh_Hant: 用於流水線和插件主動發起的串流回覆。如果啟用,將使用釘釘卡片串流方式來回覆內容 - type: boolean - required: true - default: false - name: card_auto_layout 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: false - - name: card_template_id - label: - en_US: Card Template ID - zh_Hans: 卡片模板ID - zh_Hant: 卡片範本ID - type: string - required: true - default: "填写你的卡片template_id" - + 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: Import the bundled `src/langbot/templates/dingtalk_human_input_card.json`, then enter its DingTalk template ID here. It contains the `content`, `btns`, `input_*`, `select_*`, and `index_o` variables required for native interactions. - zh_Hans: 可选。模板需包含 `content`、`btns`、`input_*`、`select_*` 和 `index_o` 变量以支持原生交互。 - zh_Hant: 可選。範本需包含 `content`、`btns`、`input_*`、`select_*` 和 `index_o` 變數以支援原生互動。 + 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/adapters/dingtalk/message_converter.py b/src/langbot/pkg/platform/adapters/dingtalk/message_converter.py index 92bd44d6b..253eed8d1 100644 --- a/src/langbot/pkg/platform/adapters/dingtalk/message_converter.py +++ b/src/langbot/pkg/platform/adapters/dingtalk/message_converter.py @@ -86,7 +86,7 @@ class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverte return content, at @staticmethod - async def target2yiri(event: DingTalkEvent, bot_name: str) -> platform_message.MessageChain: + async def target2yiri(event: DingTalkEvent, bot_id: str) -> platform_message.MessageChain: incoming_message = event.incoming_message components: list[platform_message.MessageComponent] = [ platform_message.Source( @@ -95,15 +95,19 @@ class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverte ) ] - for at_user in getattr(incoming_message, 'at_users', []) or []: - if getattr(at_user, 'dingtalk_id', None) == getattr(incoming_message, 'chatbot_user_id', None): - components.append(platform_message.At(target=bot_name, display=bot_name)) + chatbot_id = getattr(incoming_message, 'chatbot_user_id', None) + mentioned = getattr(incoming_message, 'is_in_at_list', False) or any( + chatbot_id and getattr(at_user, 'dingtalk_id', None) == chatbot_id + for at_user in getattr(incoming_message, 'at_users', []) or [] + ) + if mentioned: + components.append(platform_message.At(target=bot_id)) rich_content = event.rich_content if rich_content: for element in rich_content.get('Elements') or []: if element.get('Type') == 'text': - text = DingTalkMessageConverter._strip_bot_mention(element.get('Content', ''), bot_name) + text = element.get('Content', '') if text.strip(): components.append(platform_message.Plain(text=text)) elif element.get('Type') == 'image' and element.get('Picture'): @@ -112,7 +116,7 @@ class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverte if event.content and event.type != 'audio': components.append( platform_message.Plain( - text=DingTalkMessageConverter._strip_bot_mention(event.content, bot_name), + text=event.content, ) ) if event.picture: @@ -160,10 +164,6 @@ class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverte origin=platform_message.MessageChain(origin_components), ) - @staticmethod - def _strip_bot_mention(text: str, bot_name: str) -> str: - return text.replace('@' + bot_name, '') - @staticmethod def _message_time(incoming_message: typing.Any) -> datetime.datetime: value = getattr(incoming_message, 'create_at', None) diff --git a/src/langbot/pkg/platform/sources/dingtalk.py b/src/langbot/pkg/platform/sources/dingtalk.py index 6d76ddd3d..1401fe036 100644 --- a/src/langbot/pkg/platform/sources/dingtalk.py +++ b/src/langbot/pkg/platform/sources/dingtalk.py @@ -1,3 +1,4 @@ +from langbot.libs.dingtalk_api.thinking import visible_think_markers import asyncio import json import pathlib @@ -13,7 +14,7 @@ import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platf import langbot_plugin.api.entities.builtin.platform.events as platform_events import langbot_plugin.api.entities.builtin.platform.entities as platform_entities import langbot_plugin.api.entities.builtin.provider.session as provider_session -from langbot.libs.dingtalk_api.api import DingTalkClient +from langbot.libs.dingtalk_api.api import DingTalkClient, is_stream_card_configured import datetime from langbot.pkg.platform.logger import EventLogger from langbot.pkg.platform.human_input import format_human_input_text @@ -65,15 +66,19 @@ class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverte platform_message.Source(id=event.incoming_message.message_id, time=datetime.datetime.now()) ) - for atUser in event.incoming_message.at_users: - if atUser.dingtalk_id == event.incoming_message.chatbot_user_id: - yiri_msg_list.append(platform_message.At(target=bot_name)) + chatbot_id = getattr(event.incoming_message, 'chatbot_user_id', None) + mentioned = getattr(event.incoming_message, 'is_in_at_list', False) or any( + chatbot_id and getattr(at_user, 'dingtalk_id', None) == chatbot_id + for at_user in getattr(event.incoming_message, 'at_users', []) or [] + ) + if mentioned: + yiri_msg_list.append(platform_message.At(target=bot_name)) if event.rich_content: elements = event.rich_content.get('Elements') for element in elements: if element.get('Type') == 'text': - text = element.get('Content', '').replace('@' + bot_name, '') + text = element.get('Content', '') if text.strip(): yiri_msg_list.append(platform_message.Plain(text=text)) elif element.get('Type') == 'image' and element.get('Picture'): @@ -82,7 +87,7 @@ class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverte # 回退到原有简单逻辑 # 对于音频消息,content 来自 recognition 转写文字,在下方音频处理块中统一处理 if event.content and event.type != 'audio': - text_content = event.content.replace('@' + bot_name, '') + text_content = event.content yiri_msg_list.append(platform_message.Plain(text=text_content)) if event.picture: yiri_msg_list.append(platform_message.Image(base64=event.picture)) @@ -519,8 +524,6 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): required_keys = [ 'client_id', 'client_secret', - 'robot_name', - 'robot_code', ] missing_keys = [key for key in required_keys if key not in config] if missing_keys: @@ -528,12 +531,12 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): bot = DingTalkClient( client_id=config['client_id'], client_secret=config['client_secret'], - robot_name=config['robot_name'], - robot_code=config['robot_code'], + robot_name=config.get('robot_name', ''), + robot_code=config.get('robot_code', ''), markdown_card=config['markdown_card'], logger=logger, ) - bot_account_id = config['robot_name'] + bot_account_id = config['client_id'] super().__init__( config=config, logger=logger, @@ -610,8 +613,14 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): incoming_message = event.incoming_message markdown_enabled = self.config.get('markdown_card', False) - content, at = await DingTalkMessageConverter.yiri2target(message, markdown_enabled) + text_message = platform_message.MessageChain([ + part for part in message if not isinstance(part, (platform_message.Image, platform_message.File)) + ]) + content, at = await DingTalkMessageConverter.yiri2target(text_message, markdown_enabled) await self.bot.send_message(content, incoming_message, at) + for part in message: + if isinstance(part, (platform_message.Image, platform_message.File)): + await self.bot.send_attachment(part, incoming_message) async def reply_message_chunk( self, @@ -638,7 +647,10 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): if (msg_seq - 1) % 8 == 0 or is_final: markdown_enabled = self.config.get('markdown_card', False) - content, at = await DingTalkMessageConverter.yiri2target(message, markdown_enabled) + text_message = platform_message.MessageChain([ + part for part in message if not isinstance(part, (platform_message.Image, platform_message.File)) + ]) + content, at = await DingTalkMessageConverter.yiri2target(text_message, markdown_enabled) if not content and bot_message.content: content = bot_message.content # 兼容直接传入content的情况 @@ -707,6 +719,10 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): pass if bot_message.tool_calls is None: self.card_instance_id_dict.pop(message_id, None) + event = await DingTalkEventConverter.yiri2target(message_source) + for part in message: + if isinstance(part, (platform_message.Image, platform_message.File)): + await self.bot.send_attachment(part, event.incoming_message) async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): markdown_enabled = self.config.get('markdown_card', False) @@ -717,15 +733,12 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): await self.bot.send_proactive_message_to_group(target_id, content) async def is_stream_output_supported(self) -> bool: - is_stream = False - if self.config.get('enable-stream-reply', None): - is_stream = True - return is_stream + return is_stream_card_configured(self.config) 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', '') + 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. @@ -776,7 +789,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): # Legacy chat-card path (no form template). incoming_message = event.source_platform_object.incoming_message - card_auto_layout = self.config.get('card_auto_layout', False) + 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 ) @@ -812,8 +825,9 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): ): async def on_message(event: DingTalkEvent): try: + self.bot_account_id = getattr(event.incoming_message, 'chatbot_user_id', None) or self.bot_account_id return await callback( - await self.event_converter.target2yiri(event, self.config['robot_name']), + await self.event_converter.target2yiri(event, self.bot_account_id), self, ) except Exception: @@ -853,6 +867,8 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): 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: @@ -1196,7 +1212,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): # 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', False)} + card_data_config = {'autoLayout': self.config.get('card_auto_layout', True)} try: success = await self.bot.create_and_deliver_card( card_template_id=template_id, diff --git a/src/langbot/pkg/platform/sources/dingtalk.yaml b/src/langbot/pkg/platform/sources/dingtalk.yaml index dcde99126..d604985f1 100644 --- a/src/langbot/pkg/platform/sources/dingtalk.yaml +++ b/src/langbot/pkg/platform/sources/dingtalk.yaml @@ -27,9 +27,9 @@ spec: zh_Hans: 一键创建应用 zh_Hant: 一鍵建立應用 description: - en_US: "Scan QR code with DingTalk to automatically create an app and fill in credentials. Note: Robot Code cannot be obtained automatically, you need to copy it from the DingTalk Developer Backend manually." - zh_Hans: "使用钉钉扫码自动创建应用并填写凭据。注意:机器人代码无法自动获取,需前往钉钉开发者后台手动复制。" - zh_Hant: "使用釘釘掃碼自動建立應用並填寫憑證。注意:機器人代碼無法自動取得,需前往釘釘開發者後台手動複製。" + en_US: Scan with DingTalk to create an app and automatically fill in credentials. The bot identity is resolved automatically. + zh_Hans: 使用钉钉扫码创建应用并自动填写凭据,机器人身份会自动识别。 + zh_Hant: 使用釘釘掃碼建立應用並自動填寫憑證,機器人身分會自動識別。 type: qr-code-login login_platform: dingtalk required: false @@ -49,26 +49,32 @@ spec: type: string required: true default: "" - - name: robot_code + - name: card_template_id label: - en_US: Robot Code - zh_Hans: 机器人代码 - zh_Hant: 機器人代碼 + en_US: card template id + zh_Hans: 卡片模板ID + zh_Hant: 卡片範本ID description: - en_US: "Required for image recognition, file upload and other features. Get it from DingTalk Developer Backend > Robot Configuration." - zh_Hans: "识图、上传文件等功能必填。请前往钉钉开发者后台 > 机器人配置中获取。" - zh_Hant: "識圖、上傳檔案等功能必填。請前往釘釘開發者後台 > 機器人設定中取得。" + en_US: Template for streaming text replies. Save and publish it in DingTalk Card Platform, then paste its ID here to automatically enable streaming replies. Leave empty for ordinary replies. Separate from the human input template. + zh_Hans: 用于流式文字回复。在钉钉卡片平台保存并发布模板后,填写 ID 即自动启用流式回复,留空使用普通回复;与人工输入卡片模板分开配置。 + zh_Hant: 用於串流文字回覆。在釘釘卡片平台儲存並發布範本後,填寫 ID 即自動啟用串流回覆,留空使用一般回覆;與人工輸入卡片範本分開設定。 type: string - required: true + required: false default: "" - - name: robot_name + - name: stream_card_template_download label: - en_US: Robot Name - zh_Hans: 机器人名称 - zh_Hant: 機器人名稱 - type: string - required: true - default: "" + 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: markdown_card label: en_US: Markdown Card @@ -77,50 +83,46 @@ spec: type: boolean required: false default: true - - name: enable-stream-reply - label: - en_US: Enable Stream Reply Mode - zh_Hans: 启用钉钉卡片流式回复模式 - zh_Hant: 啟用釘釘卡片串流回覆模式 - description: - en_US: Used for streaming replies from pipelines or plugins. If enabled, the bot will use the stream of lark reply mode - zh_Hans: 用于流水线和插件主动发起的流式回复。如果启用,将使用钉钉卡片流式方式来回复内容 - zh_Hant: 用於流水線和插件主動發起的串流回覆。如果啟用,將使用釘釘卡片串流方式來回覆內容 - type: boolean - required: true - default: false - name: card_auto_layout 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: false - - name: card_template_id + default: true + - name: human_input_card_template_id label: - en_US: card template id - zh_Hans: 卡片模板ID - zh_Hant: 卡片範本ID + 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: true - default: "填写你的卡片template_id" + required: false + default: "" - name: human_input_card_template_download label: en_US: Download Human Input Card Template zh_Hans: 下载人工输入卡片模板 zh_Hant: 下載人工輸入卡片範本 description: - en_US: "Used as the only card template ID for the whole conversation turn. Download the built-in template, then import the JSON in DingTalk Open Platform > Card Platform / Card Template Management. After DingTalk creates the template, copy its template ID into the field below. The template already wires `content` (MarkdownBlock) and `btns` (ButtonGroup). Leave empty to fall back to the legacy two-card behavior." - zh_Hans: "用作整个对话回合唯一卡片的模板 ID。先下载内置模板,再到钉钉开放平台 > 卡片平台 / 卡片模板管理中导入该 JSON;钉钉生成模板后,将模板 ID 填到这里。模板已预先连好 `content` (MarkdownBlock) 与 `btns` (ButtonGroup)。留空则降级为旧的双卡行为。" - zh_Hant: "用作整個對話回合唯一卡片的範本 ID。先下載內建範本,再到釘釘開放平台 > 卡片平台 / 卡片範本管理中匯入該 JSON;釘釘產生範本後,將範本 ID 填到這裡。範本已預先連好 `content` (MarkdownBlock) 與 `btns` (ButtonGroup)。留空則降級為舊的雙卡行為。" + 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://open-dev.dingtalk.com/fe/card + 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: @@ -128,18 +130,6 @@ spec: zh_Hans: 导入指引 zh_Hant: 匯入指引 ja_JP: インポート手順 - - name: human_input_card_template_id - label: - en_US: Human Input Card Template ID - zh_Hans: 人工输入卡片模板ID - zh_Hant: 人工輸入卡片範本ID - description: - en_US: "Paste the template ID generated after importing the human input card template." - zh_Hans: "填写导入人工输入卡片模板后生成的模板 ID。" - zh_Hant: "填寫匯入人工輸入卡片範本後產生的範本 ID。" - type: string - required: false - default: "" execution: python: path: ./dingtalk.py diff --git a/src/langbot/templates/dingtalk_stream_card.json b/src/langbot/templates/dingtalk_stream_card.json new file mode 100644 index 000000000..89d10942c --- /dev/null +++ b/src/langbot/templates/dingtalk_stream_card.json @@ -0,0 +1,6 @@ +{ + "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\":\"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\"}],\"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\":\"MarkdownBlock\",\"id\":\"langbot_header_2\",\"props\":{\"mdVer\":0,\"icon\":{\"type\":\"icon\",\"icon\":\"\",\"iconType\":\"emoji\"},\"content\":{\"variable\":\"brand\",\"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\":4,\"marginLeft\":12,\"marginRight\":12},\"title\":\"LangBot\",\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\"},{\"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\":\"MarkdownBlock\",\"id\":\"langbot_header_4\",\"props\":{\"mdVer\":0,\"icon\":{\"type\":\"icon\",\"icon\":\"\",\"iconType\":\"emoji\"},\"content\":{\"variable\":\"brand\",\"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\":4,\"marginLeft\":12,\"marginRight\":12},\"title\":\"LangBot\",\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\"},{\"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\":\"MarkdownBlock\",\"id\":\"langbot_header_3\",\"props\":{\"mdVer\":0,\"icon\":{\"type\":\"icon\",\"icon\":\"\",\"iconType\":\"emoji\"},\"content\":{\"variable\":\"brand\",\"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\":4,\"marginLeft\":12,\"marginRight\":12},\"title\":\"LangBot\",\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\"},{\"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\":\"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\":[]}},\"customRightArea\":{\"type\":\"JSSlot\",\"title\":\"插槽容器\",\"id\":\"node_ocmv2bczc8b\"}},\"title\":\"操作区\",\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\",\"children\":[{\"componentName\":\"AIGenerationProcessing\",\"id\":\"node_ocmv2bczc8c\",\"props\":{},\"hidden\":false,\"title\":\"\",\"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\":\"**🤖 LangBot**\",\"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/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx index d8e64e79f..9aaaa9d95 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx +++ b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx @@ -47,7 +47,7 @@ import { TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip'; -import { systemInfo } from '@/app/infra/http'; +import { backendClient, systemInfo } from '@/app/infra/http'; import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs'; import { parseDynamicFormItemType } from './DynamicFormItemConfig'; import { @@ -304,18 +304,54 @@ function DownloadLinkField({ helpUrl?: string | null; helpLabel: string; }) { - const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin; - const downloadUrl = url.startsWith('http') ? url : `${baseUrl}${url}`; + const { t } = useTranslation(); + const [downloading, setDownloading] = useState(false); + const [failed, setFailed] = useState(false); + + const download = async () => { + if (downloading) return; + setDownloading(true); + setFailed(false); + try { + let blob: Blob; + if (/^https?:\/\//i.test(url)) { + // External templates must not receive backend credentials. + const response = await fetch(url); + if (!response.ok) throw new Error(`Download failed: ${response.status}`); + blob = await response.blob(); + } else { + const response = await backendClient.downloadFile(url); + blob = response.data; + } + const objectUrl = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = objectUrl; + link.download = filename || url.split('/').pop()?.split('?')[0] || 'download'; + document.body.appendChild(link); + link.click(); + link.remove(); + window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000); + } catch { + setFailed(true); + } finally { + setDownloading(false); + } + }; return ( {label}
- {helpUrl && ( )}
+ {failed && ( +

+ {t('common.operationFailed')} +

+ )} {description && (

{description} diff --git a/web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx b/web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx index 29459d416..1a095bdbd 100644 --- a/web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx +++ b/web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx @@ -83,7 +83,6 @@ const PLATFORM_CONFIGS: Record = { client_id: data.client_id, client_secret: data.client_secret, }), - successNoteKey: 'dingtalk.robotCodeNote', }, wecombot: { titleKey: 'wecombot.createBot',