@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.
@@ -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,
10
+ type OpenCustomerSupportAssistantAction,
9
11
  } from '@uniplat/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 ''
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  CustomerSupportAgent,
3
3
  CustomerSupportCreateSessionResult,
4
+ CustomerSupportHandoffResult,
4
5
  CustomerSupportMessage,
5
6
  CustomerSupportMessagePage,
6
7
  CustomerSupportSenderType,
@@ -8,6 +9,7 @@ import type {
8
9
  CustomerSupportSessionStatus,
9
10
  CustomerSupportTransport,
10
11
  } from '@uniplat/ai-employee-client'
12
+ import { isUuidV4 } from '@uniplat/ai-employee-client'
11
13
 
12
14
  export interface HrSaasCustomerSupportTransportOptions {
13
15
  serviceBaseUrl: string
@@ -15,8 +17,30 @@ export interface HrSaasCustomerSupportTransportOptions {
15
17
  fetch?: typeof fetch
16
18
  }
17
19
 
20
+ export type HrSaasCustomerSupportTransportErrorCode =
21
+ | 'AUTH_REQUIRED'
22
+ | 'SOURCE_SESSION_NOT_FOUND'
23
+ | 'SOURCE_SESSION_ACCESS_DENIED'
24
+ | 'HANDOFF_ACTION_NOT_FOUND'
25
+ | 'HANDOFF_ACTION_MISMATCH'
26
+ | 'UNAUTHORIZED'
27
+ | 'CUSTOMER_SUPPORT_UNAVAILABLE'
28
+ | 'REQUEST_FAILED'
29
+ | 'INVALID_RESPONSE'
30
+
31
+ export class HrSaasCustomerSupportTransportError extends Error {
32
+ readonly code: HrSaasCustomerSupportTransportErrorCode
33
+
34
+ constructor(code: HrSaasCustomerSupportTransportErrorCode) {
35
+ super('Customer support request failed')
36
+ this.name = 'HrSaasCustomerSupportTransportError'
37
+ this.code = code
38
+ }
39
+ }
40
+
18
41
  const SESSION_PAGE_ACTION = 'user_session_page'
19
42
  const SESSION_CREATE_ACTION = 'user_session_create'
43
+ const SESSION_HANDOFF_CREATE_ACTION = 'user_session_handoff_create'
20
44
  const SESSION_DETAIL_ACTION = 'user_session_detail'
21
45
  const MESSAGE_PAGE_ACTION = 'user_message_page'
22
46
  const MESSAGE_SEND_ACTION = 'user_message_send'
@@ -63,6 +87,37 @@ export function createHrSaasCustomerSupportTransport(
63
87
  if (parsedInitialMessage) result.initialMessage = parsedInitialMessage
64
88
  return result
65
89
  },
90
+ async createHandoffSession(handoffRequest) {
91
+ const sourceSessionId = requiredText(handoffRequest.sourceSessionId, 128)
92
+ const actionTransactionId = requiredText(handoffRequest.actionTransactionId, 36)
93
+ if (!isUuidV4(actionTransactionId)) {
94
+ throw new HrSaasCustomerSupportTransportError('INVALID_RESPONSE')
95
+ }
96
+ const data = record(
97
+ await request(SESSION_HANDOFF_CREATE_ACTION, {
98
+ source_session_id: sourceSessionId,
99
+ action_transaction_id: actionTransactionId,
100
+ }),
101
+ )
102
+ const session = parseSession(data.session)
103
+ const initialMessage = parseMessage(data.initial_message)
104
+ if (
105
+ !session ||
106
+ !initialMessage ||
107
+ initialMessage.sessionId !== session.id ||
108
+ typeof data.created !== 'boolean' ||
109
+ typeof data.repaired !== 'boolean'
110
+ ) {
111
+ throw new HrSaasCustomerSupportTransportError('INVALID_RESPONSE')
112
+ }
113
+ const result: CustomerSupportHandoffResult = {
114
+ session,
115
+ initialMessage,
116
+ created: data.created,
117
+ repaired: data.repaired,
118
+ }
119
+ return result
120
+ },
66
121
  async getSession(sessionId) {
67
122
  const data = await request(SESSION_DETAIL_ACTION, {
68
123
  session_id: requiredText(sessionId, 128),
@@ -169,27 +224,74 @@ async function requestData(
169
224
  accessToken: string,
170
225
  body: Record<string, unknown>,
171
226
  ): Promise<unknown> {
172
- if (!fetchImplementation) throw new Error('Fetch is unavailable')
173
- const response = await fetchImplementation(endpoint, {
174
- method: 'POST',
175
- credentials: 'include',
176
- headers: {
177
- accept: 'application/json',
178
- authorization: `Bearer ${accessToken}`,
179
- 'content-type': 'application/json',
180
- },
181
- body: JSON.stringify(body),
182
- })
183
- if (!response.ok) throw new Error('Customer support request failed')
184
- const payload: unknown = await response.json()
227
+ if (!fetchImplementation) throw new HrSaasCustomerSupportTransportError('REQUEST_FAILED')
228
+ let response: Response
229
+ try {
230
+ response = await fetchImplementation(endpoint, {
231
+ method: 'POST',
232
+ credentials: 'include',
233
+ headers: {
234
+ accept: 'application/json',
235
+ authorization: `Bearer ${accessToken}`,
236
+ 'content-type': 'application/json',
237
+ },
238
+ body: JSON.stringify(body),
239
+ })
240
+ } catch {
241
+ throw new HrSaasCustomerSupportTransportError('REQUEST_FAILED')
242
+ }
243
+ let payload: unknown = null
244
+ try {
245
+ payload = await response.json()
246
+ } catch {
247
+ if (!response.ok) throw transportResponseError({}, response.status)
248
+ throw new HrSaasCustomerSupportTransportError('INVALID_RESPONSE')
249
+ }
185
250
  const envelope = record(payload)
251
+ if (!response.ok) throw transportResponseError(envelope, response.status)
186
252
  if ('rescode' in envelope) {
187
- if (Number(envelope.rescode) !== 0) throw new Error('Customer support request failed')
253
+ if (Number(envelope.rescode) !== 0) {
254
+ throw transportResponseError(envelope, response.status)
255
+ }
188
256
  return envelope.data
189
257
  }
190
258
  return payload
191
259
  }
192
260
 
261
+ function transportResponseError(
262
+ envelope: Record<string, unknown>,
263
+ status: number,
264
+ ): HrSaasCustomerSupportTransportError {
265
+ const detail = record(envelope.data)
266
+ const error = record(envelope.error)
267
+ const candidate = [
268
+ envelope.error_code,
269
+ envelope.code,
270
+ detail.error_code,
271
+ detail.code,
272
+ error.error_code,
273
+ error.code,
274
+ ].find((value) => typeof value === 'string' && value.trim())
275
+ const code = typeof candidate === 'string' ? candidate.trim().toUpperCase() : ''
276
+ if (isTransportErrorCode(code)) return new HrSaasCustomerSupportTransportError(code)
277
+ if (status === 401) return new HrSaasCustomerSupportTransportError('UNAUTHORIZED')
278
+ if (status === 503) {
279
+ return new HrSaasCustomerSupportTransportError('CUSTOMER_SUPPORT_UNAVAILABLE')
280
+ }
281
+ return new HrSaasCustomerSupportTransportError('REQUEST_FAILED')
282
+ }
283
+
284
+ function isTransportErrorCode(value: string): value is HrSaasCustomerSupportTransportErrorCode {
285
+ return [
286
+ 'SOURCE_SESSION_NOT_FOUND',
287
+ 'SOURCE_SESSION_ACCESS_DENIED',
288
+ 'HANDOFF_ACTION_NOT_FOUND',
289
+ 'HANDOFF_ACTION_MISMATCH',
290
+ 'UNAUTHORIZED',
291
+ 'CUSTOMER_SUPPORT_UNAVAILABLE',
292
+ ].includes(value)
293
+ }
294
+
193
295
  function validateServiceBaseUrl(value: string): string {
194
296
  const raw = typeof value === 'string' ? value.trim() : ''
195
297
  if (!raw) throw new TypeError('Customer support serviceBaseUrl is required')
@@ -216,7 +318,7 @@ async function resolveAccessToken(provider: () => string | Promise<string>): Pro
216
318
  } catch {
217
319
  // Provider details may contain credentials and are intentionally discarded.
218
320
  }
219
- if (!value) throw new Error('Access token is unavailable')
321
+ if (!value) throw new HrSaasCustomerSupportTransportError('AUTH_REQUIRED')
220
322
  return value
221
323
  }
222
324
 
@@ -77,8 +77,20 @@ export type {
77
77
  CustomerSupportChatConfig,
78
78
  CustomerSupportChatEventMap,
79
79
  } from './customer-support-chat'
80
- export { createHrSaasCustomerSupportTransport } from './customer-support-transport'
81
- export type { HrSaasCustomerSupportTransportOptions } from './customer-support-transport'
80
+ export {
81
+ createHrSaasCustomerSupportTransport,
82
+ HrSaasCustomerSupportTransportError,
83
+ } from './customer-support-transport'
84
+ export type {
85
+ HrSaasCustomerSupportTransportErrorCode,
86
+ HrSaasCustomerSupportTransportOptions,
87
+ } from './customer-support-transport'
88
+ export { isUuidV4, parseAiEmployeeAssistantContent } from '@uniplat/ai-employee-client'
89
+ export type {
90
+ AiEmployeeAssistantAction,
91
+ OpenCustomerSupportAssistantAction,
92
+ ParsedAiEmployeeAssistantContent,
93
+ } from '@uniplat/ai-employee-client'
82
94
  export type {
83
95
  NotificationCenterConfig,
84
96
  NotificationCenterEventMap,