@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.
@@ -185,6 +185,102 @@ describe('AiEmployeeClient', () => {
185
185
  expect(client.messages.filter((message) => message.id === 'ws-message-1')).toHaveLength(1)
186
186
  })
187
187
 
188
+ it('deduplicates the same realtime message delivered through different patch paths', async () => {
189
+ const { client, sockets } = createHarness()
190
+ const received: string[] = []
191
+ client.on('message', ({ message }) => received.push(message.content))
192
+ await client.start()
193
+ await authenticateAndReady(sockets[0]!)
194
+
195
+ // 服务端没给消息 ID 时只能合成;replay 与实时投递的 patch 路径不同,
196
+ // 合成 ID 必须仍然相同,否则同一条消息会重复显示。
197
+ const entry = {
198
+ entry_type: { type: 'assistant_message' },
199
+ content: JSON.stringify({ text: '同一条回复' }),
200
+ timestamp: '2026-09-04T02:00:00.000Z',
201
+ }
202
+ const frameWithPath = (path: string) => ({
203
+ type: 'realtime_event',
204
+ channel: 'ai_employee',
205
+ event: {
206
+ type: 'session_message',
207
+ session_id: 'session-1',
208
+ payload: { JsonPatch: [{ path, value: entry }] },
209
+ },
210
+ })
211
+ sockets[0]?.receive(frameWithPath('/0'))
212
+ sockets[0]?.receive(frameWithPath('/3'))
213
+
214
+ expect(received).toEqual(['同一条回复'])
215
+ expect(client.messages.filter((message) => message.content === '同一条回复')).toHaveLength(1)
216
+ })
217
+
218
+ it('keeps a locally sent message single across replay and live echoes', async () => {
219
+ const { client, sockets } = createHarness()
220
+ await client.start()
221
+ await authenticateAndReady(sockets[0]!)
222
+ await client.sendMessage('给我生成一个文本文件,内容是123456')
223
+
224
+ // 同一条用户消息被服务端以不同 ID 回送两次(replay + 实时)。
225
+ const echo = (messageId: string) => ({
226
+ type: 'realtime_event',
227
+ channel: 'ai_employee',
228
+ event: {
229
+ type: 'session_message',
230
+ session_id: 'session-1',
231
+ payload: {
232
+ JsonPatch: [
233
+ {
234
+ path: '/0',
235
+ value: {
236
+ message_id: messageId,
237
+ entry_type: { type: 'user_message' },
238
+ content: JSON.stringify({ text: '给我生成一个文本文件,内容是123456' }),
239
+ },
240
+ },
241
+ ],
242
+ },
243
+ },
244
+ })
245
+ sockets[0]?.receive(echo('replay-message-1'))
246
+ sockets[0]?.receive(echo('live-message-1'))
247
+
248
+ expect(
249
+ client.messages.filter((message) => message.content === '给我生成一个文本文件,内容是123456'),
250
+ ).toHaveLength(1)
251
+ })
252
+
253
+ it('keeps the first message single when the server echoes another message id', async () => {
254
+ // 草稿会话:这条走的是 createSessionWithFirstMessage 原子建会话路径。
255
+ const { client, sockets } = createHarness(createTransport(), { draft: true })
256
+ await client.start()
257
+ await authenticateAndReady(sockets[0]!)
258
+ await client.sendMessage('首条消息内容')
259
+
260
+ sockets[0]?.receive({
261
+ type: 'realtime_event',
262
+ channel: 'ai_employee',
263
+ event: {
264
+ type: 'session_message',
265
+ session_id: 'atomic-session-default',
266
+ payload: {
267
+ JsonPatch: [
268
+ {
269
+ path: '/0',
270
+ value: {
271
+ message_id: 'server-side-other-id',
272
+ entry_type: { type: 'user_message' },
273
+ content: JSON.stringify({ text: '首条消息内容' }),
274
+ },
275
+ },
276
+ ],
277
+ },
278
+ },
279
+ })
280
+
281
+ expect(client.messages.filter((message) => message.content === '首条消息内容')).toHaveLength(1)
282
+ })
283
+
188
284
  it('uses a top-level result only when the current turn has no assistant message', async () => {
189
285
  const { client, sockets } = createHarness()
190
286
  const received: string[] = []
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniplat/ai-employee-web-components",
3
- "version": "0.29.2",
3
+ "version": "0.29.5",
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,
@@ -34,6 +34,7 @@ const requiredExports = [
34
34
  'createHrSaasCustomerSupportTransport',
35
35
  'exchangeUniplatBaseApplicationTicket',
36
36
  'loginWithUniplatBaseIdentityTicket',
37
+ 'parseAiEmployeeAssistantContent',
37
38
  'resolveOrganizationEntryDecision',
38
39
  ]
39
40
  const requiredTags = [
@@ -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,
10
+ type OpenCustomerSupportAssistantAction,
9
11
  } from '@uniplat/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 { createHrSaasCustomerSupportTransport } from './customer-support-transport'
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">${session.latestMessage || '暂无消息'}</span>
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
- <div
1101
- class="message ${message.role} ${message.role === 'assistant' ? 'markdown' : ''}"
1102
- part="message message-${message.role}"
1103
- data-message-id=${message.id}
1104
- >
1105
- ${message.role === 'assistant' ? renderSafeMarkdown(message.content) : message.content}
1106
- </div>
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 === client) this.processing = processing
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.content,
1522
+ latestMessage: messagePreview(message),
1394
1523
  updatedAt: message.createdAt,
1395
1524
  unreadCount: visible ? 0 : Math.max(0, (existing?.unreadCount ?? 0) + unreadIncrement),
1396
1525
  }
1397
- const latestMessageId = source === 'local' ? existing?.latestMessageId : message.id
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 && !this.localMessageIds.has(candidate.id)) {
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?.content ?? '',
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 ''