diff --git a/src/langbot/libs/wecom_customer_service_api/api.py b/src/langbot/libs/wecom_customer_service_api/api.py index da3caa9fd..c681be505 100644 --- a/src/langbot/libs/wecom_customer_service_api/api.py +++ b/src/langbot/libs/wecom_customer_service_api/api.py @@ -5,6 +5,7 @@ import base64 import binascii import contextvars import functools +import hashlib import httpx import json import os @@ -119,6 +120,10 @@ class WecomCSClient: 'example': [], } self._http_client: httpx.AsyncClient | None = None + self._delivery_tasks: set[asyncio.Task] = set() + self._received_messages: dict[str, float] = {} + self._sync_lock = asyncio.Lock() + self._sync_cursors: dict[str, str] = {} @asynccontextmanager async def _http_client_context(self): @@ -127,6 +132,10 @@ class WecomCSClient: yield self._http_client async def close(self) -> None: + for task in self._delivery_tasks: + task.cancel() + if self._delivery_tasks: + await asyncio.gather(*self._delivery_tasks, return_exceptions=True) if self._http_client is not None: await self._http_client.aclose() self._http_client = None @@ -170,47 +179,44 @@ class WecomCSClient: else: raise Exception(f'未获取access token: {data}') - @_bounded_token_retry async def get_detailed_message_list(self, xml_msg: str): - # 在本方法中解析消息,并且获得消息的具体内容 - if isinstance(xml_msg, bytes): - xml_msg = xml_msg.decode('utf-8') + """Drain all callback pages, including non-message events and empty pages.""" root = await asyncio.to_thread(ET.fromstring, xml_msg) - token = root.find('Token').text - open_kfid = root.find('OpenKfId').text - - # if open_kfid in self.openkfid_list: - # return None - # else: - # self.openkfid_list.append(open_kfid) - - if not await self.check_access_token(): - self.access_token = await self.get_access_token(self.secret) - - url = self.base_url + '/kf/sync_msg?access_token=' + self.access_token - async with self._http_client_context() as client: - params = { - 'token': token, - 'voice_format': 0, - 'open_kfid': open_kfid, - } - response = await client.post(url, json=params) - data = await httpclient.parse_json_response(response) - if data['errcode'] == 40014 or data['errcode'] == 42001: - self.access_token = await self.get_access_token(self.secret) - return await self.get_detailed_message_list(xml_msg) - if data['errcode'] != 0: - raise Exception('Failed to get message') - - last_msg_data = data['msg_list'][-1] - open_kfid = last_msg_data.get('open_kfid') - # 进行获取图片操作 - if last_msg_data.get('msgtype') == 'image': - media_id = last_msg_data.get('image').get('media_id') - picurl = await self.get_pic_url(media_id) - last_msg_data['picurl'] = picurl - # await self.change_service_status(userid=external_userid,openkfid=open_kfid,servicer=servicer) - return last_msg_data + token = root.findtext('Token') + open_kfid = root.findtext('OpenKfId') + if not token or not open_kfid: + raise ValueError('WeCom customer-service callback is missing Token or OpenKfId') + async with self._sync_lock: + cursor = self._sync_cursors.get(open_kfid) + messages = [] + refreshed = False + while True: + if not await self.check_access_token(): + self.access_token = await self.get_access_token(self.secret) + params = {'token': token, 'voice_format': 0, 'open_kfid': open_kfid, 'limit': 1000} + if cursor: + params['cursor'] = cursor + async with self._http_client_context() as client: + response = await client.post( + self.base_url + '/kf/sync_msg', params={'access_token': self.access_token}, json=params, + ) + data = await httpclient.parse_json_response(response) + if data.get('errcode') in (40014, 42001) and not refreshed: + self.access_token = await self.get_access_token(self.secret) + refreshed = True + continue + if data.get('errcode') != 0: + raise RuntimeError(f"WeCom sync_msg failed (errcode={data.get('errcode')})") + messages.extend(data.get('msg_list') or []) + next_cursor = data.get('next_cursor') + if data.get('has_more'): + if not next_cursor or next_cursor == cursor: + raise RuntimeError('WeCom sync_msg returned a non-advancing cursor') + cursor = next_cursor + continue + if next_cursor: + self._sync_cursors[open_kfid] = next_cursor + return messages @_bounded_token_retry async def change_service_status(self, userid: str, openkfid: str, servicer: str): @@ -292,9 +298,36 @@ class WecomCSClient: return await self.send_text_msg(open_kfid, external_userid, msgid, content) if data['errcode'] != 0: await self.logger.error(f'发送消息失败:{data}') + if data['errcode'] == 95002: + raise ValueError( + 'WeCom CS rejected the reply (95002): the customer must send a message first, ' + 'and ordinary replies must be sent within 48 hours. Entering a session alone ' + 'does not open this window; welcome replies require the event reply API.' + ) raise Exception(f'Failed to send message: {data}') return data + @_bounded_token_retry + async def send_event_text(self, code: str, msgid: str, content: str): + """Send a single event response using its short-lived, one-time code.""" + if not await self.check_access_token(): + self.access_token = await self.get_access_token(self.secret) + url = f'{self.base_url}/kf/send_msg_on_event?access_token={self.access_token}' + payload = {'code': code, 'msgid': msgid, 'msgtype': 'text', 'text': {'content': content}} + async with self._http_client_context() as client: + response = await client.post(url, json=payload) + data = await httpclient.parse_json_response(response) + if data.get('errcode') in (40014, 42001): + self.access_token = await self.get_access_token(self.secret) + return await self.send_event_text(code, msgid, content) + if data.get('errcode') != 0: + raise ValueError( + f'WeCom CS event reply failed (errcode={data.get("errcode")}): {data.get("errmsg")}. ' + 'The event code is single-use and must match the current session state; ' + 'welcome and session-end codes expire after 20 seconds. Do not retry an expired or used code.' + ) + return data + @_bounded_token_retry async def send_image_msg(self, open_kfid: str, external_userid: str, msgid: str, media_id: str): if not await self.check_access_token(): @@ -375,12 +408,11 @@ class WecomCSClient: if ret != 0: raise Exception(f'消息解密失败,错误码: {ret}') - # 解析消息并处理 - message_data = await self.get_detailed_message_list(xml_msg) - if message_data is not None: - event = WecomCSEvent.from_payload(message_data) - if event: - await self._handle_message(event) + # Acknowledge after verification; API fetching and processing must + # survive a callback disconnect. + task = asyncio.create_task(self._process_callback(xml_msg)) + self._delivery_tasks.add(task) + task.add_done_callback(self._delivery_tasks.discard) return 'success' except Exception as e: @@ -390,6 +422,46 @@ class WecomCSClient: traceback.print_exc() return f'Error processing request: {str(e)}', 400 + async def _process_callback(self, xml_msg): + try: + messages = await self.get_detailed_message_list(xml_msg) + for message_data in messages: + try: + await self._process_synced_message(message_data) + except Exception: + if self.logger: + await self.logger.error(f'WeCom customer-service event failed: {traceback.format_exc()}') + + except Exception: + if self.logger: + await self.logger.error(f'WeCom customer-service delivery failed: {traceback.format_exc()}') + + async def _process_synced_message(self, message_data): + # Agent/API and human replies must not become new customer input. + if message_data.get('msgtype') != 'event' and message_data.get('origin', 3) != 3: + return + if message_data.get('msgtype') == 'image': + media_id = (message_data.get('image') or {}).get('media_id') + if media_id: + message_data['picurl'] = await self.get_pic_url(media_id) + event = WecomCSEvent.from_payload(message_data) + if event: + # A callback disconnect must not cancel processor execution. + # Retries can return the same message from sync_msg. + now = time.monotonic() + self._received_messages = { + key: seen for key, seen in self._received_messages.items() if now - seen < 4 * 86400 + } + message_id = message_data.get('msgid') or hashlib.sha256( + json.dumps(message_data, sort_keys=True, ensure_ascii=False, separators=(',', ':')).encode() + ).hexdigest() + if not message_id or message_id not in self._received_messages: + if message_id: + if len(self._received_messages) >= 4096: + self._received_messages.pop(next(iter(self._received_messages))) + self._received_messages[message_id] = now + await self._handle_message(event) + async def run_task(self, host: str, port: int, *args, **kwargs): """ 启动 Quart 应用。 diff --git a/src/langbot/pkg/agent/runner/platform_tools.py b/src/langbot/pkg/agent/runner/platform_tools.py index 27c4945d0..3f0b4d198 100644 --- a/src/langbot/pkg/agent/runner/platform_tools.py +++ b/src/langbot/pkg/agent/runner/platform_tools.py @@ -749,6 +749,14 @@ async def execute_platform_tool( message_id=delivery['reply_target']['message_id'], ) return await bot.adapter.reply_message(source, message) + # Some platforms require a one-time event credential instead of an + # ordinary conversation send. Only pass the Host-frozen event context. + event_reply_handler = getattr(bot.adapter, 'reply_to_event', None) + if definition.name == 'event_reply' and callable(event_reply_handler): + async def api_func(target_type, target_id, message): + return await event_reply_handler( + context=context, target_type=target_type, target_id=target_id, message=message + ) 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/service/monitoring_execution_details.py b/src/langbot/pkg/api/http/service/monitoring_execution_details.py index bbd4daeed..8bfce101d 100644 --- a/src/langbot/pkg/api/http/service/monitoring_execution_details.py +++ b/src/langbot/pkg/api/http/service/monitoring_execution_details.py @@ -37,6 +37,22 @@ def event_content(content, event_data): return {'text': event_summary(event_data), 'event': event_data, 'input': content} +def event_preview(payload, fallback='', event_type=''): + """Never use serialized event metadata as a human-readable list preview.""" + if event_type.startswith('wecomcs.'): + return '' + if isinstance(payload, dict): + if payload.get('type'): + if str(payload['type']).startswith('wecomcs.'): + return '' + return event_summary(payload)[:1000] + if isinstance(payload.get('text'), str): + return payload['text'][:1000] + if fallback and not isinstance(parsed(fallback), (dict, list)): + return fallback[:1000] + return '' + + def event_item(row): metadata = parsed(row.metadata_json, {}) return { @@ -240,14 +256,13 @@ class ExecutionDetailsMixin: ): """Persist entry facts even when routing never creates a processor run.""" from ....agent.runner.event_log_store import EventLogStore - from .monitoring import _message_preview workspace = self._require_write_context(context) store = EventLogStore(self.ap.persistence_mgr.get_db_engine()) # Reuse the message sanitizer (including inline-media handling) instead # of storing an unbounded adapter object in the event journal. content = parsed(self._sanitize_message_content(json.dumps(payload, ensure_ascii=False, default=str)), {}) - summary = _message_preview(json.dumps(content, ensure_ascii=False), 1000) if content else event_type + summary = event_preview(content, event_type=event_type) await store.append_event( event_id=event_id, event_type=event_type, @@ -266,7 +281,7 @@ class ExecutionDetailsMixin: safe_routes = [] for route in routes: if isinstance(route, BaseException): - safe_routes.append({'status': 'failed', 'reason': str(route)}) + safe_routes.append({'status': 'failed', 'reason': str(route) or type(route).__name__}) elif isinstance(route, dict): safe_routes.append( { @@ -362,7 +377,7 @@ class ExecutionDetailsMixin: 'status': status, 'status_group': status, 'title': row.event_type, - 'input_preview': row.input_summary or '', + 'input_preview': event_preview(parsed(row.input_json), row.input_summary, row.event_type), 'target_kind': 'event', 'target_id': None, 'target_name': None, @@ -385,7 +400,11 @@ class ExecutionDetailsMixin: 'runner_id': None, 'debug': row.source == 'debug', 'has_error': status == 'failed', - 'status_reason': metadata.get('reason'), + 'status_reason': metadata.get('reason') or '; '.join( + route.get('reason') or route.get('failure_code') or 'failure_reason_not_recorded' + for route in metadata.get('routes', []) + if isinstance(route, dict) and route.get('status') == 'failed' + ) or None, } async def enrich_execution_rows(self, workspace, items): @@ -449,7 +468,7 @@ class ExecutionDetailsMixin: meta = parsed(event.metadata_json, {}) item.update( event_type=event.event_type, - input_preview=event.input_summary or item.get('input_preview') or '', + input_preview=event_preview(parsed(event.input_json), event.input_summary, event.event_type), user_id=event.actor_id, user_name=event.actor_name, platform=meta.get('platform') or event.source, diff --git a/src/langbot/pkg/platform/adapters/wecomcs/adapter.py b/src/langbot/pkg/platform/adapters/wecomcs/adapter.py index 2fb12ef7f..f82d1b58a 100644 --- a/src/langbot/pkg/platform/adapters/wecomcs/adapter.py +++ b/src/langbot/pkg/platform/adapters/wecomcs/adapter.py @@ -79,6 +79,12 @@ class WecomCSAdapter(WecomCSAPIMixin, abstract_platform_adapter.AbstractPlatform def get_supported_events(self) -> list[str]: return [ 'message.received', + 'message.deleted', + 'wecomcs.enter_session', + 'wecomcs.msg_send_fail', + 'wecomcs.servicer_status_change', + 'wecomcs.session_status_change', + 'wecomcs.reject_customer_msg_switch_change', 'platform.specific', ] @@ -110,6 +116,36 @@ class WecomCSAdapter(WecomCSAPIMixin, abstract_platform_adapter.AbstractPlatform ) return platform_events.MessageResult(raw={'results': raw_results}) + async def reply_to_event( + self, *, context: dict, target_type: str, target_id: str, message: platform_message.MessageChain + ) -> platform_events.MessageResult: + """Use event credentials for welcome and session-status replies.""" + event_type = context.get('event_type') + if event_type not in ('wecomcs.enter_session', 'wecomcs.session_status_change'): + return await self.send_message(target_type, target_id, message) + data = context.get('data') or {} + code_key = 'welcome_code' if event_type == 'wecomcs.enter_session' else 'msg_code' + code = data.get(code_key) or (data.get('data') or {}).get(code_key) + if not code: + raise ValueError( + f'WeCom CS did not provide {code_key} for this event; an event response is not available. ' + 'Entering a session does not open the ordinary messaging window. ' + 'Ask the customer to send a message before sending an ordinary reply.' + ) + timestamp = data.get('timestamp') + if event_type == 'wecomcs.enter_session' and timestamp and time.time() - float(timestamp) >= 20: + raise ValueError( + 'WeCom CS welcome reply expired: welcome_code is valid for only 20 seconds after entry. ' + 'Use a fast, fixed welcome response; do not wait for a long AI run or retry this event.' + ) + if not message.root or any(not isinstance(item, platform_message.Plain) for item in message.root): + raise ValueError('WeCom CS event replies currently support a single text message only') + text = ''.join(item.text for item in message.root) + if not text.strip(): + raise ValueError('WeCom CS event reply text must not be empty') + result = await self.bot.send_event_text(str(code), self._make_outbound_msgid(), text) + return platform_events.MessageResult(message_id=result.get('msgid'), raw=result) + async def reply_message( self, message_source: platform_events.MessageEvent, @@ -183,7 +219,7 @@ class WecomCSAdapter(WecomCSAPIMixin, abstract_platform_adapter.AbstractPlatform async def on_message(event: WecomCSEvent): await self._handle_native_event(event) - for msg_type in ('text', 'image', 'file', 'voice'): + for msg_type in ('text', 'image', 'file', 'voice', 'event'): self.bot.on_message(msg_type)(on_message) async def _handle_native_event(self, event: WecomCSEvent): diff --git a/src/langbot/pkg/platform/adapters/wecomcs/event_converter.py b/src/langbot/pkg/platform/adapters/wecomcs/event_converter.py index 3dc56c68e..7bb717495 100644 --- a/src/langbot/pkg/platform/adapters/wecomcs/event_converter.py +++ b/src/langbot/pkg/platform/adapters/wecomcs/event_converter.py @@ -29,6 +29,39 @@ class WecomCSEventConverter(abstract_platform_adapter.AbstractEventConverter): async def target2yiri(event: WecomCSEvent, bot: WecomCSClient | None = None) -> platform_events.Event | None: if event.type in {'text', 'image', 'file', 'voice'}: return await WecomCSEventConverter.message_to_eba(event, bot) + if event.type == 'event': + data = event.get('event') or {} + event_type = data.get('event_type', 'unknown') + customer_id = data.get('external_userid') or '' + common = { + 'adapter_name': ADAPTER_NAME, + 'timestamp': float(event.timestamp or 0), + 'source_platform_object': event, + 'chat_id': make_private_chat_id(customer_id, data.get('open_kfid')) if customer_id else '', + } + if event_type in {'user_recall_msg', 'servicer_recall_msg'}: + operator_id = data.get('servicer_userid') if event_type == 'servicer_recall_msg' else customer_id + return platform_events.MessageDeletedEvent( + message_id=data.get('recall_msgid') or '', + operator=platform_entities.User(id=operator_id) if operator_id else None, + **common, + ) + event_classes = { + 'enter_session': platform_events.WecomCSEnterSessionEvent, + 'msg_send_fail': platform_events.WecomCSMessageSendFailedEvent, + 'servicer_status_change': platform_events.WecomCSServicerStatusChangedEvent, + 'session_status_change': platform_events.WecomCSSessionStatusChangedEvent, + 'reject_customer_msg_switch_change': platform_events.WecomCSRejectCustomerMessageChangedEvent, + } + event_class = event_classes.get(event_type) + if event_class: + fields = {key: value for key, value in data.items() if key in event_class.model_fields and value is not None} + fields.update(common) + fields['data'] = dict(data) + actor_id = data.get('servicer_userid') or customer_id + fields['user'] = platform_entities.User(id=actor_id) if actor_id else None + return event_class(**fields) + return WecomCSEventConverter.platform_specific(event, f'wecomcs.{event_type}') return WecomCSEventConverter.platform_specific(event, f'wecomcs.{event.type or "unknown"}') @staticmethod diff --git a/src/langbot/pkg/platform/adapters/wecomcs/manifest.yaml b/src/langbot/pkg/platform/adapters/wecomcs/manifest.yaml index d1f04c7cc..0ab00eaac 100644 --- a/src/langbot/pkg/platform/adapters/wecomcs/manifest.yaml +++ b/src/langbot/pkg/platform/adapters/wecomcs/manifest.yaml @@ -93,6 +93,12 @@ spec: supported_events: - message.received + - message.deleted + - wecomcs.enter_session + - wecomcs.msg_send_fail + - wecomcs.servicer_status_change + - wecomcs.session_status_change + - wecomcs.reject_customer_msg_switch_change - platform.specific supported_apis: diff --git a/src/langbot/pkg/platform/botmgr.py b/src/langbot/pkg/platform/botmgr.py index b4e75bd77..7322de873 100644 --- a/src/langbot/pkg/platform/botmgr.py +++ b/src/langbot/pkg/platform/botmgr.py @@ -4,6 +4,7 @@ import asyncio import contextlib import dataclasses import functools +import hashlib import json import re import time @@ -85,6 +86,7 @@ class RuntimeBot: ): if not isinstance(execution_context, ExecutionContext): raise WorkspaceRequiredError('RuntimeBot requires an ExecutionContext') + self._ingress_dedup_lock = asyncio.Lock() if not execution_context.instance_uuid.strip() or not execution_context.workspace_uuid.strip(): raise WorkspaceRequiredError('RuntimeBot requires an instance and Workspace') if execution_context.placement_generation <= 0: @@ -888,6 +890,18 @@ class RuntimeBot: @staticmethod def _platform_event_raw_id(event: platform_events.EBAEvent) -> str: event_type = getattr(event, 'type', None) or event.__class__.__name__ + if isinstance(event, platform_events.MessageDeletedEvent) and event.message_id: + # A recall is distinct from the original message. + return f'{event_type}:{event.message_id}' + if getattr(event, 'adapter_name', None) == 'wecomcs-omni': + source = getattr(event, 'source_platform_object', None) + if isinstance(source, dict): + if source.get('msgid'): + return str(source['msgid']) + # Native service events can lack msgid. Preserve their identity + # across sync retries and process restarts instead of using UUIDs. + payload = json.dumps(source, sort_keys=True, ensure_ascii=False, separators=(',', ':')) + return f'{event_type}:{hashlib.sha256(payload.encode()).hexdigest()}' return str( getattr(event, 'message_id', None) or getattr(event, 'feedback_id', None) or f'{event_type}:{uuid.uuid4()}' ) @@ -904,6 +918,16 @@ class RuntimeBot: event.bot_uuid = self.bot_entity.uuid execution_id = self._platform_event_execution_id(event) + if getattr(event, 'adapter_name', None) == 'wecomcs-omni': + from ..agent.runner.event_log_store import EventLogStore + + # The sync API can replay history after an adapter restart. Reuse + # the durable ingress journal, including records from earlier runs. + async with self._ingress_dedup_lock: + store = EventLogStore(self.ap.persistence_mgr.get_db_engine()) + if await store.get_event(execution_id): + return + await self._persist_monitoring_ingress(event, adapter, execution_id) with ingress( getattr(self, 'ap', None), 'event_done', @@ -945,7 +969,7 @@ class RuntimeBot: if service is None or not execution_id: return try: - sender = getattr(event, 'sender', None) + sender = getattr(event, 'sender', None) or getattr(event, 'user', None) or getattr(event, 'operator', None) target_type, target_id, _ = self._infer_reply_target(event) await service.record_ingress_event( self.execution_context, diff --git a/web/src/app/home/agents/components/debug-event-data.ts b/web/src/app/home/agents/components/debug-event-data.ts index fc96cb811..b51b96077 100644 --- a/web/src/app/home/agents/components/debug-event-data.ts +++ b/web/src/app/home/agents/components/debug-event-data.ts @@ -90,6 +90,26 @@ const DEBUG_EVENT_DEFINITIONS: Record = { messageField: 'text', }, 'message.deleted': deletion, + 'wecomcs.enter_session': { + fields: [user], + defaults: { open_kfid: 'debug-kf', external_userid: 'debug-user', chat_id: 'debug-user|debug-kf', scene: 'debug', scene_param: '', welcome_code: '', wechat_channels: {} }, + }, + 'wecomcs.msg_send_fail': { + fields: [user], + defaults: { open_kfid: 'debug-kf', external_userid: 'debug-user', chat_id: 'debug-user|debug-kf', fail_msgid: 'debug-message', fail_type: 4 }, + }, + 'wecomcs.servicer_status_change': { + fields: [], + defaults: { open_kfid: 'debug-kf', servicer_userid: 'debug-servicer', status: 2, stop_type: 0 }, + }, + 'wecomcs.session_status_change': { + fields: [user], + defaults: { open_kfid: 'debug-kf', external_userid: 'debug-user', chat_id: 'debug-user|debug-kf', change_type: 2, old_servicer_userid: 'debug-servicer-1', new_servicer_userid: 'debug-servicer-2', msg_code: '' }, + }, + 'wecomcs.reject_customer_msg_switch_change': { + fields: [user], + defaults: { open_kfid: 'debug-kf', external_userid: 'debug-user', chat_id: 'debug-user|debug-kf', servicer_userid: 'debug-servicer', reject_switch: 1 }, + }, 'message.recalled': deletion, 'message.reaction': { fields: [ diff --git a/web/src/app/home/monitoring/components/executions/ExecutionDetailSheet.tsx b/web/src/app/home/monitoring/components/executions/ExecutionDetailSheet.tsx index 14b367201..75636e66a 100644 --- a/web/src/app/home/monitoring/components/executions/ExecutionDetailSheet.tsx +++ b/web/src/app/home/monitoring/components/executions/ExecutionDetailSheet.tsx @@ -49,7 +49,7 @@ function messageBody(value: unknown): unknown { if (!value || typeof value !== 'object') return null; const data = value as Record; if (['Image', 'File', 'Voice', 'At', 'AtAll', 'Quote'].includes(String(data.type))) return value; - for (const key of ['text', 'content', 'message_chain', 'message']) { + for (const key of ['text', 'content', 'message_chain', 'message', 'root']) { if (data[key] != null) return messageBody(data[key]); } return null; @@ -84,13 +84,19 @@ export default function ExecutionDetailSheet({ const [refresh, setRefresh] = useState(0); const request = useRef(0); const identity = `${workspace}:${row?.source}:${row?.id}`; + const requestedIdentity = useRef(''); const [loadedIdentity, setLoadedIdentity] = useState(''); const visible = loadedIdentity === identity ? detail : null; const id = row?.id; const source = row?.source; useEffect(() => { const version = ++request.current; - setDetail(null); + // Refresh the current trace in place so its scroll position and expanded + // disclosures survive polling. Only a different selection starts empty. + if (requestedIdentity.current !== identity) { + setDetail(null); + requestedIdentity.current = identity; + } setError(false); setSectionError(null); setBusy(null); @@ -188,11 +194,7 @@ export default function ExecutionDetailSheet({ 'deliveries', 'conversation', ].includes(section); - const body = section === 'inputs' - ? typeof item.content === 'string' ? item.content - : item.content && typeof item.content === 'object' && 'text' in item.content - ? item.content.text : null - : messageBody(item.content); + const body = messageBody(item.content); const title = content ? [ item.actor_name || item.actor_id, @@ -367,13 +369,14 @@ export default function ExecutionDetailSheet({ size="icon" disabled={loading} aria-label={t('monitoring.refreshData')} + title={error ? t('monitoring.execution.detail.loadError') : t('monitoring.refreshData')} onClick={() => setRefresh((value) => value + 1)} > - + - {loading && } - {error && ( + {loading && !visible && } + {error && !visible && ( setRefresh((value) => value + 1)} /> )} {record && ( @@ -421,7 +424,10 @@ export default function ExecutionDetailSheet({ {record.status_reason && !['stop', 'completed', 'success'].includes(record.status_reason) && (

