@csntgao/uni-base 0.6.3 → 0.6.6

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.
Files changed (35) hide show
  1. package/AGENTS.md +10 -8
  2. package/CLAUDE.md +10 -8
  3. package/README.md +27 -21
  4. 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
  5. package/docs//347/273/204/347/273/207/345/221/230/345/267/245/345/215/225/351/200/211/347/273/204/344/273/266/346/216/245/345/205/245/350/257/264/346/230/216.md +1 -1
  6. package/docs//347/273/204/347/273/207/345/221/230/345/267/245/345/244/232/351/200/211/347/273/204/344/273/266/346/216/245/345/205/245/350/257/264/346/230/216.md +1 -1
  7. package/examples/vanilla/main.js +1 -1
  8. package/examples/vanilla/package.json +3 -3
  9. package/package.json +12 -13
  10. package/packages/core/package.json +2 -2
  11. package/packages/core/src/assistant-actions.ts +120 -0
  12. package/packages/core/src/customer-support-types.ts +15 -0
  13. package/packages/core/src/index.ts +8 -0
  14. package/packages/core/src/types.ts +6 -0
  15. package/packages/core/tests/assistant-actions.test.ts +76 -0
  16. package/packages/web-components/package.json +3 -3
  17. package/packages/web-components/scripts/verify-runtime-build.mjs +1 -0
  18. package/packages/web-components/src/chat.ts +267 -15
  19. package/packages/web-components/src/customer-support-chat.ts +1 -1
  20. package/packages/web-components/src/customer-support-transport.ts +118 -16
  21. package/packages/web-components/src/index.ts +14 -2
  22. package/packages/web-components/src/notification-center.ts +1 -1
  23. package/packages/web-components/src/notification-popover.ts +1 -1
  24. package/packages/web-components/src/uniplat-base-notification-center-transport.ts +1 -1
  25. package/packages/web-components/tests/chat.test.ts +418 -10
  26. package/packages/web-components/tests/customer-support-chat.test.ts +1 -1
  27. package/packages/web-components/tests/customer-support-transport.test.ts +89 -1
  28. package/packages/web-components/tests/notification-center.test.ts +1 -1
  29. package/packages/web-components/tests/notification-popover.test.ts +1 -1
  30. package/packages/web-components/tsup.config.ts +1 -1
  31. package/packages/web-components/tsup.runtime.config.ts +1 -1
  32. package/restart +0 -0
  33. package/tsconfig.base.json +2 -2
  34. package/vitest.config.ts +2 -2
  35. package//344/272/244/346/216/245/346/226/207/346/241/243.md +21 -26
@@ -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
+ })
@@ -1,6 +1,6 @@
1
1
  {
2
- "name": "@uniplat/ai-employee-web-components",
3
- "version": "0.29.3",
2
+ "name": "@csntgao/ai-employee-web-components",
3
+ "version": "0.29.6",
4
4
  "description": "Framework-independent Web Components for Uniplat account, login, application switching and AI Employee chat",
5
5
  "type": "module",
6
6
  "sideEffects": true,
@@ -23,7 +23,7 @@
23
23
  "test": "vitest run"
24
24
  },
25
25
  "dependencies": {
26
- "@uniplat/ai-employee-client": "workspace:*",
26
+ "@csntgao/ai-employee-client": "workspace:*",
27
27
  "lit": "^3.3.1"
28
28
  },
