@csntgao/uni-base 0.1.0

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 (90) hide show
  1. package/.prettierignore +4 -0
  2. package/.prettierrc.json +6 -0
  3. package/AGENTS.md +519 -0
  4. package/CLAUDE.md +512 -0
  5. package/README.md +734 -0
  6. 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 +110 -0
  7. 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 +79 -0
  8. 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 +93 -0
  9. package/docs//350/264/246/346/210/267/344/270/216/347/231/273/345/275/225/347/273/204/344/273/266/345/212/250/346/200/201/346/216/245/345/205/245/350/257/264/346/230/216.md +201 -0
  10. package/eslint.config.js +30 -0
  11. package/examples/vanilla/index.html +33 -0
  12. package/examples/vanilla/main.js +247 -0
  13. package/examples/vanilla/package.json +15 -0
  14. package/examples/vanilla/public/runtime-config.js +10 -0
  15. package/examples/vanilla/style.css +99 -0
  16. package/package.json +31 -0
  17. package/packages/core/package.json +30 -0
  18. package/packages/core/src/client.ts +1132 -0
  19. package/packages/core/src/customer-support-client.ts +825 -0
  20. package/packages/core/src/customer-support-types.ts +123 -0
  21. package/packages/core/src/index.ts +79 -0
  22. package/packages/core/src/notification-center-client.ts +462 -0
  23. package/packages/core/src/notification-center-types.ts +154 -0
  24. package/packages/core/src/protocol.ts +237 -0
  25. package/packages/core/src/types.ts +170 -0
  26. package/packages/core/tests/client.test.ts +760 -0
  27. package/packages/core/tests/customer-support-client.test.ts +576 -0
  28. package/packages/core/tests/fake-websocket.ts +55 -0
  29. package/packages/core/tests/notification-center-client.test.ts +467 -0
  30. package/packages/core/tests/protocol.test.ts +124 -0
  31. package/packages/core/tsconfig.json +7 -0
  32. package/packages/core/tsup.config.ts +11 -0
  33. package/packages/web-components/package.json +35 -0
  34. package/packages/web-components/scripts/verify-runtime-build.mjs +111 -0
  35. package/packages/web-components/src/account-context.ts +1540 -0
  36. package/packages/web-components/src/application-switcher-transport.ts +243 -0
  37. package/packages/web-components/src/application-switcher.ts +1103 -0
  38. package/packages/web-components/src/chat.ts +1754 -0
  39. package/packages/web-components/src/customer-support-chat.ts +862 -0
  40. package/packages/web-components/src/customer-support-transport.ts +269 -0
  41. package/packages/web-components/src/index.ts +256 -0
  42. package/packages/web-components/src/notification-center.ts +1066 -0
  43. package/packages/web-components/src/notification-popover.ts +615 -0
  44. package/packages/web-components/src/organization-multi-employee-selector.ts +1564 -0
  45. package/packages/web-components/src/organization-onboarding-transport.ts +54 -0
  46. package/packages/web-components/src/organization-onboarding.ts +1216 -0
  47. package/packages/web-components/src/organization-selector.ts +534 -0
  48. package/packages/web-components/src/organization-single-employee-selector.ts +114 -0
  49. package/packages/web-components/src/prototype-icons.ts +242 -0
  50. package/packages/web-components/src/safe-image-url.ts +29 -0
  51. package/packages/web-components/src/safe-markdown.ts +192 -0
  52. package/packages/web-components/src/uniplat-base-account-context-transport.ts +625 -0
  53. package/packages/web-components/src/uniplat-base-application-switcher-transport.ts +418 -0
  54. package/packages/web-components/src/uniplat-base-application-ticket.ts +254 -0
  55. package/packages/web-components/src/uniplat-base-identity-ticket.ts +202 -0
  56. package/packages/web-components/src/uniplat-base-notification-center-transport.ts +378 -0
  57. package/packages/web-components/src/uniplat-base-organization-multi-employee-selector-transport.ts +394 -0
  58. package/packages/web-components/src/uniplat-base-organization-onboarding-transport.ts +183 -0
  59. package/packages/web-components/src/uniplat-base-organization-single-employee-selector-transport.ts +14 -0
  60. package/packages/web-components/src/user-login-transport.ts +563 -0
  61. package/packages/web-components/src/user-login.ts +2027 -0
  62. package/packages/web-components/src/user-menu.ts +880 -0
  63. package/packages/web-components/tests/account-context.test.ts +798 -0
  64. package/packages/web-components/tests/application-switcher.test.ts +562 -0
  65. package/packages/web-components/tests/chat.test.ts +1130 -0
  66. package/packages/web-components/tests/customer-support-chat.test.ts +428 -0
  67. package/packages/web-components/tests/customer-support-transport.test.ts +79 -0
  68. package/packages/web-components/tests/notification-center-transport.test.ts +271 -0
  69. package/packages/web-components/tests/notification-center.test.ts +429 -0
  70. package/packages/web-components/tests/notification-popover.test.ts +283 -0
  71. package/packages/web-components/tests/organization-multi-employee-selector-transport.test.ts +332 -0
  72. package/packages/web-components/tests/organization-multi-employee-selector.test.ts +225 -0
  73. package/packages/web-components/tests/organization-onboarding.test.ts +260 -0
  74. package/packages/web-components/tests/organization-selector.test.ts +127 -0
  75. package/packages/web-components/tests/organization-single-employee-selector.test.ts +145 -0
  76. package/packages/web-components/tests/uniplat-base-account-context-transport.test.ts +496 -0
  77. package/packages/web-components/tests/uniplat-base-application-switcher-transport.test.ts +598 -0
  78. package/packages/web-components/tests/uniplat-base-application-ticket.test.ts +233 -0
  79. package/packages/web-components/tests/uniplat-base-identity-ticket.test.ts +226 -0
  80. package/packages/web-components/tests/uniplat-base-organization-onboarding-transport.test.ts +227 -0
  81. package/packages/web-components/tests/user-login-transport.test.ts +443 -0
  82. package/packages/web-components/tests/user-login.test.ts +626 -0
  83. package/packages/web-components/tests/user-menu.test.ts +271 -0
  84. package/packages/web-components/tsconfig.json +8 -0
  85. package/packages/web-components/tsup.config.ts +12 -0
  86. package/packages/web-components/tsup.runtime.config.ts +37 -0
  87. package/pnpm-workspace.yaml +6 -0
  88. package/tsconfig.base.json +21 -0
  89. package/vitest.config.ts +20 -0
  90. package//344/272/244/346/216/245/346/226/207/346/241/243.md +254 -0
