rio-assist-widget 0.1.1 → 0.1.4

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,84 +1,87 @@
1
- import { LitElement, type PropertyValues } from 'lit';
2
- import { widgetStyles } from './rio-assist.styles';
3
- import { renderRioAssist } from './rio-assist.template';
4
- import {
5
- RioWebsocketClient,
6
- type RioIncomingMessage,
7
- } from '../../services/rioWebsocket';
8
-
9
- type ChatRole = 'user' | 'assistant';
10
-
11
- export type ChatMessage = {
12
- id: string;
13
- role: ChatRole;
14
- text: string;
15
- timestamp: number;
16
- };
17
-
18
- type ConversationItem = {
19
- id: string;
20
- title: string;
21
- updatedAt: string;
22
- };
23
-
24
- export class RioAssistWidget extends LitElement {
25
- static styles = widgetStyles;
26
-
27
- static properties = {
28
- open: { type: Boolean, state: true },
29
- message: { type: String, state: true },
30
- titleText: { type: String, attribute: 'data-title' },
31
- buttonLabel: { type: String, attribute: 'data-button-label' },
32
- placeholder: { type: String, attribute: 'data-placeholder' },
33
- accentColor: { type: String, attribute: 'data-accent-color' },
34
- apiBaseUrl: { type: String, attribute: 'data-api-base-url' },
35
- rioToken: { type: String, attribute: 'data-rio-token' },
36
- suggestionsSource: { type: String, attribute: 'data-suggestions' },
37
- messages: { state: true },
38
- isLoading: { type: Boolean, state: true },
39
- errorMessage: { type: String, state: true },
40
- showConversations: { type: Boolean, state: true },
41
- conversationSearch: { type: String, state: true },
1
+ import { LitElement, type PropertyValues } from 'lit';
2
+ import { widgetStyles } from './rio-assist.styles';
3
+ import { renderRioAssist } from './rio-assist.template';
4
+ import {
5
+ RioWebsocketClient,
6
+ type RioIncomingMessage,
7
+ } from '../../services/rioWebsocket';
8
+
9
+ type ChatRole = 'user' | 'assistant';
10
+
11
+ export type ChatMessage = {
12
+ id: string;
13
+ role: ChatRole;
14
+ text: string;
15
+ timestamp: number;
16
+ };
17
+
18
+ type ConversationItem = {
19
+ id: string;
20
+ title: string;
21
+ updatedAt: string;
22
+ };
23
+
24
+ export class RioAssistWidget extends LitElement {
25
+ static styles = widgetStyles;
26
+
27
+ static properties = {
28
+ open: { type: Boolean, state: true },
29
+ message: { type: String, state: true },
30
+ titleText: { type: String, attribute: 'data-title' },
31
+ buttonLabel: { type: String, attribute: 'data-button-label' },
32
+ placeholder: { type: String, attribute: 'data-placeholder' },
33
+ accentColor: { type: String, attribute: 'data-accent-color' },
34
+ apiBaseUrl: { type: String, attribute: 'data-api-base-url' },
35
+ rioToken: { type: String, attribute: 'data-rio-token' },
36
+ suggestionsSource: { type: String, attribute: 'data-suggestions' },
37
+ messages: { state: true },
38
+ isLoading: { type: Boolean, state: true },
39
+ errorMessage: { type: String, state: true },
40
+ showConversations: { type: Boolean, state: true },
41
+ conversationSearch: { type: String, state: true },
42
42
  conversationMenuId: { state: true },
43
43
  conversationMenuPlacement: { state: true },
44
44
  isFullscreen: { type: Boolean, state: true },
45
45
  conversationScrollbar: { state: true },
46
+ showNewConversationShortcut: { type: Boolean, state: true },
46
47
  };
47
-
48
- open = false;
49
-
50
- message = '';
51
-
52
- titleText = 'RIO Assist';
53
-
54
- buttonLabel = 'RIO Assist';
55
-
56
- placeholder = 'Pergunte alguma coisa';
57
-
58
- accentColor = '#008B9A';
59
-
60
- apiBaseUrl = '';
61
-
62
- rioToken = '';
63
-
64
- suggestionsSource = '';
65
-
66
- messages: ChatMessage[] = [];
67
-
68
- isLoading = false;
69
-
70
- errorMessage = '';
71
-
72
- showConversations = false;
73
-
74
- conversationSearch = '';
75
-
48
+
49
+ open = false;
50
+
51
+ message = '';
52
+
53
+ titleText = 'Rio Insight';
54
+
55
+ buttonLabel = 'Rio Insight';
56
+
57
+ placeholder = 'Pergunte alguma coisa';
58
+
59
+ accentColor = '#008B9A';
60
+
61
+ apiBaseUrl = '';
62
+
63
+ rioToken = '';
64
+
65
+ suggestionsSource = '';
66
+
67
+ messages: ChatMessage[] = [];
68
+
69
+ isLoading = false;
70
+
71
+ errorMessage = '';
72
+
73
+ showConversations = false;
74
+
75
+ conversationSearch = '';
76
+
76
77
  conversationMenuId: string | null = null;
77
78
 
78
79
  conversationMenuPlacement: 'above' | 'below' = 'below';
79
80
 
80
81
  isFullscreen = false;
81
82
 
83
+ showNewConversationShortcut = false;
84
+
82
85
  conversationScrollbar = {
83
86
  height: 0,
84
87
  top: 0,
@@ -93,107 +96,121 @@ export class RioAssistWidget extends LitElement {
93
96
 
94
97
  private loadingTimer: number | null = null;
95
98
 
96
- conversations: ConversationItem[] = Array.from({ length: 20 }).map(
97
- (_, index) => ({
98
- id: `${index + 1}`,
99
- title: [
100
- 'Caminhões com problema na frota de veículos.',
101
- 'Próximas manutenções periódicas preventivas.',
102
- 'Quais revisões meu plano inclui?',
103
- 'Como automatizar preenchimento de odômetro.',
104
- 'Valor das peças da próxima revisão.',
105
- 'O que é revisão de assentamento?',
106
- 'Alertas críticos ativos.',
107
- 'Veículo superaquecendo, causas e recomendações.',
108
- 'Calibragem recomendada nos pneus do e-Delivery.',
109
- 'Quantos mil km trocar o óleo do motor.',
110
- 'Qual a vida útil da bateria Moura M100HE.',
111
- ][index % 11],
112
- updatedAt: new Date(Date.now() - index * 3600_000).toISOString(),
113
- }),
114
- );
115
-
116
- get suggestions(): string[] {
117
- if (!this.suggestionsSource) {
118
- return [];
119
- }
120
-
121
- return this.suggestionsSource
122
- .split('|')
123
- .map((item) => item.trim())
124
- .filter(Boolean);
125
- }
126
-
127
- protected updated(changedProperties: PropertyValues): void {
128
- super.updated(changedProperties);
129
- this.style.setProperty('--accent-color', this.accentColor);
130
-
131
- if (
132
- changedProperties.has('isFullscreen') ||
133
- changedProperties.has('showConversations') ||
134
- changedProperties.has('conversations')
135
- ) {
136
- this.enqueueConversationScrollbarMeasure();
137
- }
138
- }
139
-
140
- protected firstUpdated(): void {
141
- this.enqueueConversationScrollbarMeasure();
142
- }
143
-
144
- disconnectedCallback(): void {
145
- super.disconnectedCallback();
146
- if (this.conversationScrollbarRaf !== null) {
147
- cancelAnimationFrame(this.conversationScrollbarRaf);
148
- this.conversationScrollbarRaf = null;
149
- }
150
-
151
- this.teardownRioClient();
152
- this.clearLoadingGuard();
153
- }
99
+ private conversationScrollbarDraggingId: number | null = null;
154
100
 
101
+ private conversationScrollbarDragState: {
102
+ startY: number;
103
+ startThumbTop: number;
104
+ trackHeight: number;
105
+ thumbHeight: number;
106
+ list: HTMLElement;
107
+ } | null = null;
108
+
109
+ conversations: ConversationItem[] = Array.from({ length: 20 }).map(
110
+ (_, index) => ({
111
+ id: `${index + 1}`,
112
+ title: [
113
+ 'Caminhões com problema na frota de veículos.',
114
+ 'Próximas manutenções periódicas preventivas.',
115
+ 'Quais revisões meu plano inclui?',
116
+ 'Como automatizar preenchimento de odômetro.',
117
+ 'Valor das peças da próxima revisão.',
118
+ 'O que é revisão de assentamento?',
119
+ 'Alertas críticos ativos.',
120
+ 'Veículo superaquecendo, causas e recomendações.',
121
+ 'Calibragem recomendada nos pneus do e-Delivery.',
122
+ 'Quantos mil km trocar o óleo do motor.',
123
+ 'Qual a vida útil da bateria Moura M100HE.',
124
+ ][index % 11],
125
+ updatedAt: new Date(Date.now() - index * 3600_000).toISOString(),
126
+ }),
127
+ );
128
+
129
+ get suggestions(): string[] {
130
+ if (!this.suggestionsSource) {
131
+ return [];
132
+ }
133
+
134
+ return this.suggestionsSource
135
+ .split('|')
136
+ .map((item) => item.trim())
137
+ .filter(Boolean);
138
+ }
139
+
140
+ protected updated(changedProperties: PropertyValues): void {
141
+ super.updated(changedProperties);
142
+ this.style.setProperty('--accent-color', this.accentColor);
143
+
144
+ if (
145
+ changedProperties.has('isFullscreen') ||
146
+ changedProperties.has('showConversations') ||
147
+ changedProperties.has('conversations')
148
+ ) {
149
+ this.enqueueConversationScrollbarMeasure();
150
+ }
151
+ }
152
+
153
+ protected firstUpdated(): void {
154
+ this.enqueueConversationScrollbarMeasure();
155
+ }
156
+
157
+ disconnectedCallback(): void {
158
+ super.disconnectedCallback();
159
+ if (this.conversationScrollbarRaf !== null) {
160
+ cancelAnimationFrame(this.conversationScrollbarRaf);
161
+ this.conversationScrollbarRaf = null;
162
+ }
163
+
164
+ this.teardownRioClient();
165
+ this.clearLoadingGuard();
166
+ }
167
+
155
168
  get filteredConversations() {
156
169
  const query = this.conversationSearch.trim().toLowerCase();
157
170
  if (!query) {
158
171
  return this.conversations;
159
172
  }
160
-
173
+
161
174
  return this.conversations.filter((conversation) =>
162
175
  conversation.title.toLowerCase().includes(query),
163
176
  );
164
177
  }
165
178
 
166
- togglePanel() {
167
- if (this.isFullscreen) {
168
- this.exitFullscreen(false);
169
- return;
170
- }
171
-
172
- this.open = !this.open;
173
- this.dispatchEvent(
174
- new CustomEvent(this.open ? 'rioassist:open' : 'rioassist:close', {
175
- bubbles: true,
176
- composed: true,
177
- }),
178
- );
179
- }
180
-
181
- closePanel() {
182
- this.isFullscreen = false;
183
- if (this.open) {
184
- this.togglePanel();
185
- }
186
- }
187
-
188
- openConversationsPanel() {
189
- this.showConversations = true;
190
- }
191
-
192
- closeConversationsPanel() {
193
- this.showConversations = false;
194
- this.conversationMenuId = null;
179
+ get hasActiveConversation() {
180
+ return this.messages.length > 0;
195
181
  }
196
182
 
183
+ togglePanel() {
184
+ if (this.isFullscreen) {
185
+ this.exitFullscreen(false);
186
+ return;
187
+ }
188
+
189
+ this.open = !this.open;
190
+ this.dispatchEvent(
191
+ new CustomEvent(this.open ? 'rioassist:open' : 'rioassist:close', {
192
+ bubbles: true,
193
+ composed: true,
194
+ }),
195
+ );
196
+ }
197
+
198
+ closePanel() {
199
+ this.isFullscreen = false;
200
+ if (this.open) {
201
+ this.togglePanel();
202
+ }
203
+ }
204
+
205
+ openConversationsPanel() {
206
+ this.showConversations = true;
207
+ }
208
+
209
+ closeConversationsPanel() {
210
+ this.showConversations = false;
211
+ this.conversationMenuId = null;
212
+ }
213
+
197
214
  toggleConversationsPanel() {
198
215
  this.showConversations = !this.showConversations;
199
216
  if (!this.showConversations) {
@@ -201,81 +218,85 @@ export class RioAssistWidget extends LitElement {
201
218
  }
202
219
  }
203
220
 
204
- handleConversationSearch(event: InputEvent) {
205
- this.conversationSearch = (event.target as HTMLInputElement).value;
206
- }
207
-
208
- handleConversationMenuToggle(event: Event, id: string) {
209
- event.stopPropagation();
210
-
211
- if (this.conversationMenuId === id) {
212
- this.conversationMenuId = null;
213
- return;
214
- }
215
-
216
- const button = event.currentTarget as HTMLElement;
217
- const container = this.renderRoot.querySelector(
218
- '.conversations-panel__surface',
219
- ) as HTMLElement | null;
220
-
221
- if (button && container) {
222
- const buttonRect = button.getBoundingClientRect();
223
- const containerRect = container.getBoundingClientRect();
224
- const spaceBelow = containerRect.bottom - buttonRect.bottom;
225
- this.conversationMenuPlacement = spaceBelow < 140 ? 'above' : 'below';
226
- } else {
227
- this.conversationMenuPlacement = 'below';
228
- }
229
-
230
- this.conversationMenuId = id;
231
- }
232
-
233
- handleConversationsPanelPointer(event: PointerEvent) {
234
- const target = event.target as HTMLElement;
235
- if (
236
- !target.closest('.conversation-menu') &&
237
- !target.closest('.conversation-menu-button')
238
- ) {
239
- this.conversationMenuId = null;
240
- }
241
- }
242
-
243
- handleConversationAction(action: 'rename' | 'delete', id: string) {
244
- this.conversationMenuId = null;
245
- const conversation = this.conversations.find((item) => item.id === id);
246
- if (!conversation) {
247
- return;
248
- }
249
-
250
- const message = `${
251
- action === 'rename' ? 'Renomear' : 'Excluir'
252
- } "${conversation.title}"`;
253
- console.info(`[Mock] ${message}`);
221
+ toggleNewConversationShortcut() {
222
+ this.showNewConversationShortcut = !this.showNewConversationShortcut;
254
223
  }
255
224
 
256
- handleCloseAction() {
257
- if (this.isFullscreen) {
258
- this.exitFullscreen(true);
259
- return;
260
- }
261
-
262
- if (this.showConversations) {
263
- this.closeConversationsPanel();
264
- } else {
265
- this.closePanel();
266
- }
267
- }
268
-
269
- enterFullscreen() {
270
- if (this.isFullscreen) {
271
- return;
272
- }
273
-
274
- this.isFullscreen = true;
275
- this.open = false;
276
- this.showConversations = false;
225
+ handleConversationSearch(event: InputEvent) {
226
+ this.conversationSearch = (event.target as HTMLInputElement).value;
277
227
  }
278
-
228
+
229
+ handleConversationMenuToggle(event: Event, id: string) {
230
+ event.stopPropagation();
231
+
232
+ if (this.conversationMenuId === id) {
233
+ this.conversationMenuId = null;
234
+ return;
235
+ }
236
+
237
+ const button = event.currentTarget as HTMLElement;
238
+ const container = this.renderRoot.querySelector(
239
+ '.conversations-panel__surface',
240
+ ) as HTMLElement | null;
241
+
242
+ if (button && container) {
243
+ const buttonRect = button.getBoundingClientRect();
244
+ const containerRect = container.getBoundingClientRect();
245
+ const spaceBelow = containerRect.bottom - buttonRect.bottom;
246
+ this.conversationMenuPlacement = spaceBelow < 140 ? 'above' : 'below';
247
+ } else {
248
+ this.conversationMenuPlacement = 'below';
249
+ }
250
+
251
+ this.conversationMenuId = id;
252
+ }
253
+
254
+ handleConversationsPanelPointer(event: PointerEvent) {
255
+ const target = event.target as HTMLElement;
256
+ if (
257
+ !target.closest('.conversation-menu') &&
258
+ !target.closest('.conversation-menu-button')
259
+ ) {
260
+ this.conversationMenuId = null;
261
+ }
262
+ }
263
+
264
+ handleConversationAction(action: 'rename' | 'delete', id: string) {
265
+ this.conversationMenuId = null;
266
+ const conversation = this.conversations.find((item) => item.id === id);
267
+ if (!conversation) {
268
+ return;
269
+ }
270
+
271
+ const message = `${
272
+ action === 'rename' ? 'Renomear' : 'Excluir'
273
+ } "${conversation.title}"`;
274
+ console.info(`[Mock] ${message}`);
275
+ }
276
+
277
+ handleCloseAction() {
278
+ if (this.isFullscreen) {
279
+ this.exitFullscreen(true);
280
+ return;
281
+ }
282
+
283
+ if (this.showConversations) {
284
+ this.closeConversationsPanel();
285
+ } else {
286
+ this.closePanel();
287
+ }
288
+ }
289
+
290
+ enterFullscreen() {
291
+ if (this.isFullscreen) {
292
+ return;
293
+ }
294
+
295
+ this.isFullscreen = true;
296
+ this.open = false;
297
+ this.showConversations = false;
298
+ }
299
+
279
300
  exitFullscreen(restorePanel: boolean) {
280
301
  if (!this.isFullscreen) {
281
302
  return;
@@ -283,15 +304,33 @@ export class RioAssistWidget extends LitElement {
283
304
 
284
305
  this.isFullscreen = false;
285
306
  this.conversationMenuId = null;
307
+ this.showNewConversationShortcut = false;
286
308
  if (restorePanel) {
287
309
  this.open = true;
288
310
  }
289
311
  }
290
-
312
+
291
313
  handleCreateConversation() {
292
- console.info('[Mock] Criar nova conversa');
293
- }
314
+ if (!this.hasActiveConversation) {
315
+ return;
316
+ }
294
317
 
318
+ this.clearLoadingGuard();
319
+ this.isLoading = false;
320
+ this.messages = [];
321
+ this.message = '';
322
+ this.errorMessage = '';
323
+ this.showConversations = false;
324
+ this.teardownRioClient();
325
+ this.showNewConversationShortcut = false;
326
+ this.dispatchEvent(
327
+ new CustomEvent('rioassist:new-conversation', {
328
+ bubbles: true,
329
+ composed: true,
330
+ }),
331
+ );
332
+ }
333
+
295
334
  handleConversationListScroll(event: Event) {
296
335
  const target = event.currentTarget as HTMLElement | null;
297
336
  if (!target) {
@@ -300,178 +339,260 @@ export class RioAssistWidget extends LitElement {
300
339
  this.updateConversationScrollbar(target);
301
340
  }
302
341
 
303
- private enqueueConversationScrollbarMeasure() {
304
- if (this.conversationScrollbarRaf !== null) {
305
- return;
306
- }
307
-
308
- this.conversationScrollbarRaf = requestAnimationFrame(() => {
309
- this.conversationScrollbarRaf = null;
310
- this.updateConversationScrollbar();
311
- });
312
- }
342
+ handleConversationScrollbarPointerDown(event: PointerEvent) {
343
+ const track = event.currentTarget as HTMLElement | null;
344
+ const list = this.renderRoot.querySelector(
345
+ '.conversation-list--sidebar',
346
+ ) as HTMLElement | null;
313
347
 
314
- private updateConversationScrollbar(target?: HTMLElement | null) {
315
- const element =
316
- target ??
317
- (this.renderRoot.querySelector(
318
- '.conversation-list--sidebar',
319
- ) as HTMLElement | null);
320
-
321
- if (!element) {
322
- if (this.conversationScrollbar.visible) {
323
- this.conversationScrollbar = { height: 0, top: 0, visible: false };
324
- }
348
+ if (!track || !list) {
325
349
  return;
326
350
  }
327
351
 
328
- const { scrollHeight, clientHeight, scrollTop } = element;
329
- if (scrollHeight <= clientHeight + 1) {
330
- if (this.conversationScrollbar.visible) {
331
- this.conversationScrollbar = { height: 0, top: 0, visible: false };
332
- }
333
- return;
352
+ const trackRect = track.getBoundingClientRect();
353
+ const thumbHeight = trackRect.height * (this.conversationScrollbar.height / 100);
354
+ const maxThumbTop = Math.max(trackRect.height - thumbHeight, 0);
355
+ const scrollRange = Math.max(list.scrollHeight - list.clientHeight, 1);
356
+ const currentThumbTop = (list.scrollTop / scrollRange) * maxThumbTop;
357
+ const offsetY = event.clientY - trackRect.top;
358
+ const isOnThumb = offsetY >= currentThumbTop && offsetY <= currentThumbTop + thumbHeight;
359
+
360
+ const nextThumbTop = isOnThumb
361
+ ? currentThumbTop
362
+ : Math.min(Math.max(offsetY - thumbHeight / 2, 0), maxThumbTop);
363
+
364
+ if (!isOnThumb) {
365
+ list.scrollTop = (nextThumbTop / Math.max(maxThumbTop, 1)) * (list.scrollHeight - list.clientHeight);
366
+ this.updateConversationScrollbar(list);
334
367
  }
335
368
 
336
- const ratio = clientHeight / scrollHeight;
337
- const height = Math.max(ratio * 100, 8);
338
- const maxTop = 100 - height;
339
- const top =
340
- scrollTop / (scrollHeight - clientHeight) * (maxTop > 0 ? maxTop : 0);
341
-
342
- this.conversationScrollbar = {
343
- height,
344
- top,
345
- visible: true,
369
+ track.setPointerCapture(event.pointerId);
370
+ this.conversationScrollbarDraggingId = event.pointerId;
371
+ this.conversationScrollbarDragState = {
372
+ startY: event.clientY,
373
+ startThumbTop: nextThumbTop,
374
+ trackHeight: trackRect.height,
375
+ thumbHeight,
376
+ list,
346
377
  };
347
- }
348
-
349
- async onSuggestionClick(suggestion: string) {
350
- await this.processMessage(suggestion);
351
- }
352
-
353
- async handleSubmit(event: SubmitEvent) {
354
378
  event.preventDefault();
355
- await this.processMessage(this.message);
356
- }
357
-
358
- private createMessage(role: ChatRole, text: string): ChatMessage {
359
- const id = typeof crypto !== 'undefined' && 'randomUUID' in crypto
360
- ? crypto.randomUUID()
361
- : `${Date.now()}-${Math.random()}`;
362
-
363
- return {
364
- id,
365
- role,
366
- text,
367
- timestamp: Date.now(),
368
- };
369
379
  }
370
380
 
371
- private async processMessage(rawValue: string) {
372
- const content = rawValue.trim();
373
- if (!content || this.isLoading) {
381
+ handleConversationScrollbarPointerMove(event: PointerEvent) {
382
+ if (
383
+ this.conversationScrollbarDraggingId === null ||
384
+ this.conversationScrollbarDraggingId !== event.pointerId ||
385
+ !this.conversationScrollbarDragState
386
+ ) {
374
387
  return;
375
388
  }
376
389
 
377
- this.dispatchEvent(
378
- new CustomEvent('rioassist:send', {
379
- detail: {
380
- message: content,
381
- apiBaseUrl: this.apiBaseUrl,
382
- token: this.rioToken,
383
- },
384
- bubbles: true,
385
- composed: true,
386
- }),
387
- );
388
-
389
- const userMessage = this.createMessage('user', content);
390
- this.messages = [...this.messages, userMessage];
391
- this.message = '';
392
- this.errorMessage = '';
393
- this.isLoading = true;
394
- this.startLoadingGuard();
395
-
396
- try {
397
- const client = this.ensureRioClient();
398
- await client.sendMessage(content);
399
- } catch (error) {
400
- this.clearLoadingGuard();
401
- this.isLoading = false;
402
- this.errorMessage = error instanceof Error
403
- ? error.message
404
- : 'Nao foi possivel enviar a mensagem para o agente.';
405
- }
406
- }
407
-
408
- private ensureRioClient() {
409
- const token = this.rioToken.trim();
410
- if (!token) {
411
- throw new Error(
412
- 'Informe o token RIO em data-rio-token para conectar no websocket do assistente.',
413
- );
390
+ const {
391
+ startY,
392
+ startThumbTop,
393
+ trackHeight,
394
+ thumbHeight,
395
+ list,
396
+ } = this.conversationScrollbarDragState;
397
+
398
+ const maxThumbTop = Math.max(trackHeight - thumbHeight, 0);
399
+ const deltaY = event.clientY - startY;
400
+ const thumbTop = Math.min(Math.max(startThumbTop + deltaY, 0), maxThumbTop);
401
+ const scrollRange = list.scrollHeight - list.clientHeight;
402
+
403
+ if (scrollRange > 0) {
404
+ list.scrollTop = (thumbTop / Math.max(maxThumbTop, 1)) * scrollRange;
405
+ this.updateConversationScrollbar(list);
414
406
  }
415
407
 
416
- if (!this.rioClient || !this.rioClient.matchesToken(token)) {
417
- this.teardownRioClient();
418
- this.rioClient = new RioWebsocketClient(token);
419
- this.rioUnsubscribe = this.rioClient.onMessage((incoming) => {
420
- this.handleIncomingMessage(incoming);
421
- });
422
- }
423
-
424
- return this.rioClient;
425
- }
426
-
427
- private handleIncomingMessage(message: RioIncomingMessage) {
428
- const assistantMessage = this.createMessage('assistant', message.text);
429
- this.messages = [...this.messages, assistantMessage];
430
- this.clearLoadingGuard();
431
- this.isLoading = false;
408
+ event.preventDefault();
432
409
  }
433
410
 
434
- private teardownRioClient() {
435
- if (this.rioUnsubscribe) {
436
- this.rioUnsubscribe();
437
- this.rioUnsubscribe = null;
411
+ handleConversationScrollbarPointerUp(event: PointerEvent) {
412
+ if (this.conversationScrollbarDraggingId !== event.pointerId) {
413
+ return;
438
414
  }
439
415
 
440
- if (this.rioClient) {
441
- this.rioClient.close();
442
- this.rioClient = null;
443
- }
444
- }
416
+ const track = event.currentTarget as HTMLElement | null;
417
+ track?.releasePointerCapture(event.pointerId);
445
418
 
446
- private startLoadingGuard() {
447
- this.clearLoadingGuard();
448
- this.loadingTimer = window.setTimeout(() => {
449
- this.loadingTimer = null;
450
- this.isLoading = false;
451
- }, 15000);
419
+ this.conversationScrollbarDraggingId = null;
420
+ this.conversationScrollbarDragState = null;
452
421
  }
453
422
 
454
- private clearLoadingGuard() {
455
- if (this.loadingTimer !== null) {
456
- window.clearTimeout(this.loadingTimer);
457
- this.loadingTimer = null;
423
+ private enqueueConversationScrollbarMeasure() {
424
+ if (this.conversationScrollbarRaf !== null) {
425
+ return;
458
426
  }
459
- }
460
-
461
- render() {
462
- return renderRioAssist(this);
463
- }
464
-
465
- }
466
- declare global {
467
- interface HTMLElementTagNameMap {
468
- 'rio-assist-widget': RioAssistWidget;
469
- }
470
- }
471
427
 
472
- if (!customElements.get('rio-assist-widget')) {
473
- customElements.define('rio-assist-widget', RioAssistWidget);
474
- }
428
+ this.conversationScrollbarRaf = requestAnimationFrame(() => {
429
+ this.conversationScrollbarRaf = null;
430
+ this.updateConversationScrollbar();
431
+ });
432
+ }
433
+
434
+ private updateConversationScrollbar(target?: HTMLElement | null) {
435
+ const element =
436
+ target ??
437
+ (this.renderRoot.querySelector(
438
+ '.conversation-list--sidebar',
439
+ ) as HTMLElement | null);
440
+
441
+ if (!element) {
442
+ if (this.conversationScrollbar.visible) {
443
+ this.conversationScrollbar = { height: 0, top: 0, visible: false };
444
+ }
445
+ return;
446
+ }
447
+
448
+ const { scrollHeight, clientHeight, scrollTop } = element;
449
+ if (scrollHeight <= clientHeight + 1) {
450
+ if (this.conversationScrollbar.visible) {
451
+ this.conversationScrollbar = { height: 0, top: 0, visible: false };
452
+ }
453
+ return;
454
+ }
455
+
456
+ const ratio = clientHeight / scrollHeight;
457
+ const height = Math.max(ratio * 100, 8);
458
+ const maxTop = 100 - height;
459
+ const top =
460
+ scrollTop / (scrollHeight - clientHeight) * (maxTop > 0 ? maxTop : 0);
461
+
462
+ this.conversationScrollbar = {
463
+ height,
464
+ top,
465
+ visible: true,
466
+ };
467
+ }
468
+
469
+ async onSuggestionClick(suggestion: string) {
470
+ await this.processMessage(suggestion);
471
+ }
472
+
473
+ async handleSubmit(event: SubmitEvent) {
474
+ event.preventDefault();
475
+ await this.processMessage(this.message);
476
+ }
477
+
478
+ private createMessage(role: ChatRole, text: string): ChatMessage {
479
+ const id = typeof crypto !== 'undefined' && 'randomUUID' in crypto
480
+ ? crypto.randomUUID()
481
+ : `${Date.now()}-${Math.random()}`;
482
+
483
+ return {
484
+ id,
485
+ role,
486
+ text,
487
+ timestamp: Date.now(),
488
+ };
489
+ }
490
+
491
+ private async processMessage(rawValue: string) {
492
+ const content = rawValue.trim();
493
+ if (!content || this.isLoading) {
494
+ return;
495
+ }
496
+
497
+ this.dispatchEvent(
498
+ new CustomEvent('rioassist:send', {
499
+ detail: {
500
+ message: content,
501
+ apiBaseUrl: this.apiBaseUrl,
502
+ token: this.rioToken,
503
+ },
504
+ bubbles: true,
505
+ composed: true,
506
+ }),
507
+ );
508
+
509
+ const userMessage = this.createMessage('user', content);
510
+ this.messages = [...this.messages, userMessage];
511
+ this.message = '';
512
+ this.errorMessage = '';
513
+ this.isLoading = true;
514
+ this.startLoadingGuard();
515
+
516
+ try {
517
+ const client = this.ensureRioClient();
518
+ await client.sendMessage(content);
519
+ } catch (error) {
520
+ this.clearLoadingGuard();
521
+ this.isLoading = false;
522
+ this.errorMessage = error instanceof Error
523
+ ? error.message
524
+ : 'Nao foi possivel enviar a mensagem para o agente.';
525
+ }
526
+ }
527
+
528
+ private ensureRioClient() {
529
+ const token = this.rioToken.trim();
530
+ if (!token) {
531
+ throw new Error(
532
+ 'Informe o token RIO em data-rio-token para conectar no websocket do assistente.',
533
+ );
534
+ }
535
+
536
+ if (!this.rioClient || !this.rioClient.matchesToken(token)) {
537
+ this.teardownRioClient();
538
+ this.rioClient = new RioWebsocketClient(token);
539
+ this.rioUnsubscribe = this.rioClient.onMessage((incoming) => {
540
+ this.handleIncomingMessage(incoming);
541
+ });
542
+ }
543
+
544
+ return this.rioClient;
545
+ }
546
+
547
+ private handleIncomingMessage(message: RioIncomingMessage) {
548
+ const assistantMessage = this.createMessage('assistant', message.text);
549
+ this.messages = [...this.messages, assistantMessage];
550
+ this.clearLoadingGuard();
551
+ this.isLoading = false;
552
+ }
553
+
554
+ private teardownRioClient() {
555
+ if (this.rioUnsubscribe) {
556
+ this.rioUnsubscribe();
557
+ this.rioUnsubscribe = null;
558
+ }
559
+
560
+ if (this.rioClient) {
561
+ this.rioClient.close();
562
+ this.rioClient = null;
563
+ }
564
+ }
565
+
566
+ private startLoadingGuard() {
567
+ this.clearLoadingGuard();
568
+ this.loadingTimer = window.setTimeout(() => {
569
+ this.loadingTimer = null;
570
+ this.isLoading = false;
571
+ }, 15000);
572
+ }
573
+
574
+ private clearLoadingGuard() {
575
+ if (this.loadingTimer !== null) {
576
+ window.clearTimeout(this.loadingTimer);
577
+ this.loadingTimer = null;
578
+ }
579
+ }
580
+
581
+ render() {
582
+ return renderRioAssist(this);
583
+ }
584
+
585
+ }
586
+ declare global {
587
+ interface HTMLElementTagNameMap {
588
+ 'rio-assist-widget': RioAssistWidget;
589
+ }
590
+ }
591
+
592
+ if (!customElements.get('rio-assist-widget')) {
593
+ customElements.define('rio-assist-widget', RioAssistWidget);
594
+ }
595
+
475
596
 
476
597
 
477
598