rio-assist-widget 0.1.23 → 0.1.27

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