@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,1132 @@
1
+ import {
2
+ createAuthenticateFrame,
3
+ createPingFrame,
4
+ createSubscriptionFrame,
5
+ hashText,
6
+ parseAiEmployeeEvent,
7
+ parseJsonRecord,
8
+ } from './protocol'
9
+ import type {
10
+ AiEmployeeClientErrorDetail,
11
+ AiEmployeeClientEventMap,
12
+ AiEmployeeClientListener,
13
+ AiEmployeeClientOptions,
14
+ AiEmployeeClientStartResult,
15
+ AiEmployeeConnectionState,
16
+ AiEmployeeErrorCode,
17
+ AiEmployeeFirstMessageOptions,
18
+ AiEmployeeFirstMessageRequest,
19
+ AiEmployeeFirstMessageRequestState,
20
+ AiEmployeeFirstMessageRequestStore,
21
+ AiEmployeeFirstMessageResult,
22
+ AiEmployeeMessage,
23
+ AiEmployeeReconnectOptions,
24
+ AiEmployeeTransportErrorCode,
25
+ AiEmployeeWebSocketFactory,
26
+ } from './types'
27
+
28
+ type UntypedListener = (detail: unknown) => void
29
+ type JsonRecord = Record<string, unknown>
30
+
31
+ const DEFAULT_RECONNECT: AiEmployeeReconnectOptions = {
32
+ initialDelayMs: 1_000,
33
+ maximumDelayMs: 30_000,
34
+ factor: 2,
35
+ jitter: 0.2,
36
+ }
37
+ const MAX_SEEN_MESSAGE_IDS = 2_000
38
+ const LOCAL_MESSAGE_MATCH_WINDOW_MS = 30_000
39
+ const MAX_AVAILABILITY_RECONNECTS = 3
40
+ const FIRST_MESSAGE_STORAGE_PREFIX = 'uniplat.ai-employee.first-message'
41
+
42
+ type AuthenticationRetryMode = 'token' | 'availability' | 'never'
43
+
44
+ interface SocketAuthenticationFailure {
45
+ generation: number
46
+ retryMode: AuthenticationRetryMode
47
+ }
48
+
49
+ interface PendingFirstMessage {
50
+ fingerprint: string
51
+ requestId: string
52
+ }
53
+
54
+ export class AiEmployeeClientError extends Error {
55
+ readonly code: AiEmployeeErrorCode
56
+
57
+ constructor(code: AiEmployeeErrorCode, message: string) {
58
+ super(message)
59
+ this.name = 'AiEmployeeClientError'
60
+ this.code = code
61
+ }
62
+ }
63
+
64
+ export class AiEmployeeTransportError extends Error {
65
+ readonly code: AiEmployeeTransportErrorCode
66
+
67
+ constructor(code: AiEmployeeTransportErrorCode) {
68
+ super('AI Employee transport request failed.')
69
+ this.name = 'AiEmployeeTransportError'
70
+ this.code = code
71
+ }
72
+ }
73
+
74
+ export class AiEmployeeClient {
75
+ readonly aeCode: string
76
+ readonly agentCode: string
77
+
78
+ private readonly realtimeUrl: string
79
+ private readonly transport: AiEmployeeClientOptions['transport']
80
+ private readonly webSocketFactory: AiEmployeeWebSocketFactory
81
+ private readonly reconnectOptions: AiEmployeeReconnectOptions
82
+ private readonly minimumHeartbeatMs: number
83
+ private readonly requestIdFactory: () => string
84
+ private readonly firstMessageRequestStore: AiEmployeeFirstMessageRequestStore | null
85
+ private readonly firstMessageRequestScope: string
86
+ private readonly listeners = new Map<keyof AiEmployeeClientEventMap, Set<UntypedListener>>()
87
+ private readonly seenMessageIds = new Set<string>()
88
+ private readonly pendingLocalMessages = new Map<string, number>()
89
+ private assistantReceivedInCurrentTurn = false
90
+
91
+ private socket: WebSocket | null = null
92
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null
93
+ private heartbeatTimer: ReturnType<typeof setInterval> | null = null
94
+ private initializePromise: Promise<AiEmployeeClientStartResult> | null = null
95
+ private currentMessages: AiEmployeeMessage[] = []
96
+ private currentSessionId: string | null
97
+ private currentState: AiEmployeeConnectionState = 'idle'
98
+ private reconnectAttempt = 0
99
+ private socketGeneration = 0
100
+ private lifecycleGeneration = 0
101
+ private shouldReconnect = false
102
+ private destroyed = false
103
+ private sending = false
104
+ private processing = false
105
+ private localMessageSequence = 0
106
+ private replayHistoryOnNextSubscription = false
107
+ private socketAuthenticationFailure: SocketAuthenticationFailure | null = null
108
+ private availabilityReconnectAttempt = 0
109
+ private pendingFirstMessage: PendingFirstMessage | null = null
110
+
111
+ constructor(options: AiEmployeeClientOptions) {
112
+ this.realtimeUrl = validateRealtimeUrl(options.realtimeUrl)
113
+ this.aeCode = requiredText(options.aeCode, 'aeCode')
114
+ this.agentCode = requiredText(options.agentCode, 'agentCode')
115
+ if (!options.transport) {
116
+ throw new AiEmployeeClientError('INVALID_CONFIGURATION', '缺少 transport 配置。')
117
+ }
118
+ if (typeof options.transport.createSessionWithFirstMessage !== 'function') {
119
+ throw new AiEmployeeClientError(
120
+ 'INVALID_CONFIGURATION',
121
+ 'transport 缺少首条消息原子创建能力。',
122
+ )
123
+ }
124
+ this.transport = options.transport
125
+ this.currentSessionId = normalizeSessionId(options.sessionId)
126
+ this.reconnectOptions = normalizeReconnectOptions(options.reconnect)
127
+ this.minimumHeartbeatMs = Math.max(1, options.minimumHeartbeatMs ?? 5_000)
128
+ this.requestIdFactory = options.requestIdFactory ?? createRequestId
129
+ this.firstMessageRequestStore =
130
+ options.firstMessageRequestStore === undefined
131
+ ? createSessionFirstMessageRequestStore()
132
+ : options.firstMessageRequestStore
133
+ this.firstMessageRequestScope = `${this.aeCode}:${this.agentCode}`
134
+ this.webSocketFactory =
135
+ options.webSocketFactory ??
136
+ ((url) => {
137
+ if (typeof WebSocket === 'undefined') {
138
+ throw new AiEmployeeClientError('WEBSOCKET_UNAVAILABLE', '当前运行环境不支持 WebSocket。')
139
+ }
140
+ return new WebSocket(url)
141
+ })
142
+ }
143
+
144
+ get connectionState(): AiEmployeeConnectionState {
145
+ return this.currentState
146
+ }
147
+
148
+ get sessionId(): string | null {
149
+ return this.currentSessionId
150
+ }
151
+
152
+ get messages(): readonly AiEmployeeMessage[] {
153
+ return [...this.currentMessages]
154
+ }
155
+
156
+ get isProcessing(): boolean {
157
+ return this.processing
158
+ }
159
+
160
+ get isDestroyed(): boolean {
161
+ return this.destroyed
162
+ }
163
+
164
+ on<K extends keyof AiEmployeeClientEventMap>(
165
+ type: K,
166
+ listener: AiEmployeeClientListener<K>,
167
+ ): () => void {
168
+ this.assertUsable()
169
+ const listeners = this.listeners.get(type) ?? new Set<UntypedListener>()
170
+ const untypedListener = listener as UntypedListener
171
+ listeners.add(untypedListener)
172
+ this.listeners.set(type, listeners)
173
+ return () => listeners.delete(untypedListener)
174
+ }
175
+
176
+ connect(): Promise<AiEmployeeClientStartResult> {
177
+ return this.start()
178
+ }
179
+
180
+ start(): Promise<AiEmployeeClientStartResult> {
181
+ this.assertUsable()
182
+ if (this.initializePromise) return this.initializePromise
183
+
184
+ const lifecycle = this.lifecycleGeneration
185
+ const pending = this.initialize(lifecycle)
186
+ this.initializePromise = pending
187
+ const clearPending = () => {
188
+ if (this.initializePromise === pending) this.initializePromise = null
189
+ }
190
+ void pending.then(clearPending, clearPending)
191
+ return pending
192
+ }
193
+
194
+ async bindSession(sessionId: string): Promise<AiEmployeeClientStartResult> {
195
+ this.assertUsable()
196
+ const target = requiredText(sessionId, 'sessionId')
197
+ const lifecycle = ++this.lifecycleGeneration
198
+ this.stopConnection(false)
199
+ this.currentSessionId = target
200
+ this.currentMessages = []
201
+ this.seenMessageIds.clear()
202
+ this.pendingLocalMessages.clear()
203
+ this.assistantReceivedInCurrentTurn = false
204
+ this.pendingFirstMessage = null
205
+ await this.clearStoredFirstMessageRequest()
206
+ this.replayHistoryOnNextSubscription = false
207
+ this.shouldReconnect = true
208
+ await this.refreshHistory(lifecycle)
209
+ this.assertUsable()
210
+ if (!this.isCurrentLifecycle(lifecycle)) {
211
+ throw new AiEmployeeClientError('SESSION_UNAVAILABLE', '会话切换已被新的操作替代。')
212
+ }
213
+ if (this.isCurrentLifecycle(lifecycle)) this.openSocket(false)
214
+ return { sessionId: target, messages: this.messages }
215
+ }
216
+
217
+ async startNewConversation(): Promise<AiEmployeeClientStartResult> {
218
+ this.assertUsable()
219
+ this.initializePromise = null
220
+ this.lifecycleGeneration += 1
221
+ this.stopConnection(false)
222
+ this.currentSessionId = null
223
+ this.currentMessages = []
224
+ this.seenMessageIds.clear()
225
+ this.pendingLocalMessages.clear()
226
+ this.assistantReceivedInCurrentTurn = false
227
+ this.pendingFirstMessage = null
228
+ await this.clearStoredFirstMessageRequest()
229
+ this.replayHistoryOnNextSubscription = false
230
+ this.shouldReconnect = true
231
+ this.openSocket(false)
232
+ return { sessionId: null, messages: [] }
233
+ }
234
+
235
+ async refreshMessages(): Promise<readonly AiEmployeeMessage[]> {
236
+ this.assertUsable()
237
+ if (!this.currentSessionId) {
238
+ throw new AiEmployeeClientError('SESSION_UNAVAILABLE', '尚未绑定数字员工会话。')
239
+ }
240
+ await this.refreshHistory(this.lifecycleGeneration)
241
+ return this.messages
242
+ }
243
+
244
+ async sendMessage(
245
+ content: string,
246
+ options: AiEmployeeFirstMessageOptions = {},
247
+ ): Promise<AiEmployeeMessage> {
248
+ this.assertUsable()
249
+ const normalizedContent = content.trim()
250
+ if (!normalizedContent) {
251
+ throw new AiEmployeeClientError('EMPTY_MESSAGE', '消息内容不能为空。')
252
+ }
253
+ if (this.sending) {
254
+ throw new AiEmployeeClientError('SEND_IN_PROGRESS', '上一条消息仍在发送中。')
255
+ }
256
+ this.sending = true
257
+ try {
258
+ const sessionId = this.currentSessionId
259
+ return sessionId
260
+ ? await this.sendFollowUp(sessionId, normalizedContent)
261
+ : await this.sendFirstMessage(normalizedContent, options)
262
+ } catch (error) {
263
+ if (error instanceof AiEmployeeClientError) throw error
264
+ if (transportErrorCode(error) === 'IDEMPOTENCY_CONFLICT') {
265
+ const detail = this.emitError(
266
+ 'FIRST_MESSAGE_CONFLICT',
267
+ '首条消息内容已变化,请开启新对话后重试。',
268
+ false,
269
+ )
270
+ throw new AiEmployeeClientError(detail.code, detail.message)
271
+ }
272
+ if (transportErrorCode(error) === 'TOO_MANY_REQUESTS') {
273
+ const detail = this.emitError('RATE_LIMITED', '请求过于频繁,请稍后再试。', false)
274
+ throw new AiEmployeeClientError(detail.code, detail.message)
275
+ }
276
+ const detail = this.emitError('SEND_FAILED', '消息发送失败,请稍后重试。', true)
277
+ throw new AiEmployeeClientError(detail.code, detail.message)
278
+ } finally {
279
+ this.sending = false
280
+ }
281
+ }
282
+
283
+ disconnect(): void {
284
+ if (this.destroyed) return
285
+ this.lifecycleGeneration += 1
286
+ this.stopConnection(true)
287
+ }
288
+
289
+ destroy(): void {
290
+ if (this.destroyed) return
291
+ this.lifecycleGeneration += 1
292
+ this.stopConnection(false)
293
+ this.destroyed = true
294
+ this.initializePromise = null
295
+ this.setState('destroyed')
296
+ this.listeners.clear()
297
+ this.currentMessages = []
298
+ this.seenMessageIds.clear()
299
+ this.pendingLocalMessages.clear()
300
+ this.assistantReceivedInCurrentTurn = false
301
+ this.pendingFirstMessage = null
302
+ }
303
+
304
+ private async sendFollowUp(sessionId: string, content: string): Promise<AiEmployeeMessage> {
305
+ this.assistantReceivedInCurrentTurn = false
306
+ await this.transport.sendMessage(sessionId, content)
307
+ this.assertUsable()
308
+ const now = new Date()
309
+ const message: AiEmployeeMessage = {
310
+ id: `local:${now.getTime()}:${++this.localMessageSequence}`,
311
+ sessionId,
312
+ role: 'user',
313
+ content,
314
+ createdAt: now.toISOString(),
315
+ }
316
+ for (const [pendingContent, sentAt] of this.pendingLocalMessages) {
317
+ if (now.getTime() - sentAt > LOCAL_MESSAGE_MATCH_WINDOW_MS) {
318
+ this.pendingLocalMessages.delete(pendingContent)
319
+ }
320
+ }
321
+ this.pendingLocalMessages.set(content, now.getTime())
322
+ this.appendMessage(message, 'local')
323
+ this.setProcessing(true)
324
+ return message
325
+ }
326
+
327
+ private async sendFirstMessage(
328
+ content: string,
329
+ options: AiEmployeeFirstMessageOptions,
330
+ ): Promise<AiEmployeeMessage> {
331
+ this.assistantReceivedInCurrentTurn = false
332
+ const createSessionWithFirstMessage = this.transport.createSessionWithFirstMessage
333
+ const lifecycle = this.lifecycleGeneration
334
+ const normalizedOptions = normalizeFirstMessageOptions(options)
335
+ const fingerprint = firstMessageFingerprint(content, normalizedOptions)
336
+ if (!this.pendingFirstMessage || this.pendingFirstMessage.fingerprint !== fingerprint) {
337
+ const stored = await this.loadStoredFirstMessageRequest()
338
+ this.pendingFirstMessage =
339
+ stored?.fingerprint === fingerprint
340
+ ? stored
341
+ : { fingerprint, requestId: requiredRequestId(this.requestIdFactory()) }
342
+ await this.saveStoredFirstMessageRequest(this.pendingFirstMessage)
343
+ }
344
+ this.assertUsable()
345
+ if (!this.isCurrentLifecycle(lifecycle) || this.currentSessionId) {
346
+ this.pendingFirstMessage = null
347
+ await this.clearStoredFirstMessageRequest()
348
+ throw new AiEmployeeClientError('SESSION_UNAVAILABLE', '首条消息受理已被新的操作替代。')
349
+ }
350
+ const request: AiEmployeeFirstMessageRequest = {
351
+ requestId: this.pendingFirstMessage.requestId,
352
+ aeCode: this.aeCode,
353
+ agentCode: this.agentCode,
354
+ content,
355
+ attachments: normalizedOptions.attachments,
356
+ trustedContext: normalizedOptions.trustedContext,
357
+ }
358
+ const rawResult = await createSessionWithFirstMessage.call(this.transport, request)
359
+ const result = normalizeFirstMessageResult(rawResult, this.aeCode, this.agentCode)
360
+ this.assertUsable()
361
+ if (!this.isCurrentLifecycle(lifecycle) || this.currentSessionId) {
362
+ this.pendingFirstMessage = null
363
+ await this.clearStoredFirstMessageRequest()
364
+ throw new AiEmployeeClientError('SESSION_UNAVAILABLE', '首条消息受理已被新的操作替代。')
365
+ }
366
+
367
+ this.currentSessionId = result.sessionId
368
+ this.pendingFirstMessage = null
369
+ await this.clearStoredFirstMessageRequest()
370
+ const message: AiEmployeeMessage = {
371
+ id: result.messageId,
372
+ sessionId: result.sessionId,
373
+ role: 'user',
374
+ content,
375
+ createdAt: result.createdAt ?? result.startedAt,
376
+ }
377
+ this.appendMessage(message, 'local')
378
+ this.setProcessing(true)
379
+ this.replayHistoryOnNextSubscription = true
380
+ this.subscribeCurrentSession(true)
381
+ return message
382
+ }
383
+
384
+ private async initialize(lifecycle: number): Promise<AiEmployeeClientStartResult> {
385
+ if (!this.currentSessionId) {
386
+ this.shouldReconnect = true
387
+ this.openSocket(false)
388
+ return { sessionId: null, messages: [] }
389
+ }
390
+
391
+ await this.refreshHistory(lifecycle)
392
+ this.assertUsable()
393
+ if (!this.isCurrentLifecycle(lifecycle)) {
394
+ throw new AiEmployeeClientError('SESSION_UNAVAILABLE', '会话初始化已被新的操作替代。')
395
+ }
396
+ this.shouldReconnect = true
397
+ this.openSocket(false)
398
+ return { sessionId: this.currentSessionId, messages: this.messages }
399
+ }
400
+
401
+ private async loadStoredFirstMessageRequest(): Promise<PendingFirstMessage | null> {
402
+ if (!this.firstMessageRequestStore) return null
403
+ try {
404
+ const state = await this.firstMessageRequestStore.load(this.firstMessageRequestScope)
405
+ if (!state || typeof state.fingerprint !== 'string') return null
406
+ return {
407
+ fingerprint: state.fingerprint,
408
+ requestId: requiredRequestId(state.requestId),
409
+ }
410
+ } catch {
411
+ return null
412
+ }
413
+ }
414
+
415
+ private async saveStoredFirstMessageRequest(state: PendingFirstMessage): Promise<void> {
416
+ if (!this.firstMessageRequestStore) return
417
+ try {
418
+ await this.firstMessageRequestStore.save(this.firstMessageRequestScope, state)
419
+ } catch {
420
+ // Storage is a recovery enhancement; an unavailable store must not block sending.
421
+ }
422
+ }
423
+
424
+ private async clearStoredFirstMessageRequest(): Promise<void> {
425
+ if (!this.firstMessageRequestStore) return
426
+ try {
427
+ await this.firstMessageRequestStore.clear(this.firstMessageRequestScope)
428
+ } catch {
429
+ // A stale recovery entry is still guarded by the request fingerprint and backend conflict.
430
+ }
431
+ }
432
+
433
+ private async refreshHistory(lifecycle: number): Promise<boolean> {
434
+ const sessionId = this.currentSessionId
435
+ if (!sessionId) return false
436
+ try {
437
+ const history = await this.transport.listMessages(sessionId)
438
+ if (!this.isCurrentLifecycle(lifecycle) || this.currentSessionId !== sessionId) return false
439
+ this.replaceHistory(history, sessionId)
440
+ return true
441
+ } catch {
442
+ if (this.isCurrentLifecycle(lifecycle)) {
443
+ this.emitError('HISTORY_LOAD_FAILED', '历史消息加载失败,实时连接仍会继续。', true)
444
+ }
445
+ return false
446
+ }
447
+ }
448
+
449
+ private replaceHistory(history: readonly AiEmployeeMessage[], sessionId: string): void {
450
+ const unique = new Map<string, AiEmployeeMessage>()
451
+ for (const candidate of history) {
452
+ const normalized = normalizeHistoryMessage(candidate, sessionId)
453
+ if (normalized) unique.set(normalized.id, normalized)
454
+ }
455
+ this.currentMessages = [...unique.values()]
456
+ this.seenMessageIds.clear()
457
+ for (const message of this.currentMessages) this.rememberMessageId(message.id)
458
+ this.emit('history', { sessionId, messages: this.messages })
459
+ }
460
+
461
+ private openSocket(isReconnect: boolean): void {
462
+ if (this.destroyed || !this.shouldReconnect) return
463
+ this.clearReconnectTimer()
464
+ this.closeCurrentSocket()
465
+ this.setState(isReconnect ? 'reconnecting' : 'connecting')
466
+
467
+ let socket: WebSocket
468
+ try {
469
+ socket = this.webSocketFactory(this.realtimeUrl)
470
+ } catch {
471
+ this.emitError('WEBSOCKET_UNAVAILABLE', '无法建立实时连接。', true)
472
+ this.scheduleReconnect()
473
+ return
474
+ }
475
+
476
+ const generation = ++this.socketGeneration
477
+ this.socket = socket
478
+ this.socketAuthenticationFailure = null
479
+ socket.onopen = () => void this.authenticateSocket(socket, generation)
480
+ socket.onmessage = (event) => void this.handleSocketData(event.data, socket, generation)
481
+ socket.onerror = () => {
482
+ if (!this.isCurrentSocket(socket, generation)) return
483
+ this.emitError('WEBSOCKET_ERROR', '实时连接发生异常。', true)
484
+ }
485
+ socket.onclose = (event) => this.handleSocketClose(socket, generation, event.code)
486
+ }
487
+
488
+ private async authenticateSocket(socket: WebSocket, generation: number): Promise<void> {
489
+ if (!this.isCurrentSocket(socket, generation)) return
490
+ this.setState('authenticating')
491
+ let accessToken = ''
492
+ try {
493
+ accessToken = (await this.transport.getAccessToken()).trim()
494
+ } catch {
495
+ // Authentication errors are deliberately not propagated because they may contain credentials.
496
+ }
497
+
498
+ if (!this.isCurrentSocket(socket, generation)) return
499
+ if (!accessToken) {
500
+ this.emitError('TOKEN_UNAVAILABLE', '无法获取实时连接凭证。', true)
501
+ socket.close()
502
+ return
503
+ }
504
+ socket.send(JSON.stringify(createAuthenticateFrame(accessToken)))
505
+ accessToken = ''
506
+ }
507
+
508
+ private async handleSocketData(
509
+ data: unknown,
510
+ socket: WebSocket,
511
+ generation: number,
512
+ ): Promise<void> {
513
+ if (!this.isCurrentSocket(socket, generation)) return
514
+ let raw = ''
515
+ if (typeof data === 'string') raw = data
516
+ else if (data instanceof Blob) raw = await data.text()
517
+ else if (data instanceof ArrayBuffer) raw = new TextDecoder().decode(data)
518
+ else {
519
+ this.emitError('PROTOCOL_ERROR', '收到无法识别的实时消息。', true)
520
+ return
521
+ }
522
+ if (!this.isCurrentSocket(socket, generation)) return
523
+ const message = parseJsonRecord(raw)
524
+ if (!message) {
525
+ this.emitError('PROTOCOL_ERROR', '收到无法解析的实时消息。', true)
526
+ return
527
+ }
528
+ this.handleServerMessage(message, socket, generation)
529
+ }
530
+
531
+ private handleServerMessage(message: JsonRecord, socket: WebSocket, generation: number): void {
532
+ const type = text(message.type)
533
+ if (type === 'realtime_ready') {
534
+ const channels = record(message.channels)
535
+ const aiChannel = record(channels.ai_employee)
536
+ if (aiChannel.available !== true) {
537
+ this.emitError('CHANNEL_UNAVAILABLE', '数字员工实时通道不可用。', false)
538
+ this.shouldReconnect = false
539
+ socket.close()
540
+ return
541
+ }
542
+ this.reconnectAttempt = 0
543
+ this.availabilityReconnectAttempt = 0
544
+ this.startHeartbeat(numberValue(message.heartbeat_interval_seconds), socket, generation)
545
+ if (this.currentSessionId) {
546
+ this.subscribeCurrentSession(this.replayHistoryOnNextSubscription, true)
547
+ } else {
548
+ this.setState('connected')
549
+ }
550
+ return
551
+ }
552
+
553
+ if (type === 'realtime_event' && message.channel === 'ai_employee') {
554
+ this.handleAiEmployeeEvent(message.event)
555
+ return
556
+ }
557
+
558
+ if (type === 'channel_error' && message.channel === 'ai_employee') {
559
+ this.handleChannelError(text(message.code))
560
+ return
561
+ }
562
+
563
+ if (type === 'authentication_error') {
564
+ const failure = authenticationFailure(text(message.code))
565
+ this.socketAuthenticationFailure = { generation, retryMode: failure.retryMode }
566
+ this.emitError(failure.code, failure.message, failure.retryMode !== 'never')
567
+ return
568
+ }
569
+
570
+ if (type === 'protocol_error') {
571
+ this.emitError('PROTOCOL_ERROR', '实时连接协议异常。', false)
572
+ }
573
+ }
574
+
575
+ private handleAiEmployeeEvent(rawEvent: unknown): void {
576
+ const event = parseAiEmployeeEvent(rawEvent)
577
+ if (event.kind === 'ignored' || event.sessionId !== this.currentSessionId) return
578
+ if (event.kind === 'session-title') {
579
+ this.emit('title', { sessionId: event.sessionId, title: event.title })
580
+ return
581
+ }
582
+
583
+ if (event.result.processing === true) this.assistantReceivedInCurrentTurn = false
584
+ if (event.result.processing !== undefined) this.setProcessing(event.result.processing)
585
+ for (const message of event.result.messages) {
586
+ if (message.role === 'user') this.assistantReceivedInCurrentTurn = false
587
+ if (message.role === 'user' && this.matchesPendingLocalMessage(message)) continue
588
+ this.appendMessage(message, 'realtime')
589
+ if (message.role === 'assistant') this.assistantReceivedInCurrentTurn = true
590
+ }
591
+ if (event.result.fallbackResult && !this.assistantReceivedInCurrentTurn) {
592
+ this.appendMessage(event.result.fallbackResult, 'realtime')
593
+ }
594
+ }
595
+
596
+ private subscribeCurrentSession(replayHistory: boolean, channelReady = false): void {
597
+ const sessionId = this.currentSessionId
598
+ const socket = this.socket
599
+ if (
600
+ !sessionId ||
601
+ !socket ||
602
+ socket.readyState !== 1 ||
603
+ (!channelReady && this.currentState !== 'connected')
604
+ )
605
+ return
606
+ this.setState('subscribing')
607
+ socket.send(JSON.stringify(createSubscriptionFrame(sessionId, 'subscribe', replayHistory)))
608
+ this.replayHistoryOnNextSubscription = false
609
+ this.setState('connected')
610
+ this.emit('ready', { sessionId })
611
+ }
612
+
613
+ private matchesPendingLocalMessage(message: AiEmployeeMessage): boolean {
614
+ const sentAt = this.pendingLocalMessages.get(message.content)
615
+ if (sentAt === undefined) return false
616
+ this.pendingLocalMessages.delete(message.content)
617
+ return Date.now() - sentAt <= LOCAL_MESSAGE_MATCH_WINDOW_MS
618
+ }
619
+
620
+ private appendMessage(message: AiEmployeeMessage, source: 'local' | 'realtime'): void {
621
+ if (this.seenMessageIds.has(message.id)) return
622
+ this.rememberMessageId(message.id)
623
+ this.currentMessages = [...this.currentMessages, message]
624
+ this.emit('message', { sessionId: message.sessionId, message, source })
625
+ }
626
+
627
+ private rememberMessageId(id: string): void {
628
+ this.seenMessageIds.add(id)
629
+ if (this.seenMessageIds.size <= MAX_SEEN_MESSAGE_IDS) return
630
+ const oldest = this.seenMessageIds.values().next().value
631
+ if (typeof oldest === 'string') this.seenMessageIds.delete(oldest)
632
+ }
633
+
634
+ private handleChannelError(code: string): void {
635
+ if (code === 'SESSION_NOT_FOUND') {
636
+ this.emitError('SESSION_NOT_FOUND', '会话不存在或当前用户无权访问。', false)
637
+ } else {
638
+ this.emitError('CHANNEL_UNAVAILABLE', '数字员工实时通道暂不可用。', true)
639
+ }
640
+ this.setProcessing(false)
641
+ }
642
+
643
+ private startHeartbeat(intervalSeconds: number, socket: WebSocket, generation: number): void {
644
+ this.clearHeartbeatTimer()
645
+ const requestedMs =
646
+ Number.isFinite(intervalSeconds) && intervalSeconds > 0 ? intervalSeconds * 1_000 : 25_000
647
+ const intervalMs = Math.max(this.minimumHeartbeatMs, requestedMs)
648
+ this.heartbeatTimer = setInterval(() => {
649
+ if (!this.isCurrentSocket(socket, generation) || socket.readyState !== 1) return
650
+ socket.send(JSON.stringify(createPingFrame()))
651
+ }, intervalMs)
652
+ }
653
+
654
+ private handleSocketClose(socket: WebSocket, generation: number, code: number): void {
655
+ if (!this.isCurrentSocket(socket, generation)) return
656
+ this.socket = null
657
+ this.clearHeartbeatTimer()
658
+ const authenticationFailure =
659
+ this.socketAuthenticationFailure?.generation === generation
660
+ ? this.socketAuthenticationFailure
661
+ : null
662
+ this.socketAuthenticationFailure = null
663
+ if (!this.shouldReconnect || this.destroyed) {
664
+ if (!this.destroyed) this.setState('disconnected')
665
+ return
666
+ }
667
+
668
+ if (authenticationFailure?.retryMode === 'never') {
669
+ this.shouldReconnect = false
670
+ this.setState('disconnected')
671
+ return
672
+ }
673
+
674
+ if (code === 4400 || code === 4403) {
675
+ this.shouldReconnect = false
676
+ if (!authenticationFailure) {
677
+ const errorCode = code === 4403 ? 'AUTHENTICATION_FAILED' : 'PROTOCOL_ERROR'
678
+ const message = code === 4403 ? '当前身份无权建立实时连接。' : '实时连接协议不受支持。'
679
+ this.emitError(errorCode, message, false)
680
+ }
681
+ this.setState('disconnected')
682
+ return
683
+ }
684
+
685
+ if (authenticationFailure?.retryMode === 'availability' || code === 1013) {
686
+ if (!authenticationFailure) {
687
+ this.emitError('REALTIME_UNAVAILABLE', '实时鉴权服务暂不可用,正在重试。', true)
688
+ }
689
+ this.scheduleAvailabilityReconnect()
690
+ return
691
+ }
692
+
693
+ if (code === 4401) {
694
+ if (!authenticationFailure) {
695
+ this.emitError('AUTHENTICATION_FAILED', '连接凭证已失效,正在重新连接。', true)
696
+ }
697
+ }
698
+ this.scheduleReconnect()
699
+ }
700
+
701
+ private scheduleAvailabilityReconnect(): void {
702
+ if (this.availabilityReconnectAttempt >= MAX_AVAILABILITY_RECONNECTS) {
703
+ this.shouldReconnect = false
704
+ this.emitError('REALTIME_UNAVAILABLE', '实时鉴权服务持续不可用,请稍后重试。', false)
705
+ this.setState('disconnected')
706
+ return
707
+ }
708
+ this.availabilityReconnectAttempt += 1
709
+ this.scheduleReconnect()
710
+ }
711
+
712
+ private scheduleReconnect(): void {
713
+ if (this.destroyed || !this.shouldReconnect || this.reconnectTimer) return
714
+ this.setState('reconnecting')
715
+ const baseDelay = Math.min(
716
+ this.reconnectOptions.initialDelayMs * this.reconnectOptions.factor ** this.reconnectAttempt,
717
+ this.reconnectOptions.maximumDelayMs,
718
+ )
719
+ const jitterRange = baseDelay * this.reconnectOptions.jitter
720
+ const delay = Math.max(0, baseDelay - jitterRange + Math.random() * jitterRange * 2)
721
+ this.reconnectAttempt += 1
722
+ const lifecycle = this.lifecycleGeneration
723
+ this.reconnectTimer = setTimeout(() => {
724
+ this.reconnectTimer = null
725
+ void this.recoverAndReconnect(lifecycle)
726
+ }, delay)
727
+ }
728
+
729
+ private async recoverAndReconnect(lifecycle: number): Promise<void> {
730
+ if (!this.isCurrentLifecycle(lifecycle) || !this.shouldReconnect) return
731
+ const historyRecovered = await this.refreshHistory(lifecycle)
732
+ if (!this.isCurrentLifecycle(lifecycle) || !this.shouldReconnect) return
733
+ this.replayHistoryOnNextSubscription = !historyRecovered
734
+ this.openSocket(true)
735
+ }
736
+
737
+ private stopConnection(setDisconnectedState: boolean): void {
738
+ this.shouldReconnect = false
739
+ this.clearReconnectTimer()
740
+ this.clearHeartbeatTimer()
741
+ this.socketAuthenticationFailure = null
742
+ this.closeCurrentSocket()
743
+ this.setProcessing(false)
744
+ if (setDisconnectedState) this.setState('disconnected')
745
+ }
746
+
747
+ private closeCurrentSocket(): void {
748
+ const socket = this.socket
749
+ this.socket = null
750
+ this.socketGeneration += 1
751
+ this.socketAuthenticationFailure = null
752
+ if (socket && socket.readyState < 2) socket.close()
753
+ }
754
+
755
+ private clearReconnectTimer(): void {
756
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
757
+ this.reconnectTimer = null
758
+ }
759
+
760
+ private clearHeartbeatTimer(): void {
761
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer)
762
+ this.heartbeatTimer = null
763
+ }
764
+
765
+ private setState(next: AiEmployeeConnectionState): void {
766
+ if (this.currentState === next) return
767
+ const previous = this.currentState
768
+ this.currentState = next
769
+ this.emit('connection-state-change', { previous, current: next })
770
+ }
771
+
772
+ private setProcessing(processing: boolean): void {
773
+ if (this.processing === processing) return
774
+ this.processing = processing
775
+ this.emit('processing-state-change', { processing })
776
+ }
777
+
778
+ private emitError(
779
+ code: AiEmployeeErrorCode,
780
+ message: string,
781
+ recoverable: boolean,
782
+ ): AiEmployeeClientErrorDetail {
783
+ const detail = { code, message, recoverable }
784
+ this.emit('error', detail)
785
+ return detail
786
+ }
787
+
788
+ private emit<K extends keyof AiEmployeeClientEventMap>(
789
+ type: K,
790
+ detail: AiEmployeeClientEventMap[K],
791
+ ): void {
792
+ for (const listener of this.listeners.get(type) ?? []) listener(detail)
793
+ }
794
+
795
+ private isCurrentSocket(socket: WebSocket, generation: number): boolean {
796
+ return !this.destroyed && this.socket === socket && this.socketGeneration === generation
797
+ }
798
+
799
+ private isCurrentLifecycle(generation: number): boolean {
800
+ return !this.destroyed && this.lifecycleGeneration === generation
801
+ }
802
+
803
+ private assertUsable(): void {
804
+ if (this.destroyed) {
805
+ throw new AiEmployeeClientError('CLIENT_DESTROYED', '客户端已销毁。')
806
+ }
807
+ }
808
+ }
809
+
810
+ function validateRealtimeUrl(rawUrl: string): string {
811
+ let url: URL
812
+ try {
813
+ url = new URL(rawUrl)
814
+ } catch {
815
+ throw new AiEmployeeClientError('INVALID_CONFIGURATION', 'realtimeUrl 不是有效地址。')
816
+ }
817
+ if (!['ws:', 'wss:'].includes(url.protocol)) {
818
+ throw new AiEmployeeClientError('INVALID_CONFIGURATION', 'realtimeUrl 只允许 ws: 或 wss:。')
819
+ }
820
+ if (url.search || url.hash || url.username || url.password) {
821
+ throw new AiEmployeeClientError(
822
+ 'INVALID_CONFIGURATION',
823
+ 'realtimeUrl 不允许包含查询参数、片段或用户凭据。',
824
+ )
825
+ }
826
+ return url.toString()
827
+ }
828
+
829
+ function requiredText(value: string, field: string): string {
830
+ const normalized = typeof value === 'string' ? value.trim() : ''
831
+ if (!normalized) {
832
+ throw new AiEmployeeClientError('INVALID_CONFIGURATION', `${field} 不能为空。`)
833
+ }
834
+ return normalized
835
+ }
836
+
837
+ function normalizeSessionId(value: string | undefined): string | null {
838
+ return typeof value === 'string' && value.trim() ? value.trim() : null
839
+ }
840
+
841
+ function normalizeFirstMessageOptions(options: AiEmployeeFirstMessageOptions): {
842
+ attachments: AiEmployeeFirstMessageRequest['attachments']
843
+ trustedContext: AiEmployeeFirstMessageRequest['trustedContext']
844
+ } {
845
+ const attachments = (options.attachments ?? []).map((attachment) => {
846
+ const transactionId =
847
+ typeof attachment.transactionId === 'string' ? attachment.transactionId.trim() : ''
848
+ const fspath = typeof attachment.fspath === 'string' ? attachment.fspath.trim() : ''
849
+ if (
850
+ !transactionId ||
851
+ !fspath.startsWith('fs/') ||
852
+ fspath.includes('\\') ||
853
+ fspath.split('/').some((segment) => segment === '..')
854
+ ) {
855
+ throw new AiEmployeeClientError('SEND_FAILED', '首条消息附件引用无效。')
856
+ }
857
+ return { transactionId, fspath }
858
+ })
859
+ const trustedContext = options.trustedContext ?? null
860
+ if (
861
+ trustedContext !== null &&
862
+ (typeof trustedContext !== 'object' || Array.isArray(trustedContext))
863
+ ) {
864
+ throw new AiEmployeeClientError('SEND_FAILED', '首条消息上下文无效。')
865
+ }
866
+ return { attachments, trustedContext }
867
+ }
868
+
869
+ function firstMessageFingerprint(
870
+ content: string,
871
+ options: ReturnType<typeof normalizeFirstMessageOptions>,
872
+ ): string {
873
+ const serialized = stableFirstMessageString([
874
+ content,
875
+ options.attachments,
876
+ options.trustedContext,
877
+ ])
878
+ return `v1:${hashText(serialized)}:${hashText(`first-message:${serialized}`)}:${serialized.length}`
879
+ }
880
+
881
+ function stableFirstMessageString(value: unknown): string {
882
+ const seen = new WeakSet<object>()
883
+ const normalize = (candidate: unknown): unknown => {
884
+ if (candidate === null || typeof candidate !== 'object') return candidate
885
+ if (seen.has(candidate)) {
886
+ throw new AiEmployeeClientError('SEND_FAILED', '首条消息上下文无效。')
887
+ }
888
+ seen.add(candidate)
889
+ if (Array.isArray(candidate)) {
890
+ const result = candidate.map((item) => normalize(item))
891
+ seen.delete(candidate)
892
+ return result
893
+ }
894
+ const source = candidate as Record<string, unknown>
895
+ const result: Record<string, unknown> = {}
896
+ for (const key of Object.keys(source).sort()) result[key] = normalize(source[key])
897
+ seen.delete(candidate)
898
+ return result
899
+ }
900
+ return JSON.stringify(normalize(value))
901
+ }
902
+
903
+ function requiredRequestId(value: string): string {
904
+ const requestId = typeof value === 'string' ? value.trim() : ''
905
+ if (
906
+ !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(requestId)
907
+ ) {
908
+ throw new AiEmployeeClientError('INVALID_CONFIGURATION', 'requestIdFactory 必须返回 UUID。')
909
+ }
910
+ return requestId
911
+ }
912
+
913
+ function createRequestId(): string {
914
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
915
+ return crypto.randomUUID()
916
+ }
917
+ if (typeof crypto === 'undefined' || typeof crypto.getRandomValues !== 'function') {
918
+ throw new AiEmployeeClientError('INVALID_CONFIGURATION', '当前环境无法安全生成 request_id。')
919
+ }
920
+ const bytes = crypto.getRandomValues(new Uint8Array(16))
921
+ bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40
922
+ bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80
923
+ const value = [...bytes].map((byte) => byte.toString(16).padStart(2, '0')).join('')
924
+ return `${value.slice(0, 8)}-${value.slice(8, 12)}-${value.slice(12, 16)}-${value.slice(16, 20)}-${value.slice(20)}`
925
+ }
926
+
927
+ function normalizeFirstMessageResult(
928
+ value: AiEmployeeFirstMessageResult,
929
+ expectedAeCode: string,
930
+ expectedAgentCode: string,
931
+ ): AiEmployeeFirstMessageResult {
932
+ if (!value || typeof value !== 'object') {
933
+ throw new AiEmployeeClientError('SEND_FAILED', '首条消息受理响应无效。')
934
+ }
935
+ const sessionId = requiredFirstMessageResultText(value.sessionId)
936
+ const messageId = requiredFirstMessageResultText(value.messageId)
937
+ const executionId = requiredFirstMessageResultText(value.executionId)
938
+ const aeCode = requiredFirstMessageResultText(value.aeCode)
939
+ const agentCode = requiredFirstMessageResultText(value.agentCode)
940
+ const conversationType = requiredFirstMessageResultText(value.conversationType)
941
+ if (aeCode !== expectedAeCode || agentCode !== expectedAgentCode) {
942
+ throw new AiEmployeeClientError('SEND_FAILED', '首条消息受理响应与当前数字员工不匹配。')
943
+ }
944
+ if (value.status !== 'running' && value.status !== 'queued') {
945
+ throw new AiEmployeeClientError('SEND_FAILED', '首条消息受理状态无效。')
946
+ }
947
+ const startedAt = requiredIsoDate(value.startedAt, 'transport startedAt')
948
+ const result: AiEmployeeFirstMessageResult = {
949
+ sessionId,
950
+ messageId,
951
+ executionId,
952
+ status: value.status,
953
+ startedAt,
954
+ aeCode,
955
+ agentCode,
956
+ conversationType,
957
+ }
958
+ if (typeof value.title === 'string' && value.title.trim()) result.title = value.title.trim()
959
+ if (value.createdAt) result.createdAt = requiredIsoDate(value.createdAt, 'transport createdAt')
960
+ return result
961
+ }
962
+
963
+ function requiredIsoDate(value: string, field: string): string {
964
+ const normalized = requiredFirstMessageResultText(value)
965
+ if (!Number.isFinite(Date.parse(normalized))) {
966
+ throw new AiEmployeeClientError('SEND_FAILED', `${field} 不是有效时间。`)
967
+ }
968
+ return normalized
969
+ }
970
+
971
+ function requiredFirstMessageResultText(value: unknown): string {
972
+ if (typeof value !== 'string' || !value.trim()) {
973
+ throw new AiEmployeeClientError('SEND_FAILED', '首条消息受理响应无效。')
974
+ }
975
+ return value.trim()
976
+ }
977
+
978
+ function authenticationFailure(code: string): {
979
+ code: AiEmployeeErrorCode
980
+ message: string
981
+ retryMode: AuthenticationRetryMode
982
+ } {
983
+ if (code === 'UNAUTHORIZED') {
984
+ return {
985
+ code: 'AUTHENTICATION_FAILED',
986
+ message: '连接凭证已失效,正在重新连接。',
987
+ retryMode: 'token',
988
+ }
989
+ }
990
+ if (code === 'ORGANIZATION_CONTEXT_REQUIRED') {
991
+ return {
992
+ code: 'ORGANIZATION_CONTEXT_REQUIRED',
993
+ message: '请选择企业/团队后再开始咨询。',
994
+ retryMode: 'never',
995
+ }
996
+ }
997
+ if (code === 'INVALID_ARGUMENT') {
998
+ return {
999
+ code: 'PROTOCOL_ERROR',
1000
+ message: '实时连接协议不受支持。',
1001
+ retryMode: 'never',
1002
+ }
1003
+ }
1004
+ if (isRealtimeAvailabilityFailure(code)) {
1005
+ return {
1006
+ code: 'REALTIME_UNAVAILABLE',
1007
+ message: '实时鉴权服务暂不可用,正在重试。',
1008
+ retryMode: 'availability',
1009
+ }
1010
+ }
1011
+ if (code === 'AI_EMPLOYEE_NOT_AVAILABLE' || code === 'APPLICATION_NOT_SUPPORTED') {
1012
+ return {
1013
+ code: 'CHANNEL_UNAVAILABLE',
1014
+ message: '当前数字员工暂不可用。',
1015
+ retryMode: 'never',
1016
+ }
1017
+ }
1018
+ if (code === 'ACCESS_DENIED') {
1019
+ return {
1020
+ code: 'AUTHENTICATION_FAILED',
1021
+ message: '当前身份无权建立实时连接。',
1022
+ retryMode: 'never',
1023
+ }
1024
+ }
1025
+ return {
1026
+ code: 'AUTHENTICATION_FAILED',
1027
+ message: '实时连接认证失败。',
1028
+ retryMode: 'never',
1029
+ }
1030
+ }
1031
+
1032
+ function transportErrorCode(error: unknown): AiEmployeeTransportErrorCode | null {
1033
+ if (error instanceof AiEmployeeTransportError) return error.code
1034
+ if (!error || typeof error !== 'object' || !('code' in error)) return null
1035
+ switch (error.code) {
1036
+ case 'UNAUTHORIZED':
1037
+ case 'ACCESS_DENIED':
1038
+ case 'AI_EMPLOYEE_NOT_AVAILABLE':
1039
+ case 'APPLICATION_NOT_SUPPORTED':
1040
+ case 'ORGANIZATION_CONTEXT_REQUIRED':
1041
+ case 'INVALID_ARGUMENT':
1042
+ case 'IDEMPOTENCY_CONFLICT':
1043
+ case 'REALTIME_UNAVAILABLE':
1044
+ case 'TOO_MANY_REQUESTS':
1045
+ return error.code
1046
+ default:
1047
+ return null
1048
+ }
1049
+ }
1050
+
1051
+ function createSessionFirstMessageRequestStore(): AiEmployeeFirstMessageRequestStore | null {
1052
+ try {
1053
+ if (typeof sessionStorage === 'undefined') return null
1054
+ } catch {
1055
+ return null
1056
+ }
1057
+ const key = (scope: string) => `${FIRST_MESSAGE_STORAGE_PREFIX}:${hashText(scope)}`
1058
+ return {
1059
+ load(scope) {
1060
+ const raw = sessionStorage.getItem(key(scope))
1061
+ if (!raw) return null
1062
+ const parsed: unknown = JSON.parse(raw)
1063
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
1064
+ const state = parsed as Partial<AiEmployeeFirstMessageRequestState>
1065
+ return typeof state.fingerprint === 'string' && typeof state.requestId === 'string'
1066
+ ? { fingerprint: state.fingerprint, requestId: state.requestId }
1067
+ : null
1068
+ },
1069
+ save(scope, state) {
1070
+ sessionStorage.setItem(
1071
+ key(scope),
1072
+ JSON.stringify({ fingerprint: state.fingerprint, requestId: state.requestId }),
1073
+ )
1074
+ },
1075
+ clear(scope) {
1076
+ sessionStorage.removeItem(key(scope))
1077
+ },
1078
+ }
1079
+ }
1080
+
1081
+ function isRealtimeAvailabilityFailure(code: string): boolean {
1082
+ return (
1083
+ code === 'REALTIME_UNAVAILABLE' || code === 'KANBAN_AUTH_FAILED' || code.startsWith('SERVICE_')
1084
+ )
1085
+ }
1086
+
1087
+ function normalizeReconnectOptions(
1088
+ options: AiEmployeeClientOptions['reconnect'],
1089
+ ): AiEmployeeReconnectOptions {
1090
+ return {
1091
+ initialDelayMs: positiveNumber(options?.initialDelayMs, DEFAULT_RECONNECT.initialDelayMs),
1092
+ maximumDelayMs: positiveNumber(options?.maximumDelayMs, DEFAULT_RECONNECT.maximumDelayMs),
1093
+ factor: Math.max(1, positiveNumber(options?.factor, DEFAULT_RECONNECT.factor)),
1094
+ jitter: Math.min(1, Math.max(0, options?.jitter ?? DEFAULT_RECONNECT.jitter)),
1095
+ }
1096
+ }
1097
+
1098
+ function positiveNumber(value: number | undefined, fallback: number): number {
1099
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback
1100
+ }
1101
+
1102
+ function normalizeHistoryMessage(
1103
+ candidate: AiEmployeeMessage,
1104
+ expectedSessionId: string,
1105
+ ): AiEmployeeMessage | null {
1106
+ if (!candidate || typeof candidate !== 'object') return null
1107
+ const id = text(candidate.id).trim()
1108
+ const content = text(candidate.content).trim()
1109
+ const role = candidate.role
1110
+ if (!id || !content || !['user', 'assistant', 'system', 'error'].includes(role)) return null
1111
+ return {
1112
+ id,
1113
+ sessionId: expectedSessionId,
1114
+ role,
1115
+ content: role === 'error' ? '消息处理出现异常,请稍后重试。' : content,
1116
+ createdAt: text(candidate.createdAt) || new Date().toISOString(),
1117
+ }
1118
+ }
1119
+
1120
+ function text(value: unknown): string {
1121
+ return typeof value === 'string' ? value : ''
1122
+ }
1123
+
1124
+ function record(value: unknown): JsonRecord {
1125
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
1126
+ ? (value as JsonRecord)
1127
+ : {}
1128
+ }
1129
+
1130
+ function numberValue(value: unknown): number {
1131
+ return typeof value === 'number' ? value : Number.NaN
1132
+ }