29
29
  "engines": {
@@ -34,6 +34,7 @@ const requiredExports = [
34
34
  'createHrSaasCustomerSupportTransport',
35
35
  'exchangeUniplatBaseApplicationTicket',
36
36
  'loginWithUniplatBaseIdentityTicket',
37
+ 'parseAiEmployeeAssistantContent',
37
38
  'resolveOrganizationEntryDecision',
38
39
  ]
39
40
  const requiredTags = [
@@ -1,15 +1,20 @@
1
1
  import {
2
2
  AiEmployeeClient,
3
3
  AiEmployeeClientError,
4
+ parseAiEmployeeAssistantContent,
4
5
  type AiEmployeeClientErrorDetail,
5
6
  type AiEmployeeClientOptions,
6
7
  type AiEmployeeConnectionState,
7
8
  type AiEmployeeMessage,
8
9
  type AiEmployeeSessionSummary,
9
- } from '@uniplat/ai-employee-client'
10
+ type OpenCustomerSupportAssistantAction,
11
+ } from '@csntgao/ai-employee-client'
10
12
  import { LitElement, css, html, nothing, type PropertyValues } from 'lit'
11
13
  import type { UniplatCustomerSupportChat, CustomerSupportChatConfig } from './customer-support-chat'
12
- import { createHrSaasCustomerSupportTransport } from './customer-support-transport'
14
+ import {
15
+ createHrSaasCustomerSupportTransport,
16
+ HrSaasCustomerSupportTransportError,
17
+ } from './customer-support-transport'
13
18
  import { prototypeIcon } from './prototype-icons'
14
19
  import { renderSafeMarkdown } from './safe-markdown'
15
20
 
@@ -22,6 +27,11 @@ interface LocalReadBoundary {
22
27
  updatedAtMs: number
23
28
  }
24
29
 
30
+ interface CustomerSupportHandoffUiState {
31
+ status: 'loading' | 'failed' | 'succeeded'
32
+ message: string
33
+ }
34
+
25
35
  const AI_ADVISOR_TITLE = '企业服务AI顾问'
26
36
 
27
37
  export interface AiEmployeeChatEventMap {
@@ -50,6 +60,7 @@ export class UniplatAiEmployeeChat extends LitElement {
50
60
  switchingSessionId: { state: true },
51
61
  creatingSession: { state: true },
52
62
  customerSupportOpen: { state: true },
63
+ customerSupportHandoffStates: { state: true },
53
64
  }
54
65
 
55
66
  static override styles = css`
@@ -635,6 +646,55 @@ export class UniplatAiEmployeeChat extends LitElement {
635
646
  text-underline-offset: 2px;
636
647
  }
637
648
 
649
+ .assistant-action {
650
+ display: flex;
651
+ align-items: flex-start;
652
+ flex-direction: column;
653
+ gap: 6px;
654
+ margin-top: 8px;
655
+ }
656
+
657
+ .assistant-action-button {
658
+ display: inline-flex;
659
+ min-height: 36px;
660
+ align-items: center;
661
+ justify-content: center;
662
+ gap: 7px;
663
+ padding: 7px 13px;
664
+ border: 1px solid color-mix(in srgb, var(--uniplat-chat-primary), transparent 58%);
665
+ border-radius: 9px;
666
+ outline: none;
667
+ background: #f0f5ff;
668
+ color: var(--uniplat-chat-primary);
669
+ cursor: pointer;
670
+ font: inherit;
671
+ font-size: 13px;
672
+ font-weight: 700;
673
+ }
674
+
675
+ .assistant-action-button:hover,
676
+ .assistant-action-button:focus-visible {
677
+ border-color: var(--uniplat-chat-primary);
678
+ background: #e6efff;
679
+ }
680
+
681
+ .assistant-action-button:disabled {
682
+ cursor: wait;
683
+ opacity: 0.65;
684
+ }
685
+
686
+ .assistant-action-button svg {
687
+ width: 16px;
688
+ height: 16px;
689
+ }
690
+
691
+ .assistant-action-error {
692
+ max-width: 280px;
693
+ color: var(--uniplat-chat-error);
694
+ font-size: 12px;
695
+ line-height: 1.5;
696
+ }
697
+
638
698
  .empty,
639
699
  .loading {
640
700
  display: grid;
@@ -778,6 +838,7 @@ export class UniplatAiEmployeeChat extends LitElement {
778
838
  declare private switchingSessionId: string
779
839
  declare private creatingSession: boolean
780
840
  declare private customerSupportOpen: boolean
841
+ declare private customerSupportHandoffStates: ReadonlyMap<string, CustomerSupportHandoffUiState>
781
842
  private config: AiEmployeeChatConfig | null = null
782
843
  private client: AiEmployeeClient | null = null
783
844
  private clientUnsubscribers: Array<() => void> = []
@@ -785,7 +846,13 @@ export class UniplatAiEmployeeChat extends LitElement {
785
846
  private sessionListGeneration = 0
786
847
  private readyDispatched = false
787
848
  private readSyncGeneration = 0
849
+ private customerSupportHandoffGeneration = 0
788
850
  private readonly localMessageIds = new Set<string>()
851
+ /**
852
+ * 只有来自 HTTP 历史与会话列表的消息 ID 是服务端持久化 ID,实时事件里的
853
+ * ws_message_id / 合成 ID 不能用于已读同步,服务端会以 INVALID_ARGUMENT 拒绝。
854
+ */
855
+ private readonly persistedMessageIds = new Set<string>()
789
856
  private readonly locallyReadBoundaries = new Map<string, LocalReadBoundary>()
790
857
  private readonly confirmedReadMessageIds = new Map<string, string>()
791
858
  private readonly queuedReadMessageIds = new Map<string, string>()
@@ -813,9 +880,11 @@ export class UniplatAiEmployeeChat extends LitElement {
813
880
  this.switchingSessionId = ''
814
881
  this.creatingSession = false
815
882
  this.customerSupportOpen = false
883
+ this.customerSupportHandoffStates = new Map()
816
884
  }
817
885
 
818
886
  configure(config: AiEmployeeChatConfig): void {
887
+ this.customerSupportHandoffGeneration += 1
819
888
  this.config = config
820
889
  this.releaseClient()
821
890
  this.resetView()
@@ -838,6 +907,7 @@ export class UniplatAiEmployeeChat extends LitElement {
838
907
  if (!this.drawerOpen) return
839
908
  this.drawerOpen = false
840
909
  this.pendingInitialMessage = ''
910
+ this.customerSupportHandoffGeneration += 1
841
911
  window.removeEventListener('keydown', this.handleWindowKeyDown)
842
912
  this.releaseClient()
843
913
  }
@@ -848,6 +918,7 @@ export class UniplatAiEmployeeChat extends LitElement {
848
918
  }
849
919
 
850
920
  override disconnectedCallback(): void {
921
+ this.customerSupportHandoffGeneration += 1
851
922
  window.removeEventListener('keydown', this.handleWindowKeyDown)
852
923
  this.releaseClient()
853
924
  super.disconnectedCallback()
@@ -1039,7 +1110,9 @@ export class UniplatAiEmployeeChat extends LitElement {
1039
1110
  >
1040
1111
  <span class="session-item-icon" aria-hidden="true">${prototypeIcon('robot')}</span>
1041
1112
  <span class="session-item-title">${session.title}</span>
1042
- <span class="session-item-preview">${session.latestMessage || '暂无消息'}</span>
1113
+ <span class="session-item-preview"
1114
+ >${sessionPreview(session.latestMessage) || '暂无消息'}</span
1115
+ >
1043
1116
  <span class="session-item-side">
1044
1117
  <span>${formatRelativeTime(session.updatedAt)}</span>
1045
1118
  ${
@@ -1089,6 +1162,10 @@ export class UniplatAiEmployeeChat extends LitElement {
1089
1162
  </div>
1090
1163
  `
1091
1164
  }
1165
+ const parsed =
1166
+ message.role === 'assistant'
1167
+ ? parseAiEmployeeAssistantContent(message.content)
1168
+ : { content: message.content, actions: [] }
1092
1169
  return html`
1093
1170
  <div class="message-row ${message.role}">
1094
1171
  <div class="assistant-message">
@@ -1097,19 +1174,60 @@ export class UniplatAiEmployeeChat extends LitElement {
1097
1174
  <span class="assistant-label"
1098
1175
  >${message.role === 'error' ? '系统提示' : '在线顾问'}</span
1099
1176
  >
1100
- <div
1101
- class="message ${message.role} ${message.role === 'assistant' ? 'markdown' : ''}"
1102
- part="message message-${message.role}"
1103
- data-message-id=${message.id}
1104
- >
1105
- ${message.role === 'assistant' ? renderSafeMarkdown(message.content) : message.content}
1106
- </div>
1177
+ ${
1178
+ parsed.content
1179
+ ? html`<div
1180
+ class="message ${message.role} ${
1181
+ message.role === 'assistant' ? 'markdown' : ''
1182
+ }"
1183
+ part="message message-${message.role}"
1184
+ data-message-id=${message.id}
1185
+ >
1186
+ ${
1187
+ message.role === 'assistant'
1188
+ ? renderSafeMarkdown(parsed.content)
1189
+ : parsed.content
1190
+ }
1191
+ </div>`
1192
+ : nothing
1193
+ }
1194
+ ${
1195
+ message.role === 'assistant'
1196
+ ? parsed.actions.map((action) => this.renderAssistantAction(message, action))
1197
+ : nothing
1198
+ }
1107
1199
  </div>
1108
1200
  </div>
1109
1201
  </div>
1110
1202
  `