@@ -0,0 +1,825 @@
1
+ import { createPingFrame, parseJsonRecord } from './protocol'
2
+ import type {
3
+ CustomerSupportClientEventMap,
4
+ CustomerSupportClientListener,
5
+ CustomerSupportClientOptions,
6
+ CustomerSupportErrorCode,
7
+ CustomerSupportErrorDetail,
8
+ CustomerSupportMessage,
9
+ CustomerSupportSession,
10
+ } from './customer-support-types'
11
+ import type { AiEmployeeConnectionState, AiEmployeeReconnectOptions } from './types'
12
+
13
+ type UntypedListener = (detail: unknown) => void
14
+ type JsonRecord = Record<string, unknown>
15
+
16
+ const DEFAULT_RECONNECT: AiEmployeeReconnectOptions = {
17
+ initialDelayMs: 1_000,
18
+ maximumDelayMs: 30_000,
19
+ factor: 2,
20
+ jitter: 0.2,
21
+ }
22
+ const MAX_TOKEN_RECONNECTS = 1
23
+ const MAX_AVAILABILITY_RECONNECTS = 3
24
+
25
+ type AuthenticationRetryMode = 'token' | 'availability' | 'never'
26
+
27
+ interface SocketAuthenticationFailure {
28
+ generation: number
29
+ retryMode: AuthenticationRetryMode
30
+ }
31
+
32
+ interface CustomerSupportReadCursorState {
33
+ acknowledged: string
34
+ inFlight: string | null
35
+ pending: string | null
36
+ generation: number
37
+ }
38
+
39
+ interface CustomerSupportHistoryRefresh {
40
+ sessionId: string
41
+ promise: Promise<void>
42
+ }
43
+
44
+ interface CustomerSupportSessionsRefresh {
45
+ promise: Promise<readonly CustomerSupportSession[]>
46
+ }
47
+
48
+ export class CustomerSupportClientError extends Error {
49
+ readonly code: CustomerSupportErrorCode
50
+
51
+ constructor(code: CustomerSupportErrorCode, message: string) {
52
+ super(message)
53
+ this.name = 'CustomerSupportClientError'
54
+ this.code = code
55
+ }
56
+ }
57
+
58
+ export class CustomerSupportClient {
59
+ private readonly realtimeUrl: string
60
+ private readonly transport: CustomerSupportClientOptions['transport']
61
+ private readonly webSocketFactory: (url: string) => WebSocket
62
+ private readonly requestIdFactory: () => string
63
+ private readonly reconnectOptions: AiEmployeeReconnectOptions
64
+ private readonly minimumHeartbeatMs: number
65
+ private readonly listeners = new Map<keyof CustomerSupportClientEventMap, Set<UntypedListener>>()
66
+
67
+ private currentSession: CustomerSupportSession | null = null
68
+ private currentSessions: CustomerSupportSession[] = []
69
+ private currentMessages: CustomerSupportMessage[] = []
70
+ private currentState: AiEmployeeConnectionState = 'idle'
71
+ private socket: WebSocket | null = null
72
+ private heartbeatTimer: ReturnType<typeof setInterval> | null = null
73
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null
74
+ private reconnectAttempt = 0
75
+ private tokenReconnectAttempt = 0
76
+ private availabilityReconnectAttempt = 0
77
+ private socketGeneration = 0
78
+ private socketAuthenticationFailure: SocketAuthenticationFailure | null = null
79
+ private destroyed = false
80
+ private shouldReconnect = false
81
+ private sending = false
82
+ private historyRefresh: CustomerSupportHistoryRefresh | null = null
83
+ private sessionsRefresh: CustomerSupportSessionsRefresh | null = null
84
+ private readonly readCursorStates = new Map<string, CustomerSupportReadCursorState>()
85
+ private readLifecycleGeneration = 0
86
+ private requestedSessionId: string | null
87
+
88
+ constructor(options: CustomerSupportClientOptions) {
89
+ this.realtimeUrl = validateRealtimeUrl(options.realtimeUrl)
90
+ if (!options.transport) {
91
+ throw new CustomerSupportClientError('INVALID_CONFIGURATION', '缺少客服 transport 配置。')
92
+ }
93
+ this.transport = options.transport
94
+ this.requestedSessionId = normalizedText(options.sessionId) || null
95
+ this.requestIdFactory = options.requestIdFactory ?? createRequestId
96
+ this.reconnectOptions = normalizeReconnect(options.reconnect)
97
+ this.minimumHeartbeatMs = Math.max(1, options.minimumHeartbeatMs ?? 5_000)
98
+ this.webSocketFactory =
99
+ options.webSocketFactory ??
100
+ ((url) => {
101
+ if (typeof WebSocket === 'undefined') {
102
+ throw new CustomerSupportClientError(
103
+ 'WEBSOCKET_UNAVAILABLE',
104
+ '当前运行环境不支持 WebSocket。',
105
+ )
106
+ }
107
+ return new WebSocket(url)
108
+ })
109
+ }
110
+
111
+ get session(): CustomerSupportSession | null {
112
+ return this.currentSession
113
+ }
114
+
115
+ get messages(): readonly CustomerSupportMessage[] {
116
+ return [...this.currentMessages]
117
+ }
118
+
119
+ get connectionState(): AiEmployeeConnectionState {
120
+ return this.currentState
121
+ }
122
+
123
+ on<K extends keyof CustomerSupportClientEventMap>(
124
+ type: K,
125
+ listener: CustomerSupportClientListener<K>,
126
+ ): () => void {
127
+ this.assertUsable()
128
+ const listeners = this.listeners.get(type) ?? new Set<UntypedListener>()
129
+ const untyped = listener as UntypedListener
130
+ listeners.add(untyped)
131
+ this.listeners.set(type, listeners)
132
+ return () => listeners.delete(untyped)
133
+ }
134
+
135
+ async start(): Promise<void> {
136
+ this.assertUsable()
137
+ try {
138
+ const sessions = await this.transport.listSessions()
139
+ this.assertUsable()
140
+ this.replaceSessions(sessions)
141
+ const requested = this.requestedSessionId
142
+ this.currentSession = requested
143
+ ? (sessions.find((session) => session.id === requested) ?? null)
144
+ : (sessions.find(
145
+ (session) => session.status !== 'closed' && hasCustomerSupportMessages(session),
146
+ ) ??
147
+ sessions.find(hasCustomerSupportMessages) ??
148
+ null)
149
+ if (this.currentSession) {
150
+ this.emit('session', { session: this.currentSession })
151
+ await this.refreshHistory()
152
+ }
153
+ } catch {
154
+ this.emitError('HISTORY_LOAD_FAILED', '客服会话加载失败,实时连接仍会继续。', true)
155
+ }
156
+ this.shouldReconnect = true
157
+ this.openSocket(false)
158
+ this.emit('ready', { sessionId: this.currentSession?.id ?? null })
159
+ }
160
+
161
+ prepareNewConversation(): void {
162
+ this.assertUsable()
163
+ this.currentSession = null
164
+ this.requestedSessionId = null
165
+ this.currentMessages = []
166
+ }
167
+
168
+ async bindSession(sessionId: string): Promise<void> {
169
+ this.assertUsable()
170
+ const normalized = requiredText(sessionId, 'sessionId')
171
+ const sessions = await this.transport.listSessions()
172
+ this.replaceSessions(sessions)
173
+ const session = sessions.find((candidate) => candidate.id === normalized)
174
+ if (!session) {
175
+ throw new CustomerSupportClientError('SESSION_UNAVAILABLE', '客服会话不存在。')
176
+ }
177
+ this.currentSession = session
178
+ this.requestedSessionId = session.id
179
+ this.emit('session', { session })
180
+ await this.refreshHistory()
181
+ }
182
+
183
+ async sendMessage(content: string): Promise<CustomerSupportMessage> {
184
+ this.assertUsable()
185
+ const normalized = content.trim()
186
+ if (!normalized) throw new CustomerSupportClientError('EMPTY_MESSAGE', '消息内容不能为空。')
187
+ if (this.sending) {
188
+ throw new CustomerSupportClientError('SEND_IN_PROGRESS', '上一条消息仍在发送中。')
189
+ }
190
+ this.sending = true
191
+ try {
192
+ if (!this.currentSession) {
193
+ const result = await this.transport.createSession({
194
+ clientRequestId: requiredRequestId(this.requestIdFactory()),
195
+ title: normalized.slice(0, 30),
196
+ initialMessage: normalized,
197
+ })
198
+ this.assertUsable()
199
+ this.currentSession = result.session
200
+ this.upsertSession(result.session)
201
+ this.requestedSessionId = result.session.id
202
+ this.emit('session', { session: result.session })
203
+ let message = result.initialMessage
204
+ if (!message) {
205
+ const page = await this.transport.listMessages(result.session.id)
206
+ message = [...page.messages]
207
+ .reverse()
208
+ .find(
209
+ (candidate) =>
210
+ candidate.senderType === 'customer' && candidate.content.trim() === normalized,
211
+ )
212
+ this.currentMessages = page.messages.filter((candidate) => candidate.id !== message?.id)
213
+ }
214
+ if (!message) throw new Error('Missing initial message')
215
+ this.appendMessage(message, 'local')
216
+ return message
217
+ }
218
+ const message = await this.transport.sendMessage(
219
+ this.currentSession.id,
220
+ requiredRequestId(this.requestIdFactory()),
221
+ normalized,
222
+ )
223
+ this.assertUsable()
224
+ this.appendMessage(message, 'local')
225
+ return message
226
+ } catch (error) {
227
+ if (error instanceof CustomerSupportClientError) throw error
228
+ const detail = this.emitError('SEND_FAILED', '消息发送失败,请稍后重试。', true)
229
+ throw new CustomerSupportClientError(detail.code, detail.message)
230
+ } finally {
231
+ this.sending = false
232
+ }
233
+ }
234
+
235
+ async listSessions(): Promise<readonly CustomerSupportSession[]> {
236
+ this.assertUsable()
237
+ return this.refreshSessions()
238
+ }
239
+
240
+ destroy(): void {
241
+ if (this.destroyed) return
242
+ this.destroyed = true
243
+ this.shouldReconnect = false
244
+ this.clearTimers()
245
+ this.socketGeneration += 1
246
+ this.socket?.close()
247
+ this.socket = null
248
+ this.setState('destroyed')
249
+ this.listeners.clear()
250
+ this.currentMessages = []
251
+ this.currentSessions = []
252
+ this.historyRefresh = null
253
+ this.sessionsRefresh = null
254
+ this.readLifecycleGeneration += 1
255
+ this.readCursorStates.clear()
256
+ }
257
+
258
+ private async refreshHistory(realtime = false): Promise<void> {
259
+ const session = this.currentSession
260
+ if (!session) return
261
+ if (this.historyRefresh?.sessionId === session.id) return this.historyRefresh.promise
262
+ const pending = (async () => {
263
+ try {
264
+ const page = await this.transport.listMessages(session.id)
265
+ if (this.destroyed || this.currentSession?.id !== session.id) return
266
+ const unique = new Map<string, CustomerSupportMessage>()
267
+ for (const message of page.messages) {
268
+ if (message.sessionId === session.id && message.id) unique.set(message.id, message)
269
+ }
270
+ const previousIds = new Set(this.currentMessages.map((message) => message.id))
271
+ this.currentMessages = [...unique.values()].sort(compareMessages)
272
+ this.emit('history', { sessionId: session.id, messages: this.messages })
273
+ if (realtime) {
274
+ for (const message of this.currentMessages) {
275
+ if (!previousIds.has(message.id)) {
276
+ this.emit('message', { sessionId: session.id, message, source: 'realtime' })
277
+ }
278
+ }
279
+ }
280
+ this.markReadIfNeeded(session.id, page.latestSequence)
281
+ } catch {
282
+ this.emitError('HISTORY_LOAD_FAILED', '历史消息加载失败,实时连接仍会继续。', true)
283
+ }
284
+ })()
285
+ const refresh = { sessionId: session.id, promise: pending }
286
+ this.historyRefresh = refresh
287
+ await pending.finally(() => {
288
+ if (this.historyRefresh === refresh) this.historyRefresh = null
289
+ })
290
+ }
291
+
292
+ private markReadIfNeeded(sessionId: string, latestSequence: string): void {
293
+ const markRead = this.transport.markRead
294
+ const cursor = normalizeReadCursor(latestSequence)
295
+ if (!markRead || !cursor || compareReadCursors(cursor, '0') <= 0 || this.destroyed) return
296
+ const state = this.readCursorState(sessionId)
297
+ if (compareReadCursors(cursor, state.acknowledged) <= 0) return
298
+ if (state.inFlight) {
299
+ if (compareReadCursors(cursor, state.inFlight) > 0) {
300
+ state.pending = maxReadCursor(state.pending, cursor)
301
+ }
302
+ return
303
+ }
304
+ this.submitReadCursor(sessionId, cursor, state, markRead)
305
+ }
306
+
307
+ private submitReadCursor(
308
+ sessionId: string,
309
+ cursor: string,
310
+ state: CustomerSupportReadCursorState,
311
+ markRead: NonNullable<CustomerSupportClientOptions['transport']['markRead']>,
312
+ ): void {
313
+ const lifecycleGeneration = this.readLifecycleGeneration
314
+ const requestGeneration = ++state.generation
315
+ state.inFlight = cursor
316
+ let request: Promise<void>
317
+ try {
318
+ request = markRead.call(this.transport, sessionId, cursor)
319
+ } catch {
320
+ state.inFlight = null
321
+ state.pending = null
322
+ return
323
+ }
324
+ void request
325
+ .then(() => {
326
+ if (!this.isCurrentReadRequest(state, lifecycleGeneration, requestGeneration, cursor))
327
+ return
328
+ state.acknowledged = maxReadCursor(state.acknowledged, cursor)
329
+ const next = state.pending
330
+ state.pending = null
331
+ state.inFlight = null
332
+ if (next && compareReadCursors(next, state.acknowledged) > 0) {
333
+ this.submitReadCursor(sessionId, next, state, markRead)
334
+ }
335
+ })
336
+ .catch(() => {
337
+ if (!this.isCurrentReadRequest(state, lifecycleGeneration, requestGeneration, cursor))
338
+ return
339
+ state.inFlight = null
340
+ state.pending = null
341
+ })
342
+ }
343
+
344
+ private readCursorState(sessionId: string): CustomerSupportReadCursorState {
345
+ const current = this.readCursorStates.get(sessionId)
346
+ if (current) return current
347
+ const created: CustomerSupportReadCursorState = {
348
+ acknowledged: '0',
349
+ inFlight: null,
350
+ pending: null,
351
+ generation: 0,
352
+ }
353
+ this.readCursorStates.set(sessionId, created)
354
+ return created
355
+ }
356
+
357
+ private isCurrentReadRequest(
358
+ state: CustomerSupportReadCursorState,
359
+ lifecycleGeneration: number,
360
+ requestGeneration: number,
361
+ cursor: string,
362
+ ): boolean {
363
+ return (
364
+ !this.destroyed &&
365
+ this.readLifecycleGeneration === lifecycleGeneration &&
366
+ state.generation === requestGeneration &&
367
+ state.inFlight === cursor
368
+ )
369
+ }
370
+
371
+ private openSocket(reconnecting: boolean): void {
372
+ if (this.destroyed || !this.shouldReconnect) return
373
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
374
+ this.socket?.close()
375
+ this.setState(reconnecting ? 'reconnecting' : 'connecting')
376
+ let socket: WebSocket
377
+ try {
378
+ socket = this.webSocketFactory(this.realtimeUrl)
379
+ } catch {
380
+ this.emitError('WEBSOCKET_UNAVAILABLE', '无法建立客服实时连接。', true)
381
+ this.scheduleReconnect()
382
+ return
383
+ }
384
+ const generation = ++this.socketGeneration
385
+ this.socket = socket
386
+ socket.onopen = () => void this.authenticate(socket, generation)
387
+ socket.onmessage = (event) => void this.handleSocketData(event.data, socket, generation)
388
+ socket.onerror = () => {
389
+ if (this.isCurrentSocket(socket, generation)) {
390
+ this.emitError('WEBSOCKET_ERROR', '客服实时连接发生异常。', true)
391
+ }
392
+ }
393
+ socket.onclose = (event) => this.handleSocketClose(socket, generation, event.code)
394
+ }
395
+
396
+ private async authenticate(socket: WebSocket, generation: number): Promise<void> {
397
+ if (!this.isCurrentSocket(socket, generation)) return
398
+ this.setState('authenticating')
399
+ let accessToken = ''
400
+ try {
401
+ accessToken = (await this.transport.getAccessToken()).trim()
402
+ } catch {
403
+ // Provider errors may contain credentials and are intentionally discarded.
404
+ }
405
+ if (!this.isCurrentSocket(socket, generation)) return
406
+ if (!accessToken) {
407
+ this.emitError('TOKEN_UNAVAILABLE', '无法获取客服实时连接凭证。', true)
408
+ socket.close()
409
+ return
410
+ }
411
+ socket.send(
412
+ JSON.stringify({
413
+ type: 'authenticate',
414
+ protocol_version: 1,
415
+ access_token: accessToken,
416
+ requested_channels: ['customer_support'],
417
+ cursors: {},
418
+ }),
419
+ )
420
+ accessToken = ''
421
+ }
422
+
423
+ private async handleSocketData(
424
+ data: unknown,
425
+ socket: WebSocket,
426
+ generation: number,
427
+ ): Promise<void> {
428
+ if (!this.isCurrentSocket(socket, generation)) return
429
+ let raw = ''
430
+ if (typeof data === 'string') raw = data
431
+ else if (data instanceof Blob) raw = await data.text()
432
+ else if (data instanceof ArrayBuffer) raw = new TextDecoder().decode(data)
433
+ const message = raw ? parseJsonRecord(raw) : null
434
+ if (!message) {
435
+ this.emitError('PROTOCOL_ERROR', '收到无法解析的客服实时消息。', true)
436
+ return
437
+ }
438
+ this.handleServerMessage(message, socket, generation)
439
+ }
440
+
441
+ private handleServerMessage(message: JsonRecord, socket: WebSocket, generation: number): void {
442
+ const type = normalizedText(message.type)
443
+ if (type === 'realtime_ready') {
444
+ const channels = record(message.channels)
445
+ if (record(channels.customer_support).available !== true) {
446
+ this.shouldReconnect = false
447
+ this.emitError('CHANNEL_UNAVAILABLE', '人工客服实时通道不可用。', false)
448
+ socket.close()
449
+ return
450
+ }
451
+ this.reconnectAttempt = 0
452
+ this.tokenReconnectAttempt = 0
453
+ this.availabilityReconnectAttempt = 0
454
+ this.startHeartbeat(numberValue(message.heartbeat_interval_seconds), socket, generation)
455
+ this.setState('connected')
456
+ return
457
+ }
458
+ if (type === 'realtime_event' && message.channel === 'customer_support') {
459
+ const event = record(message.event)
460
+ this.handleCustomerSupportEvent(event)
461
+ return
462
+ }
463
+ if (type === 'channel_resync_required' && message.channel === 'customer_support') {
464
+ void this.refreshFromRealtime()
465
+ return
466
+ }
467
+ if (type === 'authentication_error') {
468
+ const failure = authenticationFailure(normalizedText(message.code))
469
+ this.socketAuthenticationFailure = { generation, retryMode: failure.retryMode }
470
+ this.emitError(failure.code, failure.message, failure.retryMode !== 'never')
471
+ }
472
+ }
473
+
474
+ private handleCustomerSupportEvent(event: JsonRecord): void {
475
+ const eventType = normalizedText(event.event_type)
476
+ const sessionId = normalizedText(event.session_id)
477
+ if (!eventType || !sessionId) return
478
+ this.refreshSessionsFromRealtime()
479
+ if (sessionId !== this.currentSession?.id) return
480
+ if (eventType === 'support_read_state_changed') return
481
+ if (eventType === 'support_message_created') {
482
+ void this.refreshHistory(true)
483
+ return
484
+ }
485
+ void this.refreshSessionFromRealtime()
486
+ }
487
+
488
+ private async refreshFromRealtime(): Promise<void> {
489
+ await this.refreshSessions().catch(() => [...this.currentSessions])
490
+ await this.refreshSessionFromRealtime()
491
+ await this.refreshHistory(true)
492
+ }
493
+
494
+ private async refreshSessions(): Promise<readonly CustomerSupportSession[]> {
495
+ if (this.sessionsRefresh) return this.sessionsRefresh.promise
496
+ const pending = (async () => {
497
+ const sessions = await this.transport.listSessions()
498
+ if (!this.destroyed) this.replaceSessions(sessions)
499
+ return [...this.currentSessions]
500
+ })()
501
+ const refresh = { promise: pending }
502
+ this.sessionsRefresh = refresh
503
+ return pending.finally(() => {
504
+ if (this.sessionsRefresh === refresh) this.sessionsRefresh = null
505
+ })
506
+ }
507
+
508
+ private refreshSessionsFromRealtime(): void {
509
+ void this.refreshSessions().catch(() => {
510
+ // A later realtime event, panel refresh or resync can recover the list projection.
511
+ })
512
+ }
513
+
514
+ private replaceSessions(sessions: readonly CustomerSupportSession[]): void {
515
+ this.currentSessions = [...sessions]
516
+ this.emit('sessions', { sessions: [...this.currentSessions] })
517
+ }
518
+
519
+ private upsertSession(session: CustomerSupportSession): void {
520
+ const currentIndex = this.currentSessions.findIndex((candidate) => candidate.id === session.id)
521
+ if (currentIndex < 0) this.currentSessions = [session, ...this.currentSessions]
522
+ else {
523
+ this.currentSessions = this.currentSessions.map((candidate, index) =>
524
+ index === currentIndex ? session : candidate,
525
+ )
526
+ }
527
+ this.emit('sessions', { sessions: [...this.currentSessions] })
528
+ }
529
+
530
+ private async refreshSessionFromRealtime(): Promise<void> {
531
+ const sessionId = this.currentSession?.id
532
+ const getSession = this.transport.getSession
533
+ if (!sessionId || !getSession) return
534
+ try {
535
+ const session = await getSession.call(this.transport, sessionId)
536
+ if (!this.destroyed && this.currentSession?.id === sessionId) {
537
+ this.currentSession = session
538
+ this.emit('session', { session })
539
+ }
540
+ } catch {
541
+ // A later realtime event or resync can recover the optional session projection.
542
+ }
543
+ }
544
+
545
+ private appendMessage(message: CustomerSupportMessage, source: 'local' | 'realtime'): void {
546
+ if (this.currentMessages.some((candidate) => candidate.id === message.id)) return
547
+ this.currentMessages = [...this.currentMessages, message].sort(compareMessages)
548
+ this.emit('message', { sessionId: message.sessionId, message, source })
549
+ }
550
+
551
+ private startHeartbeat(intervalSeconds: number, socket: WebSocket, generation: number): void {
552
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer)
553
+ const requested = intervalSeconds > 0 ? intervalSeconds * 1_000 : 25_000
554
+ this.heartbeatTimer = setInterval(
555
+ () => {
556
+ if (this.isCurrentSocket(socket, generation) && socket.readyState === 1) {
557
+ socket.send(JSON.stringify(createPingFrame()))
558
+ }
559
+ },
560
+ Math.max(this.minimumHeartbeatMs, requested),
561
+ )
562
+ }
563
+
564
+ private handleSocketClose(socket: WebSocket, generation: number, code: number): void {
565
+ if (!this.isCurrentSocket(socket, generation)) return
566
+ this.socket = null
567
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer)
568
+ this.heartbeatTimer = null
569
+ const authenticationFailure =
570
+ this.socketAuthenticationFailure?.generation === generation
571
+ ? this.socketAuthenticationFailure
572
+ : null
573
+ this.socketAuthenticationFailure = null
574
+ if (!this.shouldReconnect || this.destroyed) {
575
+ if (!this.destroyed) this.setState('disconnected')
576
+ return
577
+ }
578
+ if (authenticationFailure?.retryMode === 'never') {
579
+ this.shouldReconnect = false
580
+ this.setState('disconnected')
581
+ return
582
+ }
583
+ if (code === 4400 || code === 4403) {
584
+ this.shouldReconnect = false
585
+ if (!authenticationFailure) {
586
+ this.emitError(
587
+ code === 4400 ? 'PROTOCOL_ERROR' : 'AUTHENTICATION_FAILED',
588
+ code === 4400 ? '客服实时连接协议不受支持。' : '当前身份无权使用人工客服。',
589
+ false,
590
+ )
591
+ }
592
+ this.setState('disconnected')
593
+ return
594
+ }
595
+ if (authenticationFailure?.retryMode === 'availability' || code === 1013) {
596
+ if (!authenticationFailure) {
597
+ this.emitError('REALTIME_UNAVAILABLE', '客服实时鉴权服务暂不可用,正在重试。', true)
598
+ }
599
+ this.scheduleAvailabilityReconnect()
600
+ return
601
+ }
602
+ if (authenticationFailure?.retryMode === 'token' || code === 4401) {
603
+ if (!authenticationFailure) {
604
+ this.emitError('AUTHENTICATION_FAILED', '客服连接凭证已失效,正在重新连接。', true)
605
+ }
606
+ if (this.tokenReconnectAttempt >= MAX_TOKEN_RECONNECTS) {
607
+ this.shouldReconnect = false
608
+ this.emitError('AUTHENTICATION_FAILED', '人工客服身份验证失败,请重新登录后再试。', false)
609
+ this.setState('disconnected')
610
+ return
611
+ }
612
+ this.tokenReconnectAttempt += 1
613
+ }
614
+ this.scheduleReconnect()
615
+ }
616
+
617
+ private scheduleAvailabilityReconnect(): void {
618
+ if (this.availabilityReconnectAttempt >= MAX_AVAILABILITY_RECONNECTS) {
619
+ this.shouldReconnect = false
620
+ this.emitError('REALTIME_UNAVAILABLE', '客服实时鉴权服务持续不可用,请稍后重试。', false)
621
+ this.setState('disconnected')
622
+ return
623
+ }
624
+ this.availabilityReconnectAttempt += 1
625
+ this.scheduleReconnect()
626
+ }
627
+
628
+ private scheduleReconnect(): void {
629
+ if (!this.shouldReconnect || this.destroyed || this.reconnectTimer) return
630
+ const base = Math.min(
631
+ this.reconnectOptions.maximumDelayMs,
632
+ this.reconnectOptions.initialDelayMs *
633
+ this.reconnectOptions.factor ** this.reconnectAttempt++,
634
+ )
635
+ const jitter = base * this.reconnectOptions.jitter * (Math.random() * 2 - 1)
636
+ this.reconnectTimer = setTimeout(
637
+ () => {
638
+ this.reconnectTimer = null
639
+ this.openSocket(true)
640
+ },
641
+ Math.max(0, base + jitter),
642
+ )
643
+ }
644
+
645
+ private clearTimers(): void {
646
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer)
647
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
648
+ this.heartbeatTimer = null
649
+ this.reconnectTimer = null
650
+ }
651
+
652
+ private isCurrentSocket(socket: WebSocket, generation: number): boolean {
653
+ return !this.destroyed && this.socket === socket && this.socketGeneration === generation
654
+ }
655
+
656
+ private setState(current: AiEmployeeConnectionState): void {
657
+ if (this.currentState === current) return
658
+ const previous = this.currentState
659
+ this.currentState = current
660
+ this.emit('connection-state-change', { previous, current })
661
+ }
662
+
663
+ private emitError(
664
+ code: CustomerSupportErrorCode,
665
+ message: string,
666
+ recoverable: boolean,
667
+ ): CustomerSupportErrorDetail {
668
+ const detail = { code, message, recoverable }
669
+ this.emit('error', detail)
670
+ return detail
671
+ }
672
+
673
+ private emit<K extends keyof CustomerSupportClientEventMap>(
674
+ type: K,
675
+ detail: CustomerSupportClientEventMap[K],
676
+ ): void {
677
+ for (const listener of this.listeners.get(type) ?? []) listener(detail)
678
+ }
679
+
680
+ private assertUsable(): void {
681
+ if (this.destroyed) {
682
+ throw new CustomerSupportClientError('CLIENT_DESTROYED', '客服客户端已经销毁。')
683
+ }
684
+ }
685
+ }
686
+
687
+ function validateRealtimeUrl(value: string): string {
688
+ let url: URL
689
+ try {
690
+ url = new URL(value)
691
+ } catch {
692
+ throw new CustomerSupportClientError('INVALID_CONFIGURATION', '客服实时地址无效。')
693
+ }
694
+ if (!['ws:', 'wss:'].includes(url.protocol) || url.search || url.hash || url.username) {
695
+ throw new CustomerSupportClientError('INVALID_CONFIGURATION', '客服实时地址不安全。')
696
+ }
697
+ return url.href
698
+ }
699
+
700
+ function normalizeReconnect(
701
+ value?: Partial<AiEmployeeReconnectOptions>,
702
+ ): AiEmployeeReconnectOptions {
703
+ return {
704
+ initialDelayMs: Math.max(0, value?.initialDelayMs ?? DEFAULT_RECONNECT.initialDelayMs),
705
+ maximumDelayMs: Math.max(1, value?.maximumDelayMs ?? DEFAULT_RECONNECT.maximumDelayMs),
706
+ factor: Math.max(1, value?.factor ?? DEFAULT_RECONNECT.factor),
707
+ jitter: Math.min(1, Math.max(0, value?.jitter ?? DEFAULT_RECONNECT.jitter)),
708
+ }
709
+ }
710
+
711
+ function compareMessages(left: CustomerSupportMessage, right: CustomerSupportMessage): number {
712
+ const leftSequence = Number(left.sequence)
713
+ const rightSequence = Number(right.sequence)
714
+ if (Number.isFinite(leftSequence) && Number.isFinite(rightSequence)) {
715
+ return leftSequence - rightSequence
716
+ }
717
+ return left.createdAt.localeCompare(right.createdAt)
718
+ }
719
+
720
+ function normalizeReadCursor(value: unknown): string | null {
721
+ const cursor = normalizedText(value)
722
+ if (!/^\d+$/.test(cursor)) return null
723
+ return cursor.replace(/^0+(?=\d)/, '')
724
+ }
725
+
726
+ function compareReadCursors(left: string, right: string): number {
727
+ if (left.length !== right.length) return left.length < right.length ? -1 : 1
728
+ if (left === right) return 0
729
+ return left < right ? -1 : 1
730
+ }
731
+
732
+ function maxReadCursor(left: string | null, right: string): string {
733
+ return left && compareReadCursors(left, right) >= 0 ? left : right
734
+ }
735
+
736
+ function hasCustomerSupportMessages(session: CustomerSupportSession): boolean {
737
+ return session.lastMessage.trim().length > 0
738
+ }
739
+
740
+ function requiredText(value: unknown, field: string): string {
741
+ const normalized = normalizedText(value)
742
+ if (!normalized) {
743
+ throw new CustomerSupportClientError('INVALID_CONFIGURATION', `${field} 不能为空。`)
744
+ }
745
+ return normalized
746
+ }
747
+
748
+ function requiredRequestId(value: string): string {
749
+ return requiredText(value, 'requestId')
750
+ }
751
+
752
+ function normalizedText(value: unknown): string {
753
+ return typeof value === 'string' ? value.trim() : ''
754
+ }
755
+
756
+ function record(value: unknown): JsonRecord {
757
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
758
+ ? (value as JsonRecord)
759
+ : {}
760
+ }
761
+
762
+ function numberValue(value: unknown): number {
763
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0
764
+ }
765
+
766
+ function createRequestId(): string {
767
+ return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
768
+ ? crypto.randomUUID()
769
+ : `request-${Date.now()}-${Math.random().toString(16).slice(2)}`
770
+ }
771
+
772
+ function authenticationFailure(code: string): {
773
+ code: CustomerSupportErrorCode
774
+ message: string
775
+ retryMode: AuthenticationRetryMode
776
+ } {
777
+ if (code === 'UNAUTHORIZED') {
778
+ return {
779
+ code: 'AUTHENTICATION_FAILED',
780
+ message: '客服连接凭证已失效,正在重新连接。',
781
+ retryMode: 'token',
782
+ }
783
+ }
784
+ if (code === 'ORGANIZATION_CONTEXT_REQUIRED') {
785
+ return {
786
+ code: 'ORGANIZATION_CONTEXT_REQUIRED',
787
+ message: '请选择企业/团队后再联系人工客服。',
788
+ retryMode: 'never',
789
+ }
790
+ }
791
+ if (code === 'INVALID_ARGUMENT') {
792
+ return {
793
+ code: 'PROTOCOL_ERROR',
794
+ message: '客服实时连接协议不受支持。',
795
+ retryMode: 'never',
796
+ }
797
+ }
798
+ if (
799
+ code === 'REALTIME_UNAVAILABLE' ||
800
+ code === 'KANBAN_AUTH_FAILED' ||
801
+ code.startsWith('SERVICE_')
802
+ ) {
803
+ return {
804
+ code: 'REALTIME_UNAVAILABLE',
805
+ message: '客服实时鉴权服务暂不可用,正在重试。',
806
+ retryMode: 'availability',
807
+ }
808
+ }
809
+ if (
810
+ code === 'ACCESS_DENIED' ||
811
+ code === 'ORIGIN_NOT_ALLOWED' ||
812
+ code === 'CHANNEL_ACCESS_DENIED'
813
+ ) {
814
+ return {
815
+ code: 'AUTHENTICATION_FAILED',
816
+ message: '当前身份无权使用人工客服。',
817
+ retryMode: 'never',
818
+ }
819
+ }
820
+ return {
821
+ code: 'AUTHENTICATION_FAILED',
822
+ message: '人工客服实时连接认证失败。',
823
+ retryMode: 'never',
824
+ }
825
+ }