rio-assist-widget 0.1.18 → 0.1.26

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.
@@ -1,34 +1,58 @@
1
1
  import { LitElement, type PropertyValues } from 'lit';
2
- import { widgetStyles } from './rio-assist.styles';
3
- import { renderRioAssist } from './rio-assist.template';
4
- import {
5
- RioWebsocketClient,
6
- type RioIncomingMessage,
7
- } from '../../services/rioWebsocket';
8
- import MarkdownIt from 'markdown-it';
9
- import markdownItTaskLists from 'markdown-it-task-lists';
10
- import DOMPurify from 'dompurify';
2
+ import { widgetStyles } from './rio-assist.styles';
3
+ import { renderRioAssist } from './rio-assist.template';
4
+ import {
5
+ RioWebsocketClient,
6
+ type RioIncomingMessage,
7
+ } from '../../services/rioWebsocket';
8
+ import MarkdownIt from 'markdown-it';
9
+ import markdownItTaskLists from 'markdown-it-task-lists';
10
+ import DOMPurify from 'dompurify';
11
11
 
12
12
  type ChatRole = 'user' | 'assistant';
13
13
 
14
- export type ChatMessage = {
15
- id: string;
16
- role: ChatRole;
17
- text: string;
18
- html?: string;
19
- timestamp: number;
20
- };
21
-
22
- type ConversationItem = {
14
+ export type ChatMessage = {
15
+ id: string;
16
+ role: ChatRole;
17
+ text: string;
18
+ html?: string;
19
+ timestamp: number;
20
+ };
21
+
22
+ type ConversationItem = {
23
+ id: string;
24
+ title: string;
25
+ updatedAt: string;
26
+ };
27
+
28
+ type ConversationDeleteTarget = {
29
+ id: string;
30
+ title: string;
31
+ index: number;
32
+ };
33
+
34
+ type ConversationRenameTarget = {
23
35
  id: string;
24
36
  title: string;
25
- updatedAt: string;
37
+ index: number;
38
+ draft: string;
26
39
  };
27
40
 
28
- type ConversationDeleteTarget = {
29
- id: string;
30
- title: string;
41
+ type ConversationActionKind = 'rename' | 'delete';
42
+
43
+ type ConversationActionAttempt = {
44
+ action: ConversationActionKind;
45
+ conversationId: string;
46
+ originalTitle: string;
31
47
  index: number;
48
+ newTitle?: string;
49
+ snapshot?: ConversationItem;
50
+ messagesSnapshot?: ChatMessage[];
51
+ wasActive?: boolean;
52
+ };
53
+
54
+ type ConversationActionErrorState = ConversationActionAttempt & {
55
+ message: string;
32
56
  };
33
57
 
34
58
  export type HeaderActionConfig = {
@@ -36,9 +60,9 @@ export type HeaderActionConfig = {
36
60
  iconUrl: string;
37
61
  ariaLabel?: string;
38
62
  onClick?: () => void;
39
- };
40
-
41
- export class RioAssistWidget extends LitElement {
63
+ };
64
+
65
+ export class RioAssistWidget extends LitElement {
42
66
  static styles = widgetStyles;
43
67
 
44
68
  static properties = {
@@ -57,15 +81,17 @@ export class RioAssistWidget extends LitElement {
57
81
  showConversations: { type: Boolean, state: true },
58
82
  conversationSearch: { type: String, state: true },
59
83
  conversationMenuId: { state: true },
60
- conversationMenuPlacement: { state: true },
61
- isFullscreen: { type: Boolean, state: true },
62
- conversationScrollbar: { state: true },
63
- showNewConversationShortcut: { type: Boolean, state: true },
84
+ conversationMenuPlacement: { state: true },
85
+ isFullscreen: { type: Boolean, state: true },
86
+ conversationScrollbar: { state: true },
87
+ showNewConversationShortcut: { type: Boolean, state: true },
64
88
  conversations: { state: true },
65
89
  conversationHistoryLoading: { type: Boolean, state: true },
66
90
  activeConversationTitle: { state: true },
67
91
  conversationHistoryError: { type: String, state: true },
68
92
  deleteConversationTarget: { attribute: false },
93
+ renameConversationTarget: { attribute: false },
94
+ conversationActionError: { attribute: false },
69
95
  headerActions: { attribute: false },
70
96
  homeUrl: { type: String, attribute: 'data-home-url' },
71
97
  };
@@ -88,12 +114,18 @@ export class RioAssistWidget extends LitElement {
88
114
 
89
115
  suggestionsSource = '';
90
116
 
117
+ private randomizedSuggestions: string[] = [];
118
+
91
119
  messages: ChatMessage[] = [];
92
120
 
93
121
  isLoading = false;
94
122
 
95
123
  errorMessage = '';
96
124
 
125
+ get loadingLabel() {
126
+ return this.loadingLabelInternal;
127
+ }
128
+
97
129
  showConversations = false;
98
130
 
99
131
  conversationSearch = '';
@@ -102,141 +134,180 @@ export class RioAssistWidget extends LitElement {
102
134
 
103
135
  conversationMenuPlacement: 'above' | 'below' = 'below';
104
136
 
105
- isFullscreen = false;
106
-
107
- showNewConversationShortcut = false;
108
-
109
- conversationScrollbar = {
110
- height: 0,
111
- top: 0,
112
- visible: false,
113
- };
114
-
115
- conversationHistoryLoading = false;
116
-
117
- conversationHistoryError = '';
118
-
119
- deleteConversationTarget: ConversationDeleteTarget | null = null;
137
+ isFullscreen = false;
138
+
139
+ showNewConversationShortcut = false;
140
+
141
+ conversationScrollbar = {
142
+ height: 0,
143
+ top: 0,
144
+ visible: false,
145
+ };
146
+
147
+ conversationHistoryLoading = false;
148
+
149
+ conversationHistoryError = '';
150
+
151
+ deleteConversationTarget: ConversationDeleteTarget | null = null;
152
+
153
+ renameConversationTarget: ConversationRenameTarget | null = null;
120
154
 
121
- private refreshConversationsAfterResponse = false;
155
+ conversationActionError: ConversationActionErrorState | null = null;
122
156
 
123
- activeConversationTitle: string | null = null;
157
+ private loadingLabelInternal = 'Rio Insight está respondendo...';
158
+ private loadingTimerSlow: number | null = null;
159
+ private loadingTimerTimeout: number | null = null;
160
+
161
+ private refreshConversationsAfterResponse = false;
162
+
163
+ activeConversationTitle: string | null = null;
124
164
 
125
165
  headerActions: HeaderActionConfig[] = [];
126
166
 
127
167
  homeUrl = '';
128
168
 
129
- private generateConversationId() {
130
- if (!this.conversationUserId) {
131
- this.conversationUserId = this.inferUserIdFromToken();
132
- }
133
-
134
- const userSegment = this.conversationUserId ?? 'user';
135
- const id = `default-${userSegment}-${this.randomId(8)}`;
136
- console.info('[RioAssist][conversation] gerando conversationId', id);
137
- return id;
138
- }
139
-
140
- private inferUserIdFromToken(): string | null {
141
- const token = this.rioToken.trim();
142
- if (!token || !token.includes('.')) {
143
- return null;
144
- }
145
-
146
- const [, payload] = token.split('.');
147
- try {
148
- const decoded = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
149
- const candidate =
150
- decoded?.userId ??
151
- decoded?.user_id ??
152
- decoded?.sub ??
153
- decoded?.id ??
154
- decoded?.email ??
155
- decoded?.username;
156
-
157
- if (candidate && typeof candidate === 'string') {
158
- return candidate.replace(/[^a-zA-Z0-9_-]/g, '');
159
- }
160
- } catch {
161
- return null;
162
- }
163
-
164
- return null;
165
- }
169
+ private pendingConversationAction: ConversationActionAttempt | null = null;
166
170
 
167
- private randomId(length: number) {
168
- const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
169
- let result = '';
170
- for (let i = 0; i < length; i += 1) {
171
- result += chars.charAt(Math.floor(Math.random() * chars.length));
172
- }
173
- return result;
174
- }
171
+ private generateConversationId() {
172
+ if (!this.conversationUserId) {
173
+ this.conversationUserId = this.inferUserIdFromToken();
174
+ }
175
+
176
+ const userSegment = this.conversationUserId ?? 'user';
177
+ const id = `default-${userSegment}-${this.randomId(8)}`;
178
+ console.info('[RioAssist][conversation] gerando conversationId', id);
179
+ return id;
180
+ }
181
+
182
+ private inferUserIdFromToken(): string | null {
183
+ const token = this.rioToken.trim();
184
+ if (!token || !token.includes('.')) {
185
+ return null;
186
+ }
187
+
188
+ const [, payload] = token.split('.');
189
+ try {
190
+ const decoded = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
191
+ const candidate =
192
+ decoded?.userId ??
193
+ decoded?.user_id ??
194
+ decoded?.sub ??
195
+ decoded?.id ??
196
+ decoded?.email ??
197
+ decoded?.username;
198
+
199
+ if (candidate && typeof candidate === 'string') {
200
+ return candidate.replace(/[^a-zA-Z0-9_-]/g, '');
201
+ }
202
+ } catch {
203
+ return null;
204
+ }
205
+
206
+ return null;
207
+ }
208
+
209
+ private randomId(length: number) {
210
+ const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
211
+ let result = '';
212
+ for (let i = 0; i < length; i += 1) {
213
+ result += chars.charAt(Math.floor(Math.random() * chars.length));
214
+ }
215
+ return result;
216
+ }
175
217
 
176
218
  private conversationScrollbarRaf: number | null = null;
177
219
 
178
- private rioClient: RioWebsocketClient | null = null;
179
-
180
- private rioUnsubscribe: (() => void) | null = null;
181
-
182
- private loadingTimer: number | null = null;
183
-
184
- private currentConversationId: string | null = null;
185
-
186
- private conversationCounter = 0;
187
-
188
- private conversationUserId: string | null = null;
189
-
190
- private conversationScrollbarDraggingId: number | null = null;
191
-
192
- private conversationScrollbarDragState: {
193
- startY: number;
194
- startThumbTop: number;
195
- trackHeight: number;
196
- thumbHeight: number;
197
- list: HTMLElement;
198
- } | null = null;
199
-
200
- private markdownRenderer = new MarkdownIt({
201
- html: false,
202
- linkify: true,
203
- breaks: true,
204
- }).use(markdownItTaskLists);
220
+ private rioClient: RioWebsocketClient | null = null;
221
+
222
+ private rioUnsubscribe: (() => void) | null = null;
223
+
224
+ private loadingTimer: number | null = null;
225
+
226
+ private currentConversationId: string | null = null;
227
+
228
+ private conversationCounter = 0;
229
+
230
+ private conversationUserId: string | null = null;
231
+
232
+ private conversationScrollbarDraggingId: number | null = null;
233
+
234
+ private conversationScrollbarDragState: {
235
+ startY: number;
236
+ startThumbTop: number;
237
+ trackHeight: number;
238
+ thumbHeight: number;
239
+ list: HTMLElement;
240
+ } | null = null;
241
+
242
+ private markdownRenderer = new MarkdownIt({
243
+ html: false,
244
+ linkify: true,
245
+ breaks: true,
246
+ }).use(markdownItTaskLists);
205
247
 
206
248
  conversations: ConversationItem[] = [];
207
249
 
208
250
  get suggestions(): string[] {
209
- if (!this.suggestionsSource) {
251
+ return this.randomizedSuggestions;
252
+ }
253
+
254
+ private parseSuggestions(source: string): string[] {
255
+ if (!source) {
210
256
  return [];
211
257
  }
212
258
 
213
- return this.suggestionsSource
259
+ return source
214
260
  .split('|')
215
261
  .map((item) => item.trim())
216
262
  .filter(Boolean);
217
263
  }
218
264
 
265
+ private pickRandomSuggestions(options: string[], count: number): string[] {
266
+ if (options.length <= count) {
267
+ return [...options];
268
+ }
269
+
270
+ const pool = [...options];
271
+ for (let index = pool.length - 1; index > 0; index -= 1) {
272
+ const swapIndex = Math.floor(Math.random() * (index + 1));
273
+ [pool[index], pool[swapIndex]] = [pool[swapIndex], pool[index]];
274
+ }
275
+
276
+ return pool.slice(0, count);
277
+ }
278
+
279
+ protected willUpdate(changedProperties: PropertyValues): void {
280
+ super.willUpdate(changedProperties);
281
+
282
+ if (changedProperties.has('suggestionsSource')) {
283
+ this.randomizedSuggestions = this.pickRandomSuggestions(
284
+ this.parseSuggestions(this.suggestionsSource),
285
+ 3,
286
+ );
287
+ }
288
+ }
289
+
219
290
  protected updated(changedProperties: PropertyValues): void {
220
291
  super.updated(changedProperties);
221
292
  this.style.setProperty('--accent-color', this.accentColor);
222
293
 
223
- if (
224
- changedProperties.has('isFullscreen') ||
225
- changedProperties.has('showConversations') ||
226
- changedProperties.has('conversations')
227
- ) {
228
- this.enqueueConversationScrollbarMeasure();
229
- }
230
-
231
- if (
232
- changedProperties.has('messages') ||
233
- (changedProperties.has('isLoading') && this.isLoading) ||
234
- (changedProperties.has('open') && this.open) ||
235
- (changedProperties.has('isFullscreen') && this.isFullscreen)
236
- ) {
237
- this.scrollConversationToBottom();
238
- }
239
- }
294
+ if (
295
+ changedProperties.has('isFullscreen') ||
296
+ changedProperties.has('showConversations') ||
297
+ changedProperties.has('conversations')
298
+ ) {
299
+ this.enqueueConversationScrollbarMeasure();
300
+ }
301
+
302
+ if (
303
+ changedProperties.has('messages') ||
304
+ (changedProperties.has('isLoading') && this.isLoading) ||
305
+ (changedProperties.has('open') && this.open) ||
306
+ (changedProperties.has('isFullscreen') && this.isFullscreen)
307
+ ) {
308
+ this.scrollConversationToBottom();
309
+ }
310
+ }
240
311
 
241
312
  protected firstUpdated(): void {
242
313
  this.enqueueConversationScrollbarMeasure();
@@ -290,48 +361,48 @@ export class RioAssistWidget extends LitElement {
290
361
  }
291
362
  }
292
363
 
293
- openConversationsPanel() {
294
- this.showConversations = true;
295
- this.requestConversationHistory();
296
- }
297
-
298
- closeConversationsPanel() {
299
- this.showConversations = false;
300
- this.conversationMenuId = null;
301
- }
302
-
303
- toggleConversationsPanel() {
304
- this.showConversations = !this.showConversations;
305
- if (!this.showConversations) {
306
- this.conversationMenuId = null;
307
- return;
308
- }
309
-
310
- this.requestConversationHistory();
311
- }
364
+ openConversationsPanel() {
365
+ this.showConversations = true;
366
+ this.requestConversationHistory();
367
+ }
312
368
 
313
- toggleNewConversationShortcut() {
314
- this.showNewConversationShortcut = !this.showNewConversationShortcut;
315
- }
316
-
317
- handleConversationSelect(conversationId: string) {
318
- if (!conversationId) {
319
- return;
320
- }
321
-
322
- this.showConversations = false;
323
- this.conversationMenuId = null;
324
- this.errorMessage = '';
325
- this.currentConversationId = conversationId;
326
- this.activeConversationTitle = this.lookupConversationTitle(conversationId);
327
-
328
- console.info('[RioAssist][history] carregando conversa', conversationId);
329
- this.requestConversationHistory(conversationId);
330
- }
331
-
332
- handleConversationSearch(event: InputEvent) {
333
- this.conversationSearch = (event.target as HTMLInputElement).value;
334
- }
369
+ closeConversationsPanel() {
370
+ this.showConversations = false;
371
+ this.conversationMenuId = null;
372
+ }
373
+
374
+ toggleConversationsPanel() {
375
+ this.showConversations = !this.showConversations;
376
+ if (!this.showConversations) {
377
+ this.conversationMenuId = null;
378
+ return;
379
+ }
380
+
381
+ this.requestConversationHistory();
382
+ }
383
+
384
+ toggleNewConversationShortcut() {
385
+ this.showNewConversationShortcut = !this.showNewConversationShortcut;
386
+ }
387
+
388
+ handleConversationSelect(conversationId: string) {
389
+ if (!conversationId) {
390
+ return;
391
+ }
392
+
393
+ this.showConversations = false;
394
+ this.conversationMenuId = null;
395
+ this.errorMessage = '';
396
+ this.currentConversationId = conversationId;
397
+ this.activeConversationTitle = this.lookupConversationTitle(conversationId);
398
+
399
+ console.info('[RioAssist][history] carregando conversa', conversationId);
400
+ this.requestConversationHistory(conversationId);
401
+ }
402
+
403
+ handleConversationSearch(event: InputEvent) {
404
+ this.conversationSearch = (event.target as HTMLInputElement).value;
405
+ }
335
406
 
336
407
  handleConversationMenuToggle(event: Event, id: string) {
337
408
  event.stopPropagation();
@@ -367,186 +438,494 @@ export class RioAssistWidget extends LitElement {
367
438
  this.conversationMenuId = null;
368
439
  }
369
440
  }
370
-
371
- handleConversationAction(action: 'rename' | 'delete', id: string) {
372
- this.conversationMenuId = null;
373
- const conversationIndex = this.conversations.findIndex((item) => item.id === id);
374
- if (conversationIndex === -1) {
441
+
442
+ handleConversationAction(action: 'rename' | 'delete', id: string) {
443
+ this.conversationMenuId = null;
444
+ const conversationIndex = this.conversations.findIndex((item) => item.id === id);
445
+ if (conversationIndex === -1) {
446
+ return;
447
+ }
448
+
449
+ const conversation = this.conversations[conversationIndex];
450
+ if (action === 'delete') {
451
+ this.deleteConversationTarget = {
452
+ id: conversation.id,
453
+ title: conversation.title,
454
+ index: conversationIndex,
455
+ };
456
+ return;
457
+ }
458
+
459
+ this.renameConversationTarget = {
460
+ id: conversation.id,
461
+ title: conversation.title,
462
+ index: conversationIndex,
463
+ draft: conversation.title,
464
+ };
465
+ }
466
+
467
+ handleHomeNavigation() {
468
+ const detail = { url: this.homeUrl || null };
469
+ const allowed = this.dispatchEvent(
470
+ new CustomEvent('rioassist:home', {
471
+ detail,
472
+ bubbles: true,
473
+ composed: true,
474
+ cancelable: true,
475
+ }),
476
+ );
477
+
478
+ if (!allowed) {
479
+ return;
480
+ }
481
+
482
+ if (this.homeUrl) {
483
+ window.location.assign(this.homeUrl);
484
+ }
485
+ }
486
+
487
+ applyConversationRename(id: string, newTitle: string) {
488
+ if (!id || !newTitle) {
489
+ return;
490
+ }
491
+
492
+ let changed = false;
493
+ this.conversations = this.conversations.map((conversation) => {
494
+ if (conversation.id === id) {
495
+ changed = true;
496
+ return { ...conversation, title: newTitle };
497
+ }
498
+ return conversation;
499
+ });
500
+
501
+ if (!changed) {
502
+ return;
503
+ }
504
+
505
+ if (this.currentConversationId === id) {
506
+ this.activeConversationTitle = newTitle;
507
+ }
508
+ }
509
+
510
+ applyConversationDeletion(id: string) {
511
+ if (!id) {
375
512
  return;
376
513
  }
377
514
 
378
- const conversation = this.conversations[conversationIndex];
379
- if (action === 'delete') {
380
- this.deleteConversationTarget = {
381
- id: conversation.id,
382
- title: conversation.title,
383
- index: conversationIndex,
384
- };
385
- return;
515
+ const wasActive = this.currentConversationId === id;
516
+ const next = this.conversations.filter((conversation) => conversation.id !== id);
517
+
518
+ if (next.length === this.conversations.length) {
519
+ return;
520
+ }
521
+
522
+ this.conversations = next;
523
+
524
+ if (wasActive) {
525
+ this.currentConversationId = null;
526
+ this.activeConversationTitle = null;
527
+ this.messages = [];
386
528
  }
387
-
388
- this.dispatchConversationAction('rename', conversation, conversationIndex);
389
529
  }
390
530
 
391
- handleHomeNavigation() {
392
- const detail = { url: this.homeUrl || null };
393
- const allowed = this.dispatchEvent(
394
- new CustomEvent('rioassist:home', {
395
- detail,
396
- bubbles: true,
397
- composed: true,
398
- cancelable: true,
399
- }),
400
- );
401
-
402
- if (!allowed) {
531
+ private restoreConversationSnapshot(snapshot: ConversationItem | undefined, index: number) {
532
+ if (!snapshot) {
403
533
  return;
404
534
  }
405
535
 
406
- if (this.homeUrl) {
407
- window.location.assign(this.homeUrl);
536
+ const exists = this.conversations.some((conversation) => conversation.id === snapshot.id);
537
+ if (exists) {
538
+ return;
408
539
  }
409
- }
410
540
 
411
- applyConversationRename(id: string, newTitle: string) {
412
- if (!id || !newTitle) {
541
+ const next = [...this.conversations];
542
+ const position = index >= 0 && index <= next.length ? index : next.length;
543
+ next.splice(position, 0, snapshot);
544
+ this.conversations = next;
545
+ }
546
+
547
+ async confirmDeleteConversation() {
548
+ const target = this.deleteConversationTarget;
549
+ if (!target) {
413
550
  return;
414
551
  }
415
552
 
416
- let changed = false;
417
- this.conversations = this.conversations.map((conversation) => {
418
- if (conversation.id === id) {
419
- changed = true;
420
- return { ...conversation, title: newTitle };
421
- }
422
- return conversation;
423
- });
553
+ const snapshot =
554
+ this.conversations[target.index] ??
555
+ this.conversations.find((item) => item.id === target.id) ?? {
556
+ id: target.id,
557
+ title: target.title,
558
+ updatedAt: new Date().toISOString(),
559
+ };
560
+ const isActive = this.currentConversationId === target.id;
561
+ this.pendingConversationAction = {
562
+ action: 'delete',
563
+ conversationId: target.id,
564
+ originalTitle: target.title,
565
+ index: target.index,
566
+ snapshot,
567
+ messagesSnapshot: isActive ? [...this.messages] : undefined,
568
+ wasActive: isActive,
569
+ };
424
570
 
425
- if (!changed) {
571
+ const success = await this.dispatchConversationAction(
572
+ 'delete',
573
+ { id: target.id, title: target.title },
574
+ target.index,
575
+ );
576
+ if (success) {
577
+ this.deleteConversationTarget = null;
426
578
  return;
427
579
  }
428
580
 
429
- if (this.currentConversationId === id) {
430
- this.activeConversationTitle = newTitle;
431
- }
581
+ this.pendingConversationAction = null;
432
582
  }
433
-
434
- applyConversationDeletion(id: string) {
435
- if (!id) {
583
+
584
+ cancelDeleteConversation() {
585
+ this.deleteConversationTarget = null;
586
+ }
587
+
588
+ handleRenameDraft(event: InputEvent) {
589
+ if (!this.renameConversationTarget) {
590
+ return;
591
+ }
592
+
593
+ this.renameConversationTarget = {
594
+ ...this.renameConversationTarget,
595
+ draft: (event.target as HTMLInputElement).value,
596
+ };
597
+ }
598
+
599
+ async confirmRenameConversation() {
600
+ const target = this.renameConversationTarget;
601
+ if (!target) {
602
+ return;
603
+ }
604
+
605
+ const newTitle = target.draft.trim();
606
+ if (!newTitle) {
436
607
  return;
437
608
  }
438
609
 
439
- const wasActive = this.currentConversationId === id;
440
- const next = this.conversations.filter((conversation) => conversation.id !== id);
610
+ this.pendingConversationAction = {
611
+ action: 'rename',
612
+ conversationId: target.id,
613
+ originalTitle: target.title,
614
+ index: target.index,
615
+ newTitle,
616
+ };
441
617
 
442
- if (next.length === this.conversations.length) {
618
+ const success = await this.dispatchConversationAction(
619
+ 'rename',
620
+ { id: target.id, title: newTitle },
621
+ target.index,
622
+ newTitle,
623
+ );
624
+ if (success) {
625
+ this.renameConversationTarget = null;
443
626
  return;
444
627
  }
445
628
 
446
- this.conversations = next;
447
-
448
- if (wasActive) {
449
- this.currentConversationId = null;
450
- this.activeConversationTitle = null;
451
- this.messages = [];
452
- }
629
+ this.pendingConversationAction = null;
453
630
  }
454
-
455
- confirmDeleteConversation() {
456
- const target = this.deleteConversationTarget;
457
- if (!target) {
458
- return;
459
- }
460
-
461
- this.dispatchConversationAction('delete', { id: target.id, title: target.title }, target.index);
462
- this.deleteConversationTarget = null;
631
+
632
+ cancelRenameConversation() {
633
+ this.renameConversationTarget = null;
463
634
  }
464
635
 
465
- cancelDeleteConversation() {
466
- this.deleteConversationTarget = null;
636
+ cancelConversationActionError() {
637
+ this.conversationActionError = null;
638
+ this.pendingConversationAction = null;
467
639
  }
468
640
 
469
- private dispatchConversationAction(
470
- action: 'rename' | 'delete',
471
- conversation: Pick<ConversationItem, 'id' | 'title'>,
472
- index: number,
473
- ) {
474
- const eventName =
475
- action === 'rename' ? 'rioassist:conversation-rename' : 'rioassist:conversation-delete';
476
- const detail = {
477
- id: conversation.id,
478
- title: conversation.title,
479
- index,
480
- action,
481
- };
482
-
483
- const allowed = this.dispatchEvent(
484
- new CustomEvent(eventName, {
485
- detail,
486
- bubbles: true,
487
- composed: true,
488
- cancelable: true,
489
- }),
490
- );
491
-
492
- if (!allowed) {
641
+ async retryConversationAction() {
642
+ const errorState = this.conversationActionError;
643
+ if (!errorState) {
493
644
  return;
494
645
  }
495
646
 
496
- if (action === 'delete') {
497
- this.applyConversationDeletion(conversation.id);
498
- }
499
- }
647
+ const indexFromState =
648
+ typeof errorState.index === 'number' ? errorState.index : this.conversations.findIndex(
649
+ (item) => item.id === errorState.conversationId,
650
+ );
651
+ const safeIndex =
652
+ indexFromState >= 0
653
+ ? indexFromState
654
+ : this.conversations.length > 0
655
+ ? this.conversations.length - 1
656
+ : 0;
657
+
658
+ const snapshot =
659
+ errorState.snapshot ??
660
+ this.conversations.find((item) => item.id === errorState.conversationId) ?? {
661
+ id: errorState.conversationId,
662
+ title: errorState.originalTitle,
663
+ updatedAt: new Date().toISOString(),
664
+ };
500
665
 
501
- handleHeaderActionClick(action: HeaderActionConfig, index: number) {
502
- const detail = {
503
- index,
504
- id: action.id ?? null,
505
- ariaLabel: action.ariaLabel ?? null,
506
- iconUrl: action.iconUrl,
666
+ this.pendingConversationAction = {
667
+ action: errorState.action,
668
+ conversationId: errorState.conversationId,
669
+ originalTitle: errorState.originalTitle,
670
+ index: safeIndex,
671
+ newTitle: errorState.newTitle,
672
+ snapshot,
673
+ messagesSnapshot: errorState.messagesSnapshot,
674
+ wasActive: errorState.wasActive,
507
675
  };
508
676
 
509
- const allowed = this.dispatchEvent(
510
- new CustomEvent('rioassist:header-action', {
511
- detail,
512
- bubbles: true,
513
- composed: true,
514
- cancelable: true,
515
- }),
516
- );
517
-
518
- if (!allowed) {
519
- return;
520
- }
677
+ this.conversationActionError = null;
521
678
 
522
- if (typeof action.onClick === 'function') {
523
- action.onClick();
524
- }
679
+ await this.dispatchConversationAction(
680
+ errorState.action,
681
+ { id: errorState.conversationId, title: errorState.newTitle ?? errorState.originalTitle },
682
+ safeIndex,
683
+ errorState.newTitle,
684
+ );
525
685
  }
526
686
 
527
- handleCloseAction() {
528
- if (this.isFullscreen) {
529
- this.exitFullscreen(true);
530
- return;
531
- }
687
+ private async dispatchConversationAction(
688
+ action: 'rename' | 'delete',
689
+ conversation: Pick<ConversationItem, 'id' | 'title'>,
690
+ index: number,
691
+ newTitle?: string,
692
+ ) {
693
+ const eventName =
694
+ action === 'rename' ? 'rioassist:conversation-rename' : 'rioassist:conversation-delete';
695
+ const detail = {
696
+ id: conversation.id,
697
+ title: conversation.title,
698
+ index,
699
+ action,
700
+ };
532
701
 
533
- if (this.showConversations) {
534
- this.closeConversationsPanel();
535
- } else {
536
- this.closePanel();
537
- }
538
- }
702
+ const allowed = this.dispatchEvent(
703
+ new CustomEvent(eventName, {
704
+ detail,
705
+ bubbles: true,
706
+ composed: true,
707
+ cancelable: true,
708
+ }),
709
+ );
539
710
 
540
- enterFullscreen() {
541
- if (this.isFullscreen) {
542
- return;
711
+ if (!allowed) {
712
+ return false;
713
+ }
714
+
715
+ if (action === 'delete') {
716
+ const ok = await this.syncConversationDeleteBackend(conversation.id);
717
+ return ok;
718
+ }
719
+
720
+ if (action === 'rename' && newTitle) {
721
+ const ok = await this.syncConversationRenameBackend(conversation.id, newTitle);
722
+ return ok;
723
+ }
724
+
725
+ return false;
726
+ }
727
+
728
+ private async syncConversationRenameBackend(conversationId: string, newTitle: string) {
729
+ try {
730
+ const client = this.ensureRioClient();
731
+ await client.renameConversation(conversationId, newTitle);
732
+ this.applyConversationRename(conversationId, newTitle);
733
+ this.conversationHistoryError = '';
734
+ return true;
735
+ } catch (error) {
736
+ console.error('[RioAssist][history] erro ao renomear conversa', error);
737
+ this.conversationHistoryError =
738
+ error instanceof Error && error.message
739
+ ? error.message
740
+ : 'Nao foi possivel renomear a conversa.';
741
+ return false;
742
+ }
743
+ }
744
+
745
+ private async syncConversationDeleteBackend(conversationId: string) {
746
+ try {
747
+ const client = this.ensureRioClient();
748
+ await client.deleteConversation(conversationId);
749
+ this.applyConversationDeletion(conversationId);
750
+ this.conversationHistoryError = '';
751
+ return true;
752
+ } catch (error) {
753
+ console.error('[RioAssist][history] erro ao excluir conversa', error);
754
+ this.conversationHistoryError =
755
+ error instanceof Error && error.message
756
+ ? error.message
757
+ : 'Nao foi possivel excluir a conversa.';
758
+ return false;
759
+ }
760
+ }
761
+
762
+ private handleConversationSystemAction(message: RioIncomingMessage) {
763
+ const action = (message.action ?? '').toLowerCase();
764
+ if (action === 'conversationrenamed') {
765
+ const data = message.data as Record<string, unknown>;
766
+ const id = this.extractString(data, ['conversationId', 'id']);
767
+ const newTitle = this.extractString(data, ['newTitle', 'title']);
768
+ if (id && newTitle) {
769
+ this.applyConversationRename(id, newTitle);
770
+ this.conversationHistoryError = '';
771
+ if (
772
+ this.pendingConversationAction &&
773
+ this.pendingConversationAction.conversationId === id &&
774
+ this.pendingConversationAction.action === 'rename'
775
+ ) {
776
+ this.pendingConversationAction = null;
777
+ this.conversationActionError = null;
778
+ }
779
+ }
780
+ return true;
781
+ }
782
+
783
+ if (action === 'conversationdeleted') {
784
+ const data = message.data as Record<string, unknown>;
785
+ const id = this.extractString(data, ['conversationId', 'id']);
786
+ if (id) {
787
+ this.applyConversationDeletion(id);
788
+ this.conversationHistoryError = '';
789
+ if (
790
+ this.pendingConversationAction &&
791
+ this.pendingConversationAction.conversationId === id &&
792
+ this.pendingConversationAction.action === 'delete'
793
+ ) {
794
+ this.pendingConversationAction = null;
795
+ this.conversationActionError = null;
796
+ }
797
+ }
798
+ return true;
799
+ }
800
+
801
+ if (action === 'processing') {
802
+ return true;
803
+ }
804
+
805
+ return false;
806
+ }
807
+
808
+ private handleConversationActionError(message: RioIncomingMessage) {
809
+ const action = (message.action ?? '').toLowerCase();
810
+ if (action !== 'error') {
811
+ return false;
812
+ }
813
+
814
+ const data = message.data as Record<string, unknown>;
815
+ const errorText =
816
+ this.extractString(data, ['error', 'message', 'detail', 'description']) ||
817
+ (typeof message.text === 'string' && message.text.trim()
818
+ ? message.text
819
+ : 'O agente retornou um erro ao processar a conversa.');
820
+
821
+ const pending = this.pendingConversationAction;
822
+ if (pending) {
823
+ if (pending.action === 'rename') {
824
+ this.applyConversationRename(pending.conversationId, pending.originalTitle);
825
+ }
826
+
827
+ if (pending.action === 'delete') {
828
+ this.restoreConversationSnapshot(pending.snapshot, pending.index);
829
+ if (pending.wasActive) {
830
+ this.currentConversationId = pending.conversationId;
831
+ this.activeConversationTitle = pending.originalTitle;
832
+ this.messages = pending.messagesSnapshot ?? this.messages;
833
+ }
834
+ }
835
+
836
+ this.conversationActionError = {
837
+ ...pending,
838
+ message: errorText,
839
+ };
840
+ this.pendingConversationAction = null;
841
+ this.clearLoadingGuard();
842
+ this.isLoading = false;
843
+ return true;
543
844
  }
544
845
 
545
- this.isFullscreen = true;
546
- this.open = false;
547
- this.showConversations = false;
548
- this.requestConversationHistory();
846
+ this.errorMessage = errorText;
847
+ this.clearLoadingGuard();
848
+ this.isLoading = false;
849
+ return true;
549
850
  }
851
+
852
+ private shouldIgnoreAssistantPayload(action?: string) {
853
+ if (!action) {
854
+ return false;
855
+ }
856
+ const normalized = action.toLowerCase();
857
+ return (
858
+ normalized === 'processing' ||
859
+ normalized === 'conversationrenamed' ||
860
+ normalized === 'conversationdeleted'
861
+ );
862
+ }
863
+
864
+ private extractString(
865
+ data: Record<string, unknown> | undefined,
866
+ keys: string[],
867
+ ): string | null {
868
+ if (!data || typeof data !== 'object') {
869
+ return null;
870
+ }
871
+ for (const key of keys) {
872
+ const value = data[key];
873
+ if (typeof value === 'string' && value.trim()) {
874
+ return value;
875
+ }
876
+ }
877
+ return null;
878
+ }
879
+
880
+ handleHeaderActionClick(action: HeaderActionConfig, index: number) {
881
+ const detail = {
882
+ index,
883
+ id: action.id ?? null,
884
+ ariaLabel: action.ariaLabel ?? null,
885
+ iconUrl: action.iconUrl,
886
+ };
887
+
888
+ const allowed = this.dispatchEvent(
889
+ new CustomEvent('rioassist:header-action', {
890
+ detail,
891
+ bubbles: true,
892
+ composed: true,
893
+ cancelable: true,
894
+ }),
895
+ );
896
+
897
+ if (!allowed) {
898
+ return;
899
+ }
900
+
901
+ if (typeof action.onClick === 'function') {
902
+ action.onClick();
903
+ }
904
+ }
905
+
906
+ handleCloseAction() {
907
+ if (this.isFullscreen) {
908
+ this.exitFullscreen(true);
909
+ return;
910
+ }
911
+
912
+ if (this.showConversations) {
913
+ this.closeConversationsPanel();
914
+ } else {
915
+ this.closePanel();
916
+ }
917
+ }
918
+
919
+ enterFullscreen() {
920
+ if (this.isFullscreen) {
921
+ return;
922
+ }
923
+
924
+ this.isFullscreen = true;
925
+ this.open = false;
926
+ this.showConversations = false;
927
+ this.requestConversationHistory();
928
+ }
550
929
 
551
930
  exitFullscreen(restorePanel: boolean) {
552
931
  if (!this.isFullscreen) {
@@ -561,25 +940,25 @@ export class RioAssistWidget extends LitElement {
561
940
  }
562
941
  }
563
942
 
564
- handleCreateConversation() {
565
- if (!this.hasActiveConversation) {
566
- return;
567
- }
568
-
569
- this.clearLoadingGuard();
570
- this.isLoading = false;
571
- this.messages = [];
572
- this.message = '';
573
- this.errorMessage = '';
574
- this.showConversations = false;
575
- this.teardownRioClient();
576
- this.currentConversationId = this.generateConversationId();
577
- this.activeConversationTitle = null;
578
- this.showNewConversationShortcut = false;
579
- this.dispatchEvent(
580
- new CustomEvent('rioassist:new-conversation', {
581
- bubbles: true,
582
- composed: true,
943
+ handleCreateConversation() {
944
+ if (!this.hasActiveConversation) {
945
+ return;
946
+ }
947
+
948
+ this.clearLoadingGuard();
949
+ this.isLoading = false;
950
+ this.messages = [];
951
+ this.message = '';
952
+ this.errorMessage = '';
953
+ this.showConversations = false;
954
+ this.teardownRioClient();
955
+ this.currentConversationId = this.generateConversationId();
956
+ this.activeConversationTitle = null;
957
+ this.showNewConversationShortcut = false;
958
+ this.dispatchEvent(
959
+ new CustomEvent('rioassist:new-conversation', {
960
+ bubbles: true,
961
+ composed: true,
583
962
  }),
584
963
  );
585
964
  }
@@ -706,95 +1085,95 @@ export class RioAssistWidget extends LitElement {
706
1085
  return;
707
1086
  }
708
1087
 
709
- const ratio = clientHeight / scrollHeight;
710
- const height = Math.max(ratio * 100, 8);
711
- const maxTop = 100 - height;
712
- const top =
713
- scrollTop / (scrollHeight - clientHeight) * (maxTop > 0 ? maxTop : 0);
714
-
715
- this.conversationScrollbar = {
716
- height,
717
- top,
718
- visible: true,
719
- };
720
- }
721
-
722
- async onSuggestionClick(suggestion: string) {
723
- await this.processMessage(suggestion);
724
- }
1088
+ const ratio = clientHeight / scrollHeight;
1089
+ const height = Math.max(ratio * 100, 8);
1090
+ const maxTop = 100 - height;
1091
+ const top =
1092
+ scrollTop / (scrollHeight - clientHeight) * (maxTop > 0 ? maxTop : 0);
725
1093
 
726
- async handleSubmit(event: SubmitEvent) {
727
- event.preventDefault();
728
- await this.processMessage(this.message);
729
- }
730
-
731
- private createMessage(role: ChatRole, text: string): ChatMessage {
732
- const id = typeof crypto !== 'undefined' && 'randomUUID' in crypto
733
- ? crypto.randomUUID()
734
- : `${Date.now()}-${Math.random()}`;
735
-
736
- return {
737
- id,
738
- role,
739
- text,
740
- html: this.renderMarkdown(text),
741
- timestamp: Date.now(),
742
- };
743
- }
744
-
745
- private async processMessage(rawValue: string) {
746
- const content = rawValue.trim();
747
- if (!content || this.isLoading) {
748
- return;
749
- }
750
-
751
- if (!this.currentConversationId) {
752
- this.currentConversationId = this.generateConversationId();
753
- this.activeConversationTitle = null;
754
- }
755
-
756
- const wasEmptyConversation = this.messages.length === 0;
757
-
758
- this.dispatchEvent(
759
- new CustomEvent('rioassist:send', {
760
- detail: {
761
- message: content,
762
- apiBaseUrl: this.apiBaseUrl,
1094
+ this.conversationScrollbar = {
1095
+ height,
1096
+ top,
1097
+ visible: true,
1098
+ };
1099
+ }
1100
+
1101
+ async onSuggestionClick(suggestion: string) {
1102
+ await this.processMessage(suggestion);
1103
+ }
1104
+
1105
+ async handleSubmit(event: SubmitEvent) {
1106
+ event.preventDefault();
1107
+ await this.processMessage(this.message);
1108
+ }
1109
+
1110
+ private createMessage(role: ChatRole, text: string): ChatMessage {
1111
+ const id = typeof crypto !== 'undefined' && 'randomUUID' in crypto
1112
+ ? crypto.randomUUID()
1113
+ : `${Date.now()}-${Math.random()}`;
1114
+
1115
+ return {
1116
+ id,
1117
+ role,
1118
+ text,
1119
+ html: this.renderMarkdown(text),
1120
+ timestamp: Date.now(),
1121
+ };
1122
+ }
1123
+
1124
+ private async processMessage(rawValue: string) {
1125
+ const content = rawValue.trim();
1126
+ if (!content || this.isLoading) {
1127
+ return;
1128
+ }
1129
+
1130
+ if (!this.currentConversationId) {
1131
+ this.currentConversationId = this.generateConversationId();
1132
+ this.activeConversationTitle = null;
1133
+ }
1134
+
1135
+ const wasEmptyConversation = this.messages.length === 0;
1136
+
1137
+ this.dispatchEvent(
1138
+ new CustomEvent('rioassist:send', {
1139
+ detail: {
1140
+ message: content,
1141
+ apiBaseUrl: this.apiBaseUrl,
763
1142
  token: this.rioToken,
764
1143
  },
765
1144
  bubbles: true,
766
1145
  composed: true,
767
1146
  }),
768
1147
  );
769
-
770
- const userMessage = this.createMessage('user', content);
771
- this.messages = [...this.messages, userMessage];
772
- if (wasEmptyConversation) {
773
- this.showNewConversationShortcut = true;
774
- this.refreshConversationsAfterResponse = true;
775
- }
776
- this.message = '';
777
- this.errorMessage = '';
778
- this.isLoading = true;
779
- this.startLoadingGuard();
780
-
781
- try {
782
- const client = this.ensureRioClient();
783
- await client.sendMessage(content, this.currentConversationId);
784
- } catch (error) {
785
- this.clearLoadingGuard();
786
- this.isLoading = false;
787
- this.errorMessage = error instanceof Error
788
- ? error.message
1148
+
1149
+ const userMessage = this.createMessage('user', content);
1150
+ this.messages = [...this.messages, userMessage];
1151
+ if (wasEmptyConversation) {
1152
+ this.showNewConversationShortcut = true;
1153
+ this.refreshConversationsAfterResponse = true;
1154
+ }
1155
+ this.message = '';
1156
+ this.errorMessage = '';
1157
+ this.isLoading = true;
1158
+ this.startLoadingGuard();
1159
+
1160
+ try {
1161
+ const client = this.ensureRioClient();
1162
+ await client.sendMessage(content, this.currentConversationId);
1163
+ } catch (error) {
1164
+ this.clearLoadingGuard();
1165
+ this.isLoading = false;
1166
+ this.errorMessage = error instanceof Error
1167
+ ? error.message
789
1168
  : 'Nao foi possivel enviar a mensagem para o agente.';
790
1169
  }
791
1170
  }
792
1171
 
793
- private ensureRioClient() {
794
- const token = this.rioToken.trim();
795
- if (!token) {
796
- throw new Error(
797
- 'Informe o token RIO em data-rio-token para conectar no websocket do assistente.',
1172
+ private ensureRioClient() {
1173
+ const token = this.rioToken.trim();
1174
+ if (!token) {
1175
+ throw new Error(
1176
+ 'Informe o token RIO em data-rio-token para conectar no websocket do assistente.',
798
1177
  );
799
1178
  }
800
1179
 
@@ -805,34 +1184,53 @@ export class RioAssistWidget extends LitElement {
805
1184
  this.handleIncomingMessage(incoming);
806
1185
  });
807
1186
  }
808
-
809
- return this.rioClient;
810
- }
811
-
812
- private handleIncomingMessage(message: RioIncomingMessage) {
813
- if (this.isHistoryPayload(message)) {
814
- this.logHistoryPayload(message);
815
- this.handleHistoryPayload(message.data);
1187
+
1188
+ return this.rioClient;
1189
+ }
1190
+
1191
+ private handleIncomingMessage(message: RioIncomingMessage) {
1192
+ if (this.isHistoryPayload(message)) {
1193
+ this.logHistoryPayload(message);
1194
+ this.handleHistoryPayload(message.data);
1195
+ return;
1196
+ }
1197
+
1198
+ if (this.handleConversationSystemAction(message)) {
816
1199
  return;
817
1200
  }
818
1201
 
819
- console.info('[RioAssist][ws] resposta de mensagem recebida', {
820
- action: message.action ?? 'message',
821
- text: message.text,
822
- raw: message.raw,
823
- data: message.data,
824
- });
825
-
826
- const assistantMessage = this.createMessage('assistant', message.text);
827
- this.messages = [...this.messages, assistantMessage];
828
- this.clearLoadingGuard();
829
- this.isLoading = false;
1202
+ if (this.handleConversationActionError(message)) {
1203
+ return;
1204
+ }
830
1205
 
831
- if (this.refreshConversationsAfterResponse) {
832
- this.refreshConversationsAfterResponse = false;
833
- this.requestConversationHistory();
1206
+ if (this.shouldIgnoreAssistantPayload(message.action)) {
1207
+ return;
834
1208
  }
835
- }
1209
+
1210
+ console.info('[RioAssist][ws] resposta de mensagem recebida', {
1211
+ action: message.action ?? 'message',
1212
+ text: message.text,
1213
+ raw: message.raw,
1214
+ data: message.data,
1215
+ });
1216
+
1217
+ // Handle "processing" type messages - just keep loading state, don't create message
1218
+ if (message.action === 'processing') {
1219
+ console.info('[RioAssist][ws] processando mensagem - aguardando resposta final');
1220
+ // Keep isLoading = true, don't create a message balloon
1221
+ return;
1222
+ }
1223
+
1224
+ const assistantMessage = this.createMessage('assistant', message.text);
1225
+ this.messages = [...this.messages, assistantMessage];
1226
+ this.clearLoadingGuard();
1227
+ this.isLoading = false;
1228
+
1229
+ if (this.refreshConversationsAfterResponse) {
1230
+ this.refreshConversationsAfterResponse = false;
1231
+ this.requestConversationHistory();
1232
+ }
1233
+ }
836
1234
 
837
1235
  private teardownRioClient() {
838
1236
  if (this.rioUnsubscribe) {
@@ -840,583 +1238,610 @@ export class RioAssistWidget extends LitElement {
840
1238
  this.rioUnsubscribe = null;
841
1239
  }
842
1240
 
843
- if (this.rioClient) {
844
- this.rioClient.close();
845
- this.rioClient = null;
846
- }
847
- }
848
-
849
- async requestConversationHistory(conversationId?: string) {
850
- try {
851
- const client = this.ensureRioClient();
852
- const limit = 50;
853
-
854
- console.info('[RioAssist][history] solicitando historico de conversas', {
855
- conversationId: conversationId ?? null,
856
- limit,
857
- });
858
-
859
- this.conversationHistoryError = '';
860
- this.conversationHistoryLoading = true;
861
- await client.requestHistory({ conversationId, limit });
862
- } catch (error) {
863
- console.error('[RioAssist][history] erro ao solicitar historico', error);
864
- this.conversationHistoryError =
865
- error instanceof Error && error.message
866
- ? error.message
867
- : 'Nao foi possivel carregar as conversas.';
868
- this.conversationHistoryLoading = false;
869
- }
870
- }
871
-
872
- private handleHistoryPayload(payload: unknown) {
873
- const entries = this.extractHistoryEntries(payload);
874
- const conversationId = this.extractConversationId(payload);
875
-
876
- if (conversationId !== null && conversationId !== undefined) {
877
- this.applyMessageHistory(entries, conversationId);
878
- return;
879
- }
880
-
881
- if (this.isMessageHistoryEntries(entries)) {
882
- this.applyMessageHistory(entries);
883
- return;
884
- }
885
-
886
- this.applyConversationHistoryFromEntries(entries);
887
-
888
- if (this.refreshConversationsAfterResponse) {
889
- this.refreshConversationsAfterResponse = false;
890
- }
891
- }
892
-
893
- private isHistoryPayload(message: RioIncomingMessage) {
894
- if (
895
- typeof message.action === 'string' &&
896
- message.action.toLowerCase().includes('history')
897
- ) {
898
- return true;
899
- }
900
-
901
- const data = message.data;
902
- if (data && typeof data === 'object') {
903
- const action = (data as any).action;
904
- if (typeof action === 'string' && action.toLowerCase().includes('history')) {
905
- return true;
906
- }
907
-
908
- if (Array.isArray((data as any).history) || Array.isArray((data as any).conversations)) {
909
- return true;
910
- }
911
- }
912
-
913
- return false;
914
- }
915
-
916
- private logHistoryPayload(message: RioIncomingMessage) {
917
- const label = '[RioAssist][history] payload recebido do websocket';
918
- if (message.data !== null && message.data !== undefined) {
919
- console.info(label, message.data);
920
- return;
921
- }
922
-
923
- console.info(label, message.raw);
924
- }
925
-
926
- private applyConversationHistoryFromEntries(entries: unknown[]) {
927
- if (entries.length === 0) {
928
- console.info('[RioAssist][history] payload sem itens para montar lista de conversas');
929
- this.conversations = [];
930
- this.conversationHistoryLoading = false;
931
- this.conversationHistoryError = '';
932
- return;
933
- }
934
-
935
- const map = new Map<string, ConversationItem>();
936
-
937
- entries.forEach((entry, index) => {
938
- if (!entry || typeof entry !== 'object') {
939
- return;
940
- }
941
-
942
- const normalized = this.normalizeConversationItem(
943
- entry as Record<string, unknown>,
944
- index,
945
- );
946
-
947
- if (!normalized) {
948
- return;
949
- }
950
-
951
- const current = map.get(normalized.id);
952
- if (!current) {
953
- map.set(normalized.id, normalized);
954
- return;
955
- }
956
-
957
- const currentTime = Date.parse(current.updatedAt);
958
- const nextTime = Date.parse(normalized.updatedAt);
959
-
960
- if (Number.isFinite(nextTime) && nextTime > currentTime) {
961
- map.set(normalized.id, normalized);
962
- }
963
- });
964
-
965
- const conversations = Array.from(map.values()).sort((a, b) => {
966
- const order = Date.parse(b.updatedAt) - Date.parse(a.updatedAt);
967
- return Number.isFinite(order) ? order : 0;
968
- });
969
-
970
- this.conversations = conversations;
971
- this.conversationHistoryLoading = false;
972
- this.conversationHistoryError = '';
973
- this.syncActiveConversationTitle();
974
- console.info('[RioAssist][history] conversas normalizadas', conversations);
975
- }
976
-
977
- private applyMessageHistory(entries: unknown[], conversationId?: string | null) {
978
- if (entries.length === 0) {
979
- console.info('[RioAssist][history] lista de mensagens vazia', { conversationId });
980
- this.messages = [];
981
- this.showConversations = false;
982
- this.clearLoadingGuard();
983
- this.isLoading = false;
984
- this.conversationHistoryLoading = false;
985
- return;
986
- }
987
-
988
- const normalized = entries.flatMap((entry, index) =>
989
- this.normalizeHistoryMessages(entry as Record<string, unknown>, index),
990
- );
991
-
992
- if (conversationId) {
993
- this.currentConversationId = conversationId;
994
- }
995
-
996
- this.messages = normalized;
997
- this.showConversations = false;
998
- this.clearLoadingGuard();
999
- this.isLoading = false;
1000
- this.showNewConversationShortcut = normalized.length > 0;
1001
- this.conversationHistoryLoading = false;
1002
- this.refreshConversationsAfterResponse = false;
1003
-
1004
- console.info('[RioAssist][history] mensagens carregadas', {
1005
- conversationId: conversationId ?? null,
1006
- total: normalized.length,
1007
- });
1008
- }
1009
-
1010
- private extractHistoryEntries(payload: unknown): unknown[] {
1011
- if (Array.isArray(payload)) {
1012
- return payload;
1013
- }
1014
-
1015
- if (payload && typeof payload === 'object') {
1016
- const record = payload as Record<string, unknown>;
1017
- const candidates = [
1018
- record.history,
1019
- record.conversations,
1020
- record.data,
1021
- record.items,
1022
- record.messages,
1023
- ];
1024
-
1025
- for (const candidate of candidates) {
1026
- if (Array.isArray(candidate)) {
1027
- return candidate;
1028
- }
1029
- }
1030
-
1031
- if (record.data && typeof record.data === 'object' && !Array.isArray(record.data)) {
1032
- const nested = this.extractHistoryEntries(record.data);
1033
- if (nested.length > 0) {
1034
- return nested;
1035
- }
1036
- }
1037
- }
1038
-
1039
- return [];
1040
- }
1041
-
1042
- private extractConversationId(payload: unknown): string | null | undefined {
1043
- if (payload && typeof payload === 'object') {
1044
- const record = payload as Record<string, unknown>;
1045
- const candidates = [
1046
- record.conversationId,
1047
- record.conversationUUID,
1048
- record.conversationUuid,
1049
- record.uuid,
1050
- record.id,
1051
- ];
1052
-
1053
- for (const candidate of candidates) {
1054
- if (candidate === null) {
1055
- return null;
1056
- }
1057
-
1058
- if (candidate !== undefined) {
1059
- return String(candidate);
1060
- }
1061
- }
1062
- }
1063
-
1064
- return undefined;
1065
- }
1066
-
1067
- private isMessageHistoryEntries(entries: unknown[]) {
1068
- return entries.some((entry) => this.looksLikeMessageHistoryEntry(entry));
1069
- }
1070
-
1071
- private looksLikeMessageHistoryEntry(entry: unknown) {
1072
- if (!entry || typeof entry !== 'object') {
1073
- return false;
1074
- }
1075
-
1076
- const item = entry as Record<string, unknown>;
1077
- const role = item.role ?? item.sender ?? item.from ?? item.author ?? item.type;
1078
- if (typeof role === 'string' && role.trim().length > 0) {
1079
- return true;
1080
- }
1081
-
1082
- if (
1083
- typeof item.content === 'string' ||
1084
- typeof item.message === 'string' ||
1085
- typeof item.text === 'string' ||
1086
- typeof item.response === 'string'
1087
- ) {
1088
- return true;
1089
- }
1090
-
1091
- if (Array.isArray(item.parts) && item.parts.length > 0) {
1092
- return true;
1093
- }
1094
-
1095
- return false;
1096
- }
1097
-
1098
- private normalizeConversationItem(
1099
- value: Record<string, unknown>,
1100
- index: number,
1101
- ): ConversationItem | null {
1102
- const rawId =
1103
- value.id ??
1104
- value.conversationId ??
1105
- value.conversationUUID ??
1106
- value.conversationUuid ??
1107
- value.uuid;
1108
-
1109
- const id = rawId !== undefined && rawId !== null ? String(rawId) : `history-${index + 1}`;
1110
-
1111
- const rawTitle =
1112
- value.title ??
1113
- value.name ??
1114
- value.topic ??
1115
- value.subject ??
1116
- value.question ??
1117
- value.query ??
1118
- value.message;
1119
-
1120
- const title =
1121
- typeof rawTitle === 'string' && rawTitle.trim().length > 0
1122
- ? rawTitle.trim()
1123
- : `Conversa ${index + 1}`;
1124
-
1125
- const rawUpdated =
1126
- value.updatedAt ??
1127
- value.updated_at ??
1128
- value.lastMessageAt ??
1129
- value.last_message_at ??
1130
- value.createdAt ??
1131
- value.created_at ??
1132
- value.timestamp ??
1133
- value.date;
1134
-
1135
- const updatedAt = this.toIsoString(rawUpdated);
1136
-
1137
- return { id, title, updatedAt };
1138
- }
1139
-
1140
- private normalizeHistoryMessages(
1141
- value: Record<string, unknown>,
1142
- index: number,
1143
- ): ChatMessage[] {
1144
- const messages: ChatMessage[] = [];
1145
-
1146
- const rawUserText = value.message ?? value.question ?? value.query ?? value.text ?? value.content;
1147
- const userText = typeof rawUserText === 'string' ? rawUserText.trim() : '';
1148
-
1149
- const rawResponseText =
1150
- value.response ?? value.answer ?? value.reply ?? value.completion ?? value.body ?? value.preview;
1151
- const responseText = typeof rawResponseText === 'string' ? rawResponseText.trim() : '';
1152
-
1153
- const rawId = value.id ?? value.messageId ?? value.uuid ?? value.conversationMessageId;
1154
- const baseId = rawId !== undefined && rawId !== null
1155
- ? String(rawId)
1156
- : `history-${index + 1}`;
1157
-
1158
- const userTimestampValue =
1159
- value.timestamp ??
1160
- value.createdAt ??
1161
- value.created_at ??
1162
- value.date ??
1163
- value.time;
1164
- const assistantTimestampValue =
1165
- value.responseTimestamp ??
1166
- value.responseTime ??
1167
- value.responseDate ??
1168
- value.response_at ??
1169
- value.updatedAt ??
1170
- value.updated_at;
1171
-
1172
- const userTimestamp = this.parseTimestamp(userTimestampValue);
1173
- const assistantTimestamp = this.parseTimestamp(
1174
- assistantTimestampValue,
1175
- userTimestamp + 1,
1176
- );
1177
-
1178
- if (responseText) {
1179
- if (userText) {
1180
- messages.push({
1181
- id: `${baseId}-user`,
1182
- role: 'user',
1183
- text: userText,
1184
- html: this.renderMarkdown(userText),
1185
- timestamp: userTimestamp,
1186
- });
1187
- }
1188
-
1189
- messages.push({
1190
- id: `${baseId}-assistant`,
1191
- role: 'assistant',
1192
- text: responseText,
1193
- html: this.renderMarkdown(responseText),
1194
- timestamp: assistantTimestamp,
1195
- });
1196
- } else if (userText) {
1197
- // Se n�o tiver resposta, n�o exibimos a mensagem do usuario isolada.
1198
- return [];
1199
- }
1200
-
1201
- if (messages.length > 0) {
1202
- return messages;
1203
- }
1204
-
1205
- const fallback = this.normalizeSingleHistoryMessage(value, index);
1206
- return fallback ? [fallback] : [];
1207
- }
1208
-
1209
- private normalizeSingleHistoryMessage(
1210
- value: Record<string, unknown>,
1211
- index: number,
1212
- ): ChatMessage | null {
1213
- const rawText =
1214
- value.text ??
1215
- value.message ??
1216
- value.content ??
1217
- value.response ??
1218
- value.body ??
1219
- value.preview;
1220
-
1221
- const text = typeof rawText === 'string' && rawText.trim().length > 0
1222
- ? rawText
1223
- : '';
1224
-
1225
- if (!text) {
1226
- return null;
1227
- }
1228
-
1229
- const role = this.normalizeRole(
1230
- value.role ??
1231
- value.sender ??
1232
- value.from ??
1233
- value.author ??
1234
- value.type ??
1235
- value.direction,
1236
- );
1237
-
1238
- const rawId = value.id ?? value.messageId ?? value.uuid ?? value.conversationMessageId;
1239
- const id = rawId !== undefined && rawId !== null
1240
- ? String(rawId)
1241
- : `history-message-${index + 1}`;
1242
-
1243
- const timestampValue =
1244
- value.timestamp ??
1245
- value.createdAt ??
1246
- value.created_at ??
1247
- value.updatedAt ??
1248
- value.updated_at ??
1249
- value.date ??
1250
- value.time;
1251
-
1252
- const timestamp = this.parseTimestamp(timestampValue);
1253
-
1254
- return {
1255
- id,
1256
- role,
1257
- text,
1258
- html: this.renderMarkdown(text),
1259
- timestamp,
1260
- };
1261
- }
1262
-
1263
- private normalizeRole(value: unknown): ChatRole {
1264
- if (typeof value === 'string') {
1265
- const normalized = value.toLowerCase();
1266
- if (normalized.includes('user') || normalized.includes('client')) {
1267
- return 'user';
1268
- }
1269
- if (normalized.includes('assistant') || normalized.includes('agent') || normalized.includes('bot')) {
1270
- return 'assistant';
1271
- }
1272
- }
1273
-
1274
- return 'assistant';
1275
- }
1276
-
1277
- private parseTimestamp(value: unknown, fallback?: number) {
1278
- const parsed = Date.parse(this.toIsoString(value));
1279
- if (Number.isFinite(parsed)) {
1280
- return parsed;
1281
- }
1282
-
1283
- if (Number.isFinite(fallback ?? NaN)) {
1284
- return fallback as number;
1285
- }
1286
-
1287
- return Date.now();
1288
- }
1289
-
1290
- private lookupConversationTitle(conversationId: string | null) {
1291
- if (!conversationId) {
1292
- return null;
1293
- }
1294
-
1295
- const found = this.conversations.find((item) => item.id === conversationId);
1296
- return found ? found.title : null;
1297
- }
1298
-
1299
- private syncActiveConversationTitle() {
1300
- if (!this.currentConversationId) {
1301
- return;
1302
- }
1303
-
1304
- const title = this.lookupConversationTitle(this.currentConversationId);
1305
- if (title) {
1306
- this.activeConversationTitle = title;
1307
- }
1308
- }
1309
-
1310
- private toIsoString(value: unknown) {
1311
- if (typeof value === 'string' || typeof value === 'number') {
1312
- const date = new Date(value);
1313
- if (!Number.isNaN(date.getTime())) {
1314
- return date.toISOString();
1315
- }
1316
- }
1317
-
1318
- return new Date().toISOString();
1319
- }
1320
-
1321
- private startLoadingGuard() {
1322
- this.clearLoadingGuard();
1323
- this.loadingTimer = window.setTimeout(() => {
1324
- this.loadingTimer = null;
1325
- this.isLoading = false;
1326
- }, 15000);
1327
- }
1328
-
1329
- private clearLoadingGuard() {
1330
- if (this.loadingTimer !== null) {
1331
- window.clearTimeout(this.loadingTimer);
1332
- this.loadingTimer = null;
1333
- }
1334
- }
1335
-
1336
- private scrollConversationToBottom() {
1337
- const containers = Array.from(
1338
- this.renderRoot.querySelectorAll('.panel-content'),
1339
- ) as HTMLElement[];
1340
-
1341
- containers.forEach((container) => {
1342
- requestAnimationFrame(() => {
1343
- container.scrollTop = container.scrollHeight;
1344
- });
1345
- });
1346
- }
1347
-
1348
- private renderMarkdown(content: string) {
1349
- const rendered = this.markdownRenderer.render(content);
1350
- const clean = DOMPurify.sanitize(rendered, {
1351
- ALLOWED_TAGS: [
1352
- 'a',
1353
- 'p',
1354
- 'ul',
1355
- 'ol',
1356
- 'li',
1357
- 'code',
1358
- 'pre',
1359
- 'strong',
1360
- 'em',
1361
- 'blockquote',
1362
- 'table',
1363
- 'thead',
1364
- 'tbody',
1365
- 'tr',
1366
- 'th',
1367
- 'td',
1368
- 'del',
1369
- 'hr',
1370
- 'br',
1371
- 'img',
1372
- 'span',
1373
- 'input',
1374
- ],
1375
- ALLOWED_ATTR: [
1376
- 'href',
1377
- 'title',
1378
- 'target',
1379
- 'rel',
1380
- 'src',
1381
- 'alt',
1382
- 'class',
1383
- 'type',
1384
- 'checked',
1385
- 'disabled',
1386
- 'aria-label',
1387
- ],
1388
- ALLOW_DATA_ATTR: false,
1389
- FORBID_TAGS: ['style', 'script'],
1390
- USE_PROFILES: { html: true },
1391
- });
1392
-
1393
- const container = document.createElement('div');
1394
- container.innerHTML = clean;
1395
-
1396
- container.querySelectorAll('a').forEach((anchor) => {
1397
- anchor.setAttribute('target', '_blank');
1398
- anchor.setAttribute('rel', 'noopener noreferrer');
1399
- });
1400
-
1401
- container.querySelectorAll('input[type="checkbox"]').forEach((checkbox) => {
1402
- checkbox.setAttribute('disabled', '');
1403
- checkbox.setAttribute('tabindex', '-1');
1404
- });
1405
-
1406
- return container.innerHTML;
1407
- }
1408
-
1409
- render() {
1410
- return renderRioAssist(this);
1411
- }
1412
-
1413
- }
1241
+ if (this.rioClient) {
1242
+ this.rioClient.close();
1243
+ this.rioClient = null;
1244
+ }
1245
+ }
1246
+
1247
+ async requestConversationHistory(conversationId?: string) {
1248
+ try {
1249
+ const client = this.ensureRioClient();
1250
+ const limit = 50;
1251
+
1252
+ console.info('[RioAssist][history] solicitando historico de conversas', {
1253
+ conversationId: conversationId ?? null,
1254
+ limit,
1255
+ });
1256
+
1257
+ this.conversationHistoryError = '';
1258
+ this.conversationHistoryLoading = true;
1259
+ await client.requestHistory({ conversationId, limit });
1260
+ } catch (error) {
1261
+ console.error('[RioAssist][history] erro ao solicitar historico', error);
1262
+ this.conversationHistoryError =
1263
+ error instanceof Error && error.message
1264
+ ? error.message
1265
+ : 'Nao foi possivel carregar as conversas.';
1266
+ this.conversationHistoryLoading = false;
1267
+ }
1268
+ }
1269
+
1270
+ private handleHistoryPayload(payload: unknown) {
1271
+ const entries = this.extractHistoryEntries(payload);
1272
+ const conversationId = this.extractConversationId(payload);
1273
+
1274
+ if (conversationId !== null && conversationId !== undefined) {
1275
+ this.applyMessageHistory(entries, conversationId);
1276
+ return;
1277
+ }
1278
+
1279
+ if (this.isMessageHistoryEntries(entries)) {
1280
+ this.applyMessageHistory(entries);
1281
+ return;
1282
+ }
1283
+
1284
+ this.applyConversationHistoryFromEntries(entries);
1285
+
1286
+ if (this.refreshConversationsAfterResponse) {
1287
+ this.refreshConversationsAfterResponse = false;
1288
+ }
1289
+ }
1290
+
1291
+ private isHistoryPayload(message: RioIncomingMessage) {
1292
+ if (
1293
+ typeof message.action === 'string' &&
1294
+ message.action.toLowerCase().includes('history')
1295
+ ) {
1296
+ return true;
1297
+ }
1298
+
1299
+ const data = message.data;
1300
+ if (data && typeof data === 'object') {
1301
+ const action = (data as any).action;
1302
+ if (typeof action === 'string' && action.toLowerCase().includes('history')) {
1303
+ return true;
1304
+ }
1305
+
1306
+ if (Array.isArray((data as any).history) || Array.isArray((data as any).conversations)) {
1307
+ return true;
1308
+ }
1309
+ }
1310
+
1311
+ return false;
1312
+ }
1313
+
1314
+ private logHistoryPayload(message: RioIncomingMessage) {
1315
+ const label = '[RioAssist][history] payload recebido do websocket';
1316
+ if (message.data !== null && message.data !== undefined) {
1317
+ console.info(label, message.data);
1318
+ return;
1319
+ }
1320
+
1321
+ console.info(label, message.raw);
1322
+ }
1323
+
1324
+ private applyConversationHistoryFromEntries(entries: unknown[]) {
1325
+ if (entries.length === 0) {
1326
+ console.info('[RioAssist][history] payload sem itens para montar lista de conversas');
1327
+ this.conversations = [];
1328
+ this.conversationHistoryLoading = false;
1329
+ this.conversationHistoryError = '';
1330
+ return;
1331
+ }
1332
+
1333
+ const map = new Map<string, ConversationItem>();
1334
+
1335
+ entries.forEach((entry, index) => {
1336
+ if (!entry || typeof entry !== 'object') {
1337
+ return;
1338
+ }
1339
+
1340
+ const normalized = this.normalizeConversationItem(
1341
+ entry as Record<string, unknown>,
1342
+ index,
1343
+ );
1344
+
1345
+ if (!normalized) {
1346
+ return;
1347
+ }
1348
+
1349
+ const current = map.get(normalized.id);
1350
+ if (!current) {
1351
+ map.set(normalized.id, normalized);
1352
+ return;
1353
+ }
1354
+
1355
+ const currentTime = Date.parse(current.updatedAt);
1356
+ const nextTime = Date.parse(normalized.updatedAt);
1357
+
1358
+ if (Number.isFinite(nextTime) && nextTime > currentTime) {
1359
+ map.set(normalized.id, normalized);
1360
+ }
1361
+ });
1362
+
1363
+ const conversations = Array.from(map.values()).sort((a, b) => {
1364
+ const order = Date.parse(b.updatedAt) - Date.parse(a.updatedAt);
1365
+ return Number.isFinite(order) ? order : 0;
1366
+ });
1367
+
1368
+ this.conversations = conversations;
1369
+ this.conversationHistoryLoading = false;
1370
+ this.conversationHistoryError = '';
1371
+ this.syncActiveConversationTitle();
1372
+ console.info('[RioAssist][history] conversas normalizadas', conversations);
1373
+ }
1374
+
1375
+ private applyMessageHistory(entries: unknown[], conversationId?: string | null) {
1376
+ if (entries.length === 0) {
1377
+ console.info('[RioAssist][history] lista de mensagens vazia', { conversationId });
1378
+ this.messages = [];
1379
+ this.showConversations = false;
1380
+ this.clearLoadingGuard();
1381
+ this.isLoading = false;
1382
+ this.conversationHistoryLoading = false;
1383
+ return;
1384
+ }
1385
+
1386
+ const normalized = entries.flatMap((entry, index) =>
1387
+ this.normalizeHistoryMessages(entry as Record<string, unknown>, index),
1388
+ );
1389
+
1390
+ if (conversationId) {
1391
+ this.currentConversationId = conversationId;
1392
+ }
1393
+
1394
+ this.messages = normalized;
1395
+ this.showConversations = false;
1396
+ this.clearLoadingGuard();
1397
+ this.isLoading = false;
1398
+ this.showNewConversationShortcut = normalized.length > 0;
1399
+ this.conversationHistoryLoading = false;
1400
+ this.refreshConversationsAfterResponse = false;
1401
+
1402
+ console.info('[RioAssist][history] mensagens carregadas', {
1403
+ conversationId: conversationId ?? null,
1404
+ total: normalized.length,
1405
+ });
1406
+ }
1407
+
1408
+ private extractHistoryEntries(payload: unknown): unknown[] {
1409
+ if (Array.isArray(payload)) {
1410
+ return payload;
1411
+ }
1412
+
1413
+ if (payload && typeof payload === 'object') {
1414
+ const record = payload as Record<string, unknown>;
1415
+ const candidates = [
1416
+ record.history,
1417
+ record.conversations,
1418
+ record.data,
1419
+ record.items,
1420
+ record.messages,
1421
+ ];
1422
+
1423
+ for (const candidate of candidates) {
1424
+ if (Array.isArray(candidate)) {
1425
+ return candidate;
1426
+ }
1427
+ }
1428
+
1429
+ if (record.data && typeof record.data === 'object' && !Array.isArray(record.data)) {
1430
+ const nested = this.extractHistoryEntries(record.data);
1431
+ if (nested.length > 0) {
1432
+ return nested;
1433
+ }
1434
+ }
1435
+ }
1436
+
1437
+ return [];
1438
+ }
1439
+
1440
+ private extractConversationId(payload: unknown): string | null | undefined {
1441
+ if (payload && typeof payload === 'object') {
1442
+ const record = payload as Record<string, unknown>;
1443
+ const candidates = [
1444
+ record.conversationId,
1445
+ record.conversationUUID,
1446
+ record.conversationUuid,
1447
+ record.uuid,
1448
+ record.id,
1449
+ ];
1450
+
1451
+ for (const candidate of candidates) {
1452
+ if (candidate === null) {
1453
+ return null;
1454
+ }
1455
+
1456
+ if (candidate !== undefined) {
1457
+ return String(candidate);
1458
+ }
1459
+ }
1460
+ }
1461
+
1462
+ return undefined;
1463
+ }
1464
+
1465
+ private isMessageHistoryEntries(entries: unknown[]) {
1466
+ return entries.some((entry) => this.looksLikeMessageHistoryEntry(entry));
1467
+ }
1468
+
1469
+ private looksLikeMessageHistoryEntry(entry: unknown) {
1470
+ if (!entry || typeof entry !== 'object') {
1471
+ return false;
1472
+ }
1473
+
1474
+ const item = entry as Record<string, unknown>;
1475
+ const role = item.role ?? item.sender ?? item.from ?? item.author ?? item.type;
1476
+ if (typeof role === 'string' && role.trim().length > 0) {
1477
+ return true;
1478
+ }
1479
+
1480
+ if (
1481
+ typeof item.content === 'string' ||
1482
+ typeof item.message === 'string' ||
1483
+ typeof item.text === 'string' ||
1484
+ typeof item.response === 'string'
1485
+ ) {
1486
+ return true;
1487
+ }
1488
+
1489
+ if (Array.isArray(item.parts) && item.parts.length > 0) {
1490
+ return true;
1491
+ }
1492
+
1493
+ return false;
1494
+ }
1495
+
1496
+ private normalizeConversationItem(
1497
+ value: Record<string, unknown>,
1498
+ index: number,
1499
+ ): ConversationItem | null {
1500
+ const rawId =
1501
+ value.id ??
1502
+ value.conversationId ??
1503
+ value.conversationUUID ??
1504
+ value.conversationUuid ??
1505
+ value.uuid;
1506
+
1507
+ const id = rawId !== undefined && rawId !== null ? String(rawId) : `history-${index + 1}`;
1508
+
1509
+ const rawTitle =
1510
+ value.title ??
1511
+ value.name ??
1512
+ value.topic ??
1513
+ value.subject ??
1514
+ value.question ??
1515
+ value.query ??
1516
+ value.message;
1517
+
1518
+ const title =
1519
+ typeof rawTitle === 'string' && rawTitle.trim().length > 0
1520
+ ? rawTitle.trim()
1521
+ : `Conversa ${index + 1}`;
1522
+
1523
+ const rawUpdated =
1524
+ value.updatedAt ??
1525
+ value.updated_at ??
1526
+ value.lastMessageAt ??
1527
+ value.last_message_at ??
1528
+ value.createdAt ??
1529
+ value.created_at ??
1530
+ value.timestamp ??
1531
+ value.date;
1532
+
1533
+ const updatedAt = this.toIsoString(rawUpdated);
1534
+
1535
+ return { id, title, updatedAt };
1536
+ }
1537
+
1538
+ private normalizeHistoryMessages(
1539
+ value: Record<string, unknown>,
1540
+ index: number,
1541
+ ): ChatMessage[] {
1542
+ const messages: ChatMessage[] = [];
1543
+
1544
+ const rawUserText = value.message ?? value.question ?? value.query ?? value.text ?? value.content;
1545
+ const userText = typeof rawUserText === 'string' ? rawUserText.trim() : '';
1546
+
1547
+ const rawResponseText =
1548
+ value.response ?? value.answer ?? value.reply ?? value.completion ?? value.body ?? value.preview;
1549
+ const responseText = typeof rawResponseText === 'string' ? rawResponseText.trim() : '';
1550
+
1551
+ const rawId = value.id ?? value.messageId ?? value.uuid ?? value.conversationMessageId;
1552
+ const baseId = rawId !== undefined && rawId !== null
1553
+ ? String(rawId)
1554
+ : `history-${index + 1}`;
1555
+
1556
+ const userTimestampValue =
1557
+ value.timestamp ??
1558
+ value.createdAt ??
1559
+ value.created_at ??
1560
+ value.date ??
1561
+ value.time;
1562
+ const assistantTimestampValue =
1563
+ value.responseTimestamp ??
1564
+ value.responseTime ??
1565
+ value.responseDate ??
1566
+ value.response_at ??
1567
+ value.updatedAt ??
1568
+ value.updated_at;
1569
+
1570
+ const userTimestamp = this.parseTimestamp(userTimestampValue);
1571
+ const assistantTimestamp = this.parseTimestamp(
1572
+ assistantTimestampValue,
1573
+ userTimestamp + 1,
1574
+ );
1575
+
1576
+ if (responseText) {
1577
+ if (userText) {
1578
+ messages.push({
1579
+ id: `${baseId}-user`,
1580
+ role: 'user',
1581
+ text: userText,
1582
+ html: this.renderMarkdown(userText),
1583
+ timestamp: userTimestamp,
1584
+ });
1585
+ }
1586
+
1587
+ messages.push({
1588
+ id: `${baseId}-assistant`,
1589
+ role: 'assistant',
1590
+ text: responseText,
1591
+ html: this.renderMarkdown(responseText),
1592
+ timestamp: assistantTimestamp,
1593
+ });
1594
+ } else if (userText) {
1595
+ // Se n�o tiver resposta, n�o exibimos a mensagem do usuario isolada.
1596
+ return [];
1597
+ }
1598
+
1599
+ if (messages.length > 0) {
1600
+ return messages;
1601
+ }
1602
+
1603
+ const fallback = this.normalizeSingleHistoryMessage(value, index);
1604
+ return fallback ? [fallback] : [];
1605
+ }
1606
+
1607
+ private normalizeSingleHistoryMessage(
1608
+ value: Record<string, unknown>,
1609
+ index: number,
1610
+ ): ChatMessage | null {
1611
+ const rawText =
1612
+ value.text ??
1613
+ value.message ??
1614
+ value.content ??
1615
+ value.response ??
1616
+ value.body ??
1617
+ value.preview;
1618
+
1619
+ const text = typeof rawText === 'string' && rawText.trim().length > 0
1620
+ ? rawText
1621
+ : '';
1622
+
1623
+ if (!text) {
1624
+ return null;
1625
+ }
1626
+
1627
+ const role = this.normalizeRole(
1628
+ value.role ??
1629
+ value.sender ??
1630
+ value.from ??
1631
+ value.author ??
1632
+ value.type ??
1633
+ value.direction,
1634
+ );
1635
+
1636
+ const rawId = value.id ?? value.messageId ?? value.uuid ?? value.conversationMessageId;
1637
+ const id = rawId !== undefined && rawId !== null
1638
+ ? String(rawId)
1639
+ : `history-message-${index + 1}`;
1640
+
1641
+ const timestampValue =
1642
+ value.timestamp ??
1643
+ value.createdAt ??
1644
+ value.created_at ??
1645
+ value.updatedAt ??
1646
+ value.updated_at ??
1647
+ value.date ??
1648
+ value.time;
1649
+
1650
+ const timestamp = this.parseTimestamp(timestampValue);
1651
+
1652
+ return {
1653
+ id,
1654
+ role,
1655
+ text,
1656
+ html: this.renderMarkdown(text),
1657
+ timestamp,
1658
+ };
1659
+ }
1660
+
1661
+ private normalizeRole(value: unknown): ChatRole {
1662
+ if (typeof value === 'string') {
1663
+ const normalized = value.toLowerCase();
1664
+ if (normalized.includes('user') || normalized.includes('client')) {
1665
+ return 'user';
1666
+ }
1667
+ if (normalized.includes('assistant') || normalized.includes('agent') || normalized.includes('bot')) {
1668
+ return 'assistant';
1669
+ }
1670
+ }
1671
+
1672
+ return 'assistant';
1673
+ }
1674
+
1675
+ private parseTimestamp(value: unknown, fallback?: number) {
1676
+ const parsed = Date.parse(this.toIsoString(value));
1677
+ if (Number.isFinite(parsed)) {
1678
+ return parsed;
1679
+ }
1680
+
1681
+ if (Number.isFinite(fallback ?? NaN)) {
1682
+ return fallback as number;
1683
+ }
1684
+
1685
+ return Date.now();
1686
+ }
1687
+
1688
+ private lookupConversationTitle(conversationId: string | null) {
1689
+ if (!conversationId) {
1690
+ return null;
1691
+ }
1692
+
1693
+ const found = this.conversations.find((item) => item.id === conversationId);
1694
+ return found ? found.title : null;
1695
+ }
1696
+
1697
+ private syncActiveConversationTitle() {
1698
+ if (!this.currentConversationId) {
1699
+ return;
1700
+ }
1701
+
1702
+ const title = this.lookupConversationTitle(this.currentConversationId);
1703
+ if (title) {
1704
+ this.activeConversationTitle = title;
1705
+ }
1706
+ }
1707
+
1708
+ private toIsoString(value: unknown) {
1709
+ if (typeof value === 'string' || typeof value === 'number') {
1710
+ const date = new Date(value);
1711
+ if (!Number.isNaN(date.getTime())) {
1712
+ return date.toISOString();
1713
+ }
1714
+ }
1715
+
1716
+ return new Date().toISOString();
1717
+ }
1718
+
1719
+ private startLoadingGuard() {
1720
+ this.clearLoadingGuard();
1721
+ this.loadingLabelInternal = 'RIO Insight está respondendo';
1722
+
1723
+ // Após 20s, mensagem de processamento prolongado.
1724
+ this.loadingTimerSlow = window.setTimeout(() => {
1725
+ this.loadingLabelInternal = 'RIO Insight continua respondendo';
1726
+ this.requestUpdate();
1727
+ }, 20000);
1728
+
1729
+ // Após 60s, aviso de demora maior.
1730
+ this.loadingTimerTimeout = window.setTimeout(() => {
1731
+ this.loadingLabelInternal =
1732
+ 'RIO Insight ainda está processando sua resposta. Peço que aguarde um pouco mais';
1733
+ this.requestUpdate();
1734
+ }, 60000);
1735
+
1736
+ // Após 120s, novo aviso de demora maior.
1737
+ this.loadingTimerTimeout = window.setTimeout(() => {
1738
+ this.loadingLabelInternal =
1739
+ 'Essa solicitação está demorando um pouco mais que o esperado. Pode favor, aguarde mais um pouco';
1740
+ this.requestUpdate();
1741
+ }, 120000);
1742
+ }
1743
+
1744
+ private clearLoadingGuard() {
1745
+ if (this.loadingTimer !== null) {
1746
+ window.clearTimeout(this.loadingTimer);
1747
+ this.loadingTimer = null;
1748
+ }
1749
+
1750
+ if (this.loadingTimerSlow !== null) {
1751
+ window.clearTimeout(this.loadingTimerSlow);
1752
+ this.loadingTimerSlow = null;
1753
+ }
1754
+
1755
+ if (this.loadingTimerTimeout !== null) {
1756
+ window.clearTimeout(this.loadingTimerTimeout);
1757
+ this.loadingTimerTimeout = null;
1758
+ }
1759
+ }
1760
+
1761
+ private scrollConversationToBottom() {
1762
+ const containers = Array.from(
1763
+ this.renderRoot.querySelectorAll('.panel-content'),
1764
+ ) as HTMLElement[];
1765
+
1766
+ containers.forEach((container) => {
1767
+ requestAnimationFrame(() => {
1768
+ container.scrollTop = container.scrollHeight;
1769
+ });
1770
+ });
1771
+ }
1772
+
1773
+ private renderMarkdown(content: string) {
1774
+ const rendered = this.markdownRenderer.render(content);
1775
+ const clean = DOMPurify.sanitize(rendered, {
1776
+ ALLOWED_TAGS: [
1777
+ 'a',
1778
+ 'p',
1779
+ 'ul',
1780
+ 'ol',
1781
+ 'li',
1782
+ 'code',
1783
+ 'pre',
1784
+ 'strong',
1785
+ 'em',
1786
+ 'blockquote',
1787
+ 'table',
1788
+ 'thead',
1789
+ 'tbody',
1790
+ 'tr',
1791
+ 'th',
1792
+ 'td',
1793
+ 'del',
1794
+ 'hr',
1795
+ 'br',
1796
+ 'img',
1797
+ 'span',
1798
+ 'input',
1799
+ ],
1800
+ ALLOWED_ATTR: [
1801
+ 'href',
1802
+ 'title',
1803
+ 'target',
1804
+ 'rel',
1805
+ 'src',
1806
+ 'alt',
1807
+ 'class',
1808
+ 'type',
1809
+ 'checked',
1810
+ 'disabled',
1811
+ 'aria-label',
1812
+ ],
1813
+ ALLOW_DATA_ATTR: false,
1814
+ FORBID_TAGS: ['style', 'script'],
1815
+ USE_PROFILES: { html: true },
1816
+ });
1817
+
1818
+ const container = document.createElement('div');
1819
+ container.innerHTML = clean;
1820
+
1821
+ container.querySelectorAll('a').forEach((anchor) => {
1822
+ anchor.setAttribute('target', '_blank');
1823
+ anchor.setAttribute('rel', 'noopener noreferrer');
1824
+ });
1825
+
1826
+ container.querySelectorAll('input[type="checkbox"]').forEach((checkbox) => {
1827
+ checkbox.setAttribute('disabled', '');
1828
+ checkbox.setAttribute('tabindex', '-1');
1829
+ });
1830
+
1831
+ return container.innerHTML;
1832
+ }
1833
+
1834
+ render() {
1835
+ return renderRioAssist(this);
1836
+ }
1837
+
1838
+ }
1414
1839
  declare global {
1415
1840
  interface HTMLElementTagNameMap {
1416
1841
  'rio-assist-widget': RioAssistWidget;
1417
1842
  }
1418
1843
  }
1419
-
1420
- if (!customElements.get('rio-assist-widget')) {
1421
- customElements.define('rio-assist-widget', RioAssistWidget);
1422
- }
1844
+
1845
+ if (!customElements.get('rio-assist-widget')) {
1846
+ customElements.define('rio-assist-widget', RioAssistWidget);
1847
+ }