@csntgao/uni-base 0.6.2 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -180,13 +180,13 @@ pnpm --filter @uniplat/ai-employee-web-components pack
180
180
  5. 用户输入始终通过宿主 transport 的 HTTP 能力发送,不通过 WebSocket 发送。
181
181
  6. `4401` 仅表示 JWT 无效或过期,需要重新调用 `getAccessToken()` 并退避重连;`4400` 和 `4403` 是不可自动恢复的协议或权限错误。`1013` 以及认证帧中的 `SERVICE_*`、`KANBAN_AUTH_FAILED`、`REALTIME_UNAVAILABLE` 表示服务暂不可用,最多退避重连 3 次,不能误报为凭证失效。
182
182
  7. 重连前优先通过 HTTP 恢复历史;历史恢复失败时才请求 WebSocket replay 兜底。
183
- 8. 实时消息必须按稳定 ID 去重,并忽略其他 session 的事件。
183
+ 8. 实时消息必须按稳定 ID 去重,并忽略其他 session 的事件。服务端缺少消息 ID 时的合成 ID 只能取消息本身,不得把 JSON Patch 的 `path`/`op` 等投递信封算进指纹,否则同一条消息经 replay 与实时两条路径会得到不同 ID 而重复显示。本地已发送的用户消息在回声窗口内不消费匹配条目,replay 与实时的多次回声都要挡住;首条消息与后续消息都必须登记该窗口。
184
184
  9. 主动断开和销毁后不得遗留 socket、心跳、重连任务或事件监听。
185
185
  10. 未指定既有 `sessionId` 时,打开抽屉和点击“开启新对话”只能建立本地草稿,不得创建后端空 Session。首条消息必须通过 `createSessionWithFirstMessage()` 原子提交 `request_id + ae_code + agent_code + content + trusted_context + attachments`;失败重试同一操作必须复用原 `request_id`,成功后才绑定服务端返回的 Session 并订阅实时通道。已有 Session 的后续消息继续使用 `sendMessage(sessionId, content)`。
186
186
 
187
187
  `ai_employee` channel、`session_message`、`session_title_updated`、订阅帧字段以及关闭码都是后端契约,不要仅为前端命名一致性自行修改。
188
188
 
189
- 人工客服使用同一个 `/api/user-realtime/ws`,但必须单独请求并订阅 `customer_support` channel;不得把客服会话并入 `ai_employee` 状态机。客服 HTTP transport 复用 HR-SaaS `customer_support_api` 的 `user_session_page`、`user_session_create`、`user_message_page`、`user_message_send` 和 `user_mark_read`。首条用户消息必须通过 `user_session_create.initial_message` 原子创建客服会话,不得先创建空会话再发送。点击“转人工”应释放数字员工连接并在同一抽屉切换到 `<uniplat-customer-support-chat>`;不得再向数字员工发送模拟转人工文本,也不得要求 Host 拼装第二个抽屉。未显式配置 `customerSupport` 时,数字员工组件必须复用自身的 `realtimeUrl`、`transport.getAccessToken()` 与同源 `/api/general/project/hr_saas/service/customer_support_api`;只有非标准网关或独立鉴权场景才要求 Host 覆盖。
189
+ 人工客服使用同一个 `/api/user-realtime/ws`,但必须单独请求并订阅 `customer_support` channel;不得把客服会话并入 `ai_employee` 状态机。客服 HTTP transport 复用 HR-SaaS `customer_support_api` 的 `user_session_page`、`user_session_create`、`user_session_handoff_create`、`user_message_page`、`user_message_send` 和 `user_mark_read`。首条用户消息必须通过 `user_session_create.initial_message` 原子创建客服会话,不得先创建空会话再发送。职责越界动作的转接必须通过 `user_session_handoff_create` 原子创建客服会话,只提交来源数字员工会话 ID 和服务端生成的动作 UUID;客户端不得提交 `ae_code`、`agent_code`、组织、用户或角色作为权限依据。点击“转人工”应释放数字员工连接并在同一抽屉切换到 `<uniplat-customer-support-chat>`;不得再向数字员工发送模拟转人工文本,也不得要求 Host 拼装第二个抽屉。未显式配置 `customerSupport` 时,数字员工组件必须复用自身的 `realtimeUrl`、`transport.getAccessToken()` 与同源 `/api/general/project/hr_saas/service/customer_support_api`;只有非标准网关或独立鉴权场景才要求 Host 覆盖。
190
190
 
191
191
  ## 安全要求
192
192
 
@@ -224,10 +224,12 @@ chat.open(initialMessage?)
224
224
 
225
225
  `initialMessage` 是可选字符串;非空时由组件在初始化成功后通过既有消息发送链路自动发送:未配置 `sessionId` 时使用 `createSessionWithFirstMessage()` 原子创建会话并提交首条消息,已配置 `sessionId` 时使用 `sendMessage()` 作为该会话的后续消息。Host 不得代为创建会话或操作组件私有输入状态。未传、空字符串或纯空白时只打开抽屉,保持原有行为。
226
226
 