- {record.status_reason} + {record.status_reason.split('; ').map((reason) => + reason === 'failure_reason_not_recorded' + ? t('common.failureReasonNotRecorded') : reason, + ).join('; ')}

)} {mainSections.map((section) => renderSection(section, true))} diff --git a/web/src/app/home/monitoring/components/executions/ExecutionTable.tsx b/web/src/app/home/monitoring/components/executions/ExecutionTable.tsx index a052455b4..507f86a0e 100644 --- a/web/src/app/home/monitoring/components/executions/ExecutionTable.tsx +++ b/web/src/app/home/monitoring/components/executions/ExecutionTable.tsx @@ -171,7 +171,7 @@ export default function ExecutionTable({
- {row.input_preview || '—'} + {row.input_preview || executionTargetLabel(row, t) || '—'}
{[row.bot_name, row.user_name || row.user_id] diff --git a/web/src/app/home/monitoring/page.tsx b/web/src/app/home/monitoring/page.tsx index 691a083ea..2b7ab099d 100644 --- a/web/src/app/home/monitoring/page.tsx +++ b/web/src/app/home/monitoring/page.tsx @@ -394,7 +394,7 @@ function MonitoringPageContent() { ) } > - {t('common.previous')} + {t('operationTrace.previousPage')} {executionOffset + 1}– @@ -414,7 +414,7 @@ function MonitoringPageContent() { ) } > - {t('common.next')} + {t('operationTrace.nextPage')}
)} diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 43f3e93df..9c548a5b9 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -340,6 +340,7 @@ const enUS = { editionCloud: 'Cloud', }, common: { + failureReasonNotRecorded: 'The failure reason was not recorded. Check the bot logs or retry to capture more details.', loadFailedTitle: 'Unable to load this content', loadFailedHint: 'Please try again in a moment. If this continues, check your connection and service status.', customValue: 'Custom', @@ -896,6 +897,7 @@ const enUS = { advancedEventValues: 'View all', eventGroup: 'Group', eventGroupNames: { + wecomcs: "WeCom Customer Service", bot: 'Bot status', feedback: 'Feedback', friend: 'Friends', @@ -997,6 +999,11 @@ const enUS = { eventWildcard: 'All events', eventNamespaceWildcard: '{{namespace}}.*', eventNames: { + wecomcs_enter_session: "Customer entered conversation", + wecomcs_msg_send_fail: "Customer service message failed", + wecomcs_servicer_status_change: "Representative availability changed", + wecomcs_session_status_change: "Customer service conversation changed", + wecomcs_reject_customer_msg_switch_change: "Customer message rejection changed", message_received: 'Message received', message_edited: 'Message edited', message_deleted: 'Message deleted', @@ -1014,6 +1021,11 @@ const enUS = { platform_specific: 'Platform-specific event', }, eventDescriptions: { + wecomcs_enter_session: "A customer opened a service link; includes entry source and welcome code.", + wecomcs_msg_send_fail: "WeChat reported an outgoing message failure, with message ID and failure type.", + wecomcs_servicer_status_change: "A representative started, stopped or suspended service.", + wecomcs_session_status_change: "A representative accepted, transferred, closed or reopened a conversation.", + wecomcs_reject_customer_msg_switch_change: "A representative enabled or disabled rejection of customer messages.", all: 'Matches every event received by this adapter.', namespace: 'Matches all {{group}} events.', namespace_bot: diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index 6544f7d30..bcbbb07f2 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -343,6 +343,7 @@ const esES = { editionCloud: 'Cloud', }, common: { + failureReasonNotRecorded: 'No se registró el motivo del fallo. Consulta los registros del bot o vuelve a enviar el mensaje para obtener más detalles.', loadFailedTitle: 'No se pudo cargar el contenido', loadFailedHint: 'Inténtalo de nuevo. Si el problema persiste, comprueba la conexión y el estado del servicio.', customValue: 'Personalizado', @@ -914,6 +915,7 @@ const esES = { advancedEventValues: 'Ver todos', eventGroup: 'Grupo', eventGroupNames: { + wecomcs: "Atención al cliente de WeCom", bot: 'Estado del bot', feedback: 'Comentarios', friend: 'Amigos', @@ -1016,6 +1018,11 @@ const esES = { eventWildcard: 'Todos los eventos', eventNamespaceWildcard: '{{namespace}}.*', eventNames: { + wecomcs_enter_session: "Cliente entró en la conversación", + wecomcs_msg_send_fail: "Fallo de envío de atención al cliente", + wecomcs_servicer_status_change: "Disponibilidad del agente modificada", + wecomcs_session_status_change: "Estado de conversación modificado", + wecomcs_reject_customer_msg_switch_change: "Rechazo de mensajes modificado", message_received: 'Mensaje recibido', message_edited: 'Mensaje editado', message_deleted: 'Mensaje eliminado', @@ -1033,6 +1040,11 @@ const esES = { platform_specific: 'Evento específico de la plataforma', }, eventDescriptions: { + wecomcs_enter_session: "Un cliente abrió el enlace de atención; incluye origen y código de bienvenida.", + wecomcs_msg_send_fail: "WeChat notificó un fallo de envío con el ID y el tipo de error.", + wecomcs_servicer_status_change: "Un agente inició, detuvo o suspendió la atención.", + wecomcs_session_status_change: "Un agente aceptó, transfirió, cerró o reabrió la conversación.", + wecomcs_reject_customer_msg_switch_change: "Un agente activó o desactivó el rechazo de mensajes del cliente.", all: 'Coincide con todos los eventos recibidos por este adaptador.', namespace: 'Coincide con todos los eventos de {{group}}.', namespace_bot: diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 62674fa9b..6f676de7b 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -339,6 +339,7 @@ const jaJP = { editionCloud: 'Cloud', }, common: { + failureReasonNotRecorded: 'この記録には失敗の理由が保存されていません。ボットのログを確認するか、再送信して詳細を確認してください。', loadFailedTitle: '読み込めませんでした', loadFailedHint: 'しばらくしてから再試行してください。解決しない場合は、接続とサービスの状態を確認してください。', customValue: 'カスタム', @@ -901,6 +902,7 @@ const jaJP = { advancedEventValues: 'すべて表示', eventGroup: 'グループ', eventGroupNames: { + wecomcs: "WeCom カスタマーサービス", bot: 'ボットの状態', feedback: 'フィードバック', friend: '友だち', @@ -1002,6 +1004,11 @@ const jaJP = { eventWildcard: 'すべてのイベント', eventNamespaceWildcard: '{{namespace}}.*', eventNames: { + wecomcs_enter_session: "顧客が会話に参加", + wecomcs_msg_send_fail: "カスタマーサービスの送信失敗", + wecomcs_servicer_status_change: "担当者の受付状態変更", + wecomcs_session_status_change: "カスタマーサービスの会話状態変更", + wecomcs_reject_customer_msg_switch_change: "顧客メッセージの受信拒否設定変更", message_received: 'メッセージ受信', message_edited: 'メッセージ編集', message_deleted: 'メッセージ削除', @@ -1019,6 +1026,11 @@ const jaJP = { platform_specific: 'プラットフォーム固有イベント', }, eventDescriptions: { + wecomcs_enter_session: "顧客がリンクから会話を開きました。流入元と歓迎メッセージ用コードを含みます。", + wecomcs_msg_send_fail: "WeChat が送信失敗を通知しました。メッセージ ID と失敗種別を含みます。", + wecomcs_servicer_status_change: "担当者が受付を開始、停止、または一時停止しました。", + wecomcs_session_status_change: "担当者が会話を受付、転送、終了、または再開しました。", + wecomcs_reject_customer_msg_switch_change: "担当者が顧客メッセージの受信拒否を有効または無効にしました。", all: 'このアダプターが受信するすべてのイベントに一致します。', namespace: 'すべての{{group}}イベントに一致します。', namespace_bot: diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index 27bcd17ec..7c07d7fc3 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -342,6 +342,7 @@ const ruRU = { editionCloud: 'Cloud', }, common: { + failureReasonNotRecorded: 'Причина сбоя не сохранена. Проверьте журнал бота или отправьте сообщение повторно для получения подробностей.', loadFailedTitle: 'Не удалось загрузить данные', loadFailedHint: 'Повторите попытку позже. Если проблема остаётся, проверьте подключение и состояние сервиса.', customValue: 'Свой вариант', @@ -909,6 +910,7 @@ const ruRU = { advancedEventValues: 'Показать все', eventGroup: 'Группа', eventGroupNames: { + wecomcs: "Поддержка WeCom", bot: 'Состояние бота', feedback: 'Обратная связь', friend: 'Друзья', @@ -1012,6 +1014,11 @@ const ruRU = { eventWildcard: 'Все события', eventNamespaceWildcard: '{{namespace}}.*', eventNames: { + wecomcs_enter_session: "Клиент вошёл в диалог", + wecomcs_msg_send_fail: "Ошибка отправки сообщения поддержки", + wecomcs_servicer_status_change: "Статус оператора изменён", + wecomcs_session_status_change: "Статус диалога поддержки изменён", + wecomcs_reject_customer_msg_switch_change: "Приём сообщений клиента изменён", message_received: 'Получено сообщение', message_edited: 'Сообщение изменено', message_deleted: 'Сообщение удалено', @@ -1029,6 +1036,11 @@ const ruRU = { platform_specific: 'Событие конкретной платформы', }, eventDescriptions: { + wecomcs_enter_session: "Клиент открыл ссылку поддержки; событие содержит источник и код приветствия.", + wecomcs_msg_send_fail: "WeChat сообщил об ошибке отправки с ID сообщения и типом ошибки.", + wecomcs_servicer_status_change: "Оператор начал, остановил или приостановил приём обращений.", + wecomcs_session_status_change: "Оператор принял, передал, завершил или возобновил диалог.", + wecomcs_reject_customer_msg_switch_change: "Оператор включил или отключил отказ в приёме сообщений клиента.", all: 'Соответствует всем событиям, полученным адаптером.', namespace: 'Соответствует всем событиям {{group}}.', namespace_bot: diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index a9b67fa94..f8a7bf621 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -335,6 +335,7 @@ const thTH = { editionCloud: 'Cloud', }, common: { + failureReasonNotRecorded: 'ไม่ได้บันทึกสาเหตุของข้อผิดพลาด โปรดตรวจสอบบันทึกของบอตหรือส่งข้อความอีกครั้งเพื่อดูรายละเอียด', loadFailedTitle: 'ไม่สามารถโหลดเนื้อหาได้', loadFailedHint: 'โปรดลองอีกครั้ง หากยังโหลดไม่ได้ ให้ตรวจสอบการเชื่อมต่อและสถานะบริการ', customValue: 'กำหนดเอง', @@ -883,6 +884,7 @@ const thTH = { advancedEventValues: 'ดูทั้งหมด', eventGroup: 'กลุ่ม', eventGroupNames: { + wecomcs: "บริการลูกค้า WeCom", bot: 'สถานะบอท', feedback: 'ข้อเสนอแนะ', friend: 'เพื่อน', @@ -980,6 +982,11 @@ const thTH = { eventWildcard: 'ทุกเหตุการณ์', eventNamespaceWildcard: '{{namespace}}.*', eventNames: { + wecomcs_enter_session: "ลูกค้าเข้าสู่การสนทนา", + wecomcs_msg_send_fail: "ส่งข้อความบริการลูกค้าไม่สำเร็จ", + wecomcs_servicer_status_change: "สถานะเจ้าหน้าที่เปลี่ยนแปลง", + wecomcs_session_status_change: "สถานะการสนทนาบริการลูกค้าเปลี่ยนแปลง", + wecomcs_reject_customer_msg_switch_change: "การปฏิเสธข้อความลูกค้าเปลี่ยนแปลง", message_received: 'ได้รับข้อความ', message_edited: 'แก้ไขข้อความ', message_deleted: 'ลบข้อความ', @@ -997,6 +1004,11 @@ const thTH = { platform_specific: 'เหตุการณ์เฉพาะแพลตฟอร์ม', }, eventDescriptions: { + wecomcs_enter_session: "ลูกค้าเปิดลิงก์บริการ พร้อมข้อมูลแหล่งที่มาและรหัสต้อนรับ", + wecomcs_msg_send_fail: "WeChat แจ้งการส่งล้มเหลว พร้อม ID ข้อความและประเภทข้อผิดพลาด", + wecomcs_servicer_status_change: "เจ้าหน้าที่เริ่ม หยุด หรือพักการให้บริการ", + wecomcs_session_status_change: "เจ้าหน้าที่รับ โอน ปิด หรือเปิดการสนทนาอีกครั้ง", + wecomcs_reject_customer_msg_switch_change: "เจ้าหน้าที่เปิดหรือปิดการปฏิเสธข้อความลูกค้า", all: 'ตรงกับทุกเหตุการณ์ที่อะแดปเตอร์นี้ได้รับ', namespace: 'ตรงกับทุกเหตุการณ์ของ {{group}}', namespace_bot: diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index 8c7f0358a..ad51ed64f 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -339,6 +339,7 @@ const viVN = { editionCloud: 'Cloud', }, common: { + failureReasonNotRecorded: 'Chưa lưu nguyên nhân lỗi. Hãy kiểm tra nhật ký bot hoặc gửi lại tin nhắn để biết thêm chi tiết.', loadFailedTitle: 'Không thể tải nội dung', loadFailedHint: 'Vui lòng thử lại sau. Nếu vẫn không tải được, hãy kiểm tra kết nối và trạng thái dịch vụ.', customValue: 'Tùy chỉnh', @@ -898,6 +899,7 @@ const viVN = { advancedEventValues: 'Xem tất cả', eventGroup: 'Nhóm', eventGroupNames: { + wecomcs: "Dịch vụ khách hàng WeCom", bot: 'Trạng thái bot', feedback: 'Phản hồi', friend: 'Bạn bè', @@ -997,6 +999,11 @@ const viVN = { eventWildcard: 'Tất cả sự kiện', eventNamespaceWildcard: '{{namespace}}.*', eventNames: { + wecomcs_enter_session: "Khách hàng vào hội thoại", + wecomcs_msg_send_fail: "Gửi tin nhắn hỗ trợ thất bại", + wecomcs_servicer_status_change: "Trạng thái nhân viên thay đổi", + wecomcs_session_status_change: "Trạng thái hội thoại hỗ trợ thay đổi", + wecomcs_reject_customer_msg_switch_change: "Cài đặt từ chối tin nhắn thay đổi", message_received: 'Nhận tin nhắn', message_edited: 'Tin nhắn được chỉnh sửa', message_deleted: 'Tin nhắn bị xóa', @@ -1014,6 +1021,11 @@ const viVN = { platform_specific: 'Sự kiện riêng của nền tảng', }, eventDescriptions: { + wecomcs_enter_session: "Khách hàng mở liên kết hỗ trợ; gồm nguồn truy cập và mã chào mừng.", + wecomcs_msg_send_fail: "WeChat thông báo gửi thất bại, gồm ID tin nhắn và loại lỗi.", + wecomcs_servicer_status_change: "Nhân viên bắt đầu, dừng hoặc tạm dừng tiếp nhận.", + wecomcs_session_status_change: "Nhân viên tiếp nhận, chuyển, kết thúc hoặc mở lại hội thoại.", + wecomcs_reject_customer_msg_switch_change: "Nhân viên bật hoặc tắt từ chối tin nhắn khách hàng.", all: 'Khớp mọi sự kiện bộ chuyển đổi này nhận được.', namespace: 'Khớp mọi sự kiện {{group}}.', namespace_bot: diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 9a85ea073..c670e1ece 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -319,6 +319,7 @@ const zhHans = { editionCloud: 'Cloud', }, common: { + failureReasonNotRecorded: '这条历史记录未保存失败原因,请查看机器人日志,或重新发送消息获取详细错误。', loadFailedTitle: '暂时无法加载', loadFailedHint: '请稍后重试。如果仍无法加载,请检查网络连接与服务状态。', customValue: '自定义', @@ -843,6 +844,7 @@ const zhHans = { advancedEventValues: '查看全部', eventGroup: '事件组', eventGroupNames: { + wecomcs: "企业微信客服", bot: '机器人状态', feedback: '反馈', friend: '好友', @@ -940,6 +942,11 @@ const zhHans = { eventWildcard: '全部事件', eventNamespaceWildcard: '{{namespace}}.*', eventNames: { + wecomcs_enter_session: "客户进入会话", + wecomcs_msg_send_fail: "客服消息发送失败", + wecomcs_servicer_status_change: "接待人员状态变更", + wecomcs_session_status_change: "客服会话状态变更", + wecomcs_reject_customer_msg_switch_change: "拒收客户消息设置变更", message_received: '收到消息', message_edited: '消息被编辑', message_deleted: '消息被删除', @@ -957,6 +964,11 @@ const zhHans = { platform_specific: '平台特定事件', }, eventDescriptions: { + wecomcs_enter_session: "客户通过客服链接进入会话,可获取来源和欢迎语凭据。", + wecomcs_msg_send_fail: "微信通知客服消息发送失败,包含消息 ID 和失败类型。", + wecomcs_servicer_status_change: "接待人员开始、停止接待或暂时挂起。", + wecomcs_session_status_change: "人工客服接入、转接、结束或重新接入会话。", + wecomcs_reject_customer_msg_switch_change: "接待人员开启或取消拒收客户消息。", all: '匹配此适配器收到的全部事件。', namespace: '匹配所有{{group}}事件。', namespace_bot: '匹配机器人入群、退群、禁言和解除禁言等状态事件。', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 7e06ea8d4..7ed3d854f 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -316,6 +316,7 @@ const zhHant = { editionCloud: 'Cloud', }, common: { + failureReasonNotRecorded: '這筆歷史記錄未儲存失敗原因,請查看機器人日誌,或重新傳送訊息取得詳細錯誤。', loadFailedTitle: '暫時無法載入', loadFailedHint: '請稍後重試。如果仍無法載入,請檢查網路連線與服務狀態。', customValue: '自訂', @@ -842,6 +843,7 @@ const zhHant = { advancedEventValues: '查看全部', eventGroup: '事件組', eventGroupNames: { + wecomcs: "企業微信客服", bot: '機器人狀態', feedback: '反饋', friend: '好友', @@ -939,6 +941,11 @@ const zhHant = { eventWildcard: '全部事件', eventNamespaceWildcard: '{{namespace}}.*', eventNames: { + wecomcs_enter_session: "客戶進入會話", + wecomcs_msg_send_fail: "客服訊息傳送失敗", + wecomcs_servicer_status_change: "接待人員狀態變更", + wecomcs_session_status_change: "客服會話狀態變更", + wecomcs_reject_customer_msg_switch_change: "拒收客戶訊息設定變更", message_received: '收到訊息', message_edited: '訊息已編輯', message_deleted: '訊息已刪除', @@ -956,6 +963,11 @@ const zhHant = { platform_specific: '平台特定事件', }, eventDescriptions: { + wecomcs_enter_session: "客戶透過客服連結進入會話,可取得來源和歡迎語憑證。", + wecomcs_msg_send_fail: "微信通知客服訊息傳送失敗,包含訊息 ID 和失敗類型。", + wecomcs_servicer_status_change: "接待人員開始、停止接待或暫時掛起。", + wecomcs_session_status_change: "人工客服接入、轉接、結束或重新接入會話。", + wecomcs_reject_customer_msg_switch_change: "接待人員開啟或取消拒收客戶訊息。", all: '匹配此適配器收到的全部事件。', namespace: '匹配所有{{group}}事件。', namespace_bot: '匹配機器人入羣、退羣、禁言和解除禁言等狀態事件。',