@csntgao/uni-base 0.6.3 → 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 +5 -3
- package/CLAUDE.md +5 -3
- package/README.md +6 -2
- package/docs//346/225/260/345/255/227/345/221/230/345/267/245/345/256/214/346/225/264/346/212/275/345/261/211/346/216/245/345/205/245/350/257/264/346/230/216.md +46 -0
- package/package.json +12 -13
- package/packages/core/package.json +1 -1
- package/packages/core/src/assistant-actions.ts +120 -0
- package/packages/core/src/customer-support-types.ts +15 -0
- package/packages/core/src/index.ts +8 -0
- package/packages/core/src/types.ts +6 -0
- package/packages/core/tests/assistant-actions.test.ts +76 -0
- package/packages/web-components/package.json +1 -1
- package/packages/web-components/scripts/verify-runtime-build.mjs +1 -0
- package/packages/web-components/src/chat.ts +266 -14
- package/packages/web-components/src/customer-support-transport.ts +117 -15
- package/packages/web-components/src/index.ts +14 -2
- package/packages/web-components/tests/chat.test.ts +417 -9
- package/packages/web-components/tests/customer-support-transport.test.ts +89 -1
- package/restart +0 -0
package/AGENTS.md
CHANGED
|
@@ -186,7 +186,7 @@ pnpm --filter @uniplat/ai-employee-web-components pack
|
|
|
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`
|
|
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()`
|
|
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`
|
|
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
|
@@ -186,7 +186,7 @@ pnpm --filter @uniplat/ai-employee-web-components pack
|
|
|
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`
|
|
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()`
|
|
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`
|
|
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.
|
|
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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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,
|
|
@@ -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
|
+
})
|