227
- 不要把对象、回调或 token 改为 HTML attribute。`sessionId` 可选;传入时直接绑定已有会话,不传时组件只建立本地草稿。transport 必须提供 `createSessionWithFirstMessage()`,由 SDK 生成 UUID `request_id` 并在首条消息时一次性提交创建会话所需的数据;Host 只负责协议字段映射,不得先调用空 Session 创建接口。网络失败、组件重建或同一标签页刷新后,用户重试未改变的同一首条消息时复用原 `request_id`;默认恢复存储只允许包含 UUID 和输入摘要,不得保存消息正文、附件内容、JWT 或后端响应。成功响应中的 `sessionId/messageId/executionId/aeCode/agentCode/conversationType` 是唯一权威标识。`getOrCreateSession()/createSession()` 已从公开 transport 删除,不再保留旧空会话兼容分支。聊天组件初始关闭,Host 只负责配置并调用 `open()` 激活;固定右侧抽屉、标题、关闭按钮、消息区、输入区和转人工入口均由组件自身渲染,关闭时组件自行调用 `close()` 并释放实时连接。Host 不得再包裹重复抽屉或拼装抽屉内部界面。标题左侧会话入口由组件内部切换到原型“我的会话”视图;真实列表复用 transport 的可选 `listSessions()`,选择后由组件调用 core `bindSession()`,可选 `markSessionRead()` 更新已读状态。当前选定会话始终视为已读,在聊天界面和“我的会话”列表中都不显示未读角标,也不计入标题入口的聚合未读数;历史加载完成、列表加载完成、当前会话收到实时消息以及从会话列表返回时,组件应立即清除该会话本地未读并异步同步 Host,失败后在下一次触发时重试。历史消息与列表响应使用不同消息 ID 时,以列表返回的 `latestMessageId` 再次同步,不得让当前会话重新出现未读角标;其他未选定会话的新消息仍应计为未读。
227
+ 不要把对象、回调或 token 改为 HTML attribute。`sessionId` 可选;传入时直接绑定已有会话,不传时组件只建立本地草稿。transport 必须提供 `createSessionWithFirstMessage()`,由 SDK 生成 UUID `request_id` 并在首条消息时一次性提交创建会话所需的数据;Host 只负责协议字段映射,不得先调用空 Session 创建接口。网络失败、组件重建或同一标签页刷新后,用户重试未改变的同一首条消息时复用原 `request_id`;默认恢复存储只允许包含 UUID 和输入摘要,不得保存消息正文、附件内容、JWT 或后端响应。成功响应中的 `sessionId/messageId/executionId/aeCode/agentCode/conversationType` 是唯一权威标识。`getOrCreateSession()/createSession()` 已从公开 transport 删除,不再保留旧空会话兼容分支。聊天组件初始关闭,Host 只负责配置并调用 `open()` 激活;固定右侧抽屉、标题、关闭按钮、消息区、输入区和转人工入口均由组件自身渲染,关闭时组件自行调用 `close()` 并释放实时连接。Host 不得再包裹重复抽屉或拼装抽屉内部界面。标题左侧会话入口由组件内部切换到原型“我的会话”视图;真实列表复用 transport 的可选 `listSessions()`,选择后由组件调用 core `bindSession()`,可选 `markSessionRead()` 更新已读状态。已读同步只能使用服务端持久化消息 ID:HTTP 历史 `listMessages()` 与 `listSessions().latestMessageId` 返回的 ID 才可作为 `markSessionRead()` 的游标,实时事件里的 `ws_message_id` 与本地合成 ID 一律不得提交,服务端会以 `INVALID_ARGUMENT` 拒绝。本轮回复结束(`processing` 由真变假)且会话可见时应重新拉取历史,拿到持久化 ID 再同步已读。当前选定会话始终视为已读,在聊天界面和“我的会话”列表中都不显示未读角标,也不计入标题入口的聚合未读数;历史加载完成、列表加载完成、当前会话收到实时消息以及从会话列表返回时,组件应立即清除该会话本地未读并异步同步 Host,失败后在下一次触发时重试。历史消息与列表响应使用不同消息 ID 时,以列表返回的 `latestMessageId` 再次同步,不得让当前会话重新出现未读角标;其他未选定会话的新消息仍应计为未读。
228
228
 
229
229
  数字员工的 `assistant` 回复必须通过共享的安全 Markdown 渲染器展示;用户、系统和错误消息保持普通文本。Markdown 只能生成白名单结构,原始 HTML 必须作为文本转义,链接只允许 HTTP(S) 和 `mailto:`,不得直接注入未净化 HTML。
230
230
 
231
+ 数字员工最终回复尾部可以携带一个平台生成的 `assistant_action/open_customer_support` JSON 结构块。SDK 公共解析器只接受字段集合严格等于 `type/action/transaction-id/trigger/entry`、固定枚举值正确且 `transaction-id` 为 UUID4 的独立尾部 JSON;合法动作从展示文本和会话摘要中移除原始 JSON,并在对应 assistant 消息下渲染“发起人工客服”,非法、不完整、重复或夹在正文中的动作不得执行。动作不得按 `ae_code` 或 `agent_code` 分支。点击时调用客服 transport 的可选 `createHandoffSession({ sourceSessionId, actionTransactionId })`;加载中禁止并发提交,失败后以同一 UUID 重试,成功后使用接口返回的客服会话 ID 切换同一抽屉。动作 UUID 不得进入 DOM、公共事件、URL、日志或安全错误信息。
232
+
231
233
  工作台内置应用切换通过 Account Context 的 `workbench` 配置:
232
234
 
233
235
  ```ts
@@ -248,7 +250,7 @@ customerSupportChat.configure({
248
250
  })
249
251
  ```
250
252
 
251
- `transport` 负责客服会话列表、首条消息原子创建、历史消息、后续发送、已读同步与 `getAccessToken()`;真实 HR-SaaS 对接使用 `createHrSaasCustomerSupportTransport()`。组件初始关闭,由 Host 或数字员工组件调用 `open()`。组件自身拥有完整抽屉、会话列表、消息区、输入区和关闭行为。公开事件为 `ready`、`message-sent`、`message-received`、`connection-state-change`、`closed` 与 `error`,不得包含 JWT、transport 或后端原始异常。
253
+ `transport` 负责客服会话列表、首条消息原子创建、职责越界交接、历史消息、后续发送、已读同步与 `getAccessToken()`;职责越界交接通过可选 `createHandoffSession()` 暴露,真实 HR-SaaS 对接使用 `createHrSaasCustomerSupportTransport()` 并映射 `user_session_handoff_create`。组件初始关闭,由 Host 或数字员工组件调用 `open()`。组件自身拥有完整抽屉、会话列表、消息区、输入区和关闭行为。公开事件为 `ready`、`message-sent`、`message-received`、`connection-state-change`、`closed` 与 `error`,不得包含 JWT、动作 UUID、transport 或后端原始异常。
252
254
 
253
255
  通知中心通过 JavaScript property 配置:
254
256
 
package/CLAUDE.md CHANGED
@@ -180,13 +180,13 @@ pnpm --filter @uniplat/ai-employee-web-components pack
180
180
  5. 用户输入始终通过宿主 transport 的 HTTP 能力发送,不通过 WebSocket 发送。
181
181
  6. `4401` 仅表示 JWT 无效或过期,需要重新调用 `getAccessToken()` 并退避重连;`4400` 和 `4403` 是不可自动恢复的协议或权限错误。`1013` 以及认证帧中的 `SERVICE_*`、`KANBAN_AUTH_FAILED`、`REALTIME_UNAVAILABLE` 表示服务暂不可用,最多退避重连 3 次,不能误报为凭证失效。
182
182
  7. 重连前优先通过 HTTP 恢复历史;历史恢复失败时才请求 WebSocket replay 兜底。
