@csntgao/uni-base 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/.prettierignore +4 -0
  2. package/.prettierrc.json +6 -0
  3. package/AGENTS.md +519 -0
  4. package/CLAUDE.md +512 -0
  5. package/README.md +734 -0
  6. package/docs//346/225/260/345/255/227/345/221/230/345/267/245/345/256/214/346/225/264/346/212/275/345/261/211/346/216/245/345/205/245/350/257/264/346/230/216.md +110 -0
  7. package/docs//347/273/204/347/273/207/345/221/230/345/267/245/345/215/225/351/200/211/347/273/204/344/273/266/346/216/245/345/205/245/350/257/264/346/230/216.md +79 -0
  8. package/docs//347/273/204/347/273/207/345/221/230/345/267/245/345/244/232/351/200/211/347/273/204/344/273/266/346/216/245/345/205/245/350/257/264/346/230/216.md +93 -0
  9. package/docs//350/264/246/346/210/267/344/270/216/347/231/273/345/275/225/347/273/204/344/273/266/345/212/250/346/200/201/346/216/245/345/205/245/350/257/264/346/230/216.md +201 -0
  10. package/eslint.config.js +30 -0
  11. package/examples/vanilla/index.html +33 -0
  12. package/examples/vanilla/main.js +247 -0
  13. package/examples/vanilla/package.json +15 -0
  14. package/examples/vanilla/public/runtime-config.js +10 -0
  15. package/examples/vanilla/style.css +99 -0
  16. package/package.json +31 -0
  17. package/packages/core/package.json +30 -0
  18. package/packages/core/src/client.ts +1132 -0
  19. package/packages/core/src/customer-support-client.ts +825 -0
  20. package/packages/core/src/customer-support-types.ts +123 -0
  21. package/packages/core/src/index.ts +79 -0
  22. package/packages/core/src/notification-center-client.ts +462 -0
  23. package/packages/core/src/notification-center-types.ts +154 -0
  24. package/packages/core/src/protocol.ts +237 -0
  25. package/packages/core/src/types.ts +170 -0
  26. package/packages/core/tests/client.test.ts +760 -0
  27. package/packages/core/tests/customer-support-client.test.ts +576 -0
  28. package/packages/core/tests/fake-websocket.ts +55 -0
  29. package/packages/core/tests/notification-center-client.test.ts +467 -0
  30. package/packages/core/tests/protocol.test.ts +124 -0
  31. package/packages/core/tsconfig.json +7 -0
  32. package/packages/core/tsup.config.ts +11 -0
  33. package/packages/web-components/package.json +35 -0
  34. package/packages/web-components/scripts/verify-runtime-build.mjs +111 -0
  35. package/packages/web-components/src/account-context.ts +1540 -0
  36. package/packages/web-components/src/application-switcher-transport.ts +243 -0
  37. package/packages/web-components/src/application-switcher.ts +1103 -0
  38. package/packages/web-components/src/chat.ts +1754 -0
  39. package/packages/web-components/src/customer-support-chat.ts +862 -0
  40. package/packages/web-components/src/customer-support-transport.ts +269 -0
  41. package/packages/web-components/src/index.ts +256 -0
  42. package/packages/web-components/src/notification-center.ts +1066 -0
  43. package/packages/web-components/src/notification-popover.ts +615 -0
  44. package/packages/web-components/src/organization-multi-employee-selector.ts +1564 -0
  45. package/packages/web-components/src/organization-onboarding-transport.ts +54 -0
  46. package/packages/web-components/src/organization-onboarding.ts +1216 -0
  47. package/packages/web-components/src/organization-selector.ts +534 -0
  48. package/packages/web-components/src/organization-single-employee-selector.ts +114 -0
  49. package/packages/web-components/src/prototype-icons.ts +242 -0
  50. package/packages/web-components/src/safe-image-url.ts +29 -0
  51. package/packages/web-components/src/safe-markdown.ts +192 -0
  52. package/packages/web-components/src/uniplat-base-account-context-transport.ts +625 -0
  53. package/packages/web-components/src/uniplat-base-application-switcher-transport.ts +418 -0
  54. package/packages/web-components/src/uniplat-base-application-ticket.ts +254 -0
  55. package/packages/web-components/src/uniplat-base-identity-ticket.ts +202 -0
  56. package/packages/web-components/src/uniplat-base-notification-center-transport.ts +378 -0
  57. package/packages/web-components/src/uniplat-base-organization-multi-employee-selector-transport.ts +394 -0
  58. package/packages/web-components/src/uniplat-base-organization-onboarding-transport.ts +183 -0
  59. package/packages/web-components/src/uniplat-base-organization-single-employee-selector-transport.ts +14 -0
  60. package/packages/web-components/src/user-login-transport.ts +563 -0
  61. package/packages/web-components/src/user-login.ts +2027 -0
  62. package/packages/web-components/src/user-menu.ts +880 -0
  63. package/packages/web-components/tests/account-context.test.ts +798 -0
  64. package/packages/web-components/tests/application-switcher.test.ts +562 -0
  65. package/packages/web-components/tests/chat.test.ts +1130 -0
  66. package/packages/web-components/tests/customer-support-chat.test.ts +428 -0
  67. package/packages/web-components/tests/customer-support-transport.test.ts +79 -0
  68. package/packages/web-components/tests/notification-center-transport.test.ts +271 -0
  69. package/packages/web-components/tests/notification-center.test.ts +429 -0
  70. package/packages/web-components/tests/notification-popover.test.ts +283 -0
  71. package/packages/web-components/tests/organization-multi-employee-selector-transport.test.ts +332 -0
  72. package/packages/web-components/tests/organization-multi-employee-selector.test.ts +225 -0
  73. package/packages/web-components/tests/organization-onboarding.test.ts +260 -0
  74. package/packages/web-components/tests/organization-selector.test.ts +127 -0
  75. package/packages/web-components/tests/organization-single-employee-selector.test.ts +145 -0
  76. package/packages/web-components/tests/uniplat-base-account-context-transport.test.ts +496 -0
  77. package/packages/web-components/tests/uniplat-base-application-switcher-transport.test.ts +598 -0
  78. package/packages/web-components/tests/uniplat-base-application-ticket.test.ts +233 -0
  79. package/packages/web-components/tests/uniplat-base-identity-ticket.test.ts +226 -0
  80. package/packages/web-components/tests/uniplat-base-organization-onboarding-transport.test.ts +227 -0
  81. package/packages/web-components/tests/user-login-transport.test.ts +443 -0
  82. package/packages/web-components/tests/user-login.test.ts +626 -0
  83. package/packages/web-components/tests/user-menu.test.ts +271 -0
  84. package/packages/web-components/tsconfig.json +8 -0
  85. package/packages/web-components/tsup.config.ts +12 -0
  86. package/packages/web-components/tsup.runtime.config.ts +37 -0
  87. package/pnpm-workspace.yaml +6 -0
  88. package/tsconfig.base.json +21 -0
  89. package/vitest.config.ts +20 -0
  90. package//344/272/244/346/216/245/346/226/207/346/241/243.md +254 -0
