@csntgao/uni-base 0.6.3 → 0.6.6
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 +10 -8
- package/CLAUDE.md +10 -8
- package/README.md +27 -21
- 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/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 +1 -1
- 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 +1 -1
- package/examples/vanilla/main.js +1 -1
- package/examples/vanilla/package.json +3 -3
- package/package.json +12 -13
- package/packages/core/package.json +2 -2
- package/packages/core/src/assistant-actions.ts +120 -0
- package/packages/core/src/customer-support-types.ts +15 -0
- package/packages/core/src/index.ts +8 -0
- package/packages/core/src/types.ts +6 -0
- package/packages/core/tests/assistant-actions.test.ts +76 -0
- package/packages/web-components/package.json +3 -3
- package/packages/web-components/scripts/verify-runtime-build.mjs +1 -0
- package/packages/web-components/src/chat.ts +267 -15
- package/packages/web-components/src/customer-support-chat.ts +1 -1
- package/packages/web-components/src/customer-support-transport.ts +118 -16
- package/packages/web-components/src/index.ts +14 -2
- package/packages/web-components/src/notification-center.ts +1 -1
- package/packages/web-components/src/notification-popover.ts +1 -1
- package/packages/web-components/src/uniplat-base-notification-center-transport.ts +1 -1
- package/packages/web-components/tests/chat.test.ts +418 -10
- package/packages/web-components/tests/customer-support-chat.test.ts +1 -1
- package/packages/web-components/tests/customer-support-transport.test.ts +89 -1
- package/packages/web-components/tests/notification-center.test.ts +1 -1
- package/packages/web-components/tests/notification-popover.test.ts +1 -1
- package/packages/web-components/tsup.config.ts +1 -1
- package/packages/web-components/tsup.runtime.config.ts +1 -1
- package/restart +0 -0
- package/tsconfig.base.json +2 -2
- package/vitest.config.ts +2 -2
- package//344/272/244/346/216/245/346/226/207/346/241/243.md +21 -26
|
@@ -52,6 +52,18 @@ export interface CustomerSupportCreateSessionResult {
|
|
|
52
52
|
initialMessage?: CustomerSupportMessage
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
export interface CustomerSupportHandoffRequest {
|
|
56
|
+
sourceSessionId: string
|
|
57
|
+
actionTransactionId: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface CustomerSupportHandoffResult {
|
|
61
|
+
session: CustomerSupportSession
|
|
62
|
+
initialMessage: CustomerSupportMessage
|
|
63
|
+
created: boolean
|
|
64
|
+
repaired: boolean
|
|
65
|
+
}
|
|
66
|
+
|
|
55
67
|
export interface CustomerSupportTransport {
|
|
56
68
|
getAccessToken(): Promise<string>
|
|
57
69
|
listSessions(): Promise<readonly CustomerSupportSession[]>
|
|
@@ -59,6 +71,9 @@ export interface CustomerSupportTransport {
|
|
|
59
71
|
createSession(
|
|
60
72
|
request: CustomerSupportCreateSessionRequest,
|
|
61
73
|
): Promise<CustomerSupportCreateSessionResult>
|
|
74
|
+
createHandoffSession?(
|
|
75
|
+
request: CustomerSupportHandoffRequest,
|
|
76
|
+
): Promise<CustomerSupportHandoffResult>
|
|
62
77
|
listMessages(sessionId: string): Promise<CustomerSupportMessagePage>
|
|
63
78
|
sendMessage(
|
|
64
79
|
sessionId: string,
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
export { AiEmployeeClient, AiEmployeeClientError, AiEmployeeTransportError } from './client'
|
|
2
|
+
export { isUuidV4, parseAiEmployeeAssistantContent } from './assistant-actions'
|
|
3
|
+
export type {
|
|
4
|
+
AiEmployeeAssistantAction,
|
|
5
|
+
OpenCustomerSupportAssistantAction,
|
|
6
|
+
ParsedAiEmployeeAssistantContent,
|
|
7
|
+
} from './assistant-actions'
|
|
2
8
|
export { CustomerSupportClient, CustomerSupportClientError } from './customer-support-client'
|
|
3
9
|
export {
|
|
4
10
|
NotificationCenterClient,
|
|
@@ -50,6 +56,8 @@ export type {
|
|
|
50
56
|
CustomerSupportCreateSessionResult,
|
|
51
57
|
CustomerSupportErrorCode,
|
|
52
58
|
CustomerSupportErrorDetail,
|
|
59
|
+
CustomerSupportHandoffRequest,
|
|
60
|
+
CustomerSupportHandoffResult,
|
|
53
61
|
CustomerSupportMessage,
|
|
54
62
|
CustomerSupportMessagePage,
|
|
55
63
|
CustomerSupportSenderType,
|
|
@@ -112,6 +112,12 @@ export type AiEmployeeErrorCode =
|
|
|
112
112
|
| 'RATE_LIMITED'
|
|
113
113
|
| 'SEND_IN_PROGRESS'
|
|
114
114
|
| 'EMPTY_MESSAGE'
|
|
115
|
+
| 'SOURCE_SESSION_NOT_FOUND'
|
|
116
|
+
| 'SOURCE_SESSION_ACCESS_DENIED'
|
|
117
|
+
| 'HANDOFF_ACTION_NOT_FOUND'
|
|
118
|
+
| 'HANDOFF_ACTION_MISMATCH'
|
|
119
|
+
| 'CUSTOMER_SUPPORT_UNAVAILABLE'
|
|
120
|
+
| 'CUSTOMER_SUPPORT_HANDOFF_FAILED'
|
|
115
121
|
| 'CLIENT_DESTROYED'
|
|
116
122
|
|
|
117
123
|
export interface AiEmployeeClientErrorDetail {
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { parseAiEmployeeAssistantContent } from '../src'
|
|
3
|
+
|
|
4
|
+
const transactionId = '8f0b790c-1c5f-4f8c-95cc-4ead9d9cfb1a'
|
|
5
|
+
|
|
6
|
+
function actionJson(overrides: Record<string, unknown> = {}): string {
|
|
7
|
+
return JSON.stringify(
|
|
8
|
+
{
|
|
9
|
+
type: 'assistant_action',
|
|
10
|
+
action: 'open_customer_support',
|
|
11
|
+
'transaction-id': transactionId,
|
|
12
|
+
trigger: 'out_of_scope',
|
|
13
|
+
entry: 'help_and_support',
|
|
14
|
+
...overrides,
|
|
15
|
+
},
|
|
16
|
+
null,
|
|
17
|
+
2,
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('parseAiEmployeeAssistantContent', () => {
|
|
22
|
+
it('extracts one valid fenced action and removes the raw JSON from display content', () => {
|
|
23
|
+
const parsed = parseAiEmployeeAssistantContent(
|
|
24
|
+
`该问题超出我的职责范围。\n\n您可以从‘帮助与支持’菜单项入口发起人工客服。\n\n\`\`\`json\n${actionJson()}\n\`\`\``,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
expect(parsed.content).toBe(
|
|
28
|
+
'该问题超出我的职责范围。\n\n您可以从‘帮助与支持’菜单项入口发起人工客服。',
|
|
29
|
+
)
|
|
30
|
+
expect(parsed.actions).toEqual([
|
|
31
|
+
{
|
|
32
|
+
type: 'assistant_action',
|
|
33
|
+
action: 'open_customer_support',
|
|
34
|
+
transactionId,
|
|
35
|
+
trigger: 'out_of_scope',
|
|
36
|
+
entry: 'help_and_support',
|
|
37
|
+
},
|
|
38
|
+
])
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('accepts a trailing standalone raw JSON action', () => {
|
|
42
|
+
const parsed = parseAiEmployeeAssistantContent(`暂时无法处理。\n${actionJson()}`)
|
|
43
|
+
|
|
44
|
+
expect(parsed.content).toBe('暂时无法处理。')
|
|
45
|
+
expect(parsed.actions[0]?.transactionId).toBe(transactionId)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it.each([
|
|
49
|
+
actionJson({ 'transaction-id': 'not-a-uuid' }),
|
|
50
|
+
actionJson({ trigger: 'technical_failure' }),
|
|
51
|
+
actionJson({ entry: 'other' }),
|
|
52
|
+
actionJson({ extra: true }),
|
|
53
|
+
'{"type":"assistant_action"}',
|
|
54
|
+
])('does not execute malformed or incomplete actions', (json) => {
|
|
55
|
+
const source = `无法处理。\n\n\`\`\`json\n${json}\n\`\`\``
|
|
56
|
+
const parsed = parseAiEmployeeAssistantContent(source)
|
|
57
|
+
|
|
58
|
+
expect(parsed).toEqual({ content: source, actions: [] })
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('rejects an ambiguous response containing the action more than once', () => {
|
|
62
|
+
const block = `\`\`\`json\n${actionJson()}\n\`\`\``
|
|
63
|
+
const source = `无法处理。\n\n${block}\n\n${block}`
|
|
64
|
+
|
|
65
|
+
expect(parseAiEmployeeAssistantContent(source)).toEqual({ content: source, actions: [] })
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('leaves ordinary JSON and inline action-looking prose unchanged', () => {
|
|
69
|
+
const ordinary = '示例:{"action":"open_customer_support"},这不是平台动作。'
|
|
70
|
+
|
|
71
|
+
expect(parseAiEmployeeAssistantContent(ordinary)).toEqual({
|
|
72
|
+
content: ordinary,
|
|
73
|
+
actions: [],
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
})
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"name": "@
|
|
3
|
-
"version": "0.29.
|
|
2
|
+
"name": "@csntgao/ai-employee-web-components",
|
|
3
|
+
"version": "0.29.6",
|
|
4
4
|
"description": "Framework-independent Web Components for Uniplat account, login, application switching and AI Employee chat",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": true,
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"test": "vitest run"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@
|
|
26
|
+
"@csntgao/ai-employee-client": "workspace:*",
|
|
27
27
|
"lit": "^3.3.1"
|
|
28
28
|
},
|
|
29
29
|
"engines": {
|
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
import {
|
|
2
2
|
AiEmployeeClient,
|
|
3
3
|
AiEmployeeClientError,
|
|
4
|
+
parseAiEmployeeAssistantContent,
|
|
4
5
|
type AiEmployeeClientErrorDetail,
|
|
5
6
|
type AiEmployeeClientOptions,
|
|
6
7
|
type AiEmployeeConnectionState,
|
|
7
8
|
type AiEmployeeMessage,
|
|
8
9
|
type AiEmployeeSessionSummary,
|
|
9
|
-
|
|
10
|
+
type OpenCustomerSupportAssistantAction,
|
|
11
|
+
} from '@csntgao/ai-employee-client'
|
|
10
12
|
import { LitElement, css, html, nothing, type PropertyValues } from 'lit'
|
|
11
13
|
import type { UniplatCustomerSupportChat, CustomerSupportChatConfig } from './customer-support-chat'
|
|
12
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
createHrSaasCustomerSupportTransport,
|
|
16
|
+
HrSaasCustomerSupportTransportError,
|
|
17
|
+
} from './customer-support-transport'
|
|
13
18
|
import { prototypeIcon } from './prototype-icons'
|
|
14
19
|
import { renderSafeMarkdown } from './safe-markdown'
|
|
15
20
|
|
|
@@ -22,6 +27,11 @@ interface LocalReadBoundary {
|
|
|
22
27
|
updatedAtMs: number
|
|
23
28
|
}
|
|
24
29
|
|
|
30
|
+
interface CustomerSupportHandoffUiState {
|
|
31
|
+
status: 'loading' | 'failed' | 'succeeded'
|
|
32
|
+
message: string
|
|
33
|
+
}
|
|
34
|
+
|
|
25
35
|
const AI_ADVISOR_TITLE = '企业服务AI顾问'
|
|
26
36
|
|
|
27
37
|
export interface AiEmployeeChatEventMap {
|
|
@@ -50,6 +60,7 @@ export class UniplatAiEmployeeChat extends LitElement {
|
|
|
50
60
|
switchingSessionId: { state: true },
|
|
51
61
|
creatingSession: { state: true },
|
|
52
62
|
customerSupportOpen: { state: true },
|
|
63
|
+
customerSupportHandoffStates: { state: true },
|
|
53
64
|
}
|
|
54
65
|
|
|
55
66
|
static override styles = css`
|
|
@@ -635,6 +646,55 @@ export class UniplatAiEmployeeChat extends LitElement {
|
|
|
635
646
|
text-underline-offset: 2px;
|
|
636
647
|
}
|
|
637
648
|
|
|
649
|
+
.assistant-action {
|
|
650
|
+
display: flex;
|
|
651
|
+
align-items: flex-start;
|
|
652
|
+
flex-direction: column;
|
|
653
|
+
gap: 6px;
|
|
654
|
+
margin-top: 8px;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
.assistant-action-button {
|
|
658
|
+
display: inline-flex;
|
|
659
|
+
min-height: 36px;
|
|
660
|
+
align-items: center;
|
|
661
|
+
justify-content: center;
|
|
662
|
+
gap: 7px;
|
|
663
|
+
padding: 7px 13px;
|
|
664
|
+
border: 1px solid color-mix(in srgb, var(--uniplat-chat-primary), transparent 58%);
|
|
665
|
+
border-radius: 9px;
|
|
666
|
+
outline: none;
|
|
667
|
+
background: #f0f5ff;
|
|
668
|
+
color: var(--uniplat-chat-primary);
|
|
669
|
+
cursor: pointer;
|
|
670
|
+
font: inherit;
|
|
671
|
+
font-size: 13px;
|
|
672
|
+
font-weight: 700;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
.assistant-action-button:hover,
|
|
676
|
+
.assistant-action-button:focus-visible {
|
|
677
|
+
border-color: var(--uniplat-chat-primary);
|
|
678
|
+
background: #e6efff;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
.assistant-action-button:disabled {
|
|
682
|
+
cursor: wait;
|
|
683
|
+
opacity: 0.65;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
.assistant-action-button svg {
|
|
687
|
+
width: 16px;
|
|
688
|
+
height: 16px;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
.assistant-action-error {
|
|
692
|
+
max-width: 280px;
|
|
693
|
+
color: var(--uniplat-chat-error);
|
|
694
|
+
font-size: 12px;
|
|
695
|
+
line-height: 1.5;
|
|
696
|
+
}
|
|
697
|
+
|
|
638
698
|
.empty,
|
|
639
699
|
.loading {
|
|
640
700
|
display: grid;
|
|
@@ -778,6 +838,7 @@ export class UniplatAiEmployeeChat extends LitElement {
|
|
|
778
838
|
declare private switchingSessionId: string
|
|
779
839
|
declare private creatingSession: boolean
|
|
780
840
|
declare private customerSupportOpen: boolean
|
|
841
|
+
declare private customerSupportHandoffStates: ReadonlyMap<string, CustomerSupportHandoffUiState>
|
|
781
842
|
private config: AiEmployeeChatConfig | null = null
|
|
782
843
|
private client: AiEmployeeClient | null = null
|
|
783
844
|
private clientUnsubscribers: Array<() => void> = []
|
|
@@ -785,7 +846,13 @@ export class UniplatAiEmployeeChat extends LitElement {
|
|
|
785
846
|
private sessionListGeneration = 0
|
|
786
847
|
private readyDispatched = false
|
|
787
848
|
private readSyncGeneration = 0
|
|
849
|
+
private customerSupportHandoffGeneration = 0
|
|
788
850
|
private readonly localMessageIds = new Set<string>()
|
|
851
|
+
/**
|
|
852
|
+
* 只有来自 HTTP 历史与会话列表的消息 ID 是服务端持久化 ID,实时事件里的
|
|
853
|
+
* ws_message_id / 合成 ID 不能用于已读同步,服务端会以 INVALID_ARGUMENT 拒绝。
|
|
854
|
+
*/
|
|
855
|
+
private readonly persistedMessageIds = new Set<string>()
|
|
789
856
|
private readonly locallyReadBoundaries = new Map<string, LocalReadBoundary>()
|
|
790
857
|
private readonly confirmedReadMessageIds = new Map<string, string>()
|
|
791
858
|
private readonly queuedReadMessageIds = new Map<string, string>()
|
|
@@ -813,9 +880,11 @@ export class UniplatAiEmployeeChat extends LitElement {
|
|
|
813
880
|
this.switchingSessionId = ''
|
|
814
881
|
this.creatingSession = false
|
|
815
882
|
this.customerSupportOpen = false
|
|
883
|
+
this.customerSupportHandoffStates = new Map()
|
|
816
884
|
}
|
|
817
885
|
|
|
818
886
|
configure(config: AiEmployeeChatConfig): void {
|
|
887
|
+
this.customerSupportHandoffGeneration += 1
|
|
819
888
|
this.config = config
|
|
820
889
|
this.releaseClient()
|
|
821
890
|
this.resetView()
|
|
@@ -838,6 +907,7 @@ export class UniplatAiEmployeeChat extends LitElement {
|
|
|
838
907
|
if (!this.drawerOpen) return
|
|
839
908
|
this.drawerOpen = false
|
|
840
909
|
this.pendingInitialMessage = ''
|
|
910
|
+
this.customerSupportHandoffGeneration += 1
|
|
841
911
|
window.removeEventListener('keydown', this.handleWindowKeyDown)
|
|
842
912
|
this.releaseClient()
|
|
843
913
|
}
|
|
@@ -848,6 +918,7 @@ export class UniplatAiEmployeeChat extends LitElement {
|
|
|
848
918
|
}
|
|
849
919
|
|
|
850
920
|
override disconnectedCallback(): void {
|
|
921
|
+
this.customerSupportHandoffGeneration += 1
|
|
851
922
|
window.removeEventListener('keydown', this.handleWindowKeyDown)
|
|
852
923
|
this.releaseClient()
|
|
853
924
|
super.disconnectedCallback()
|
|
@@ -1039,7 +1110,9 @@ export class UniplatAiEmployeeChat extends LitElement {
|
|
|
1039
1110
|
>
|
|
1040
1111
|
<span class="session-item-icon" aria-hidden="true">${prototypeIcon('robot')}</span>
|
|
1041
1112
|
<span class="session-item-title">${session.title}</span>
|
|
1042
|
-
<span class="session-item-preview"
|
|
1113
|
+
<span class="session-item-preview"
|
|
1114
|
+
>${sessionPreview(session.latestMessage) || '暂无消息'}</span
|
|
1115
|
+
>
|
|
1043
1116
|
<span class="session-item-side">
|
|
1044
1117
|
<span>${formatRelativeTime(session.updatedAt)}</span>
|
|
1045
1118
|
${
|
|
@@ -1089,6 +1162,10 @@ export class UniplatAiEmployeeChat extends LitElement {
|
|
|
1089
1162
|
</div>
|
|
1090
1163
|
`
|
|
1091
1164
|
}
|
|
1165
|
+
const parsed =
|
|
1166
|
+
message.role === 'assistant'
|
|
1167
|
+
? parseAiEmployeeAssistantContent(message.content)
|
|
1168
|
+
: { content: message.content, actions: [] }
|
|
1092
1169
|
return html`
|
|
1093
1170
|
<div class="message-row ${message.role}">
|
|
1094
1171
|
<div class="assistant-message">
|
|
@@ -1097,19 +1174,60 @@ export class UniplatAiEmployeeChat extends LitElement {
|
|
|
1097
1174
|
<span class="assistant-label"
|
|
1098
1175
|
>${message.role === 'error' ? '系统提示' : '在线顾问'}</span
|
|
1099
1176
|
>
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1177
|
+
${
|
|
1178
|
+
parsed.content
|
|
1179
|
+
? html`<div
|
|
1180
|
+
class="message ${message.role} ${
|
|
1181
|
+
message.role === 'assistant' ? 'markdown' : ''
|
|
1182
|
+
}"
|
|
1183
|
+
part="message message-${message.role}"
|
|
1184
|
+
data-message-id=${message.id}
|
|
1185
|
+
>
|
|
1186
|
+
${
|
|
1187
|
+
message.role === 'assistant'
|
|
1188
|
+
? renderSafeMarkdown(parsed.content)
|
|
1189
|
+
: parsed.content
|
|
1190
|
+
}
|
|
1191
|
+
</div>`
|
|
1192
|
+
: nothing
|
|
1193
|
+
}
|
|
1194
|
+
${
|
|
1195
|
+
message.role === 'assistant'
|
|
1196
|
+
? parsed.actions.map((action) => this.renderAssistantAction(message, action))
|
|
1197
|
+
: nothing
|
|
1198
|
+
}
|
|
1107
1199
|
</div>
|
|
1108
1200
|
</div>
|
|
1109
1201
|
</div>
|
|
1110
1202
|
`
|
|
1111
1203
|
}
|
|
1112
1204
|
|
|
1205
|
+
private renderAssistantAction(
|
|
1206
|
+
message: AiEmployeeMessage,
|
|
1207
|
+
action: OpenCustomerSupportAssistantAction,
|
|
1208
|
+
) {
|
|
1209
|
+
const state = this.customerSupportHandoffStates.get(action.transactionId)
|
|
1210
|
+
const loading = state?.status === 'loading'
|
|
1211
|
+
const succeeded = state?.status === 'succeeded'
|
|
1212
|
+
return html`<div class="assistant-action" part="assistant-action">
|
|
1213
|
+
<button
|
|
1214
|
+
class="assistant-action-button"
|
|
1215
|
+
part="assistant-action-button"
|
|
1216
|
+
type="button"
|
|
1217
|
+
?disabled=${loading || succeeded}
|
|
1218
|
+
@click=${() => void this.handleCustomerSupportHandoff(message, action)}
|
|
1219
|
+
>
|
|
1220
|
+
${prototypeIcon(succeeded ? 'circle-check-filled' : 'headset')}
|
|
1221
|
+
${loading ? '正在发起人工客服…' : succeeded ? '已发起人工客服' : '发起人工客服'}
|
|
1222
|
+
</button>
|
|
1223
|
+
${
|
|
1224
|
+
state?.status === 'failed'
|
|
1225
|
+
? html`<span class="assistant-action-error" role="status">${state.message}</span>`
|
|
1226
|
+
: nothing
|
|
1227
|
+
}
|
|
1228
|
+
</div>`
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1113
1231
|
private async startClient(): Promise<void> {
|
|
1114
1232
|
const config = this.config
|
|
1115
1233
|
if (!config || this.client || !this.isConnected) return
|
|
@@ -1159,6 +1277,7 @@ export class UniplatAiEmployeeChat extends LitElement {
|
|
|
1159
1277
|
client.on('history', ({ sessionId, messages }) => {
|
|
1160
1278
|
if (this.client !== client) return
|
|
1161
1279
|
this.messages = messages
|
|
1280
|
+
for (const message of messages) this.persistedMessageIds.add(message.id)
|
|
1162
1281
|
if (this.isCurrentConversationVisible(sessionId)) void this.markCurrentSessionRead()
|
|
1163
1282
|
}),
|
|
1164
1283
|
client.on('message', ({ sessionId, message, source }) => {
|
|
@@ -1180,7 +1299,14 @@ export class UniplatAiEmployeeChat extends LitElement {
|
|
|
1180
1299
|
this.dispatchPublicEvent('connection-state-change', { state: current })
|
|
1181
1300
|
}),
|
|
1182
1301
|
client.on('processing-state-change', ({ processing }) => {
|
|
1183
|
-
if (this.client
|
|
1302
|
+
if (this.client !== client) return
|
|
1303
|
+
const finished = this.processing && !processing
|
|
1304
|
+
this.processing = processing
|
|
1305
|
+
// 实时事件只带 ws_message_id;本轮结束后重新取一次历史,
|
|
1306
|
+
// 才能用持久化 ID 同步已读。
|
|
1307
|
+
if (finished && this.isCurrentConversationVisible(client.sessionId ?? '')) {
|
|
1308
|
+
void client.refreshMessages().catch(() => undefined)
|
|
1309
|
+
}
|
|
1184
1310
|
}),
|
|
1185
1311
|
client.on('title', ({ sessionId, title }) => {
|
|
1186
1312
|
if (this.client !== client) return
|
|
@@ -1252,6 +1378,9 @@ export class UniplatAiEmployeeChat extends LitElement {
|
|
|
1252
1378
|
try {
|
|
1253
1379
|
const sessions = normalizeSessionSummaries(await listSessions.call(transport))
|
|
1254
1380
|
if (!this.isCurrentSessionList(generation)) return
|
|
1381
|
+
for (const session of sessions) {
|
|
1382
|
+
if (session.latestMessageId) this.persistedMessageIds.add(session.latestMessageId)
|
|
1383
|
+
}
|
|
1255
1384
|
const mergedSessions = mergeCurrentSession(sessions, this.currentSessionSummary())
|
|
1256
1385
|
this.sessions = this.applyLocallyReadState(mergedSessions)
|
|
1257
1386
|
const currentSession = mergedSessions.find(
|
|
@@ -1390,11 +1519,14 @@ export class UniplatAiEmployeeChat extends LitElement {
|
|
|
1390
1519
|
const summary: AiEmployeeSessionSummary = {
|
|
1391
1520
|
sessionId: message.sessionId,
|
|
1392
1521
|
title: existing?.title || this.chatTitle || '新对话',
|
|
1393
|
-
latestMessage: message
|
|
1522
|
+
latestMessage: messagePreview(message),
|
|
1394
1523
|
updatedAt: message.createdAt,
|
|
1395
1524
|
unreadCount: visible ? 0 : Math.max(0, (existing?.unreadCount ?? 0) + unreadIncrement),
|
|
1396
1525
|
}
|
|
1397
|
-
|
|
1526
|
+
// 实时消息 ID 不是持久化 ID,不能当作已读游标。
|
|
1527
|
+
const latestMessageId = this.persistedMessageIds.has(message.id)
|
|
1528
|
+
? message.id
|
|
1529
|
+
: existing?.latestMessageId
|
|
1398
1530
|
if (latestMessageId) summary.latestMessageId = latestMessageId
|
|
1399
1531
|
this.sessions = [
|
|
1400
1532
|
summary,
|
|
@@ -1410,7 +1542,7 @@ export class UniplatAiEmployeeChat extends LitElement {
|
|
|
1410
1542
|
let latestServerMessage: AiEmployeeMessage | undefined
|
|
1411
1543
|
for (let index = this.messages.length - 1; index >= 0; index -= 1) {
|
|
1412
1544
|
const candidate = this.messages[index]
|
|
1413
|
-
if (candidate &&
|
|
1545
|
+
if (candidate && this.persistedMessageIds.has(candidate.id)) {
|
|
1414
1546
|
latestServerMessage = candidate
|
|
1415
1547
|
break
|
|
1416
1548
|
}
|
|
@@ -1418,7 +1550,7 @@ export class UniplatAiEmployeeChat extends LitElement {
|
|
|
1418
1550
|
const summary: AiEmployeeSessionSummary = {
|
|
1419
1551
|
sessionId,
|
|
1420
1552
|
title: this.chatTitle || '新对话',
|
|
1421
|
-
latestMessage: latestMessage
|
|
1553
|
+
latestMessage: latestMessage ? messagePreview(latestMessage) : '',
|
|
1422
1554
|
updatedAt: latestMessage?.createdAt ?? new Date().toISOString(),
|
|
1423
1555
|
unreadCount: 0,
|
|
1424
1556
|
}
|
|
@@ -1495,6 +1627,48 @@ export class UniplatAiEmployeeChat extends LitElement {
|
|
|
1495
1627
|
})
|
|
1496
1628
|
return
|
|
1497
1629
|
}
|
|
1630
|
+
this.openCustomerSupport(customerSupport)
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
private async handleCustomerSupportHandoff(
|
|
1634
|
+
message: AiEmployeeMessage,
|
|
1635
|
+
action: OpenCustomerSupportAssistantAction,
|
|
1636
|
+
): Promise<void> {
|
|
1637
|
+
const key = action.transactionId
|
|
1638
|
+
const currentState = this.customerSupportHandoffStates.get(key)
|
|
1639
|
+
if (currentState?.status === 'loading' || currentState?.status === 'succeeded') return
|
|
1640
|
+
const customerSupport = this.resolveCustomerSupportConfig()
|
|
1641
|
+
const createHandoffSession = customerSupport?.transport.createHandoffSession
|
|
1642
|
+
if (!customerSupport || !createHandoffSession) {
|
|
1643
|
+
const detail: AiEmployeeClientErrorDetail = {
|
|
1644
|
+
code: 'CUSTOMER_SUPPORT_UNAVAILABLE',
|
|
1645
|
+
message: '人工客服转接暂未配置。',
|
|
1646
|
+
recoverable: false,
|
|
1647
|
+
}
|
|
1648
|
+
this.setCustomerSupportHandoffState(key, { status: 'failed', message: detail.message })
|
|
1649
|
+
this.dispatchPublicEvent('error', detail)
|
|
1650
|
+
return
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
const generation = this.customerSupportHandoffGeneration
|
|
1654
|
+
this.setCustomerSupportHandoffState(key, { status: 'loading', message: '' })
|
|
1655
|
+
try {
|
|
1656
|
+
const result = await createHandoffSession.call(customerSupport.transport, {
|
|
1657
|
+
sourceSessionId: message.sessionId,
|
|
1658
|
+
actionTransactionId: action.transactionId,
|
|
1659
|
+
})
|
|
1660
|
+
if (!this.isCurrentCustomerSupportHandoff(generation, message.sessionId)) return
|
|
1661
|
+
this.setCustomerSupportHandoffState(key, { status: 'succeeded', message: '' })
|
|
1662
|
+
this.openCustomerSupport({ ...customerSupport, sessionId: result.session.id })
|
|
1663
|
+
} catch (error) {
|
|
1664
|
+
if (!this.isCurrentCustomerSupportHandoff(generation, message.sessionId)) return
|
|
1665
|
+
const detail = customerSupportHandoffError(error)
|
|
1666
|
+
this.setCustomerSupportHandoffState(key, { status: 'failed', message: detail.message })
|
|
1667
|
+
this.dispatchPublicEvent('error', detail)
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
private openCustomerSupport(customerSupport: CustomerSupportChatConfig): void {
|
|
1498
1672
|
this.releaseClient()
|
|
1499
1673
|
this.customerSupportOpen = true
|
|
1500
1674
|
void this.updateComplete.then(() => {
|
|
@@ -1507,6 +1681,20 @@ export class UniplatAiEmployeeChat extends LitElement {
|
|
|
1507
1681
|
})
|
|
1508
1682
|
}
|
|
1509
1683
|
|
|
1684
|
+
private setCustomerSupportHandoffState(key: string, state: CustomerSupportHandoffUiState): void {
|
|
1685
|
+
const next = new Map(this.customerSupportHandoffStates)
|
|
1686
|
+
next.set(key, state)
|
|
1687
|
+
this.customerSupportHandoffStates = next
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
private isCurrentCustomerSupportHandoff(generation: number, sessionId: string): boolean {
|
|
1691
|
+
return (
|
|
1692
|
+
generation === this.customerSupportHandoffGeneration &&
|
|
1693
|
+
this.drawerOpen &&
|
|
1694
|
+
this.client?.sessionId === sessionId
|
|
1695
|
+
)
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1510
1698
|
private resolveCustomerSupportConfig(): CustomerSupportChatConfig | null {
|
|
1511
1699
|
const config = this.config
|
|
1512
1700
|
if (!config) return null
|
|
@@ -1603,6 +1791,7 @@ export class UniplatAiEmployeeChat extends LitElement {
|
|
|
1603
1791
|
private resetView(): void {
|
|
1604
1792
|
this.readSyncGeneration += 1
|
|
1605
1793
|
this.localMessageIds.clear()
|
|
1794
|
+
this.persistedMessageIds.clear()
|
|
1606
1795
|
this.locallyReadBoundaries.clear()
|
|
1607
1796
|
this.confirmedReadMessageIds.clear()
|
|
1608
1797
|
this.queuedReadMessageIds.clear()
|
|
@@ -1619,9 +1808,72 @@ export class UniplatAiEmployeeChat extends LitElement {
|
|
|
1619
1808
|
this.switchingSessionId = ''
|
|
1620
1809
|
this.creatingSession = false
|
|
1621
1810
|
this.connectionState = 'idle'
|
|
1811
|
+
this.customerSupportHandoffStates = new Map()
|
|
1622
1812
|
}
|
|
1623
1813
|
}
|
|
1624
1814
|
|
|
1815
|
+
function customerSupportHandoffError(error: unknown): AiEmployeeClientErrorDetail {
|
|
1816
|
+
if (error instanceof HrSaasCustomerSupportTransportError) {
|
|
1817
|
+
if (error.code === 'SOURCE_SESSION_NOT_FOUND') {
|
|
1818
|
+
return {
|
|
1819
|
+
code: 'SOURCE_SESSION_NOT_FOUND',
|
|
1820
|
+
message: '来源会话不存在,请刷新后重试。',
|
|
1821
|
+
recoverable: false,
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
if (error.code === 'SOURCE_SESSION_ACCESS_DENIED') {
|
|
1825
|
+
return {
|
|
1826
|
+
code: 'SOURCE_SESSION_ACCESS_DENIED',
|
|
1827
|
+
message: '当前身份无权转接此会话。',
|
|
1828
|
+
recoverable: false,
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
if (error.code === 'HANDOFF_ACTION_NOT_FOUND') {
|
|
1832
|
+
return {
|
|
1833
|
+
code: 'HANDOFF_ACTION_NOT_FOUND',
|
|
1834
|
+
message: '该人工客服入口已失效,请刷新会话后重试。',
|
|
1835
|
+
recoverable: false,
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
if (error.code === 'HANDOFF_ACTION_MISMATCH') {
|
|
1839
|
+
return {
|
|
1840
|
+
code: 'HANDOFF_ACTION_MISMATCH',
|
|
1841
|
+
message: '转接状态已变化,请刷新会话后重试。',
|
|
1842
|
+
recoverable: true,
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1845
|
+
if (error.code === 'AUTH_REQUIRED' || error.code === 'UNAUTHORIZED') {
|
|
1846
|
+
return {
|
|
1847
|
+
code: 'AUTHENTICATION_FAILED',
|
|
1848
|
+
message: '登录状态已失效,请重新登录后再试。',
|
|
1849
|
+
recoverable: true,
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
if (error.code === 'CUSTOMER_SUPPORT_UNAVAILABLE' || error.code === 'REQUEST_FAILED') {
|
|
1853
|
+
return {
|
|
1854
|
+
code: 'CUSTOMER_SUPPORT_UNAVAILABLE',
|
|
1855
|
+
message: '人工客服暂时不可用,请稍后重试。',
|
|
1856
|
+
recoverable: true,
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
return {
|
|
1861
|
+
code: 'CUSTOMER_SUPPORT_HANDOFF_FAILED',
|
|
1862
|
+
message: '人工客服转接失败,请稍后重试。',
|
|
1863
|
+
recoverable: true,
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
function messagePreview(message: AiEmployeeMessage): string {
|
|
1868
|
+
return message.role === 'assistant' ? sessionPreview(message.content) : message.content
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
function sessionPreview(content: string): string {
|
|
1872
|
+
const parsed = parseAiEmployeeAssistantContent(content)
|
|
1873
|
+
if (parsed.actions.length === 0) return content
|
|
1874
|
+
return parsed.content || '可发起人工客服'
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1625
1877
|
function formatMessageTime(value: string): string {
|
|
1626
1878
|
const date = new Date(value)
|
|
1627
1879
|
if (Number.isNaN(date.getTime())) return ''
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
type CustomerSupportErrorDetail,
|
|
7
7
|
type CustomerSupportMessage,
|
|
8
8
|
type CustomerSupportSession,
|
|
9
|
-
} from '@
|
|
9
|
+
} from '@csntgao/ai-employee-client'
|
|
10
10
|
import { LitElement, css, html, nothing } from 'lit'
|
|
11
11
|
import { prototypeIcon } from './prototype-icons'
|
|
12
12
|
|