183
- 8. 实时消息必须按稳定 ID 去重,并忽略其他 session 的事件。
183
+ 8. 实时消息必须按稳定 ID 去重,并忽略其他 session 的事件。服务端缺少消息 ID 时的合成 ID 只能取消息本身,不得把 JSON Patch 的 `path`/`op` 等投递信封算进指纹,否则同一条消息经 replay 与实时两条路径会得到不同 ID 而重复显示。本地已发送的用户消息在回声窗口内不消费匹配条目,replay 与实时的多次回声都要挡住;首条消息与后续消息都必须登记该窗口。
184
184
  9. 主动断开和销毁后不得遗留 socket、心跳、重连任务或事件监听。
185
185
  10. 未指定既有 `sessionId` 时,打开抽屉和点击“开启新对话”只能建立本地草稿,不得创建后端空 Session。首条消息必须通过 `createSessionWithFirstMessage()` 原子提交 `request_id + ae_code + agent_code + content + trusted_context + attachments`;失败重试同一操作必须复用原 `request_id`,成功后才绑定服务端返回的 Session 并订阅实时通道。已有 Session 的后续消息继续使用 `sendMessage(sessionId, content)`。
186
186
 
187
187
  `ai_employee` channel、`session_message`、`session_title_updated`、订阅帧字段以及关闭码都是后端契约,不要仅为前端命名一致性自行修改。
188
188
 
189
- 人工客服使用同一个 `/api/user-realtime/ws`,但必须单独请求并订阅 `customer_support` channel;不得把客服会话并入 `ai_employee` 状态机。客服 HTTP transport 复用 HR-SaaS `customer_support_api` 的 `user_session_page`、`user_session_create`、`user_message_page`、`user_message_send` 和 `user_mark_read`。首条用户消息必须通过 `user_session_create.initial_message` 原子创建客服会话,不得先创建空会话再发送。点击“转人工”应释放数字员工连接并在同一抽屉切换到 `<uniplat-customer-support-chat>`;不得再向数字员工发送模拟转人工文本,也不得要求 Host 拼装第二个抽屉。未显式配置 `customerSupport` 时,数字员工组件必须复用自身的 `realtimeUrl`、`transport.getAccessToken()` 与同源 `/api/general/project/hr_saas/service/customer_support_api`;只有非标准网关或独立鉴权场景才要求 Host 覆盖。
189
+ 人工客服使用同一个 `/api/user-realtime/ws`,但必须单独请求并订阅 `customer_support` channel;不得把客服会话并入 `ai_employee` 状态机。客服 HTTP transport 复用 HR-SaaS `customer_support_api` 的 `user_session_page`、`user_session_create`、`user_session_handoff_create`、`user_message_page`、`user_message_send` 和 `user_mark_read`。首条用户消息必须通过 `user_session_create.initial_message` 原子创建客服会话,不得先创建空会话再发送。职责越界动作的转接必须通过 `user_session_handoff_create` 原子创建客服会话,只提交来源数字员工会话 ID 和服务端生成的动作 UUID;客户端不得提交 `ae_code`、`agent_code`、组织、用户或角色作为权限依据。点击“转人工”应释放数字员工连接并在同一抽屉切换到 `<uniplat-customer-support-chat>`;不得再向数字员工发送模拟转人工文本,也不得要求 Host 拼装第二个抽屉。未显式配置 `customerSupport` 时,数字员工组件必须复用自身的 `realtimeUrl`、`transport.getAccessToken()` 与同源 `/api/general/project/hr_saas/service/customer_support_api`;只有非标准网关或独立鉴权场景才要求 Host 覆盖。
190
190
 
191
191
  ## 安全要求
192
192
 
@@ -224,10 +224,12 @@ chat.open(initialMessage?)
224
224
 
225
225
  `initialMessage` 是可选字符串;非空时由组件在初始化成功后通过既有消息发送链路自动发送:未配置 `sessionId` 时使用 `createSessionWithFirstMessage()` 原子创建会话并提交首条消息,已配置 `sessionId` 时使用 `sendMessage()` 作为该会话的后续消息。Host 不得代为创建会话或操作组件私有输入状态。未传、空字符串或纯空白时只打开抽屉,保持原有行为。
226
226
 
227
- 不要把对象、回调或 token 改为 HTML attribute。`sessionId` 可选;传入时直接绑定已有会话,不传时组件只建立本地草稿。transport 必须提供 `createSessionWithFirstMessage()`,由 SDK 生成 UUID `request_id` 并在首条消息时一次性提交创建会话所需的数据;Host 只负责协议字段映射,不得先调用空 Session 创建接口。网络失败、组件重建或同一标签页刷新后,用户重试未改变的同一首条消息时复用原 `request_id`;默认恢复存储只允许包含 UUID 和输入摘要,不得保存消息正文、附件内容、JWT 或后端响应。成功响应中的 `sessionId/messageId/executionId/aeCode/agentCode/conversationType` 是唯一权威标识。`getOrCreateSession()/createSession()` 已从公开 transport 删除,不再保留旧空会话兼容分支。聊天组件初始关闭,Host 只负责配置并调用 `open()` 激活;固定右侧抽屉、标题、关闭按钮、消息区、输入区和转人工入口均由组件自身渲染,关闭时组件自行调用 `close()` 并释放实时连接。Host 不得再包裹重复抽屉或拼装抽屉内部界面。标题左侧会话入口由组件内部切换到原型“我的会话”视图;真实列表复用 transport 的可选 `listSessions()`,选择后由组件调用 core `bindSession()`,可选 `markSessionRead()` 更新已读状态。当前选定会话始终视为已读,在聊天界面和“我的会话”列表中都不显示未读角标,也不计入标题入口的聚合未读数;历史加载完成、列表加载完成、当前会话收到实时消息以及从会话列表返回时,组件应立即清除该会话本地未读并异步同步 Host,失败后在下一次触发时重试。历史消息与列表响应使用不同消息 ID 时,以列表返回的 `latestMessageId` 再次同步,不得让当前会话重新出现未读角标;其他未选定会话的新消息仍应计为未读。
227
+ 不要把对象、回调或 token 改为 HTML attribute。`sessionId` 可选;传入时直接绑定已有会话,不传时组件只建立本地草稿。transport 必须提供 `createSessionWithFirstMessage()`,由 SDK 生成 UUID `request_id` 并在首条消息时一次性提交创建会话所需的数据;Host 只负责协议字段映射,不得先调用空 Session 创建接口。网络失败、组件重建或同一标签页刷新后,用户重试未改变的同一首条消息时复用原 `request_id`;默认恢复存储只允许包含 UUID 和输入摘要,不得保存消息正文、附件内容、JWT 或后端响应。成功响应中的 `sessionId/messageId/executionId/aeCode/agentCode/conversationType` 是唯一权威标识。`getOrCreateSession()/createSession()` 已从公开 transport 删除,不再保留旧空会话兼容分支。聊天组件初始关闭,Host 只负责配置并调用 `open()` 激活;固定右侧抽屉、标题、关闭按钮、消息区、输入区和转人工入口均由组件自身渲染,关闭时组件自行调用 `close()` 并释放实时连接。Host 不得再包裹重复抽屉或拼装抽屉内部界面。标题左侧会话入口由组件内部切换到原型“我的会话”视图;真实列表复用 transport 的可选 `listSessions()`,选择后由组件调用 core `bindSession()`,可选 `markSessionRead()` 更新已读状态。已读同步只能使用服务端持久化消息 ID:HTTP 历史 `listMessages()` 与 `listSessions().latestMessageId` 返回的 ID 才可作为 `markSessionRead()` 的游标,实时事件里的 `ws_message_id` 与本地合成 ID 一律不得提交,服务端会以 `INVALID_ARGUMENT` 拒绝。本轮回复结束(`processing` 由真变假)且会话可见时应重新拉取历史,拿到持久化 ID 再同步已读。当前选定会话始终视为已读,在聊天界面和“我的会话”列表中都不显示未读角标,也不计入标题入口的聚合未读数;历史加载完成、列表加载完成、当前会话收到实时消息以及从会话列表返回时,组件应立即清除该会话本地未读并异步同步 Host,失败后在下一次触发时重试。历史消息与列表响应使用不同消息 ID 时,以列表返回的 `latestMessageId` 再次同步,不得让当前会话重新出现未读角标;其他未选定会话的新消息仍应计为未读。
228
228
 
