@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.
- package/.prettierignore +4 -0
- package/.prettierrc.json +6 -0
- package/AGENTS.md +519 -0
- package/CLAUDE.md +512 -0
- package/README.md +734 -0
- 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
- 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
- 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
- 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
- package/eslint.config.js +30 -0
- package/examples/vanilla/index.html +33 -0
- package/examples/vanilla/main.js +247 -0
- package/examples/vanilla/package.json +15 -0
- package/examples/vanilla/public/runtime-config.js +10 -0
- package/examples/vanilla/style.css +99 -0
- package/package.json +31 -0
- package/packages/core/package.json +30 -0
- package/packages/core/src/client.ts +1132 -0
- package/packages/core/src/customer-support-client.ts +825 -0
- package/packages/core/src/customer-support-types.ts +123 -0
- package/packages/core/src/index.ts +79 -0
- package/packages/core/src/notification-center-client.ts +462 -0
- package/packages/core/src/notification-center-types.ts +154 -0
- package/packages/core/src/protocol.ts +237 -0
- package/packages/core/src/types.ts +170 -0
- package/packages/core/tests/client.test.ts +760 -0
- package/packages/core/tests/customer-support-client.test.ts +576 -0
- package/packages/core/tests/fake-websocket.ts +55 -0
- package/packages/core/tests/notification-center-client.test.ts +467 -0
- package/packages/core/tests/protocol.test.ts +124 -0
- package/packages/core/tsconfig.json +7 -0
- package/packages/core/tsup.config.ts +11 -0
- package/packages/web-components/package.json +35 -0
- package/packages/web-components/scripts/verify-runtime-build.mjs +111 -0
- package/packages/web-components/src/account-context.ts +1540 -0
- package/packages/web-components/src/application-switcher-transport.ts +243 -0
- package/packages/web-components/src/application-switcher.ts +1103 -0
- package/packages/web-components/src/chat.ts +1754 -0
- package/packages/web-components/src/customer-support-chat.ts +862 -0
- package/packages/web-components/src/customer-support-transport.ts +269 -0
- package/packages/web-components/src/index.ts +256 -0
- package/packages/web-components/src/notification-center.ts +1066 -0
- package/packages/web-components/src/notification-popover.ts +615 -0
- package/packages/web-components/src/organization-multi-employee-selector.ts +1564 -0
- package/packages/web-components/src/organization-onboarding-transport.ts +54 -0
- package/packages/web-components/src/organization-onboarding.ts +1216 -0
- package/packages/web-components/src/organization-selector.ts +534 -0
- package/packages/web-components/src/organization-single-employee-selector.ts +114 -0
- package/packages/web-components/src/prototype-icons.ts +242 -0
- package/packages/web-components/src/safe-image-url.ts +29 -0
- package/packages/web-components/src/safe-markdown.ts +192 -0
- package/packages/web-components/src/uniplat-base-account-context-transport.ts +625 -0
- package/packages/web-components/src/uniplat-base-application-switcher-transport.ts +418 -0
- package/packages/web-components/src/uniplat-base-application-ticket.ts +254 -0
- package/packages/web-components/src/uniplat-base-identity-ticket.ts +202 -0
- package/packages/web-components/src/uniplat-base-notification-center-transport.ts +378 -0
- package/packages/web-components/src/uniplat-base-organization-multi-employee-selector-transport.ts +394 -0
- package/packages/web-components/src/uniplat-base-organization-onboarding-transport.ts +183 -0
- package/packages/web-components/src/uniplat-base-organization-single-employee-selector-transport.ts +14 -0
- package/packages/web-components/src/user-login-transport.ts +563 -0
- package/packages/web-components/src/user-login.ts +2027 -0
- package/packages/web-components/src/user-menu.ts +880 -0
- package/packages/web-components/tests/account-context.test.ts +798 -0
- package/packages/web-components/tests/application-switcher.test.ts +562 -0
- package/packages/web-components/tests/chat.test.ts +1130 -0
- package/packages/web-components/tests/customer-support-chat.test.ts +428 -0
- package/packages/web-components/tests/customer-support-transport.test.ts +79 -0
- package/packages/web-components/tests/notification-center-transport.test.ts +271 -0
- package/packages/web-components/tests/notification-center.test.ts +429 -0
- package/packages/web-components/tests/notification-popover.test.ts +283 -0
- package/packages/web-components/tests/organization-multi-employee-selector-transport.test.ts +332 -0
- package/packages/web-components/tests/organization-multi-employee-selector.test.ts +225 -0
- package/packages/web-components/tests/organization-onboarding.test.ts +260 -0
- package/packages/web-components/tests/organization-selector.test.ts +127 -0
- package/packages/web-components/tests/organization-single-employee-selector.test.ts +145 -0
- package/packages/web-components/tests/uniplat-base-account-context-transport.test.ts +496 -0
- package/packages/web-components/tests/uniplat-base-application-switcher-transport.test.ts +598 -0
- package/packages/web-components/tests/uniplat-base-application-ticket.test.ts +233 -0
- package/packages/web-components/tests/uniplat-base-identity-ticket.test.ts +226 -0
- package/packages/web-components/tests/uniplat-base-organization-onboarding-transport.test.ts +227 -0
- package/packages/web-components/tests/user-login-transport.test.ts +443 -0
- package/packages/web-components/tests/user-login.test.ts +626 -0
- package/packages/web-components/tests/user-menu.test.ts +271 -0
- package/packages/web-components/tsconfig.json +8 -0
- package/packages/web-components/tsup.config.ts +12 -0
- package/packages/web-components/tsup.runtime.config.ts +37 -0
- package/pnpm-workspace.yaml +6 -0
- package/tsconfig.base.json +21 -0
- package/vitest.config.ts +20 -0
- 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
|
+
}
|