@@ -0,0 +1,1754 @@
1
+ import {
2
+ AiEmployeeClient,
3
+ AiEmployeeClientError,
4
+ type AiEmployeeClientErrorDetail,
5
+ type AiEmployeeClientOptions,
6
+ type AiEmployeeConnectionState,
7
+ type AiEmployeeMessage,
8
+ type AiEmployeeSessionSummary,
9
+ } from '@uniplat/ai-employee-client'
10
+ import { LitElement, css, html, nothing, type PropertyValues } from 'lit'
11
+ import type { UniplatCustomerSupportChat, CustomerSupportChatConfig } from './customer-support-chat'
12
+ import { createHrSaasCustomerSupportTransport } from './customer-support-transport'
13
+ import { prototypeIcon } from './prototype-icons'
14
+ import { renderSafeMarkdown } from './safe-markdown'
15
+
16
+ export interface AiEmployeeChatConfig extends AiEmployeeClientOptions {
17
+ customerSupport?: CustomerSupportChatConfig
18
+ }
19
+
20
+ interface LocalReadBoundary {
21
+ latestMessageId?: string
22
+ updatedAtMs: number
23
+ }
24
+
25
+ const AI_ADVISOR_TITLE = '企业服务AI顾问'
26
+
27
+ export interface AiEmployeeChatEventMap {
28
+ ready: CustomEvent<{ sessionId: string }>
29
+ 'message-sent': CustomEvent<{ sessionId: string; message: AiEmployeeMessage }>
30
+ 'message-received': CustomEvent<{ sessionId: string; message: AiEmployeeMessage }>
31
+ 'connection-state-change': CustomEvent<{ state: AiEmployeeConnectionState }>
32
+ error: CustomEvent<AiEmployeeClientErrorDetail>
33
+ }
34
+
35
+ export class UniplatAiEmployeeChat extends LitElement {
36
+ static override properties = {
37
+ messages: { state: true },
38
+ draft: { state: true },
39
+ loadingHistory: { state: true },
40
+ sending: { state: true },
41
+ processing: { state: true },
42
+ connectionState: { state: true },
43
+ visibleError: { state: true },
44
+ chatTitle: { state: true },
45
+ drawerOpen: { state: true },
46
+ sessionPanelOpen: { state: true },
47
+ sessions: { state: true },
48
+ loadingSessions: { state: true },
49
+ sessionListError: { state: true },
50
+ switchingSessionId: { state: true },
51
+ creatingSession: { state: true },
52
+ customerSupportOpen: { state: true },
53
+ }
54
+
55
+ static override styles = css`
56
+ :host {
57
+ --uniplat-chat-primary: #2e6bf5;
58
+ --uniplat-chat-primary-contrast: #ffffff;
59
+ --uniplat-chat-background: #ffffff;
60
+ --uniplat-chat-surface: #f7fafd;
61
+ --uniplat-chat-text: #25303f;
62
+ --uniplat-chat-muted: #8b98a6;
63
+ --uniplat-chat-border: #e4ebf3;
64
+ --uniplat-chat-error: #b42318;
65
+ --uniplat-chat-font-family:
66
+ Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
67
+ --uniplat-chat-width: 420px;
68
+ --uniplat-chat-height: 100dvh;
69
+ --uniplat-chat-radius: 0;
70
+ --uniplat-chat-z-index: 500;
71
+ display: contents;
72
+ color: var(--uniplat-chat-text);
73
+ font-family: var(--uniplat-chat-font-family);
74
+ line-height: 1.5;
75
+ }
76
+
77
+ * {
78
+ box-sizing: border-box;
79
+ }
80
+
81
+ .chat {
82
+ position: fixed;
83
+ z-index: var(--uniplat-chat-z-index);
84
+ inset: 0 0 0 auto;
85
+ display: flex;
86
+ flex-direction: column;
87
+ width: var(--uniplat-chat-width);
88
+ max-width: 96vw;
89
+ height: var(--uniplat-chat-height);
90
+ overflow: hidden;
91
+ border: 0;
92
+ border-left: 1px solid var(--uniplat-chat-border);
93
+ border-radius: var(--uniplat-chat-radius);
94
+ background: var(--uniplat-chat-background);
95
+ box-shadow: -14px 0 34px rgb(20 40 80 / 8%);
96
+ }
97
+
98
+ .header {
99
+ display: flex;
100
+ align-items: center;
101
+ flex: 0 0 auto;
102
+ gap: 10px;
103
+ padding: 16px 18px;
104
+ border-bottom: 1px solid #edf1f6;
105
+ background: var(--uniplat-chat-background);
106
+ }
107
+
108
+ .advisor-mark,
109
+ .assistant-avatar {
110
+ display: flex;
111
+ flex: 0 0 auto;
112
+ align-items: center;
113
+ justify-content: center;
114
+ }
115
+
116
+ .conversation-mark {
117
+ position: relative;
118
+ display: flex;
119
+ width: 34px;
120
+ height: 34px;
121
+ min-width: 0;
122
+ min-height: 0;
123
+ flex: 0 0 auto;
124
+ align-items: center;
125
+ justify-content: center;
126
+ padding: 0;
127
+ border: 0;
128
+ border-radius: 9px;
129
+ outline: none;
130
+ background: transparent;
131
+ color: #5a6673;
132
+ cursor: pointer;
133
+ }
134
+
135
+ .conversation-mark:hover,
136
+ .conversation-mark:focus-visible {
137
+ background: #f0f3f8;
138
+ color: #25303f;
139
+ }
140
+
141
+ .conversation-unread {
142
+ position: absolute;
143
+ top: -4px;
144
+ right: -4px;
145
+ display: grid;
146
+ min-width: 16px;
147
+ height: 16px;
148
+ place-items: center;
149
+ padding: 0 4px;
150
+ border: 2px solid var(--uniplat-chat-background);
151
+ border-radius: 999px;
152
+ background: #e03131;
153
+ color: #fff;
154
+ font-size: 9px;
155
+ font-weight: 800;
156
+ line-height: 1;
157
+ }
158
+
159
+ .advisor-mark {
160
+ width: 38px;
161
+ height: 38px;
162
+ border-radius: 11px;
163
+ background: #eaf1fe;
164
+ color: var(--uniplat-chat-primary);
165
+ }
166
+
167
+ .conversation-mark svg {
168
+ width: 19px;
169
+ height: 19px;
170
+ }
171
+
172
+ .advisor-mark svg {
173
+ width: 20px;
174
+ height: 20px;
175
+ }
176
+
177
+ .heading {
178
+ min-width: 0;
179
+ flex: 1 1 0;
180
+ }
181
+
182
+ .title {
183
+ overflow: hidden;
184
+ color: #182634;
185
+ font-size: 16px;
186
+ font-weight: 800;
187
+ text-overflow: ellipsis;
188
+ white-space: nowrap;
189
+ }
190
+
191
+ .subtitle {
192
+ overflow: hidden;
193
+ margin-top: 2px;
194
+ color: var(--uniplat-chat-muted);
195
+ font-size: 12.5px;
196
+ text-overflow: ellipsis;
197
+ white-space: nowrap;
198
+ }
199
+
200
+ .close-button {
201
+ display: flex;
202
+ width: 32px;
203
+ height: 32px;
204
+ min-width: 0;
205
+ min-height: 0;
206
+ flex: 0 0 auto;
207
+ align-items: center;
208
+ justify-content: center;
209
+ padding: 0;
210
+ border: 0;
211
+ border-radius: 8px;
212
+ outline: none;
213
+ background: transparent;
214
+ color: var(--uniplat-chat-muted);
215
+ cursor: pointer;
216
+ }
217
+
218
+ .close-button:hover,
219
+ .close-button:focus-visible {
220
+ background: #f0f3f8;
221
+ color: #25303f;
222
+ }
223
+
224
+ .close-button svg {
225
+ width: 18px;
226
+ height: 18px;
227
+ }
228
+
229
+ .session-header {
230
+ display: flex;
231
+ min-height: 68px;
232
+ flex: 0 0 auto;
233
+ align-items: center;
234
+ gap: 10px;
235
+ padding: 16px 18px;
236
+ border-bottom: 1px solid #edf1f6;
237
+ background: var(--uniplat-chat-background);
238
+ }
239
+
240
+ .session-back-button {
241
+ display: flex;
242
+ width: 32px;
243
+ height: 32px;
244
+ min-width: 0;
245
+ min-height: 0;
246
+ flex: 0 0 auto;
247
+ align-items: center;
248
+ justify-content: center;
249
+ padding: 0;
250
+ border: 0;
251
+ border-radius: 8px;
252
+ outline: none;
253
+ background: transparent;
254
+ color: #5a6673;
255
+ cursor: pointer;
256
+ }
257
+
258
+ .session-back-button:hover,
259
+ .session-back-button:focus-visible {
260
+ background: #f0f3f8;
261
+ color: #25303f;
262
+ }
263
+
264
+ .session-back-button svg {
265
+ width: 18px;
266
+ height: 18px;
267
+ }
268
+
269
+ .session-heading {
270
+ min-width: 0;
271
+ flex: 1 1 0;
272
+ color: #182634;
273
+ font-size: 16px;
274
+ font-weight: 800;
275
+ }
276
+
277
+ .session-new-button {
278
+ display: flex;
279
+ width: 32px;
280
+ height: 32px;
281
+ min-width: 0;
282
+ min-height: 0;
283
+ flex: 0 0 auto;
284
+ align-items: center;
285
+ justify-content: center;
286
+ padding: 0;
287
+ border: 0;
288
+ border-radius: 8px;
289
+ outline: none;
290
+ background: transparent;
291
+ color: var(--uniplat-chat-primary);
292
+ cursor: pointer;
293
+ }
294
+
295
+ .session-new-button:hover,
296
+ .session-new-button:focus-visible {
297
+ background: #f0f5ff;
298
+ }
299
+
300
+ .session-new-button:disabled {
301
+ cursor: wait;
302
+ opacity: 0.5;
303
+ }
304
+
305
+ .session-new-button svg {
306
+ width: 19px;
307
+ height: 19px;
308
+ }
309
+
310
+ .session-list {
311
+ min-height: 0;
312
+ flex: 1 1 0;
313
+ overflow-y: auto;
314
+ padding: 10px 12px 18px;
315
+ background: var(--uniplat-chat-background);
316
+ overscroll-behavior: contain;
317
+ }
318
+
319
+ .session-list-loading,
320
+ .session-list-empty,
321
+ .session-list-error {
322
+ display: grid;
323
+ min-height: 180px;
324
+ place-items: center;
325
+ padding: 24px;
326
+ color: var(--uniplat-chat-muted);
327
+ font-size: 13px;
328
+ text-align: center;
329
+ }
330
+
331
+ .session-list-error {
332
+ color: var(--uniplat-chat-error);
333
+ }
334
+
335
+ .session-list-notice {
336
+ margin: 2px 4px 10px;
337
+ padding: 8px 10px;
338
+ border: 1px solid color-mix(in srgb, var(--uniplat-chat-error), transparent 76%);
339
+ border-radius: 8px;
340
+ background: color-mix(in srgb, var(--uniplat-chat-error), transparent 96%);
341
+ color: var(--uniplat-chat-error);
342
+ font-size: 12px;
343
+ line-height: 1.5;
344
+ }
345
+
346
+ .session-item {
347
+ display: grid;
348
+ width: 100%;
349
+ min-width: 0;
350
+ min-height: 70px;
351
+ grid-template-columns: 42px minmax(0, 1fr) auto;
352
+ grid-template-rows: auto auto;
353
+ align-items: center;
354
+ column-gap: 10px;
355
+ padding: 10px 12px;
356
+ border: 0;
357
+ border-radius: 10px;
358
+ outline: none;
359
+ background: transparent;
360
+ color: var(--uniplat-chat-text);
361
+ cursor: pointer;
362
+ font: inherit;
363
+ text-align: left;
364
+ }
365
+
366
+ .session-item + .session-item {
367
+ margin-top: 4px;
368
+ }
369
+
370
+ .session-item:hover,
371
+ .session-item:focus-visible,
372
+ .session-item.current {
373
+ background: #f0f5ff;
374
+ }
375
+
376
+ .session-item:disabled {
377
+ cursor: wait;
378
+ opacity: 0.65;
379
+ }
380
+
381
+ .session-item-icon {
382
+ display: flex;
383
+ width: 38px;
384
+ height: 38px;
385
+ grid-row: 1 / span 2;
386
+ align-items: center;
387
+ justify-content: center;
388
+ border-radius: 10px;
389
+ background: #eaf1fe;
390
+ color: var(--uniplat-chat-primary);
391
+ }
392
+
393
+ .session-item-icon svg {
394
+ width: 19px;
395
+ height: 19px;
396
+ }
397
+
398
+ .session-item-title,
399
+ .session-item-preview {
400
+ min-width: 0;
401
+ overflow: hidden;
402
+ text-overflow: ellipsis;
403
+ white-space: nowrap;
404
+ }
405
+
406
+ .session-item-title {
407
+ align-self: end;
408
+ font-size: 13.5px;
409
+ font-weight: 800;
410
+ }
411
+
412
+ .session-item-preview {
413
+ align-self: start;
414
+ color: var(--uniplat-chat-muted);
415
+ font-size: 11.5px;
416
+ }
417
+
418
+ .session-item-side {
419
+ display: flex;
420
+ min-width: 42px;
421
+ height: 100%;
422
+ grid-column: 3;
423
+ grid-row: 1 / span 2;
424
+ flex-direction: column;
425
+ align-items: flex-end;
426
+ justify-content: space-between;
427
+ gap: 6px;
428
+ color: #a6b1be;
429
+ font-size: 10.5px;
430
+ }
431
+
432
+ .session-item-unread {
433
+ display: grid;
434
+ min-width: 17px;
435
+ height: 17px;
436
+ place-items: center;
437
+ padding: 0 4px;
438
+ border-radius: 999px;
439
+ background: #e03131;
440
+ color: #fff;
441
+ font-size: 9px;
442
+ font-weight: 800;
443
+ }
444
+
445
+ .sr-only {
446
+ position: absolute;
447
+ width: 1px;
448
+ height: 1px;
449
+ overflow: hidden;
450
+ clip: rect(0, 0, 0, 0);
451
+ white-space: nowrap;
452
+ clip-path: inset(50%);
453
+ }
454
+
455
+ .messages {
456
+ min-height: 0;
457
+ flex: 1 1 0;
458
+ overflow-y: auto;
459
+ padding: 16px 18px;
460
+ background: var(--uniplat-chat-surface);
461
+ overscroll-behavior: contain;
462
+ }
463
+
464
+ .time-marker {
465
+ margin-bottom: 14px;
466
+ color: #a6b1be;
467
+ font-size: 11.5px;
468
+ text-align: center;
469
+ }
470
+
471
+ .message-row {
472
+ display: flex;
473
+ align-items: flex-start;
474
+ margin: 0 0 14px;
475
+ }
476
+
477
+ .message-row.user {
478
+ justify-content: flex-end;
479
+ }
480
+
481
+ .message-row.system {
482
+ justify-content: center;
483
+ }
484
+
485
+ .assistant-message {
486
+ display: flex;
487
+ max-width: 88%;
488
+ gap: 9px;
489
+ }
490
+
491
+ .assistant-avatar {
492
+ width: 30px;
493
+ height: 30px;
494
+ border-radius: 50%;
495
+ background: #eaf1fe;
496
+ color: var(--uniplat-chat-primary);
497
+ }
498
+
499
+ .assistant-avatar svg {
500
+ width: 16px;
501
+ height: 16px;
502
+ }
503
+
504
+ .assistant-content {
505
+ min-width: 0;
506
+ }
507
+
508
+ .assistant-label {
509
+ display: block;
510
+ margin-bottom: 4px;
511
+ color: #a6b1be;
512
+ font-size: 11.5px;
513
+ }
514
+
515
+ .message {
516
+ width: fit-content;
517
+ min-width: 0;
518
+ max-width: 82%;
519
+ padding: 11px 14px;
520
+ border: 1px solid #e7edf5;
521
+ border-radius: 2px 12px 12px;
522
+ background: var(--uniplat-chat-background);
523
+ color: var(--uniplat-chat-text);
524
+ font-size: 13.5px;
525
+ line-height: 1.8;
526
+ overflow-wrap: anywhere;
527
+ white-space: pre-wrap;
528
+ }
529
+
530
+ .assistant-content .message {
531
+ display: block;
532
+ max-width: none;
533
+ }
534
+
535
+ .message.user {
536
+ border-color: var(--uniplat-chat-primary);
537
+ border-radius: 12px 2px 12px 12px;
538
+ background: var(--uniplat-chat-primary);
539
+ color: var(--uniplat-chat-primary-contrast);
540
+ }
541
+
542
+ .message.system {
543
+ border: 0;
544
+ border-radius: 999px;
545
+ background: #edf1f6;
546
+ color: #7c8998;
547
+ font-size: 12px;
548
+ line-height: 1.5;
549
+ }
550
+
551
+ .message.error {
552
+ border-color: color-mix(in srgb, var(--uniplat-chat-error), transparent 65%);
553
+ color: var(--uniplat-chat-error);
554
+ }
555
+
556
+ .message.markdown {
557
+ white-space: normal;
558
+ }
559
+
560
+ .message.markdown .md-heading {
561
+ margin: 0 0 8px;
562
+ font-weight: 750;
563
+ line-height: 1.45;
564
+ }
565
+
566
+ .message.markdown .md-h1 {
567
+ font-size: 1.3em;
568
+ }
569
+
570
+ .message.markdown .md-h2 {
571
+ font-size: 1.2em;
572
+ }
573
+
574
+ .message.markdown .md-h3,
575
+ .message.markdown .md-h4 {
576
+ font-size: 1.08em;
577
+ }
578
+
579
+ .message.markdown .md-p {
580
+ margin: 0 0 9px;
581
+ }
582
+
583
+ .message.markdown > :last-child {
584
+ margin-bottom: 0;
585
+ }
586
+
587
+ .message.markdown .md-list {
588
+ margin: 0 0 9px;
589
+ padding-left: 1.5em;
590
+ }
591
+
592
+ .message.markdown .md-list li + li {
593
+ margin-top: 3px;
594
+ }
595
+
596
+ .message.markdown .md-code {
597
+ padding: 1px 5px;
598
+ border-radius: 4px;
599
+ background: #f1f5f9;
600
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
601
+ font-size: 0.92em;
602
+ }
603
+
604
+ .message.markdown .md-pre {
605
+ overflow: auto;
606
+ margin: 0 0 9px;
607
+ padding: 10px 12px;
608
+ border-radius: 8px;
609
+ background: #f1f5f9;
610
+ white-space: pre;
611
+ }
612
+
613
+ .message.markdown .md-pre .md-code,
614
+ .message.markdown .md-pre code {
615
+ padding: 0;
616
+ background: transparent;
617
+ }
618
+
619
+ .message.markdown .md-quote {
620
+ margin: 0 0 9px;
621
+ padding-left: 10px;
622
+ border-left: 3px solid #d8e2f0;
623
+ color: #64748b;
624
+ }
625
+
626
+ .message.markdown .md-hr {
627
+ margin: 10px 0;
628
+ border: 0;
629
+ border-top: 1px solid #e2e8f0;
630
+ }
631
+
632
+ .message.markdown a {
633
+ color: var(--uniplat-chat-primary);
634
+ text-decoration: underline;
635
+ text-underline-offset: 2px;
636
+ }
637
+
638
+ .empty,
639
+ .loading {
640
+ display: grid;
641
+ min-height: 100%;
642
+ place-items: center;
643
+ padding: 28px;
644
+ color: var(--uniplat-chat-muted);
645
+ font-size: 13.5px;
646
+ text-align: center;
647
+ }
648
+
649
+ .activity {
650
+ margin: 4px 0 0 39px;
651
+ color: #a6b1be;
652
+ font-size: 12px;
653
+ }
654
+
655
+ .error-banner {
656
+ margin: 0 0 12px;
657
+ padding: 8px 10px;
658
+ border: 1px solid color-mix(in srgb, var(--uniplat-chat-error), transparent 70%);
659
+ border-radius: 8px;
660
+ background: color-mix(in srgb, var(--uniplat-chat-error), transparent 94%);
661
+ color: var(--uniplat-chat-error);
662
+ font-size: 12px;
663
+ }
664
+
665
+ .composer {
666
+ flex: 0 0 auto;
667
+ padding: 12px 18px 16px;
668
+ border-top: 1px solid #edf1f6;
669
+ background: var(--uniplat-chat-background);
670
+ }
671
+
672
+ .composer-row {
673
+ display: flex;
674
+ align-items: center;
675
+ gap: 10px;
676
+ }
677
+
678
+ input {
679
+ width: 100%;
680
+ min-width: 0;
681
+ height: 44px;
682
+ flex: 1 1 0;
683
+ padding: 0 12px;
684
+ border: 1px solid #e2e9f3;
685
+ border-radius: 10px;
686
+ outline: none;
687
+ background: var(--uniplat-chat-background);
688
+ color: var(--uniplat-chat-text);
689
+ font: inherit;
690
+ font-size: 14px;
691
+ }
692
+
693
+ input:focus {
694
+ border-color: var(--uniplat-chat-primary);
695
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--uniplat-chat-primary), transparent 86%);
696
+ }
697
+
698
+ input:disabled {
699
+ cursor: not-allowed;
700
+ opacity: 0.65;
701
+ }
702
+
703
+ .send-button {
704
+ display: flex;
705
+ height: 44px;
706
+ min-width: 0;
707
+ min-height: 0;
708
+ flex: 0 0 auto;
709
+ align-items: center;
710
+ padding: 0 18px;
711
+ border: 0;
712
+ border-radius: 10px;
713
+ background: var(--uniplat-chat-primary);
714
+ color: var(--uniplat-chat-primary-contrast);
715
+ cursor: pointer;
716
+ font: inherit;
717
+ font-size: 14px;
718
+ font-weight: 700;
719
+ }
720
+
721
+ .send-button:disabled {
722
+ cursor: not-allowed;
723
+ opacity: 0.5;
724
+ }
725
+
726
+ .composer-actions {
727
+ display: flex;
728
+ align-items: center;
729
+ margin-top: 10px;
730
+ }
731
+
732
+ .human-service-button {
733
+ display: inline-flex;
734
+ min-width: 0;
735
+ min-height: 0;
736
+ align-items: center;
737
+ gap: 5px;
738
+ padding: 0;
739
+ border: 0;
740
+ background: transparent;
741
+ color: var(--uniplat-chat-primary);
742
+ cursor: pointer;
743
+ font: inherit;
744
+ font-size: 12.5px;
745
+ font-weight: 700;
746
+ }
747
+
748
+ .human-service-button svg {
749
+ width: 15px;
750
+ height: 15px;
751
+ }
752
+
753
+ .human-service-button:disabled {
754
+ cursor: not-allowed;
755
+ opacity: 0.5;
756
+ }
757
+
758
+ @media (max-width: 480px) {
759
+ :host {
760
+ --uniplat-chat-width: 100vw;
761
+ }
762
+ }
763
+ `
764
+
765
+ declare private messages: readonly AiEmployeeMessage[]
766
+ declare private draft: string
767
+ declare private loadingHistory: boolean
768
+ declare private sending: boolean
769
+ declare private processing: boolean
770
+ declare private connectionState: AiEmployeeConnectionState
771
+ declare private visibleError: string
772
+ declare private chatTitle: string
773
+ declare private drawerOpen: boolean
774
+ declare private sessionPanelOpen: boolean
775
+ declare private sessions: readonly AiEmployeeSessionSummary[]
776
+ declare private loadingSessions: boolean
777
+ declare private sessionListError: string
778
+ declare private switchingSessionId: string
779
+ declare private creatingSession: boolean
780
+ declare private customerSupportOpen: boolean
781
+ private config: AiEmployeeChatConfig | null = null
782
+ private client: AiEmployeeClient | null = null
783
+ private clientUnsubscribers: Array<() => void> = []
784
+ private startGeneration = 0
785
+ private sessionListGeneration = 0
786
+ private readyDispatched = false
787
+ private readSyncGeneration = 0
788
+ private readonly localMessageIds = new Set<string>()
789
+ private readonly locallyReadBoundaries = new Map<string, LocalReadBoundary>()
790
+ private readonly confirmedReadMessageIds = new Map<string, string>()
791
+ private readonly queuedReadMessageIds = new Map<string, string>()
792
+ private readonly readSyncChains = new Map<string, Promise<void>>()
793
+ private pendingInitialMessage = ''
794
+ private readonly handleWindowKeyDown = (event: KeyboardEvent): void => {
795
+ if (event.key === 'Escape') this.close()
796
+ }
797
+
798
+ constructor() {
799
+ super()
800
+ this.messages = []
801
+ this.draft = ''
802
+ this.loadingHistory = false
803
+ this.sending = false
804
+ this.processing = false
805
+ this.connectionState = 'idle'
806
+ this.visibleError = ''
807
+ this.chatTitle = '企业服务顾问'
808
+ this.drawerOpen = false
809
+ this.sessionPanelOpen = false
810
+ this.sessions = []
811
+ this.loadingSessions = false
812
+ this.sessionListError = ''
813
+ this.switchingSessionId = ''
814
+ this.creatingSession = false
815
+ this.customerSupportOpen = false
816
+ }
817
+
818
+ configure(config: AiEmployeeChatConfig): void {
819
+ this.config = config
820
+ this.releaseClient()
821
+ this.resetView()
822
+ this.customerSupportOpen = false
823
+ if (this.isConnected && this.drawerOpen) void this.startClient()
824
+ }
825
+
826
+ open(initialMessage?: string): void {
827
+ if (this.drawerOpen) return
828
+ this.pendingInitialMessage = typeof initialMessage === 'string' ? initialMessage.trim() : ''
829
+ this.drawerOpen = true
830
+ window.addEventListener('keydown', this.handleWindowKeyDown)
831
+ if (this.isConnected && this.config) void this.startClient()
832
+ void this.updateComplete.then(() => {
833
+ this.renderRoot.querySelector<HTMLButtonElement>('.close-button')?.focus()
834
+ })
835
+ }
836
+
837
+ close(): void {
838
+ if (!this.drawerOpen) return
839
+ this.drawerOpen = false
840
+ this.pendingInitialMessage = ''
841
+ window.removeEventListener('keydown', this.handleWindowKeyDown)
842
+ this.releaseClient()
843
+ }
844
+
845
+ override connectedCallback(): void {
846
+ super.connectedCallback()
847
+ if (this.drawerOpen && this.config && !this.client) void this.startClient()
848
+ }
849
+
850
+ override disconnectedCallback(): void {
851
+ window.removeEventListener('keydown', this.handleWindowKeyDown)
852
+ this.releaseClient()
853
+ super.disconnectedCallback()
854
+ }
855
+
856
+ override render() {
857
+ if (!this.drawerOpen) return nothing
858
+ if (this.customerSupportOpen) {
859
+ return html`<uniplat-customer-support-chat
860
+ @ai-chat-requested=${this.handleAiChatRequested}
861
+ @closed=${this.handleCustomerSupportClosed}
862
+ ></uniplat-customer-support-chat>`
863
+ }
864
+ if (this.sessionPanelOpen) return this.renderSessionPanel()
865
+ const inputDisabled = !this.client || this.loadingHistory || this.sending
866
+ const canSend = !inputDisabled && this.draft.trim().length > 0
867
+
868
+ return html`
869
+ <section
870
+ class="chat"
871
+ part="container"
872
+ role="dialog"
873
+ aria-modal="false"
874
+ aria-label=${AI_ADVISOR_TITLE}
875
+ >
876
+ <header class="header" part="header">
877
+ <button
878
+ class="conversation-mark"
879
+ part="session-list-button"
880
+ type="button"
881
+ title="切换会话"
882
+ aria-label="打开我的会话"
883
+ @click=${this.openSessionPanel}
884
+ >
885
+ ${prototypeIcon('messages')}
886
+ ${
887
+ this.totalUnreadCount > 0
888
+ ? html`<span
889
+ class="conversation-unread"
890
+ aria-label="${this.totalUnreadCount} 条未读"
891
+ >${formatUnreadCount(this.totalUnreadCount)}</span
892
+ >`
893
+ : nothing
894
+ }
895
+ </button>
896
+ <span class="advisor-mark" aria-hidden="true">${prototypeIcon('robot')}</span>
897
+ <span class="heading">
898
+ <span class="title">${AI_ADVISOR_TITLE}</span>
899
+ <span class="subtitle">7×24 小时在线,任何问题都可以问我</span>
900
+ </span>
901
+ <span class="sr-only" part="status" aria-live="polite">
902
+ ${connectionLabel(this.connectionState)}
903
+ </span>
904
+ <button
905
+ class="close-button"
906
+ part="close-button"
907
+ type="button"
908
+ aria-label="关闭企业服务AI顾问"
909
+ @click=${this.close}
910
+ >
911
+ ${prototypeIcon('x')}
912
+ </button>
913
+ </header>
914
+
915
+ <main class="messages" part="messages" role="log" aria-live="polite">
916
+ ${
917
+ this.visibleError
918
+ ? html`<div class="error-banner" part="error">${this.visibleError}</div>`
919
+ : nothing
920
+ }
921
+ ${this.renderTimeMarker()} ${this.renderMessages()}
922
+ ${
923
+ this.processing
924
+ ? html`<p class="activity" part="activity">数字员工处理中…</p>`
925
+ : nothing
926
+ }
927
+ </main>
928
+
929
+ <form class="composer" part="composer" @submit=${this.handleSubmit}>
930
+ <div class="composer-row">
931
+ <input
932
+ part="input"
933
+ type="text"
934
+ aria-label="消息内容"
935
+ placeholder="输入你的问题……"
936
+ .value=${this.draft}
937
+ ?disabled=${inputDisabled}
938
+ @input=${this.handleInput}
939
+ />
940
+ <button class="send-button" part="send-button" type="submit" ?disabled=${!canSend}>
941
+ ${this.sending ? '发送中…' : '发送'}
942
+ </button>
943
+ </div>
944
+ <div class="composer-actions">
945
+ <button
946
+ class="human-service-button"
947
+ part="human-service-button"
948
+ type="button"
949
+ ?disabled=${inputDisabled}
950
+ @click=${this.handleHumanServiceRequest}
951
+ >
952
+ ${prototypeIcon('headset')}转人工
953
+ </button>
954
+ </div>
955
+ </form>
956
+ </section>
957
+ `
958
+ }
959
+
960
+ protected override updated(changed: PropertyValues): void {
961
+ if (changed.has('messages') || changed.has('processing') || changed.has('visibleError')) {
962
+ const container = this.renderRoot.querySelector<HTMLElement>('.messages')
963
+ if (container) container.scrollTop = container.scrollHeight
964
+ }
965
+ }
966
+
967
+ private renderSessionPanel() {
968
+ return html`
969
+ <section class="chat" part="container" role="dialog" aria-modal="false" aria-label="我的会话">
970
+ <header class="session-header" part="session-header">
971
+ <button
972
+ class="session-back-button"
973
+ part="session-back-button"
974
+ type="button"
975
+ aria-label="返回当前会话"
976
+ @click=${this.closeSessionPanel}
977
+ >
978
+ ${prototypeIcon('arrow-left')}
979
+ </button>
980
+ <span class="session-heading">我的会话</span>
981
+ <button
982
+ class="session-new-button"
983
+ part="session-new-button"
984
+ type="button"
985
+ title="开启新对话"
986
+ aria-label="开启新对话"
987
+ ?disabled=${this.creatingSession || this.switchingSessionId !== ''}
988
+ @click=${() => void this.startNewSession()}
989
+ >
990
+ ${prototypeIcon('plus')}
991
+ </button>
992
+ <button
993
+ class="close-button"
994
+ part="close-button"
995
+ type="button"
996
+ aria-label="关闭企业服务AI顾问"
997
+ @click=${this.close}
998
+ >
999
+ ${prototypeIcon('x')}
1000
+ </button>
1001
+ </header>
1002
+ <main class="session-list" part="session-list" aria-live="polite">
1003
+ ${this.renderSessionListContent()}
1004
+ </main>
1005
+ </section>
1006
+ `
1007
+ }
1008
+
1009
+ private renderSessionListContent() {
1010
+ if (this.loadingSessions) {
1011
+ return html`<div class="session-list-loading" part="session-list-loading">正在加载会话…</div>`
1012
+ }
1013
+ if (this.sessionListError && this.sessions.length === 0) {
1014
+ return html`<div class="session-list-error" part="session-list-error">
1015
+ ${this.sessionListError}
1016
+ </div>`
1017
+ }
1018
+ if (this.sessions.length === 0) {
1019
+ return html`<div class="session-list-empty" part="session-list-empty">暂无历史会话</div>`
1020
+ }
1021
+ return html`
1022
+ ${
1023
+ this.sessionListError
1024
+ ? html`<div class="session-list-notice" part="session-list-notice">
1025
+ ${this.sessionListError}
1026
+ </div>`
1027
+ : nothing
1028
+ }
1029
+ ${this.sessions.map((session) => {
1030
+ const current = session.sessionId === this.client?.sessionId
1031
+ return html`
1032
+ <button
1033
+ class="session-item ${current ? 'current' : ''}"
1034
+ part="session-item ${current ? 'session-item-current' : ''}"
1035
+ type="button"
1036
+ aria-current=${current ? 'true' : nothing}
1037
+ ?disabled=${this.creatingSession || this.switchingSessionId !== ''}
1038
+ @click=${() => void this.selectSession(session)}
1039
+ >
1040
+ <span class="session-item-icon" aria-hidden="true">${prototypeIcon('robot')}</span>
1041
+ <span class="session-item-title">${session.title}</span>
1042
+ <span class="session-item-preview">${session.latestMessage || '暂无消息'}</span>
1043
+ <span class="session-item-side">
1044
+ <span>${formatRelativeTime(session.updatedAt)}</span>
1045
+ ${
1046
+ !current && session.unreadCount > 0
1047
+ ? html`<span
1048
+ class="session-item-unread"
1049
+ aria-label="${session.unreadCount} 条未读"
1050
+ >${formatUnreadCount(session.unreadCount)}</span
1051
+ >`
1052
+ : nothing
1053
+ }
1054
+ </span>
1055
+ </button>
1056
+ `
1057
+ })}
1058
+ `
1059
+ }
1060
+
1061
+ private renderMessages() {
1062
+ if (this.loadingHistory) {
1063
+ return html`<div class="loading" part="loading">正在加载消息…</div>`
1064
+ }
1065
+ if (this.messages.length === 0) {
1066
+ return html`<div class="empty" part="empty">开始和AI客服对话吧</div>`
1067
+ }
1068
+ return this.messages.map((message) => this.renderMessage(message))
1069
+ }
1070
+
1071
+ private renderTimeMarker() {
1072
+ const firstMessage = this.messages[0]
1073
+ if (!firstMessage) return nothing
1074
+ return html`<div class="time-marker" part="time-marker">
1075
+ 今天 ${formatMessageTime(firstMessage.createdAt)}
1076
+ </div>`
1077
+ }
1078
+
1079
+ private renderMessage(message: AiEmployeeMessage) {
1080
+ if (message.role === 'user' || message.role === 'system') {
1081
+ return html`
1082
+ <div class="message-row ${message.role}">
1083
+ <div
1084
+ class="message ${message.role}"
1085
+ part="message message-${message.role}"
1086
+ data-message-id=${message.id}
1087
+ .textContent=${message.content}
1088
+ ></div>
1089
+ </div>
1090
+ `
1091
+ }
1092
+ return html`
1093
+ <div class="message-row ${message.role}">
1094
+ <div class="assistant-message">
1095
+ <span class="assistant-avatar" aria-hidden="true">${prototypeIcon('robot')}</span>
1096
+ <div class="assistant-content">
1097
+ <span class="assistant-label"
1098
+ >${message.role === 'error' ? '系统提示' : '在线顾问'}</span
1099
+ >
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>
1107
+ </div>
1108
+ </div>
1109
+ </div>
1110
+ `
1111
+ }
1112
+
1113
+ private async startClient(): Promise<void> {
1114
+ const config = this.config
1115
+ if (!config || this.client || !this.isConnected) return
1116
+ const generation = ++this.startGeneration
1117
+ this.loadingHistory = true
1118
+ this.visibleError = ''
1119
+ this.readyDispatched = false
1120
+
1121
+ let client: AiEmployeeClient
1122
+ try {
1123
+ client = new AiEmployeeClient(config)
1124
+ } catch (error) {
1125
+ this.loadingHistory = false
1126
+ this.handlePublicError(publicError(error, 'INVALID_CONFIGURATION', '聊天配置无效。'))
1127
+ return
1128
+ }
1129
+
1130
+ this.client = client
1131
+ this.attachClientListeners(client)
1132
+ let started = false
1133
+ try {
1134
+ const result = await client.start()
1135
+ if (!this.isCurrentStart(client, generation)) return
1136
+ this.messages = result.messages
1137
+ started = true
1138
+ } catch (error) {
1139
+ if (this.isCurrentStart(client, generation)) {
1140
+ this.handlePublicError(publicError(error, 'SESSION_UNAVAILABLE', '聊天初始化失败。'))
1141
+ }
1142
+ } finally {
1143
+ if (this.isCurrentStart(client, generation)) this.loadingHistory = false
1144
+ }
1145
+ if (started && this.isCurrentStart(client, generation)) {
1146
+ await this.sendPendingInitialMessage(client)
1147
+ }
1148
+ }
1149
+
1150
+ private async sendPendingInitialMessage(client: AiEmployeeClient): Promise<void> {
1151
+ const content = this.pendingInitialMessage
1152
+ if (!content || this.client !== client) return
1153
+ this.pendingInitialMessage = ''
1154
+ await this.sendContent(content)
1155
+ }
1156
+
1157
+ private attachClientListeners(client: AiEmployeeClient): void {
1158
+ this.clientUnsubscribers = [
1159
+ client.on('history', ({ sessionId, messages }) => {
1160
+ if (this.client !== client) return
1161
+ this.messages = messages
1162
+ if (this.isCurrentConversationVisible(sessionId)) void this.markCurrentSessionRead()
1163
+ }),
1164
+ client.on('message', ({ sessionId, message, source }) => {
1165
+ if (this.client !== client) return
1166
+ if (source === 'local') this.localMessageIds.add(message.id)
1167
+ this.messages = client.messages
1168
+ const visible = this.isCurrentConversationVisible(sessionId)
1169
+ const summary = this.updateSessionFromMessage(message, source, visible)
1170
+ if (visible && source === 'realtime') void this.markSessionRead(summary)
1171
+ if (source === 'local') {
1172
+ this.dispatchPublicEvent('message-sent', { sessionId, message })
1173
+ } else {
1174
+ this.dispatchPublicEvent('message-received', { sessionId, message })
1175
+ }
1176
+ }),
1177
+ client.on('connection-state-change', ({ current }) => {
1178
+ if (this.client !== client) return
1179
+ this.connectionState = current
1180
+ this.dispatchPublicEvent('connection-state-change', { state: current })
1181
+ }),
1182
+ client.on('processing-state-change', ({ processing }) => {
1183
+ if (this.client === client) this.processing = processing
1184
+ }),
1185
+ client.on('title', ({ sessionId, title }) => {
1186
+ if (this.client !== client) return
1187
+ this.chatTitle = title
1188
+ this.sessions = this.sessions.map((session) =>
1189
+ session.sessionId === sessionId ? { ...session, title } : session,
1190
+ )
1191
+ }),
1192
+ client.on('ready', ({ sessionId }) => {
1193
+ if (this.client !== client || this.readyDispatched) return
1194
+ this.readyDispatched = true
1195
+ this.dispatchPublicEvent('ready', { sessionId })
1196
+ }),
1197
+ client.on('error', (detail) => {
1198
+ if (this.client === client) this.handlePublicError(detail)
1199
+ }),
1200
+ ]
1201
+ }
1202
+
1203
+ private get totalUnreadCount(): number {
1204
+ const visibleSessionId = this.currentVisibleSessionId
1205
+ return this.sessions.reduce(
1206
+ (total, session) =>
1207
+ total + (session.sessionId === visibleSessionId ? 0 : session.unreadCount),
1208
+ 0,
1209
+ )
1210
+ }
1211
+
1212
+ private get currentVisibleSessionId(): string {
1213
+ return this.drawerOpen && !this.sessionPanelOpen ? (this.client?.sessionId ?? '') : ''
1214
+ }
1215
+
1216
+ private isCurrentConversationVisible(sessionId: string): boolean {
1217
+ return Boolean(sessionId) && sessionId === this.currentVisibleSessionId
1218
+ }
1219
+
1220
+ private openSessionPanel(): void {
1221
+ if (this.sessionPanelOpen) return
1222
+ void this.markCurrentSessionRead()
1223
+ this.sessionPanelOpen = true
1224
+ void this.refreshSessions()
1225
+ void this.updateComplete.then(() => {
1226
+ this.renderRoot.querySelector<HTMLButtonElement>('.session-back-button')?.focus()
1227
+ })
1228
+ }
1229
+
1230
+ private closeSessionPanel(): void {
1231
+ if (!this.sessionPanelOpen) return
1232
+ this.sessionListGeneration += 1
1233
+ this.loadingSessions = false
1234
+ this.sessionPanelOpen = false
1235
+ void this.markCurrentSessionRead()
1236
+ void this.updateComplete.then(() => {
1237
+ this.renderRoot.querySelector<HTMLButtonElement>('.conversation-mark')?.focus()
1238
+ })
1239
+ }
1240
+
1241
+ private async refreshSessions(): Promise<void> {
1242
+ const transport = this.config?.transport
1243
+ const listSessions = transport?.listSessions
1244
+ const generation = ++this.sessionListGeneration
1245
+ this.loadingSessions = true
1246
+ this.sessionListError = ''
1247
+ if (!listSessions) {
1248
+ this.loadingSessions = false
1249
+ this.sessions = this.currentSessionSummary()
1250
+ return
1251
+ }
1252
+ try {
1253
+ const sessions = normalizeSessionSummaries(await listSessions.call(transport))
1254
+ if (!this.isCurrentSessionList(generation)) return
1255
+ const mergedSessions = mergeCurrentSession(sessions, this.currentSessionSummary())
1256
+ this.sessions = this.applyLocallyReadState(mergedSessions)
1257
+ const currentSession = mergedSessions.find(
1258
+ (session) => session.sessionId === this.client?.sessionId,
1259
+ )
1260
+ if (currentSession) {
1261
+ this.sessions = this.sessions.map((session) =>
1262
+ session.sessionId === currentSession.sessionId && session.unreadCount > 0
1263
+ ? { ...session, unreadCount: 0 }
1264
+ : session,
1265
+ )
1266
+ void this.markSessionRead(currentSession)
1267
+ }
1268
+ } catch {
1269
+ if (!this.isCurrentSessionList(generation)) return
1270
+ this.sessions = this.currentSessionSummary()
1271
+ this.sessionListError =
1272
+ this.sessions.length > 0
1273
+ ? '历史会话暂时无法加载,已显示当前会话。'
1274
+ : '会话列表加载失败,请稍后重试。'
1275
+ this.dispatchPublicEvent('error', {
1276
+ code: 'SESSION_UNAVAILABLE',
1277
+ message: this.sessionListError,
1278
+ recoverable: true,
1279
+ })
1280
+ } finally {
1281
+ if (this.isCurrentSessionList(generation)) this.loadingSessions = false
1282
+ }
1283
+ }
1284
+
1285
+ private async selectSession(session: AiEmployeeSessionSummary): Promise<void> {
1286
+ const client = this.client
1287
+ if (!client || this.switchingSessionId || this.creatingSession) return
1288
+ if (client.sessionId === session.sessionId) {
1289
+ this.closeSessionPanel()
1290
+ void this.markSessionRead(session)
1291
+ return
1292
+ }
1293
+
1294
+ this.switchingSessionId = session.sessionId
1295
+ this.sessionListError = ''
1296
+ this.readyDispatched = false
1297
+ try {
1298
+ const result = await client.bindSession(session.sessionId)
1299
+ if (this.client !== client || !this.drawerOpen) return
1300
+ this.messages = result.messages
1301
+ this.chatTitle = session.title
1302
+ this.sessionPanelOpen = false
1303
+ void this.markCurrentSessionRead()
1304
+ } catch {
1305
+ if (this.client === client && this.drawerOpen) {
1306
+ this.sessionListError = '会话切换失败,请稍后重试。'
1307
+ this.dispatchPublicEvent('error', {
1308
+ code: 'SESSION_UNAVAILABLE',
1309
+ message: this.sessionListError,
1310
+ recoverable: true,
1311
+ })
1312
+ }
1313
+ } finally {
1314
+ if (this.client === client) this.switchingSessionId = ''
1315
+ }
1316
+ }
1317
+
1318
+ private async startNewSession(): Promise<void> {
1319
+ const client = this.client
1320
+ if (!client || this.creatingSession || this.switchingSessionId) return
1321
+
1322
+ this.creatingSession = true
1323
+ this.sessionListError = ''
1324
+ this.readyDispatched = false
1325
+ try {
1326
+ const result = await client.startNewConversation()
1327
+ if (this.client !== client || !this.drawerOpen) return
1328
+ this.messages = result.messages
1329
+ this.chatTitle = '新对话'
1330
+ if (result.sessionId) {
1331
+ const createdAt = new Date().toISOString()
1332
+ this.sessions = [
1333
+ {
1334
+ sessionId: result.sessionId,
1335
+ title: '新对话',
1336
+ latestMessage: '',
1337
+ updatedAt: createdAt,
1338
+ unreadCount: 0,
1339
+ },
1340
+ ...this.sessions.filter((session) => session.sessionId !== result.sessionId),
1341
+ ]
1342
+ }
1343
+ this.sessionPanelOpen = false
1344
+ } catch {
1345
+ if (this.client === client && this.drawerOpen) {
1346
+ this.sessionListError = '新对话创建失败,请稍后重试。'
1347
+ this.dispatchPublicEvent('error', {
1348
+ code: 'SESSION_UNAVAILABLE',
1349
+ message: this.sessionListError,
1350
+ recoverable: true,
1351
+ })
1352
+ }
1353
+ } finally {
1354
+ if (this.client === client) this.creatingSession = false
1355
+ }
1356
+ }
1357
+
1358
+ private applyLocallyReadState(
1359
+ sessions: readonly AiEmployeeSessionSummary[],
1360
+ ): AiEmployeeSessionSummary[] {
1361
+ return sessions.map((session) => {
1362
+ const boundary = this.locallyReadBoundaries.get(session.sessionId)
1363
+ if (!boundary) return session
1364
+ const updatedAtMs = Date.parse(session.updatedAt)
1365
+ const sameMessage =
1366
+ Boolean(boundary.latestMessageId) && session.latestMessageId === boundary.latestMessageId
1367
+ const withinReadBoundary = Number.isFinite(updatedAtMs) && updatedAtMs <= boundary.updatedAtMs
1368
+ if (!sameMessage && !withinReadBoundary) return session
1369
+ return session.unreadCount > 0 ? { ...session, unreadCount: 0 } : session
1370
+ })
1371
+ }
1372
+
1373
+ private rememberLocalReadBoundary(session: AiEmployeeSessionSummary): void {
1374
+ const updatedAtMs = Date.parse(session.updatedAt)
1375
+ if (!Number.isFinite(updatedAtMs)) return
1376
+ const existing = this.locallyReadBoundaries.get(session.sessionId)
1377
+ if (existing && existing.updatedAtMs > updatedAtMs) return
1378
+ const boundary: LocalReadBoundary = { updatedAtMs }
1379
+ if (session.latestMessageId) boundary.latestMessageId = session.latestMessageId
1380
+ this.locallyReadBoundaries.set(session.sessionId, boundary)
1381
+ }
1382
+
1383
+ private updateSessionFromMessage(
1384
+ message: AiEmployeeMessage,
1385
+ source: 'local' | 'realtime',
1386
+ visible: boolean,
1387
+ ): AiEmployeeSessionSummary {
1388
+ const existing = this.sessions.find((session) => session.sessionId === message.sessionId)
1389
+ const unreadIncrement = source === 'realtime' && !visible && message.role !== 'user' ? 1 : 0
1390
+ const summary: AiEmployeeSessionSummary = {
1391
+ sessionId: message.sessionId,
1392
+ title: existing?.title || this.chatTitle || '新对话',
1393
+ latestMessage: message.content,
1394
+ updatedAt: message.createdAt,
1395
+ unreadCount: visible ? 0 : Math.max(0, (existing?.unreadCount ?? 0) + unreadIncrement),
1396
+ }
1397
+ const latestMessageId = source === 'local' ? existing?.latestMessageId : message.id
1398
+ if (latestMessageId) summary.latestMessageId = latestMessageId
1399
+ this.sessions = [
1400
+ summary,
1401
+ ...this.sessions.filter((session) => session.sessionId !== message.sessionId),
1402
+ ]
1403
+ return summary
1404
+ }
1405
+
1406
+ private currentSessionSummary(): AiEmployeeSessionSummary[] {
1407
+ const sessionId = this.client?.sessionId
1408
+ if (!sessionId) return []
1409
+ const latestMessage = this.messages.at(-1)
1410
+ let latestServerMessage: AiEmployeeMessage | undefined
1411
+ for (let index = this.messages.length - 1; index >= 0; index -= 1) {
1412
+ const candidate = this.messages[index]
1413
+ if (candidate && !this.localMessageIds.has(candidate.id)) {
1414
+ latestServerMessage = candidate
1415
+ break
1416
+ }
1417
+ }
1418
+ const summary: AiEmployeeSessionSummary = {
1419
+ sessionId,
1420
+ title: this.chatTitle || '新对话',
1421
+ latestMessage: latestMessage?.content ?? '',
1422
+ updatedAt: latestMessage?.createdAt ?? new Date().toISOString(),
1423
+ unreadCount: 0,
1424
+ }
1425
+ if (latestServerMessage) summary.latestMessageId = latestServerMessage.id
1426
+ return [summary]
1427
+ }
1428
+
1429
+ private async markSessionRead(session: AiEmployeeSessionSummary): Promise<void> {
1430
+ const transport = this.config?.transport
1431
+ const markSessionRead = transport?.markSessionRead
1432
+ this.rememberLocalReadBoundary(session)
1433
+ this.sessions = this.applyLocallyReadState(this.sessions)
1434
+ const latestMessageId = session.latestMessageId
1435
+ if (!latestMessageId) return
1436
+
1437
+ if (!markSessionRead) return
1438
+ if (this.confirmedReadMessageIds.get(session.sessionId) === latestMessageId) return
1439
+ if (this.queuedReadMessageIds.get(session.sessionId) === latestMessageId) return
1440
+
1441
+ const generation = this.readSyncGeneration
1442
+ const previous = this.readSyncChains.get(session.sessionId) ?? Promise.resolve()
1443
+ this.queuedReadMessageIds.set(session.sessionId, latestMessageId)
1444
+ const task = previous
1445
+ .catch(() => undefined)
1446
+ .then(async () => {
1447
+ if (generation !== this.readSyncGeneration || this.config?.transport !== transport) return
1448
+ try {
1449
+ await markSessionRead.call(transport, session.sessionId, latestMessageId)
1450
+ if (generation === this.readSyncGeneration && this.config?.transport === transport) {
1451
+ this.confirmedReadMessageIds.set(session.sessionId, latestMessageId)
1452
+ }
1453
+ } catch {
1454
+ // The visible conversation remains locally read; a later visibility trigger retries sync.
1455
+ }
1456
+ })
1457
+ .finally(() => {
1458
+ if (this.readSyncChains.get(session.sessionId) === task) {
1459
+ this.readSyncChains.delete(session.sessionId)
1460
+ }
1461
+ if (this.queuedReadMessageIds.get(session.sessionId) === latestMessageId) {
1462
+ this.queuedReadMessageIds.delete(session.sessionId)
1463
+ }
1464
+ })
1465
+ this.readSyncChains.set(session.sessionId, task)
1466
+ await task
1467
+ }
1468
+
1469
+ private async markCurrentSessionRead(): Promise<void> {
1470
+ const summary = this.currentSessionSummary()[0]
1471
+ if (summary) await this.markSessionRead(summary)
1472
+ }
1473
+
1474
+ private isCurrentSessionList(generation: number): boolean {
1475
+ return (
1476
+ this.sessionListGeneration === generation &&
1477
+ this.drawerOpen &&
1478
+ this.sessionPanelOpen &&
1479
+ this.isConnected
1480
+ )
1481
+ }
1482
+
1483
+ private async handleSubmit(event: SubmitEvent): Promise<void> {
1484
+ event.preventDefault()
1485
+ await this.sendContent(this.draft, true)
1486
+ }
1487
+
1488
+ private handleHumanServiceRequest(): void {
1489
+ const customerSupport = this.resolveCustomerSupportConfig()
1490
+ if (!customerSupport) {
1491
+ this.handlePublicError({
1492
+ code: 'INVALID_CONFIGURATION',
1493
+ message: '人工客服暂未配置。',
1494
+ recoverable: false,
1495
+ })
1496
+ return
1497
+ }
1498
+ this.releaseClient()
1499
+ this.customerSupportOpen = true
1500
+ void this.updateComplete.then(() => {
1501
+ const component = this.renderRoot.querySelector<UniplatCustomerSupportChat>(
1502
+ 'uniplat-customer-support-chat',
1503
+ )
1504
+ if (!component || !this.customerSupportOpen) return
1505
+ component.configure(customerSupport)
1506
+ void component.open()
1507
+ })
1508
+ }
1509
+
1510
+ private resolveCustomerSupportConfig(): CustomerSupportChatConfig | null {
1511
+ const config = this.config
1512
+ if (!config) return null
1513
+ if (config.customerSupport) return config.customerSupport
1514
+ return {
1515
+ realtimeUrl: config.realtimeUrl,
1516
+ transport: createHrSaasCustomerSupportTransport({
1517
+ serviceBaseUrl: '/api/general/project/hr_saas/service/customer_support_api',
1518
+ getAccessToken: () => config.transport.getAccessToken(),
1519
+ }),
1520
+ reconnect: config.reconnect,
1521
+ webSocketFactory: config.webSocketFactory,
1522
+ requestIdFactory: config.requestIdFactory,
1523
+ minimumHeartbeatMs: config.minimumHeartbeatMs,
1524
+ }
1525
+ }
1526
+
1527
+ private handleCustomerSupportClosed = (): void => {
1528
+ this.customerSupportOpen = false
1529
+ this.drawerOpen = false
1530
+ window.removeEventListener('keydown', this.handleWindowKeyDown)
1531
+ }
1532
+
1533
+ private handleAiChatRequested = (): void => {
1534
+ this.customerSupportOpen = false
1535
+ void this.updateComplete.then(() => {
1536
+ if (this.drawerOpen && this.isConnected && this.config && !this.client) {
1537
+ void this.startClient()
1538
+ }
1539
+ })
1540
+ }
1541
+
1542
+ private async sendContent(value: string, clearDraft = false): Promise<void> {
1543
+ const client = this.client
1544
+ const content = value.trim()
1545
+ if (!client || !content || this.sending) return
1546
+ this.sending = true
1547
+ this.visibleError = ''
1548
+ try {
1549
+ await client.sendMessage(content)
1550
+ if (this.client === client && clearDraft) this.draft = ''
1551
+ } catch (error) {
1552
+ if (
1553
+ !(error instanceof AiEmployeeClientError) ||
1554
+ !['SEND_FAILED', 'RATE_LIMITED', 'FIRST_MESSAGE_CONFLICT'].includes(error.code)
1555
+ ) {
1556
+ this.handlePublicError(publicError(error, 'SEND_FAILED', '消息发送失败,请稍后重试。'))
1557
+ }
1558
+ } finally {
1559
+ if (this.client === client) this.sending = false
1560
+ }
1561
+ }
1562
+
1563
+ private handleInput(event: InputEvent): void {
1564
+ this.draft = (event.currentTarget as HTMLInputElement).value
1565
+ }
1566
+
1567
+ private handlePublicError(detail: AiEmployeeClientErrorDetail): void {
1568
+ this.visibleError = detail.message
1569
+ this.dispatchPublicEvent('error', detail)
1570
+ }
1571
+
1572
+ private dispatchPublicEvent<K extends keyof AiEmployeeChatEventMap>(
1573
+ type: K,
1574
+ detail: AiEmployeeChatEventMap[K]['detail'],
1575
+ ): void {
1576
+ this.dispatchEvent(new CustomEvent(type, { detail, bubbles: true, composed: true }))
1577
+ }
1578
+
1579
+ private isCurrentStart(client: AiEmployeeClient, generation: number): boolean {
1580
+ return (
1581
+ this.client === client &&
1582
+ this.startGeneration === generation &&
1583
+ this.isConnected &&
1584
+ this.drawerOpen
1585
+ )
1586
+ }
1587
+
1588
+ private releaseClient(): void {
1589
+ this.startGeneration += 1
1590
+ this.sessionListGeneration += 1
1591
+ for (const unsubscribe of this.clientUnsubscribers.splice(0)) unsubscribe()
1592
+ this.client?.destroy()
1593
+ this.client = null
1594
+ this.loadingHistory = false
1595
+ this.sending = false
1596
+ this.processing = false
1597
+ this.loadingSessions = false
1598
+ this.switchingSessionId = ''
1599
+ this.creatingSession = false
1600
+ this.connectionState = 'disconnected'
1601
+ }
1602
+
1603
+ private resetView(): void {
1604
+ this.readSyncGeneration += 1
1605
+ this.localMessageIds.clear()
1606
+ this.locallyReadBoundaries.clear()
1607
+ this.confirmedReadMessageIds.clear()
1608
+ this.queuedReadMessageIds.clear()
1609
+ this.readSyncChains.clear()
1610
+ this.messages = []
1611
+ this.draft = ''
1612
+ this.pendingInitialMessage = ''
1613
+ this.visibleError = ''
1614
+ this.chatTitle = '企业服务顾问'
1615
+ this.sessionPanelOpen = false
1616
+ this.sessions = []
1617
+ this.loadingSessions = false
1618
+ this.sessionListError = ''
1619
+ this.switchingSessionId = ''
1620
+ this.creatingSession = false
1621
+ this.connectionState = 'idle'
1622
+ }
1623
+ }
1624
+
1625
+ function formatMessageTime(value: string): string {
1626
+ const date = new Date(value)
1627
+ if (Number.isNaN(date.getTime())) return ''
1628
+ return new Intl.DateTimeFormat('zh-CN', {
1629
+ hour: '2-digit',
1630
+ minute: '2-digit',
1631
+ hour12: false,
1632
+ }).format(date)
1633
+ }
1634
+
1635
+ function formatRelativeTime(value: string): string {
1636
+ const date = new Date(value)
1637
+ if (Number.isNaN(date.getTime())) return ''
1638
+ const elapsed = Date.now() - date.getTime()
1639
+ if (elapsed < 60_000) return '刚刚'
1640
+ if (elapsed < 3_600_000) return `${Math.max(1, Math.floor(elapsed / 60_000))} 分钟前`
1641
+ if (elapsed < 86_400_000) return `${Math.max(1, Math.floor(elapsed / 3_600_000))} 小时前`
1642
+ if (elapsed < 172_800_000) return '昨天'
1643
+ return new Intl.DateTimeFormat('zh-CN', { month: 'numeric', day: 'numeric' }).format(date)
1644
+ }
1645
+
1646
+ function formatUnreadCount(value: number): string {
1647
+ return value > 99 ? '99+' : String(value)
1648
+ }
1649
+
1650
+ function normalizeSessionSummaries(value: unknown): AiEmployeeSessionSummary[] {
1651
+ if (!Array.isArray(value)) return []
1652
+ const sessions: AiEmployeeSessionSummary[] = []
1653
+ const identifiers = new Set<string>()
1654
+ for (const candidate of value) {
1655
+ if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) continue
1656
+ const record = candidate as Record<string, unknown>
1657
+ const sessionId = safeSessionText(record.sessionId, 128)
1658
+ if (!sessionId || identifiers.has(sessionId)) continue
1659
+ const latestMessage = safeSessionText(record.latestMessage, 240)
1660
+ const serverTitle = safeSessionText(record.title, 100)
1661
+ const title =
1662
+ serverTitle && serverTitle !== '新对话'
1663
+ ? serverTitle
1664
+ : deriveSessionTitle(latestMessage) || '新对话'
1665
+ const latestMessageId = safeSessionText(record.latestMessageId, 128)
1666
+ const updatedAt = safeSessionDate(record.updatedAt)
1667
+ const unreadCount = safeUnreadCount(record.unreadCount)
1668
+ const session: AiEmployeeSessionSummary = {
1669
+ sessionId,
1670
+ title,
1671
+ latestMessage,
1672
+ updatedAt,
1673
+ unreadCount,
1674
+ }
1675
+ if (latestMessageId) session.latestMessageId = latestMessageId
1676
+ sessions.push(session)
1677
+ identifiers.add(sessionId)
1678
+ }
1679
+ return sessions
1680
+ }
1681
+
1682
+ function mergeCurrentSession(
1683
+ sessions: AiEmployeeSessionSummary[],
1684
+ current: AiEmployeeSessionSummary[],
1685
+ ): AiEmployeeSessionSummary[] {
1686
+ const currentSession = current[0]
1687
+ if (
1688
+ !currentSession ||
1689
+ sessions.some((session) => session.sessionId === currentSession.sessionId)
1690
+ ) {
1691
+ return sessions
1692
+ }
1693
+ return [currentSession, ...sessions]
1694
+ }
1695
+
1696
+ function safeSessionText(value: unknown, maximumLength: number): string {
1697
+ return typeof value === 'string' ? value.trim().slice(0, maximumLength) : ''
1698
+ }
1699
+
1700
+ function deriveSessionTitle(latestMessage: string): string {
1701
+ const normalized = latestMessage
1702
+ .replace(/[*_`#>]/g, '')
1703
+ .replace(/\s+/g, ' ')
1704
+ .replace(/^(?:您好|你好)[!!,,。.::\s]*/u, '')
1705
+ .trim()
1706
+ if (!normalized) return ''
1707
+
1708
+ const sentenceEnd = normalized.search(/[。!?!?]/u)
1709
+ const candidate =
1710
+ sentenceEnd >= 5 && sentenceEnd < 24 ? normalized.slice(0, sentenceEnd + 1) : normalized
1711
+ const characters = Array.from(candidate)
1712
+ return characters.length > 18 ? `${characters.slice(0, 18).join('')}…` : candidate
1713
+ }
1714
+
1715
+ function safeSessionDate(value: unknown): string {
1716
+ if (typeof value !== 'string') return ''
1717
+ const date = new Date(value)
1718
+ return Number.isNaN(date.getTime()) ? '' : date.toISOString()
1719
+ }
1720
+
1721
+ function safeUnreadCount(value: unknown): number {
1722
+ return typeof value === 'number' && Number.isSafeInteger(value) && value > 0
1723
+ ? Math.min(value, 999)
1724
+ : 0
1725
+ }
1726
+
1727
+ function connectionLabel(state: AiEmployeeConnectionState): string {
1728
+ switch (state) {
1729
+ case 'connected':
1730
+ return '已连接'
1731
+ case 'connecting':
1732
+ case 'authenticating':
1733
+ case 'subscribing':
1734
+ return '连接中'
1735
+ case 'reconnecting':
1736
+ return '重新连接中'
1737
+ case 'destroyed':
1738
+ case 'disconnected':
1739
+ return '已断线'
1740
+ default:
1741
+ return '未连接'
1742
+ }
1743
+ }
1744
+
1745
+ function publicError(
1746
+ error: unknown,
1747
+ fallbackCode: AiEmployeeClientErrorDetail['code'],
1748
+ fallbackMessage: string,
1749
+ ): AiEmployeeClientErrorDetail {
1750
+ if (error instanceof AiEmployeeClientError) {
1751
+ return { code: error.code, message: error.message, recoverable: false }
1752
+ }
1753
+ return { code: fallbackCode, message: fallbackMessage, recoverable: true }
1754
+ }