229
229
  数字员工的 `assistant` 回复必须通过共享的安全 Markdown 渲染器展示;用户、系统和错误消息保持普通文本。Markdown 只能生成白名单结构,原始 HTML 必须作为文本转义,链接只允许 HTTP(S) 和 `mailto:`,不得直接注入未净化 HTML。
230
230
 
231
+ 数字员工最终回复尾部可以携带一个平台生成的 `assistant_action/open_customer_support` JSON 结构块。SDK 公共解析器只接受字段集合严格等于 `type/action/transaction-id/trigger/entry`、固定枚举值正确且 `transaction-id` 为 UUID4 的独立尾部 JSON;合法动作从展示文本和会话摘要中移除原始 JSON,并在对应 assistant 消息下渲染“发起人工客服”,非法、不完整、重复或夹在正文中的动作不得执行。动作不得按 `ae_code` 或 `agent_code` 分支。点击时调用客服 transport 的可选 `createHandoffSession({ sourceSessionId, actionTransactionId })`;加载中禁止并发提交,失败后以同一 UUID 重试,成功后使用接口返回的客服会话 ID 切换同一抽屉。动作 UUID 不得进入 DOM、公共事件、URL、日志或安全错误信息。
232
+
231
233
  工作台内置应用切换通过 Account Context 的 `workbench` 配置:
232
234
 
233
235
  ```ts
@@ -248,7 +250,7 @@ customerSupportChat.configure({
248
250
  })
249
251
  ```
250
252
 
251
- `transport` 负责客服会话列表、首条消息原子创建、历史消息、后续发送、已读同步与 `getAccessToken()`;真实 HR-SaaS 对接使用 `createHrSaasCustomerSupportTransport()`。组件初始关闭,由 Host 或数字员工组件调用 `open()`。组件自身拥有完整抽屉、会话列表、消息区、输入区和关闭行为。公开事件为 `ready`、`message-sent`、`message-received`、`connection-state-change`、`closed` 与 `error`,不得包含 JWT、transport 或后端原始异常。
253
+ `transport` 负责客服会话列表、首条消息原子创建、职责越界交接、历史消息、后续发送、已读同步与 `getAccessToken()`;职责越界交接通过可选 `createHandoffSession()` 暴露,真实 HR-SaaS 对接使用 `createHrSaasCustomerSupportTransport()` 并映射 `user_session_handoff_create`。组件初始关闭,由 Host 或数字员工组件调用 `open()`。组件自身拥有完整抽屉、会话列表、消息区、输入区和关闭行为。公开事件为 `ready`、`message-sent`、`message-received`、`connection-state-change`、`closed` 与 `error`,不得包含 JWT、动作 UUID、transport 或后端原始异常。
252
254
 
253
255
  通知中心通过 JavaScript property 配置:
254
256
 