1111
1203
  }
1112
1204
 
1205
+ private renderAssistantAction(
1206
+ message: AiEmployeeMessage,
1207
+ action: OpenCustomerSupportAssistantAction,
1208
+ ) {
1209
+ const state = this.customerSupportHandoffStates.get(action.transactionId)
1210
+ const loading = state?.status === 'loading'
1211
+ const succeeded = state?.status === 'succeeded'
1212
+ return html`<div class="assistant-action" part="assistant-action">
1213
+ <button
1214
+ class="assistant-action-button"
1215
+ part="assistant-action-button"
1216
+ type="button"
1217
+ ?disabled=${loading || succeeded}
1218
+ @click=${() => void this.handleCustomerSupportHandoff(message, action)}
1219
+ >
1220
+ ${prototypeIcon(succeeded ? 'circle-check-filled' : 'headset')}
1221
+ ${loading ? '正在发起人工客服…' : succeeded ? '已发起人工客服' : '发起人工客服'}
1222
+ </button>
1223
+ ${
1224
+ state?.status === 'failed'
1225
+ ? html`<span class="assistant-action-error" role="status">${state.message}</span>`
1226
+ : nothing
1227
+ }
1228
+ </div>`
1229
+ }
1230
+
1113
1231
  private async startClient(): Promise<void> {
1114
1232
  const config = this.config
1115
1233
  if (!config || this.client || !this.isConnected) return
@@ -1159,6 +1277,7 @@ export class UniplatAiEmployeeChat extends LitElement {
1159
1277
  client.on('history', ({ sessionId, messages }) => {
1160
1278
  if (this.client !== client) return
1161
1279
  this.messages = messages
1280
+ for (const message of messages) this.persistedMessageIds.add(message.id)
1162
1281
  if (this.isCurrentConversationVisible(sessionId)) void this.markCurrentSessionRead()
1163
1282
  }),
1164
1283
  client.on('message', ({ sessionId, message, source }) => {
@@ -1180,7 +1299,14 @@ export class UniplatAiEmployeeChat extends LitElement {
1180
1299
  this.dispatchPublicEvent('connection-state-change', { state: current })
1181
1300
  }),
1182
1301
  client.on('processing-state-change', ({ processing }) => {
1183
- if (this.client === client) this.processing = processing
1302
+ if (this.client !== client) return
1303
+ const finished = this.processing && !processing
1304
+ this.processing = processing
1305
+ // 实时事件只带 ws_message_id;本轮结束后重新取一次历史,
1306
+ // 才能用持久化 ID 同步已读。
1307
+ if (finished && this.isCurrentConversationVisible(client.sessionId ?? '')) {
1308
+ void client.refreshMessages().catch(() => undefined)
1309
+ }
1184
1310
  }),
1185
1311
  client.on('title', ({ sessionId, title }) => {
1186
1312
  if (this.client !== client) return
@@ -1252,6 +1378,9 @@ export class UniplatAiEmployeeChat extends LitElement {
1252
1378
  try {
1253
1379
  const sessions = normalizeSessionSummaries(await listSessions.call(transport))
1254
1380
  if (!this.isCurrentSessionList(generation)) return
1381
+ for (const session of sessions) {
1382
+ if (session.latestMessageId) this.persistedMessageIds.add(session.latestMessageId)
1383
+ }
1255
1384
  const mergedSessions = mergeCurrentSession(sessions, this.currentSessionSummary())
1256
1385
  this.sessions = this.applyLocallyReadState(mergedSessions)
1257
1386
  const currentSession = mergedSessions.find(
@@ -1390,11 +1519,14 @@ export class UniplatAiEmployeeChat extends LitElement {
1390
1519
  const summary: AiEmployeeSessionSummary = {
1391
1520
  sessionId: message.sessionId,
1392
1521
  title: existing?.title || this.chatTitle || '新对话',
1393
- latestMessage: message.content,
1522
+ latestMessage: messagePreview(message),
1394
1523
  updatedAt: message.createdAt,
1395
1524
  unreadCount: visible ? 0 : Math.max(0, (existing?.unreadCount ?? 0) + unreadIncrement),
1396
1525
  }
1397
- const latestMessageId = source === 'local' ? existing?.latestMessageId : message.id
1526
+ // 实时消息 ID 不是持久化 ID,不能当作已读游标。
1527
+ const latestMessageId = this.persistedMessageIds.has(message.id)
1528
+ ? message.id
1529
+ : existing?.latestMessageId
1398
1530
  if (latestMessageId) summary.latestMessageId = latestMessageId
1399
1531
  this.sessions = [
1400
1532
  summary,
@@ -1410,7 +1542,7 @@ export class UniplatAiEmployeeChat extends LitElement {
1410
1542
  let latestServerMessage: AiEmployeeMessage | undefined
1411
1543
  for (let index = this.messages.length - 1; index >= 0; index -= 1) {
1412
1544
  const candidate = this.messages[index]
1413
- if (candidate && !this.localMessageIds.has(candidate.id)) {
1545
+ if (candidate && this.persistedMessageIds.has(candidate.id)) {
1414
1546
  latestServerMessage = candidate
1415
1547
  break
1416
1548
  }
@@ -1418,7 +1550,7 @@ export class UniplatAiEmployeeChat extends LitElement {
1418
1550
  const summary: AiEmployeeSessionSummary = {
1419
1551
  sessionId,
1420
1552
  title: this.chatTitle || '新对话',
1421
- latestMessage: latestMessage?.content ?? '',
1553
+ latestMessage: latestMessage ? messagePreview(latestMessage) : '',
1422
1554
  updatedAt: latestMessage?.createdAt ?? new Date().toISOString(),
1423
1555
  unreadCount: 0,
1424
1556
  }
@@ -1495,6 +1627,48 @@ export class UniplatAiEmployeeChat extends LitElement {
1495
1627
  })
1496
1628
  return
1497
1629
  }
1630
+ this.openCustomerSupport(customerSupport)
1631
+ }
1632
+
1633
+ private async handleCustomerSupportHandoff(
1634
+ message: AiEmployeeMessage,
1635
+ action: OpenCustomerSupportAssistantAction,
1636
+ ): Promise<void> {
1637
+ const key = action.transactionId
1638
+ const currentState = this.customerSupportHandoffStates.get(key)
1639
+ if (currentState?.status === 'loading' || currentState?.status === 'succeeded') return
1640
+ const customerSupport = this.resolveCustomerSupportConfig()
1641
+ const createHandoffSession = customerSupport?.transport.createHandoffSession
1642
+ if (!customerSupport || !createHandoffSession) {
1643
+ const detail: AiEmployeeClientErrorDetail = {
1644
+ code: 'CUSTOMER_SUPPORT_UNAVAILABLE',
1645
+ message: '人工客服转接暂未配置。',
1646
+ recoverable: false,
1647
+ }
1648
+ this.setCustomerSupportHandoffState(key, { status: 'failed', message: detail.message })
1649
+ this.dispatchPublicEvent('error', detail)
1650
+ return
1651
+ }
1652
+
1653
+ const generation = this.customerSupportHandoffGeneration
1654
+ this.setCustomerSupportHandoffState(key, { status: 'loading', message: '' })
1655
+ try {
1656
+ const result = await createHandoffSession.call(customerSupport.transport, {
1657
+ sourceSessionId: message.sessionId,
1658
+ actionTransactionId: action.transactionId,
1659
+ })
1660
+ if (!this.isCurrentCustomerSupportHandoff(generation, message.sessionId)) return
1661
+ this.setCustomerSupportHandoffState(key, { status: 'succeeded', message: '' })
1662
+ this.openCustomerSupport({ ...customerSupport, sessionId: result.session.id })
1663
+ } catch (error) {
1664
+ if (!this.isCurrentCustomerSupportHandoff(generation, message.sessionId)) return
1665
+ const detail = customerSupportHandoffError(error)
1666
+ this.setCustomerSupportHandoffState(key, { status: 'failed', message: detail.message })
1667
+ this.dispatchPublicEvent('error', detail)
1668
+ }
1669
+ }
1670
+
1671
+ private openCustomerSupport(customerSupport: CustomerSupportChatConfig): void {
1498
1672
  this.releaseClient()
1499
1673
  this.customerSupportOpen = true
1500
1674
  void this.updateComplete.then(() => {
@@ -1507,6 +1681,20 @@ export class UniplatAiEmployeeChat extends LitElement {
1507
1681
  })
1508
1682
  }
1509
1683
 
1684
+ private setCustomerSupportHandoffState(key: string, state: CustomerSupportHandoffUiState): void {
1685
+ const next = new Map(this.customerSupportHandoffStates)
1686
+ next.set(key, state)
1687
+ this.customerSupportHandoffStates = next
1688
+ }
1689
+
1690
+ private isCurrentCustomerSupportHandoff(generation: number, sessionId: string): boolean {
1691
+ return (
1692
+ generation === this.customerSupportHandoffGeneration &&
1693
+ this.drawerOpen &&
1694
+ this.client?.sessionId === sessionId
1695
+ )
1696
+ }
1697
+
1510
1698
  private resolveCustomerSupportConfig(): CustomerSupportChatConfig | null {
1511
1699
  const config = this.config
1512
1700
  if (!config) return null
@@ -1603,6 +1791,7 @@ export class UniplatAiEmployeeChat extends LitElement {
1603
1791
  private resetView(): void {
1604
1792
  this.readSyncGeneration += 1
1605
1793
  this.localMessageIds.clear()
1794
+ this.persistedMessageIds.clear()
1606
1795
  this.locallyReadBoundaries.clear()
1607
1796
  this.confirmedReadMessageIds.clear()
1608
1797
  this.queuedReadMessageIds.clear()
@@ -1619,9 +1808,72 @@ export class UniplatAiEmployeeChat extends LitElement {
1619
1808
  this.switchingSessionId = ''
1620
1809
  this.creatingSession = false
1621
1810
  this.connectionState = 'idle'
1811
+ this.customerSupportHandoffStates = new Map()
1622
1812
  }
1623
1813
  }
1624
1814
 
1815
+ function customerSupportHandoffError(error: unknown): AiEmployeeClientErrorDetail {
1816
+ if (error instanceof HrSaasCustomerSupportTransportError) {
1817
+ if (error.code === 'SOURCE_SESSION_NOT_FOUND') {
1818
+ return {
1819
+ code: 'SOURCE_SESSION_NOT_FOUND',
1820
+ message: '来源会话不存在,请刷新后重试。',
1821
+ recoverable: false,
1822
+ }
1823
+ }
1824
+ if (error.code === 'SOURCE_SESSION_ACCESS_DENIED') {
1825
+ return {
1826
+ code: 'SOURCE_SESSION_ACCESS_DENIED',
1827
+ message: '当前身份无权转接此会话。',
1828
+ recoverable: false,
1829
+ }
1830
+ }
1831
+ if (error.code === 'HANDOFF_ACTION_NOT_FOUND') {
1832
+ return {
1833
+ code: 'HANDOFF_ACTION_NOT_FOUND',
1834
+ message: '该人工客服入口已失效,请刷新会话后重试。',
1835
+ recoverable: false,
1836
+ }
1837
+ }
1838
+ if (error.code === 'HANDOFF_ACTION_MISMATCH') {
1839
+ return {
1840
+ code: 'HANDOFF_ACTION_MISMATCH',
1841
+ message: '转接状态已变化,请刷新会话后重试。',
1842
+ recoverable: true,
1843
+ }
1844
+ }
1845
+ if (error.code === 'AUTH_REQUIRED' || error.code === 'UNAUTHORIZED') {
1846
+ return {
1847
+ code: 'AUTHENTICATION_FAILED',
1848
+ message: '登录状态已失效,请重新登录后再试。',
1849
+ recoverable: true,
1850
+ }
1851
+ }
1852
+ if (error.code === 'CUSTOMER_SUPPORT_UNAVAILABLE' || error.code === 'REQUEST_FAILED') {
1853
+ return {
1854
+ code: 'CUSTOMER_SUPPORT_UNAVAILABLE',
1855
+ message: '人工客服暂时不可用,请稍后重试。',
1856
+ recoverable: true,
1857
+ }
1858
+ }
1859
+ }
1860
+ return {
1861
+ code: 'CUSTOMER_SUPPORT_HANDOFF_FAILED',
1862
+ message: '人工客服转接失败,请稍后重试。',
1863
+ recoverable: true,
1864
+ }
1865
+ }
1866
+
1867
+ function messagePreview(message: AiEmployeeMessage): string {
1868
+ return message.role === 'assistant' ? sessionPreview(message.content) : message.content
1869
+ }
1870
+
1871
+ function sessionPreview(content: string): string {
1872
+ const parsed = parseAiEmployeeAssistantContent(content)
1873
+ if (parsed.actions.length === 0) return content
1874
+ return parsed.content || '可发起人工客服'
1875
+ }
1876
+
1625
1877
  function formatMessageTime(value: string): string {
1626
1878
  const date = new Date(value)
1627
1879
  if (Number.isNaN(date.getTime())) return ''
@@ -6,7 +6,7 @@ import {
6
6
  type CustomerSupportErrorDetail,
7
7
  type CustomerSupportMessage,
8
8
  type CustomerSupportSession,
9
- } from '@uniplat/ai-employee-client'
9
+ } from '@csntgao/ai-employee-client'
10
10
  import { LitElement, css, html, nothing } from 'lit'
11
11
  import { prototypeIcon } from './prototype-icons'
12
12