rio-assist-widget 0.1.34 → 0.1.36

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