@csntgao/uni-base 0.6.2 → 0.6.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +6 -4
- package/CLAUDE.md +6 -4
- package/README.md +6 -2
- package/docs//346/225/260/345/255/227/345/221/230/345/267/245/345/256/214/346/225/264/346/212/275/345/261/211/346/216/245/345/205/245/350/257/264/346/230/216.md +46 -0
- package/package.json +12 -13
- package/packages/core/package.json +1 -1
- package/packages/core/src/assistant-actions.ts +120 -0
- package/packages/core/src/client.ts +21 -7
- package/packages/core/src/customer-support-types.ts +15 -0
- package/packages/core/src/index.ts +8 -0
- package/packages/core/src/protocol.ts +5 -7
- package/packages/core/src/types.ts +6 -0
- package/packages/core/tests/assistant-actions.test.ts +76 -0
- package/packages/core/tests/client.test.ts +96 -0
- package/packages/web-components/package.json +1 -1
- package/packages/web-components/scripts/verify-runtime-build.mjs +1 -0
- package/packages/web-components/src/chat.ts +266 -14
- package/packages/web-components/src/customer-support-transport.ts +117 -15
- package/packages/web-components/src/index.ts +14 -2
- package/packages/web-components/tests/chat.test.ts +417 -9
- package/packages/web-components/tests/customer-support-transport.test.ts +89 -1
- package/restart +0 -0
- package//344/272/244/346/216/245/346/226/207/346/241/243.md +34 -17
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
CustomerSupportAgent,
|
|
3
3
|
CustomerSupportCreateSessionResult,
|
|
4
|
+
CustomerSupportHandoffResult,
|
|
4
5
|
CustomerSupportMessage,
|
|
5
6
|
CustomerSupportMessagePage,
|
|
6
7
|
CustomerSupportSenderType,
|
|
@@ -8,6 +9,7 @@ import type {
|
|
|
8
9
|
CustomerSupportSessionStatus,
|
|
9
10
|
CustomerSupportTransport,
|
|
10
11
|
} from '@uniplat/ai-employee-client'
|
|
12
|
+
import { isUuidV4 } from '@uniplat/ai-employee-client'
|
|
11
13
|
|
|
12
14
|
export interface HrSaasCustomerSupportTransportOptions {
|
|
13
15
|
serviceBaseUrl: string
|
|
@@ -15,8 +17,30 @@ export interface HrSaasCustomerSupportTransportOptions {
|
|
|
15
17
|
fetch?: typeof fetch
|
|
16
18
|
}
|
|
17
19
|
|
|
20
|
+
export type HrSaasCustomerSupportTransportErrorCode =
|
|
21
|
+
| 'AUTH_REQUIRED'
|
|
22
|
+
| 'SOURCE_SESSION_NOT_FOUND'
|
|
23
|
+
| 'SOURCE_SESSION_ACCESS_DENIED'
|
|
24
|
+
| 'HANDOFF_ACTION_NOT_FOUND'
|
|
25
|
+
| 'HANDOFF_ACTION_MISMATCH'
|
|
26
|
+
| 'UNAUTHORIZED'
|
|
27
|
+
| 'CUSTOMER_SUPPORT_UNAVAILABLE'
|
|
28
|
+
| 'REQUEST_FAILED'
|
|
29
|
+
| 'INVALID_RESPONSE'
|
|
30
|
+
|
|
31
|
+
export class HrSaasCustomerSupportTransportError extends Error {
|
|
32
|
+
readonly code: HrSaasCustomerSupportTransportErrorCode
|
|
33
|
+
|
|
34
|
+
constructor(code: HrSaasCustomerSupportTransportErrorCode) {
|
|
35
|
+
super('Customer support request failed')
|
|
36
|
+
this.name = 'HrSaasCustomerSupportTransportError'
|
|
37
|
+
this.code = code
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
18
41
|
const SESSION_PAGE_ACTION = 'user_session_page'
|
|
19
42
|
const SESSION_CREATE_ACTION = 'user_session_create'
|
|
43
|
+
const SESSION_HANDOFF_CREATE_ACTION = 'user_session_handoff_create'
|
|
20
44
|
const SESSION_DETAIL_ACTION = 'user_session_detail'
|
|
21
45
|
const MESSAGE_PAGE_ACTION = 'user_message_page'
|
|
22
46
|
const MESSAGE_SEND_ACTION = 'user_message_send'
|
|
@@ -63,6 +87,37 @@ export function createHrSaasCustomerSupportTransport(
|
|
|
63
87
|
if (parsedInitialMessage) result.initialMessage = parsedInitialMessage
|
|
64
88
|
return result
|
|
65
89
|
},
|
|
90
|
+
async createHandoffSession(handoffRequest) {
|
|
91
|
+
const sourceSessionId = requiredText(handoffRequest.sourceSessionId, 128)
|
|
92
|
+
const actionTransactionId = requiredText(handoffRequest.actionTransactionId, 36)
|
|
93
|
+
if (!isUuidV4(actionTransactionId)) {
|
|
94
|
+
throw new HrSaasCustomerSupportTransportError('INVALID_RESPONSE')
|
|
95
|
+
}
|
|
96
|
+
const data = record(
|
|
97
|
+
await request(SESSION_HANDOFF_CREATE_ACTION, {
|
|
98
|
+
source_session_id: sourceSessionId,
|
|
99
|
+
action_transaction_id: actionTransactionId,
|
|
100
|
+
}),
|
|
101
|
+
)
|
|
102
|
+
const session = parseSession(data.session)
|
|
103
|
+
const initialMessage = parseMessage(data.initial_message)
|
|
104
|
+
if (
|
|
105
|
+
!session ||
|
|
106
|
+
!initialMessage ||
|
|
107
|
+
initialMessage.sessionId !== session.id ||
|
|
108
|
+
typeof data.created !== 'boolean' ||
|
|
109
|
+
typeof data.repaired !== 'boolean'
|
|
110
|
+
) {
|
|
111
|
+
throw new HrSaasCustomerSupportTransportError('INVALID_RESPONSE')
|
|
112
|
+
}
|
|
113
|
+
const result: CustomerSupportHandoffResult = {
|
|
114
|
+
session,
|
|
115
|
+
initialMessage,
|
|
116
|
+
created: data.created,
|
|
117
|
+
repaired: data.repaired,
|
|
118
|
+
}
|
|
119
|
+
return result
|
|
120
|
+
},
|
|
66
121
|
async getSession(sessionId) {
|
|
67
122
|
const data = await request(SESSION_DETAIL_ACTION, {
|
|
68
123
|
session_id: requiredText(sessionId, 128),
|
|
@@ -169,27 +224,74 @@ async function requestData(
|
|
|
169
224
|
accessToken: string,
|
|
170
225
|
body: Record<string, unknown>,
|
|
171
226
|
): Promise<unknown> {
|
|
172
|
-
if (!fetchImplementation) throw new
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
227
|
+
if (!fetchImplementation) throw new HrSaasCustomerSupportTransportError('REQUEST_FAILED')
|
|
228
|
+
let response: Response
|
|
229
|
+
try {
|
|
230
|
+
response = await fetchImplementation(endpoint, {
|
|
231
|
+
method: 'POST',
|
|
232
|
+
credentials: 'include',
|
|
233
|
+
headers: {
|
|
234
|
+
accept: 'application/json',
|
|
235
|
+
authorization: `Bearer ${accessToken}`,
|
|
236
|
+
'content-type': 'application/json',
|
|
237
|
+
},
|
|
238
|
+
body: JSON.stringify(body),
|
|
239
|
+
})
|
|
240
|
+
} catch {
|
|
241
|
+
throw new HrSaasCustomerSupportTransportError('REQUEST_FAILED')
|
|
242
|
+
}
|
|
243
|
+
let payload: unknown = null
|
|
244
|
+
try {
|
|
245
|
+
payload = await response.json()
|
|
246
|
+
} catch {
|
|
247
|
+
if (!response.ok) throw transportResponseError({}, response.status)
|
|
248
|
+
throw new HrSaasCustomerSupportTransportError('INVALID_RESPONSE')
|
|
249
|
+
}
|
|
185
250
|
const envelope = record(payload)
|
|
251
|
+
if (!response.ok) throw transportResponseError(envelope, response.status)
|
|
186
252
|
if ('rescode' in envelope) {
|
|
187
|
-
if (Number(envelope.rescode) !== 0)
|
|
253
|
+
if (Number(envelope.rescode) !== 0) {
|
|
254
|
+
throw transportResponseError(envelope, response.status)
|
|
255
|
+
}
|
|
188
256
|
return envelope.data
|
|
189
257
|
}
|
|
190
258
|
return payload
|
|
191
259
|
}
|
|
192
260
|
|
|
261
|
+
function transportResponseError(
|
|
262
|
+
envelope: Record<string, unknown>,
|
|
263
|
+
status: number,
|
|
264
|
+
): HrSaasCustomerSupportTransportError {
|
|
265
|
+
const detail = record(envelope.data)
|
|
266
|
+
const error = record(envelope.error)
|
|
267
|
+
const candidate = [
|
|
268
|
+
envelope.error_code,
|
|
269
|
+
envelope.code,
|
|
270
|
+
detail.error_code,
|
|
271
|
+
detail.code,
|
|
272
|
+
error.error_code,
|
|
273
|
+
error.code,
|
|
274
|
+
].find((value) => typeof value === 'string' && value.trim())
|
|
275
|
+
const code = typeof candidate === 'string' ? candidate.trim().toUpperCase() : ''
|
|
276
|
+
if (isTransportErrorCode(code)) return new HrSaasCustomerSupportTransportError(code)
|
|
277
|
+
if (status === 401) return new HrSaasCustomerSupportTransportError('UNAUTHORIZED')
|
|
278
|
+
if (status === 503) {
|
|
279
|
+
return new HrSaasCustomerSupportTransportError('CUSTOMER_SUPPORT_UNAVAILABLE')
|
|
280
|
+
}
|
|
281
|
+
return new HrSaasCustomerSupportTransportError('REQUEST_FAILED')
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function isTransportErrorCode(value: string): value is HrSaasCustomerSupportTransportErrorCode {
|
|
285
|
+
return [
|
|
286
|
+
'SOURCE_SESSION_NOT_FOUND',
|
|
287
|
+
'SOURCE_SESSION_ACCESS_DENIED',
|
|
288
|
+
'HANDOFF_ACTION_NOT_FOUND',
|
|
289
|
+
'HANDOFF_ACTION_MISMATCH',
|
|
290
|
+
'UNAUTHORIZED',
|
|
291
|
+
'CUSTOMER_SUPPORT_UNAVAILABLE',
|
|
292
|
+
].includes(value)
|
|
293
|
+
}
|
|
294
|
+
|
|
193
295
|
function validateServiceBaseUrl(value: string): string {
|
|
194
296
|
const raw = typeof value === 'string' ? value.trim() : ''
|
|
195
297
|
if (!raw) throw new TypeError('Customer support serviceBaseUrl is required')
|
|
@@ -216,7 +318,7 @@ async function resolveAccessToken(provider: () => string | Promise<string>): Pro
|
|
|
216
318
|
} catch {
|
|
217
319
|
// Provider details may contain credentials and are intentionally discarded.
|
|
218
320
|
}
|
|
219
|
-
if (!value) throw new
|
|
321
|
+
if (!value) throw new HrSaasCustomerSupportTransportError('AUTH_REQUIRED')
|
|
220
322
|
return value
|
|
221
323
|
}
|
|
222
324
|
|
|
@@ -77,8 +77,20 @@ export type {
|
|
|
77
77
|
CustomerSupportChatConfig,
|
|
78
78
|
CustomerSupportChatEventMap,
|
|
79
79
|
} from './customer-support-chat'
|
|
80
|
-
export {
|
|
81
|
-
|
|
80
|
+
export {
|
|
81
|
+
createHrSaasCustomerSupportTransport,
|
|
82
|
+
HrSaasCustomerSupportTransportError,
|
|
83
|
+
} from './customer-support-transport'
|
|
84
|
+
export type {
|
|
85
|
+
HrSaasCustomerSupportTransportErrorCode,
|
|
86
|
+
HrSaasCustomerSupportTransportOptions,
|
|
87
|
+
} from './customer-support-transport'
|
|
88
|
+
export { isUuidV4, parseAiEmployeeAssistantContent } from '@uniplat/ai-employee-client'
|
|
89
|
+
export type {
|
|
90
|
+
AiEmployeeAssistantAction,
|
|
91
|
+
OpenCustomerSupportAssistantAction,
|
|
92
|
+
ParsedAiEmployeeAssistantContent,
|
|
93
|
+
} from '@uniplat/ai-employee-client'
|
|
82
94
|
export type {
|
|
83
95
|
NotificationCenterConfig,
|
|
84
96
|
NotificationCenterEventMap,
|
|
@@ -2,13 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
4
4
|
import {
|
|
5
|
-
AiEmployeeTransportError,
|
|
6
5
|
type AiEmployeeMessage,
|
|
7
6
|
type AiEmployeeTransport,
|
|
7
|
+
type CustomerSupportHandoffRequest,
|
|
8
|
+
AiEmployeeTransportError,
|
|
9
|
+
type CustomerSupportHandoffResult,
|
|
10
|
+
type CustomerSupportSession,
|
|
11
|
+
type CustomerSupportTransport,
|
|
8
12
|
} from '@uniplat/ai-employee-client'
|
|
13
|
+
import { HrSaasCustomerSupportTransportError } from '../src'
|
|
9
14
|
import '../src'
|
|
10
15
|
import type { UniplatAiEmployeeChat } from '../src'
|
|
11
16
|
|
|
17
|
+
const customerSupportActionTransactionId = '8f0b790c-1c5f-4f8c-95cc-4ead9d9cfb1a'
|
|
18
|
+
|
|
19
|
+
function customerSupportActionContent(): string {
|
|
20
|
+
return `该问题超出我的职责范围。\n\n您可以从‘帮助与支持’菜单项入口发起人工客服。\n\n\`\`\`json
|
|
21
|
+
{
|
|
22
|
+
"type": "assistant_action",
|
|
23
|
+
"action": "open_customer_support",
|
|
24
|
+
"transaction-id": "${customerSupportActionTransactionId}",
|
|
25
|
+
"trigger": "out_of_scope",
|
|
26
|
+
"entry": "help_and_support"
|
|
27
|
+
}
|
|
28
|
+
\`\`\``
|
|
29
|
+
}
|
|
30
|
+
|
|
12
31
|
class ComponentFakeWebSocket {
|
|
13
32
|
readonly url: string
|
|
14
33
|
readyState = 0
|
|
@@ -66,6 +85,60 @@ function createTransport(history: AiEmployeeMessage[] = []) {
|
|
|
66
85
|
} satisfies AiEmployeeTransport
|
|
67
86
|
}
|
|
68
87
|
|
|
88
|
+
function createCustomerSupportTransport() {
|
|
89
|
+
const session: CustomerSupportSession = {
|
|
90
|
+
id: 'support-session-81',
|
|
91
|
+
sessionNo: 'CS-81',
|
|
92
|
+
title: '职责越界转接',
|
|
93
|
+
status: 'waiting',
|
|
94
|
+
statusText: '等待客服接入',
|
|
95
|
+
primaryAgent: null,
|
|
96
|
+
lastMessage: '已从数字员工转接',
|
|
97
|
+
unreadCount: 0,
|
|
98
|
+
openedAt: '2026-09-09T08:00:00.000Z',
|
|
99
|
+
updatedAt: '2026-09-09T08:00:00.000Z',
|
|
100
|
+
version: '1',
|
|
101
|
+
}
|
|
102
|
+
const handoffResult: CustomerSupportHandoffResult = {
|
|
103
|
+
session,
|
|
104
|
+
initialMessage: {
|
|
105
|
+
id: 'support-message-91',
|
|
106
|
+
sessionId: session.id,
|
|
107
|
+
sequence: '1',
|
|
108
|
+
clientMessageId: '',
|
|
109
|
+
senderType: 'customer',
|
|
110
|
+
senderName: '',
|
|
111
|
+
content: '已从数字员工转接',
|
|
112
|
+
createdAt: '2026-09-09T08:00:00.000Z',
|
|
113
|
+
},
|
|
114
|
+
created: true,
|
|
115
|
+
repaired: false,
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
session,
|
|
119
|
+
handoffResult,
|
|
120
|
+
transport: {
|
|
121
|
+
getAccessToken: vi.fn(async () => 'customer.support.jwt'),
|
|
122
|
+
listSessions: vi.fn(async () => [session]),
|
|
123
|
+
getSession: vi.fn(async () => session),
|
|
124
|
+
createSession: vi.fn(async () => ({ session })),
|
|
125
|
+
createHandoffSession: vi.fn(async (request: CustomerSupportHandoffRequest) => {
|
|
126
|
+
void request
|
|
127
|
+
return handoffResult
|
|
128
|
+
}),
|
|
129
|
+
listMessages: vi.fn(async () => ({
|
|
130
|
+
messages: [],
|
|
131
|
+
latestSequence: '1',
|
|
132
|
+
sessionVersion: '1',
|
|
133
|
+
})),
|
|
134
|
+
sendMessage: vi.fn(async () => {
|
|
135
|
+
throw new Error('not used')
|
|
136
|
+
}),
|
|
137
|
+
markRead: vi.fn(async () => undefined),
|
|
138
|
+
} satisfies CustomerSupportTransport,
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
69
142
|
async function settle(element: UniplatAiEmployeeChat): Promise<void> {
|
|
70
143
|
await Promise.resolve()
|
|
71
144
|
await element.updateComplete
|
|
@@ -413,6 +486,254 @@ describe('<uniplat-ai-employee-chat>', () => {
|
|
|
413
486
|
expect(chat.shadowRoot?.textContent).toContain('继续咨询招聘方案')
|
|
414
487
|
})
|
|
415
488
|
|
|
489
|
+
it('renders a trusted handoff action from history without exposing its raw JSON', async () => {
|
|
490
|
+
const transport = createTransport([
|
|
491
|
+
{
|
|
492
|
+
id: 'assistant-action-message',
|
|
493
|
+
sessionId: 'session-1',
|
|
494
|
+
role: 'assistant',
|
|
495
|
+
content: customerSupportActionContent(),
|
|
496
|
+
createdAt: '2026-09-09T08:00:00.000Z',
|
|
497
|
+
},
|
|
498
|
+
])
|
|
499
|
+
const chat = document.createElement('uniplat-ai-employee-chat')
|
|
500
|
+
chat.configure({
|
|
501
|
+
realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
|
|
502
|
+
aeCode: 'payroll',
|
|
503
|
+
agentCode: 'specialist',
|
|
504
|
+
sessionId: 'session-1',
|
|
505
|
+
transport,
|
|
506
|
+
webSocketFactory: () => new ComponentFakeWebSocket('wss://kanban.example.com').asWebSocket(),
|
|
507
|
+
})
|
|
508
|
+
document.body.append(chat)
|
|
509
|
+
chat.open()
|
|
510
|
+
|
|
511
|
+
await vi.waitFor(() =>
|
|
512
|
+
expect(chat.shadowRoot?.querySelector('.assistant-action-button')).not.toBeNull(),
|
|
513
|
+
)
|
|
514
|
+
expect(chat.shadowRoot?.textContent).toContain('该问题超出我的职责范围。')
|
|
515
|
+
expect(chat.shadowRoot?.textContent).toContain('发起人工客服')
|
|
516
|
+
expect(chat.shadowRoot?.textContent).not.toContain('open_customer_support')
|
|
517
|
+
expect(chat.shadowRoot?.textContent).not.toContain(customerSupportActionTransactionId)
|
|
518
|
+
|
|
519
|
+
chat.shadowRoot?.querySelector<HTMLButtonElement>('.conversation-mark')?.click()
|
|
520
|
+
await settle(chat)
|
|
521
|
+
expect(chat.shadowRoot?.querySelector('.session-item-preview')?.textContent).toContain(
|
|
522
|
+
'该问题超出我的职责范围。',
|
|
523
|
+
)
|
|
524
|
+
expect(chat.shadowRoot?.querySelector('.session-item-preview')?.textContent).not.toContain(
|
|
525
|
+
'open_customer_support',
|
|
526
|
+
)
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
it('submits one handoff request and opens the returned customer support session', async () => {
|
|
530
|
+
const aiSockets: ComponentFakeWebSocket[] = []
|
|
531
|
+
const supportSockets: ComponentFakeWebSocket[] = []
|
|
532
|
+
const transport = createTransport([
|
|
533
|
+
{
|
|
534
|
+
id: 'assistant-action-message',
|
|
535
|
+
sessionId: 'session-1',
|
|
536
|
+
role: 'assistant',
|
|
537
|
+
content: customerSupportActionContent(),
|
|
538
|
+
createdAt: '2026-09-09T08:00:00.000Z',
|
|
539
|
+
},
|
|
540
|
+
])
|
|
541
|
+
const support = createCustomerSupportTransport()
|
|
542
|
+
const completion = deferred<CustomerSupportHandoffResult>()
|
|
543
|
+
support.transport.createHandoffSession.mockImplementation(() => completion.promise)
|
|
544
|
+
const chat = document.createElement('uniplat-ai-employee-chat')
|
|
545
|
+
chat.configure({
|
|
546
|
+
realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
|
|
547
|
+
aeCode: 'archives',
|
|
548
|
+
agentCode: 'default',
|
|
549
|
+
sessionId: 'session-1',
|
|
550
|
+
transport,
|
|
551
|
+
webSocketFactory: (url) => {
|
|
552
|
+
const socket = new ComponentFakeWebSocket(url)
|
|
553
|
+
aiSockets.push(socket)
|
|
554
|
+
return socket.asWebSocket()
|
|
555
|
+
},
|
|
556
|
+
customerSupport: {
|
|
557
|
+
realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
|
|
558
|
+
transport: support.transport,
|
|
559
|
+
webSocketFactory: (url) => {
|
|
560
|
+
const socket = new ComponentFakeWebSocket(url)
|
|
561
|
+
supportSockets.push(socket)
|
|
562
|
+
return socket.asWebSocket()
|
|
563
|
+
},
|
|
564
|
+
},
|
|
565
|
+
})
|
|
566
|
+
document.body.append(chat)
|
|
567
|
+
chat.open()
|
|
568
|
+
await vi.waitFor(() =>
|
|
569
|
+
expect(chat.shadowRoot?.querySelector('.assistant-action-button')).not.toBeNull(),
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
const button = chat.shadowRoot?.querySelector<HTMLButtonElement>('.assistant-action-button')
|
|
573
|
+
button?.click()
|
|
574
|
+
button?.click()
|
|
575
|
+
await chat.updateComplete
|
|
576
|
+
|
|
577
|
+
expect(support.transport.createHandoffSession).toHaveBeenCalledTimes(1)
|
|
578
|
+
expect(support.transport.createHandoffSession).toHaveBeenCalledWith({
|
|
579
|
+
sourceSessionId: 'session-1',
|
|
580
|
+
actionTransactionId: customerSupportActionTransactionId,
|
|
581
|
+
})
|
|
582
|
+
expect(
|
|
583
|
+
chat.shadowRoot?.querySelector<HTMLButtonElement>('.assistant-action-button')?.disabled,
|
|
584
|
+
).toBe(true)
|
|
585
|
+
|
|
586
|
+
completion.resolve(support.handoffResult)
|
|
587
|
+
await vi.waitFor(() =>
|
|
588
|
+
expect(chat.shadowRoot?.querySelector('uniplat-customer-support-chat')).not.toBeNull(),
|
|
589
|
+
)
|
|
590
|
+
await vi.waitFor(() =>
|
|
591
|
+
expect(support.transport.listMessages).toHaveBeenCalledWith('support-session-81'),
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
expect(aiSockets[0]?.closeCount).toBe(1)
|
|
595
|
+
expect(supportSockets).toHaveLength(1)
|
|
596
|
+
expect(
|
|
597
|
+
chat.shadowRoot?.querySelector('uniplat-customer-support-chat')?.shadowRoot?.textContent,
|
|
598
|
+
).toContain('企业客服人工顾问')
|
|
599
|
+
})
|
|
600
|
+
|
|
601
|
+
it('retries a failed handoff with the same trusted action id', async () => {
|
|
602
|
+
const transport = createTransport([
|
|
603
|
+
{
|
|
604
|
+
id: 'assistant-action-message',
|
|
605
|
+
sessionId: 'session-1',
|
|
606
|
+
role: 'assistant',
|
|
607
|
+
content: customerSupportActionContent(),
|
|
608
|
+
createdAt: '2026-09-09T08:00:00.000Z',
|
|
609
|
+
},
|
|
610
|
+
])
|
|
611
|
+
const support = createCustomerSupportTransport()
|
|
612
|
+
support.transport.createHandoffSession
|
|
613
|
+
.mockRejectedValueOnce(
|
|
614
|
+
new HrSaasCustomerSupportTransportError('CUSTOMER_SUPPORT_UNAVAILABLE'),
|
|
615
|
+
)
|
|
616
|
+
.mockResolvedValueOnce(support.handoffResult)
|
|
617
|
+
const chat = document.createElement('uniplat-ai-employee-chat')
|
|
618
|
+
const publicErrors: unknown[] = []
|
|
619
|
+
chat.addEventListener('error', (event) =>
|
|
620
|
+
publicErrors.push((event as unknown as CustomEvent).detail),
|
|
621
|
+
)
|
|
622
|
+
chat.configure({
|
|
623
|
+
realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
|
|
624
|
+
aeCode: 'social-security',
|
|
625
|
+
agentCode: 'default',
|
|
626
|
+
sessionId: 'session-1',
|
|
627
|
+
transport,
|
|
628
|
+
webSocketFactory: () => new ComponentFakeWebSocket('wss://kanban.example.com').asWebSocket(),
|
|
629
|
+
customerSupport: {
|
|
630
|
+
realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
|
|
631
|
+
transport: support.transport,
|
|
632
|
+
webSocketFactory: () =>
|
|
633
|
+
new ComponentFakeWebSocket('wss://kanban.example.com').asWebSocket(),
|
|
634
|
+
},
|
|
635
|
+
})
|
|
636
|
+
document.body.append(chat)
|
|
637
|
+
chat.open()
|
|
638
|
+
await vi.waitFor(() =>
|
|
639
|
+
expect(chat.shadowRoot?.querySelector('.assistant-action-button')).not.toBeNull(),
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
chat.shadowRoot?.querySelector<HTMLButtonElement>('.assistant-action-button')?.click()
|
|
643
|
+
await vi.waitFor(() =>
|
|
644
|
+
expect(chat.shadowRoot?.textContent).toContain('人工客服暂时不可用,请稍后重试。'),
|
|
645
|
+
)
|
|
646
|
+
chat.shadowRoot?.querySelector<HTMLButtonElement>('.assistant-action-button')?.click()
|
|
647
|
+
await vi.waitFor(() =>
|
|
648
|
+
expect(chat.shadowRoot?.querySelector('uniplat-customer-support-chat')).not.toBeNull(),
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
expect(support.transport.createHandoffSession).toHaveBeenCalledTimes(2)
|
|
652
|
+
expect(support.transport.createHandoffSession.mock.calls[0]?.[0]).toEqual(
|
|
653
|
+
support.transport.createHandoffSession.mock.calls[1]?.[0],
|
|
654
|
+
)
|
|
655
|
+
expect(publicErrors).toContainEqual({
|
|
656
|
+
code: 'CUSTOMER_SUPPORT_UNAVAILABLE',
|
|
657
|
+
message: '人工客服暂时不可用,请稍后重试。',
|
|
658
|
+
recoverable: true,
|
|
659
|
+
})
|
|
660
|
+
expect(JSON.stringify(publicErrors)).not.toContain(customerSupportActionTransactionId)
|
|
661
|
+
})
|
|
662
|
+
|
|
663
|
+
it('renders the same trusted action from realtime messages and ignores malformed actions', async () => {
|
|
664
|
+
const sockets: ComponentFakeWebSocket[] = []
|
|
665
|
+
const transport = createTransport()
|
|
666
|
+
const chat = document.createElement('uniplat-ai-employee-chat')
|
|
667
|
+
chat.configure({
|
|
668
|
+
realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
|
|
669
|
+
aeCode: 'enterprise-advisor',
|
|
670
|
+
agentCode: 'non-default',
|
|
671
|
+
sessionId: 'session-1',
|
|
672
|
+
transport,
|
|
673
|
+
webSocketFactory: (url) => {
|
|
674
|
+
const socket = new ComponentFakeWebSocket(url)
|
|
675
|
+
sockets.push(socket)
|
|
676
|
+
return socket.asWebSocket()
|
|
677
|
+
},
|
|
678
|
+
})
|
|
679
|
+
document.body.append(chat)
|
|
680
|
+
chat.open()
|
|
681
|
+
await vi.waitFor(() => expect(sockets).toHaveLength(1))
|
|
682
|
+
await authenticateAndReadyComponent(sockets[0]!)
|
|
683
|
+
|
|
684
|
+
sockets[0]?.receive({
|
|
685
|
+
type: 'realtime_event',
|
|
686
|
+
channel: 'ai_employee',
|
|
687
|
+
event: {
|
|
688
|
+
type: 'session_message',
|
|
689
|
+
session_id: 'session-1',
|
|
690
|
+
payload: {
|
|
691
|
+
JsonPatch: [
|
|
692
|
+
{
|
|
693
|
+
path: '/0',
|
|
694
|
+
value: {
|
|
695
|
+
ws_message_id: 'realtime-action-message',
|
|
696
|
+
entry_type: { type: 'assistant_message' },
|
|
697
|
+
content: JSON.stringify({ text: customerSupportActionContent() }),
|
|
698
|
+
},
|
|
699
|
+
},
|
|
700
|
+
],
|
|
701
|
+
},
|
|
702
|
+
},
|
|
703
|
+
})
|
|
704
|
+
await vi.waitFor(() =>
|
|
705
|
+
expect(chat.shadowRoot?.querySelectorAll('.assistant-action-button')).toHaveLength(1),
|
|
706
|
+
)
|
|
707
|
+
|
|
708
|
+
const malformed = customerSupportActionContent().replace(
|
|
709
|
+
customerSupportActionTransactionId,
|
|
710
|
+
'not-a-uuid',
|
|
711
|
+
)
|
|
712
|
+
sockets[0]?.receive({
|
|
713
|
+
type: 'realtime_event',
|
|
714
|
+
channel: 'ai_employee',
|
|
715
|
+
event: {
|
|
716
|
+
type: 'session_message',
|
|
717
|
+
session_id: 'session-1',
|
|
718
|
+
payload: {
|
|
719
|
+
JsonPatch: [
|
|
720
|
+
{
|
|
721
|
+
path: '/0',
|
|
722
|
+
value: {
|
|
723
|
+
ws_message_id: 'malformed-action-message',
|
|
724
|
+
entry_type: { type: 'assistant_message' },
|
|
725
|
+
content: JSON.stringify({ text: malformed }),
|
|
726
|
+
},
|
|
727
|
+
},
|
|
728
|
+
],
|
|
729
|
+
},
|
|
730
|
+
},
|
|
731
|
+
})
|
|
732
|
+
await settle(chat)
|
|
733
|
+
|
|
734
|
+
expect(chat.shadowRoot?.querySelectorAll('.assistant-action-button')).toHaveLength(1)
|
|
735
|
+
})
|
|
736
|
+
|
|
416
737
|
it('owns the drawer lifecycle and switches to the customer support component', async () => {
|
|
417
738
|
const sockets: ComponentFakeWebSocket[] = []
|
|
418
739
|
const transport = createTransport()
|
|
@@ -903,7 +1224,7 @@ describe('<uniplat-ai-employee-chat>', () => {
|
|
|
903
1224
|
{
|
|
904
1225
|
path: '/0',
|
|
905
1226
|
value: {
|
|
906
|
-
ws_message_id: '204',
|
|
1227
|
+
ws_message_id: 'ws-204',
|
|
907
1228
|
entry_type: { type: 'assistant_message' },
|
|
908
1229
|
content: JSON.stringify({ text: '后来到达的未读回复' }),
|
|
909
1230
|
create_time: '2026-08-15T01:01:00.000Z',
|
|
@@ -921,10 +1242,12 @@ describe('<uniplat-ai-employee-chat>', () => {
|
|
|
921
1242
|
expect(chat.shadowRoot?.querySelector('.session-item-unread')).toBeNull()
|
|
922
1243
|
|
|
923
1244
|
chat.shadowRoot?.querySelector<HTMLButtonElement>('.session-item')?.click()
|
|
924
|
-
await vi.waitFor(() =>
|
|
925
|
-
expect(transport.markSessionRead).toHaveBeenCalledWith('session-1', '204'),
|
|
926
|
-
)
|
|
927
1245
|
await settle(chat)
|
|
1246
|
+
// 实时事件只带 ws_message_id,已读同步不得使用它。
|
|
1247
|
+
expect(transport.markSessionRead).not.toHaveBeenCalledWith('session-1', 'ws-204')
|
|
1248
|
+
expect(
|
|
1249
|
+
transport.markSessionRead.mock.calls.map((call) => (call as unknown as string[])[1]),
|
|
1250
|
+
).toEqual(['203'])
|
|
928
1251
|
expect(chat.shadowRoot?.querySelector('.conversation-unread')).toBeNull()
|
|
929
1252
|
})
|
|
930
1253
|
|
|
@@ -1047,7 +1370,7 @@ describe('<uniplat-ai-employee-chat>', () => {
|
|
|
1047
1370
|
{
|
|
1048
1371
|
path: '/0',
|
|
1049
1372
|
value: {
|
|
1050
|
-
ws_message_id: '402',
|
|
1373
|
+
ws_message_id: 'ws-402',
|
|
1051
1374
|
entry_type: { type: 'assistant_message' },
|
|
1052
1375
|
content: JSON.stringify({ text: '当前实时回复' }),
|
|
1053
1376
|
create_time: '2026-08-15T01:02:00.000Z',
|
|
@@ -1057,12 +1380,16 @@ describe('<uniplat-ai-employee-chat>', () => {
|
|
|
1057
1380
|
},
|
|
1058
1381
|
},
|
|
1059
1382
|
})
|
|
1060
|
-
await
|
|
1061
|
-
|
|
1062
|
-
)
|
|
1383
|
+
await settle(chat)
|
|
1384
|
+
// 实时事件的 ws_message_id 不是持久化 ID,服务端会拒绝,不能拿它同步已读。
|
|
1385
|
+
expect(transport.markSessionRead).not.toHaveBeenCalledWith('session-1', 'ws-402')
|
|
1063
1386
|
|
|
1064
1387
|
chat.shadowRoot?.querySelector<HTMLButtonElement>('.conversation-mark')?.click()
|
|
1065
1388
|
await vi.waitFor(() => expect(transport.listSessions).toHaveBeenCalledTimes(1))
|
|
1389
|
+
// 列表返回的 latestMessageId 才是持久化 ID,这时才同步。
|
|
1390
|
+
await vi.waitFor(() =>
|
|
1391
|
+
expect(transport.markSessionRead).toHaveBeenCalledWith('session-1', '402'),
|
|
1392
|
+
)
|
|
1066
1393
|
expect(chat.shadowRoot?.querySelector('.session-item.current .session-item-unread')).toBeNull()
|
|
1067
1394
|
|
|
1068
1395
|
chat.shadowRoot?.querySelector<HTMLButtonElement>('.session-back-button')?.click()
|
|
@@ -1070,6 +1397,87 @@ describe('<uniplat-ai-employee-chat>', () => {
|
|
|
1070
1397
|
expect(chat.shadowRoot?.querySelector('.conversation-unread')?.textContent).toBe('2')
|
|
1071
1398
|
})
|
|
1072
1399
|
|
|
1400
|
+
it('refetches history after the turn ends and marks read with the persisted id', async () => {
|
|
1401
|
+
const sockets: ComponentFakeWebSocket[] = []
|
|
1402
|
+
let history: AiEmployeeMessage[] = [
|
|
1403
|
+
{
|
|
1404
|
+
id: '501',
|
|
1405
|
+
sessionId: 'session-1',
|
|
1406
|
+
role: 'assistant',
|
|
1407
|
+
content: '旧回复',
|
|
1408
|
+
createdAt: '2026-08-15T01:00:00.000Z',
|
|
1409
|
+
},
|
|
1410
|
+
]
|
|
1411
|
+
const transport = {
|
|
1412
|
+
...createTransport(history),
|
|
1413
|
+
listMessages: vi.fn(async () => history),
|
|
1414
|
+
markSessionRead: vi.fn(async () => undefined),
|
|
1415
|
+
} satisfies AiEmployeeTransport
|
|
1416
|
+
const chat = document.createElement('uniplat-ai-employee-chat')
|
|
1417
|
+
chat.configure({
|
|
1418
|
+
realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
|
|
1419
|
+
aeCode: 'recruit',
|
|
1420
|
+
agentCode: 'default',
|
|
1421
|
+
sessionId: 'session-1',
|
|
1422
|
+
transport,
|
|
1423
|
+
webSocketFactory: (url) => {
|
|
1424
|
+
const socket = new ComponentFakeWebSocket(url)
|
|
1425
|
+
sockets.push(socket)
|
|
1426
|
+
return socket.asWebSocket()
|
|
1427
|
+
},
|
|
1428
|
+
})
|
|
1429
|
+
document.body.append(chat)
|
|
1430
|
+
chat.open()
|
|
1431
|
+
await vi.waitFor(() =>
|
|
1432
|
+
expect(transport.markSessionRead).toHaveBeenCalledWith('session-1', '501'),
|
|
1433
|
+
)
|
|
1434
|
+
await authenticateAndReadyComponent(sockets[0]!)
|
|
1435
|
+
|
|
1436
|
+
// 服务端持久化后历史里才有数字 ID。
|
|
1437
|
+
history = [
|
|
1438
|
+
...history,
|
|
1439
|
+
{
|
|
1440
|
+
id: '502',
|
|
1441
|
+
sessionId: 'session-1',
|
|
1442
|
+
role: 'assistant',
|
|
1443
|
+
content: '新回复',
|
|
1444
|
+
createdAt: '2026-08-15T01:02:00.000Z',
|
|
1445
|
+
},
|
|
1446
|
+
]
|
|
1447
|
+
|
|
1448
|
+
const turnFrame = (processing: boolean) => ({
|
|
1449
|
+
type: 'realtime_event',
|
|
1450
|
+
channel: 'ai_employee',
|
|
1451
|
+
event: {
|
|
1452
|
+
type: 'session_message',
|
|
1453
|
+
session_id: 'session-1',
|
|
1454
|
+
payload: {
|
|
1455
|
+
Ready: processing ? true : undefined,
|
|
1456
|
+
finished: processing ? undefined : true,
|
|
1457
|
+
JsonPatch: [
|
|
1458
|
+
{
|
|
1459
|
+
path: '/0',
|
|
1460
|
+
value: {
|
|
1461
|
+
ws_message_id: 'ws-502',
|
|
1462
|
+
entry_type: { type: 'assistant_message' },
|
|
1463
|
+
content: JSON.stringify({ text: '新回复' }),
|
|
1464
|
+
create_time: '2026-08-15T01:02:00.000Z',
|
|
1465
|
+
},
|
|
1466
|
+
},
|
|
1467
|
+
],
|
|
1468
|
+
},
|
|
1469
|
+
},
|
|
1470
|
+
})
|
|
1471
|
+
sockets[0]?.receive(turnFrame(true))
|
|
1472
|
+
await settle(chat)
|
|
1473
|
+
sockets[0]?.receive(turnFrame(false))
|
|
1474
|
+
|
|
1475
|
+
await vi.waitFor(() =>
|
|
1476
|
+
expect(transport.markSessionRead).toHaveBeenCalledWith('session-1', '502'),
|
|
1477
|
+
)
|
|
1478
|
+
expect(transport.markSessionRead).not.toHaveBeenCalledWith('session-1', 'ws-502')
|
|
1479
|
+
})
|
|
1480
|
+
|
|
1073
1481
|
it('shows the current conversation when the Host has not added session listing yet', async () => {
|
|
1074
1482
|
const chat = document.createElement('uniplat-ai-employee-chat')
|
|
1075
1483
|
chat.configure({
|