package/README.md CHANGED
@@ -680,6 +680,10 @@ accountContext.configure({
680
680
 
681
681
  数字员工的 `assistant` 回复按 Markdown 渲染,支持标题、强调、删除线、行内代码、代码块、引用、有序/无序列表、分隔线及安全链接;用户消息、系统消息和错误消息仍按普通文本显示。原始 HTML 不会注入 DOM,非 HTTP(S) 或 `mailto:` 链接不会生成可点击链接。
682
682
 
683
+ 当 KanbanBackend 在最终 assistant 回复尾部附加规范的 `assistant_action/open_customer_support` JSON 时,组件会隐藏原始 JSON,并在该消息下显示“发起人工客服”。解析不依赖 `aeCode` 或 `agentCode`;只有字段集合完整、固定值正确、`transaction-id` 为 UUID4 且动作作为独立尾部 JSON 出现时才可执行。非法、不完整、重复或正文内的动作不会触发接口。SDK 同时导出 `parseAiEmployeeAssistantContent()`,供其他消息视图复用同一安全解析规则。
684
+
685
+ 用户点击该动作后,组件调用客服 transport 的 `createHandoffSession()`,请求体只映射为 `source_session_id` 和 `action_transaction_id`。请求期间按钮禁用,失败后可使用同一动作 UUID 重试;成功后组件释放数字员工连接,并使用接口返回的客服会话 ID 在同一抽屉打开人工客服。动作 UUID 不会进入 DOM、公共事件、URL、日志或错误文案。
686
+
683
687
  点击“转人工”后,数字员工连接会先释放,再由同一抽屉切换为 `<uniplat-customer-support-chat>`;不会向数字员工发送一条模拟转人工文本,也不要求 Host 创建第二个抽屉。默认情况下,组件复用数字员工的 `realtimeUrl` 和 `transport.getAccessToken()`,并请求同源 `/api/general/project/hr_saas/service/customer_support_api`,因此使用标准网关路径的 Host 无需增加配置。只有客服网关地址或鉴权提供者不同时,才显式传入 `customerSupport` 覆盖默认配置:
684
688
 
685
689
  ```ts
@@ -704,7 +708,7 @@ chat.configure({
704
708
 
705
709
  ### Customer Support Chat
706
710
 
707
- `<uniplat-customer-support-chat>` 也可以独立使用。它直接复用 HR-SaaS 的 `user_session_page`、`user_session_create`、`user_message_page`、`user_message_send` 和 `user_mark_read`,实时连接订阅 Kanban 的 `customer_support` channel。首条消息通过 `user_session_create.initial_message` 原子创建会话,后续消息使用 `user_message_send`。打开抽屉或点击“新对话”只进入本地空白态,不提前创建等待中会话;历史遗留的无消息 `waiting` 会话也不会被自动恢复。只有首条消息成功创建会话后,界面才显示“等待客服接入”。
711
+ `<uniplat-customer-support-chat>` 也可以独立使用。它直接复用 HR-SaaS 的 `user_session_page`、`user_session_create`、`user_session_handoff_create`、`user_message_page`、`user_message_send` 和 `user_mark_read`,实时连接订阅 Kanban 的 `customer_support` channel。首条消息通过 `user_session_create.initial_message` 原子创建会话,后续消息使用 `user_message_send`;数字员工职责越界交接由 transport 的可选 `createHandoffSession({ sourceSessionId, actionTransactionId })` 调用 `user_session_handoff_create`。打开抽屉或点击“新对话”只进入本地空白态,不提前创建等待中会话;历史遗留的无消息 `waiting` 会话也不会被自动恢复。只有首条消息成功创建会话后,界面才显示“等待客服接入”。
708
712
 
709
713
  ```html
710
714
  <uniplat-customer-support-chat id="customer-support"></uniplat-customer-support-chat>
@@ -753,7 +757,7 @@ uniplat-ai-employee-chat {
753
757
  }
754
758
  ```
755
759
 
756
- 主要 `::part`:`container`、`header`、`session-list-button`、`session-header`、`session-back-button`、`session-list`、`session-item`、`session-item-current`、`session-list-loading`、`session-list-empty`、`session-list-error`、`close-button`、`status`、`messages`、`time-marker`、`message`、`message-user`、`message-assistant`、`message-system`、`message-error`、`composer`、`input`、`send-button`、`human-service-button`、`empty`、`loading`、`activity` 和 `error`。
760
+ 主要 `::part`:`container`、`header`、`session-list-button`、`session-header`、`session-back-button`、`session-list`、`session-item`、`session-item-current`、`session-list-loading`、`session-list-empty`、`session-list-error`、`close-button`、`status`、`messages`、`time-marker`、`message`、`message-user`、`message-assistant`、`message-system`、`message-error`、`assistant-action`、`assistant-action-button`、`composer`、`input`、`send-button`、`human-service-button`、`empty`、`loading`、`activity` 和 `error`。
757
761
 
758
762
  ## Vue 和 Nuxt
759
763
 
@@ -36,6 +36,52 @@ Host 不再渲染或包裹以下内容:
36
36
 
37
37
  Host 仍负责提供 `realtimeUrl`、`aeCode`、`agentCode` 和 transport。JWT 继续只由 `transport.getAccessToken()` 返回,不得写入组件属性、HTML attribute 或 URL。
38
38
 
39
+ ## 职责越界转人工
40
+
41
+ KanbanBackend 可以在最终 assistant 回复末尾附加以下平台动作:
42
+
43
+ ```json
44
+ {
45
+ "type": "assistant_action",
46
+ "action": "open_customer_support",
47
+ "transaction-id": "550e8400-e29b-41d4-a716-446655440000",
48
+ "trigger": "out_of_scope",
49
+ "entry": "help_and_support"
50
+ }
51
+ ```
52
+
53
+ 组件会在历史消息和实时消息中统一解析这个独立尾部 JSON。合法动作的原始 JSON 不显示,并在对应回复下渲染“发起人工客服”;字段缺失、额外字段、非 UUID4、重复动作或正文中的 JSON 都不会执行。该能力不按数字员工或 Agent 分支。其他消息视图可从 core 或 Web Components 包导入 `parseAiEmployeeAssistantContent()` 复用相同解析规则。
54
+
55
+ 标准 `createHrSaasCustomerSupportTransport()` 已实现 `createHandoffSession()`,并将公开参数严格映射为:
56
+
57
+ ```json
58
+ {
59
+ "source_session_id": "数字员工会话 ID",
60
+ "action_transaction_id": "动作 transaction-id"
61
+ }
62
+ ```
63
+
64
+ 点击期间组件会禁止并发提交;失败时显示稳定的安全提示,用户重试会继续使用原动作 UUID;成功时按接口返回的客服会话 ID 释放数字员工连接并切换同一抽屉。Host 无需重建匿名身份,也不得把 `ae_code`、`agent_code`、`org_id`、`user_id` 或角色字段加入转接请求。JWT 继续由原 transport 的 `getAccessToken()` 按请求获取。动作 UUID 不进入 DOM、公共事件、URL、日志或错误文案。
65
+
66
+ 如果 Host 覆盖 `customerSupport.transport`,自定义 transport 需要实现可选方法:
67
+
68
+ ```ts
69
+ async createHandoffSession({ sourceSessionId, actionTransactionId }) {
70
+ const response = await hostApi.createCustomerSupportHandoff({
71
+ source_session_id: sourceSessionId,
72
+ action_transaction_id: actionTransactionId,
73
+ })
74
+ return {
75
+ session: response.session,
76
+ initialMessage: response.initial_message,
77
+ created: response.created,
78
+ repaired: response.repaired,
79
+ }
80
+ }
81
+ ```
82
+
83
+ 若未显式覆盖客服配置,组件会自动复用数字员工 `realtimeUrl`、`transport.getAccessToken()` 和同源 HR-SaaS 客服网关,因此 QQXBWebsite 的标准接入不需要增加新的 token 或 WebSocket 状态机。
84
+
39
85
  ## 会话列表
40
86
 
41
87
  标题左侧按钮由组件内部负责,点击后切换为原型中的“我的会话”界面。面板头部的“开启新对话”按钮只切换到本地草稿,不创建后端 Session。用户发送首条消息时,组件才通过 Host transport 原子创建会话并提交首条消息:
package/package.json CHANGED
@@ -1,21 +1,10 @@
1
1
  {
2
2
  "name": "@csntgao/uni-base",
3
- "version": "0.6.2",
3
+ "version": "0.6.5",
4
4
  "type": "module",
5
- "packageManager": "pnpm@11.3.0",
6
5
  "publishConfig": {
7
6
  "access": "public"
8
7
  },
9
- "scripts": {
10
- "build": "pnpm --filter @uniplat/ai-employee-client build && pnpm --filter @uniplat/ai-employee-web-components build && pnpm --filter @uniplat/ai-employee-vanilla-example build",
11
- "dev": "pnpm --filter @uniplat/ai-employee-client build && pnpm --filter @uniplat/ai-employee-web-components build && pnpm --filter @uniplat/ai-employee-vanilla-example dev",
12
- "lint": "eslint . --max-warnings 0",
13
- "format": "prettier --write .",
14
- "format:check": "prettier --check .",
15
- "test": "vitest run",
16
- "test:watch": "vitest",
17
- "typecheck": "pnpm --filter @uniplat/ai-employee-client typecheck && pnpm --filter @uniplat/ai-employee-web-components typecheck"
18
- },
19
8
  "devDependencies": {
20
9
  "@eslint/js": "^9.32.0",
21
10
  "eslint": "^9.32.0",
@@ -27,5 +16,15 @@
27
16
  "typescript-eslint": "^8.39.0",
28
17
  "vite": "^7.1.0",
29
18
  "vitest": "^3.2.4"
19
+ },
20
+ "scripts": {
21
+ "build": "pnpm --filter @uniplat/ai-employee-client build && pnpm --filter @uniplat/ai-employee-web-components build && pnpm --filter @uniplat/ai-employee-vanilla-example build",
22
+ "dev": "pnpm --filter @uniplat/ai-employee-client build && pnpm --filter @uniplat/ai-employee-web-components build && pnpm --filter @uniplat/ai-employee-vanilla-example dev",
23
+ "lint": "eslint . --max-warnings 0",
24
+ "format": "prettier --write .",
25
+ "format:check": "prettier --check .",
26
+ "test": "vitest run",
27
+ "test:watch": "vitest",
28
+ "typecheck": "pnpm --filter @uniplat/ai-employee-client typecheck && pnpm --filter @uniplat/ai-employee-web-components typecheck"
30
29
  }
31
- }
30
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniplat/ai-employee-client",
3
- "version": "0.14.0",
3
+ "version": "0.14.2",
4
4
  "description": "Browser client for Uniplat AI Employee sessions and realtime messages",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -0,0 +1,120 @@
1
+ export interface OpenCustomerSupportAssistantAction {
2
+ type: 'assistant_action'
3
+ action: 'open_customer_support'
4
+ transactionId: string
5
+ trigger: 'out_of_scope'
6
+ entry: 'help_and_support'
7
+ }
8
+
9
+ export type AiEmployeeAssistantAction = OpenCustomerSupportAssistantAction
10
+
11
+ export interface ParsedAiEmployeeAssistantContent {
12
+ content: string
13
+ actions: readonly AiEmployeeAssistantAction[]
14
+ }
15
+
16
+ interface StructuredJsonCandidate {
17
+ content: string
18
+ json: string
19
+ }
20
+
21
+ const ACTION_KEYS = ['action', 'entry', 'transaction-id', 'trigger', 'type'] as const
22
+ const OPEN_CUSTOMER_SUPPORT_MARKER = /"action"\s*:\s*"open_customer_support"/g
23
+ const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
24
+
25
+ /**
26
+ * Parses a trusted platform action from the final standalone JSON block of an
27
+ * assistant response. Invalid or ambiguous input is returned unchanged and is
28
+ * never made executable.
29
+ */
30
+ export function parseAiEmployeeAssistantContent(content: string): ParsedAiEmployeeAssistantContent {
31
+ const source = typeof content === 'string' ? content : ''
32
+ const candidate = trailingStructuredJson(source)
33
+ if (!candidate || markerCount(source) !== 1) return unchanged(source)
34
+
35
+ let value: unknown
36
+ try {
37
+ value = JSON.parse(candidate.json)
38
+ } catch {
39
+ return unchanged(source)
40
+ }
41
+ const action = parseOpenCustomerSupportAction(value)
42
+ if (!action) return unchanged(source)
43
+ return Object.freeze({
44
+ content: candidate.content,
45
+ actions: Object.freeze([action]),
46
+ })
47
+ }
48
+
49
+ export function isUuidV4(value: unknown): value is string {
50
+ return typeof value === 'string' && UUID_V4_PATTERN.test(value)
51
+ }
52
+
53
+ function trailingStructuredJson(content: string): StructuredJsonCandidate | null {
54
+ const withoutTrailingWhitespace = content.trimEnd()
55
+ const fenced = withoutTrailingWhitespace.match(
56
+ /(?:^|\r?\n)[\t ]*```json[\t ]*\r?\n([\s\S]*?)\r?\n[\t ]*```$/i,
57
+ )
58
+ if (fenced?.index !== undefined) {
59
+ return {
60
+ content: withoutTrailingWhitespace.slice(0, fenced.index).trimEnd(),
61
+ json: fenced[1]?.trim() ?? '',
62
+ }
63
+ }
64
+
65
+ const starts = [...withoutTrailingWhitespace.matchAll(/(?:^|\r?\n)[\t ]*(\{)/g)]
66
+ for (let index = starts.length - 1; index >= 0; index -= 1) {
67
+ const match = starts[index]
68
+ const objectOffset =
69
+ match?.index === undefined ? -1 : match.index + (match[0]?.indexOf('{') ?? -1)
70
+ if (objectOffset < 0) continue
71
+ const json = withoutTrailingWhitespace.slice(objectOffset).trim()
72
+ try {
73
+ JSON.parse(json)
74
+ return {
75
+ content: withoutTrailingWhitespace.slice(0, match?.index ?? 0).trimEnd(),
76
+ json,
77
+ }
78
+ } catch {
79
+ // Try an earlier line-start object. Nested or malformed trailing JSON is
80
+ // never accepted merely because it contains an action-looking fragment.
81
+ }
82
+ }
83
+ return null
84
+ }
85
+
86
+ function parseOpenCustomerSupportAction(value: unknown): OpenCustomerSupportAssistantAction | null {
87
+ if (!isRecord(value)) return null
88
+ const keys = Object.keys(value).sort()
89
+ if (keys.length !== ACTION_KEYS.length || keys.some((key, index) => key !== ACTION_KEYS[index])) {
90
+ return null
91
+ }
92
+ if (
93
+ value.type !== 'assistant_action' ||
94
+ value.action !== 'open_customer_support' ||
95
+ !isUuidV4(value['transaction-id']) ||
96
+ value.trigger !== 'out_of_scope' ||
97
+ value.entry !== 'help_and_support'
98
+ ) {
99
+ return null
100
+ }
101
+ return Object.freeze({
102
+ type: 'assistant_action',
103
+ action: 'open_customer_support',
104
+ transactionId: value['transaction-id'],
105
+ trigger: 'out_of_scope',
106
+ entry: 'help_and_support',
107
+ })
108
+ }
109
+
110
+ function markerCount(value: string): number {
111
+ return [...value.matchAll(OPEN_CUSTOMER_SUPPORT_MARKER)].length
112
+ }
113
+
114
+ function unchanged(content: string): ParsedAiEmployeeAssistantContent {
115
+ return Object.freeze({ content, actions: Object.freeze([]) })
116
+ }
117
+
118
+ function isRecord(value: unknown): value is Record<string, unknown> {
119
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
120
+ }
@@ -313,12 +313,7 @@ export class AiEmployeeClient {
313
313
  content,
314
314
  createdAt: now.toISOString(),
315
315
  }
316
- for (const [pendingContent, sentAt] of this.pendingLocalMessages) {
317
- if (now.getTime() - sentAt > LOCAL_MESSAGE_MATCH_WINDOW_MS) {
318
- this.pendingLocalMessages.delete(pendingContent)
319
- }
320
- }
321
- this.pendingLocalMessages.set(content, now.getTime())
316
+ this.rememberPendingLocalMessage(content)
322
317
  this.appendMessage(message, 'local')
323
318
  this.setProcessing(true)
324
319
  return message
@@ -374,6 +369,9 @@ export class AiEmployeeClient {
374
369
  content,
375
370
  createdAt: result.createdAt ?? result.startedAt,
376
371
  }
372
+ // 首条消息同样要登记本地回声窗口:服务端 replay 与实时事件可能带着与
373
+ // createSessionWithFirstMessage 不同的消息 ID 回来。
374
+ this.rememberPendingLocalMessage(content)
377
375
  this.appendMessage(message, 'local')
378
376
  this.setProcessing(true)
379
377
  this.replayHistoryOnNextSubscription = true
@@ -610,11 +608,27 @@ export class AiEmployeeClient {
610
608
  this.emit('ready', { sessionId })
611
609
  }
612
610
 
611
+ private rememberPendingLocalMessage(content: string): void {
612
+ const now = Date.now()
613
+ for (const [pendingContent, sentAt] of this.pendingLocalMessages) {
614
+ if (now - sentAt > LOCAL_MESSAGE_MATCH_WINDOW_MS) {
615
+ this.pendingLocalMessages.delete(pendingContent)
616
+ }
617
+ }
618
+ this.pendingLocalMessages.set(content, now)
619
+ }
620
+
621
+ /**
622
+ * 本地已渲染的用户消息会被服务端以 replay 和实时事件多次回送,且不保证复用
623
+ * 同一个消息 ID。窗口期内不消费该条目,多次回声都要挡住;本地发送本身每次都
624
+ * 会追加气泡,所以重复发送同样内容不会因此丢消息。
625
+ */
613
626
  private matchesPendingLocalMessage(message: AiEmployeeMessage): boolean {
614
627
  const sentAt = this.pendingLocalMessages.get(message.content)
615
628
  if (sentAt === undefined) return false
629
+ if (Date.now() - sentAt <= LOCAL_MESSAGE_MATCH_WINDOW_MS) return true
616
630
  this.pendingLocalMessages.delete(message.content)
617
- return Date.now() - sentAt <= LOCAL_MESSAGE_MATCH_WINDOW_MS
631
+ return false
618
632
  }
619
633
 
620
634
  private appendMessage(message: AiEmployeeMessage, source: 'local' | 'realtime'): void {
@@ -52,6 +52,18 @@ export interface CustomerSupportCreateSessionResult {
52
52
  initialMessage?: CustomerSupportMessage
53
53
  }
54
54
 
55
+ export interface CustomerSupportHandoffRequest {
56
+ sourceSessionId: string
57
+ actionTransactionId: string
58
+ }
59
+
60
+ export interface CustomerSupportHandoffResult {
61
+ session: CustomerSupportSession
62
+ initialMessage: CustomerSupportMessage
63
+ created: boolean
64
+ repaired: boolean
65
+ }
66
+
55
67
  export interface CustomerSupportTransport {
56
68
  getAccessToken(): Promise<string>
57
69
  listSessions(): Promise<readonly CustomerSupportSession[]>
@@ -59,6 +71,9 @@ export interface CustomerSupportTransport {
59
71
  createSession(
60
72
  request: CustomerSupportCreateSessionRequest,
61
73
  ): Promise<CustomerSupportCreateSessionResult>
74
+ createHandoffSession?(
75
+ request: CustomerSupportHandoffRequest,
76
+ ): Promise<CustomerSupportHandoffResult>
62
77
  listMessages(sessionId: string): Promise<CustomerSupportMessagePage>
63
78
  sendMessage(
64
79
  sessionId: string,
@@ -1,4 +1,10 @@
1
1
  export { AiEmployeeClient, AiEmployeeClientError, AiEmployeeTransportError } from './client'
2
+ export { isUuidV4, parseAiEmployeeAssistantContent } from './assistant-actions'
3
+ export type {
4
+ AiEmployeeAssistantAction,
5
+ OpenCustomerSupportAssistantAction,
6
+ ParsedAiEmployeeAssistantContent,
7
+ } from './assistant-actions'
2
8
  export { CustomerSupportClient, CustomerSupportClientError } from './customer-support-client'
3
9
  export {
4
10
  NotificationCenterClient,
@@ -50,6 +56,8 @@ export type {
50
56
  CustomerSupportCreateSessionResult,
51
57
  CustomerSupportErrorCode,
52
58
  CustomerSupportErrorDetail,
59
+ CustomerSupportHandoffRequest,
60
+ CustomerSupportHandoffResult,
53
61
  CustomerSupportMessage,
54
62
  CustomerSupportMessagePage,
55
63
  CustomerSupportSenderType,
@@ -96,7 +96,7 @@ function parseSessionPayload(payload: JsonRecord, sessionId: string): ParsedSess
96
96
  for (const rawPatch of patches) {
97
97
  const patch = asRecord(rawPatch)
98
98
  const value = asRecord(patch.value)
99
- const parsed = messageFromEntry(value, sessionId, patch)
99
+ const parsed = messageFromEntry(value, sessionId)
100
100
  if (parsed) messages.push(parsed)
101
101
  }
102
102
 
@@ -129,11 +129,7 @@ function parseSessionPayload(payload: JsonRecord, sessionId: string): ParsedSess
129
129
  return { messages, fallbackResult, processing }
130
130
  }
131
131
 
132
- function messageFromEntry(
133
- entry: JsonRecord,
134
- sessionId: string,
135
- patch: JsonRecord,
136
- ): AiEmployeeMessage | null {
132
+ function messageFromEntry(entry: JsonRecord, sessionId: string): AiEmployeeMessage | null {
137
133
  const entryTypeValue = entry.entry_type
138
134
  const entryType =
139
135
  typeof entryTypeValue === 'string' ? entryTypeValue : stringValue(asRecord(entryTypeValue).type)
@@ -168,7 +164,9 @@ function messageFromEntry(
168
164
  role === 'user'
169
165
  ? messageId || webSocketMessageId || stringValue(entry.id)
170
166
  : webSocketMessageId || messageId || stringValue(entry.id)
171
- const fingerprint = stableString({ entry, path: patch.path, op: patch.op })
167
+ // 同一条消息经 replay 与实时两条路径投递时 patch path/op 不同,
168
+ // 合成 ID 只能取消息本身,否则同一条消息会得到不同 ID 而重复显示。
169
+ const fingerprint = stableString(entry)
172
170
  const id = explicitId || `realtime:${sessionId}:${hashText(fingerprint)}`
173
171
  return createMessage(sessionId, role, content, id, entry)
174
172
  }
@@ -112,6 +112,12 @@ export type AiEmployeeErrorCode =
112
112
  | 'RATE_LIMITED'
113
113
  | 'SEND_IN_PROGRESS'
114
114
  | 'EMPTY_MESSAGE'
115
+ | 'SOURCE_SESSION_NOT_FOUND'
116
+ | 'SOURCE_SESSION_ACCESS_DENIED'
117
+ | 'HANDOFF_ACTION_NOT_FOUND'
118
+ | 'HANDOFF_ACTION_MISMATCH'
119
+ | 'CUSTOMER_SUPPORT_UNAVAILABLE'
120
+ | 'CUSTOMER_SUPPORT_HANDOFF_FAILED'
115
121
  | 'CLIENT_DESTROYED'
116
122
 
117
123
  export interface AiEmployeeClientErrorDetail {
@@ -0,0 +1,76 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { parseAiEmployeeAssistantContent } from '../src'
3
+
4
+ const transactionId = '8f0b790c-1c5f-4f8c-95cc-4ead9d9cfb1a'
5
+
6
+ function actionJson(overrides: Record<string, unknown> = {}): string {
7
+ return JSON.stringify(
8
+ {
9
+ type: 'assistant_action',
10
+ action: 'open_customer_support',
11
+ 'transaction-id': transactionId,
12
+ trigger: 'out_of_scope',
13
+ entry: 'help_and_support',
14
+ ...overrides,
15
+ },
16
+ null,
17
+ 2,
18
+ )
19
+ }
20
+
21
+ describe('parseAiEmployeeAssistantContent', () => {
22
+ it('extracts one valid fenced action and removes the raw JSON from display content', () => {
23
+ const parsed = parseAiEmployeeAssistantContent(
24
+ `该问题超出我的职责范围。\n\n您可以从‘帮助与支持’菜单项入口发起人工客服。\n\n\`\`\`json\n${actionJson()}\n\`\`\``,
25
+ )
26
+
27
+ expect(parsed.content).toBe(
28
+ '该问题超出我的职责范围。\n\n您可以从‘帮助与支持’菜单项入口发起人工客服。',
29
+ )
30
+ expect(parsed.actions).toEqual([
31
+ {
32
+ type: 'assistant_action',
33
+ action: 'open_customer_support',
34
+ transactionId,
35
+ trigger: 'out_of_scope',
36
+ entry: 'help_and_support',
37
+ },
38
+ ])
39
+ })
40
+
41
+ it('accepts a trailing standalone raw JSON action', () => {
42
+ const parsed = parseAiEmployeeAssistantContent(`暂时无法处理。\n${actionJson()}`)
43
+
44
+ expect(parsed.content).toBe('暂时无法处理。')
45
+ expect(parsed.actions[0]?.transactionId).toBe(transactionId)
46
+ })
47
+
48
+ it.each([
49
+ actionJson({ 'transaction-id': 'not-a-uuid' }),
50
+ actionJson({ trigger: 'technical_failure' }),
51
+ actionJson({ entry: 'other' }),
52
+ actionJson({ extra: true }),
53
+ '{"type":"assistant_action"}',
54
+ ])('does not execute malformed or incomplete actions', (json) => {
55
+ const source = `无法处理。\n\n\`\`\`json\n${json}\n\`\`\``
56
+ const parsed = parseAiEmployeeAssistantContent(source)
57
+
58
+ expect(parsed).toEqual({ content: source, actions: [] })
59
+ })
60
+
61
+ it('rejects an ambiguous response containing the action more than once', () => {
62
+ const block = `\`\`\`json\n${actionJson()}\n\`\`\``
63
+ const source = `无法处理。\n\n${block}\n\n${block}`
64
+
65
+ expect(parseAiEmployeeAssistantContent(source)).toEqual({ content: source, actions: [] })
66
+ })
67
+
68
+ it('leaves ordinary JSON and inline action-looking prose unchanged', () => {
69
+ const ordinary = '示例:{"action":"open_customer_support"},这不是平台动作。'
70
+
71
+ expect(parseAiEmployeeAssistantContent(ordinary)).toEqual({
72
+ content: ordinary,
73
+ actions: [],
74
+ })
75
+ })
76
+ })