rio-assist-widget 0.1.9 → 0.1.10

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rio-assist-widget",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
4
4
  "description": "Web Component do painel lateral Rio Insight, pronto para ser embutido em qualquer projeto.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -1,4 +1,4 @@
1
- import { LitElement, type PropertyValues } from 'lit';
1
+ import { LitElement, type PropertyValues } from 'lit';
2
2
  import { widgetStyles } from './rio-assist.styles';
3
3
  import { renderRioAssist } from './rio-assist.template';
4
4
  import {
@@ -47,8 +47,9 @@ export class RioAssistWidget extends LitElement {
47
47
  conversationMenuPlacement: { state: true },
48
48
  isFullscreen: { type: Boolean, state: true },
49
49
  conversationScrollbar: { state: true },
50
- showNewConversationShortcut: { type: Boolean, state: true },
51
- };
50
+ showNewConversationShortcut: { type: Boolean, state: true },
51
+ conversations: { state: true },
52
+ };
52
53
 
53
54
  open = false;
54
55
 
@@ -116,25 +117,7 @@ export class RioAssistWidget extends LitElement {
116
117
  breaks: true,
117
118
  }).use(markdownItTaskLists);
118
119
 
119
- conversations: ConversationItem[] = Array.from({ length: 20 }).map(
120
- (_, index) => ({
121
- id: `${index + 1}`,
122
- title: [
123
- 'Caminhões com problema na frota de veículos.',
124
- 'Próximas manutenções periódicas preventivas.',
125
- 'Quais revisões meu plano inclui?',
126
- 'Como automatizar preenchimento de odômetro.',
127
- 'Valor das peças da próxima revisão.',
128
- 'O que é revisão de assentamento?',
129
- 'Alertas críticos ativos.',
130
- 'Veículo superaquecendo, causas e recomendações.',
131
- 'Calibragem recomendada nos pneus do e-Delivery.',
132
- 'Quantos mil km trocar o óleo do motor.',
133
- 'Qual a vida útil da bateria Moura M100HE.',
134
- ][index % 11],
135
- updatedAt: new Date(Date.now() - index * 3600_000).toISOString(),
136
- }),
137
- );
120
+ conversations: ConversationItem[] = [];
138
121
 
139
122
  get suggestions(): string[] {
140
123
  if (!this.suggestionsSource) {
@@ -221,21 +204,25 @@ export class RioAssistWidget extends LitElement {
221
204
  }
222
205
  }
223
206
 
224
- openConversationsPanel() {
225
- this.showConversations = true;
226
- }
227
-
228
- closeConversationsPanel() {
229
- this.showConversations = false;
230
- this.conversationMenuId = null;
231
- }
232
-
233
- toggleConversationsPanel() {
234
- this.showConversations = !this.showConversations;
235
- if (!this.showConversations) {
236
- this.conversationMenuId = null;
237
- }
238
- }
207
+ openConversationsPanel() {
208
+ this.showConversations = true;
209
+ this.requestConversationHistory();
210
+ }
211
+
212
+ closeConversationsPanel() {
213
+ this.showConversations = false;
214
+ this.conversationMenuId = null;
215
+ }
216
+
217
+ toggleConversationsPanel() {
218
+ this.showConversations = !this.showConversations;
219
+ if (!this.showConversations) {
220
+ this.conversationMenuId = null;
221
+ return;
222
+ }
223
+
224
+ this.requestConversationHistory();
225
+ }
239
226
 
240
227
  toggleNewConversationShortcut() {
241
228
  this.showNewConversationShortcut = !this.showNewConversationShortcut;
@@ -550,11 +537,11 @@ export class RioAssistWidget extends LitElement {
550
537
  }
551
538
  }
552
539
 
553
- private ensureRioClient() {
554
- const token = this.rioToken.trim();
555
- if (!token) {
556
- throw new Error(
557
- 'Informe o token RIO em data-rio-token para conectar no websocket do assistente.',
540
+ private ensureRioClient() {
541
+ const token = this.rioToken.trim();
542
+ if (!token) {
543
+ throw new Error(
544
+ 'Informe o token RIO em data-rio-token para conectar no websocket do assistente.',
558
545
  );
559
546
  }
560
547
 
@@ -565,16 +552,22 @@ export class RioAssistWidget extends LitElement {
565
552
  this.handleIncomingMessage(incoming);
566
553
  });
567
554
  }
568
-
569
- return this.rioClient;
570
- }
571
-
572
- private handleIncomingMessage(message: RioIncomingMessage) {
573
- const assistantMessage = this.createMessage('assistant', message.text);
574
- this.messages = [...this.messages, assistantMessage];
575
- this.clearLoadingGuard();
576
- this.isLoading = false;
577
- }
555
+
556
+ return this.rioClient;
557
+ }
558
+
559
+ private handleIncomingMessage(message: RioIncomingMessage) {
560
+ if (this.isHistoryPayload(message)) {
561
+ this.logHistoryPayload(message);
562
+ this.applyConversationHistory(message.data);
563
+ return;
564
+ }
565
+
566
+ const assistantMessage = this.createMessage('assistant', message.text);
567
+ this.messages = [...this.messages, assistantMessage];
568
+ this.clearLoadingGuard();
569
+ this.isLoading = false;
570
+ }
578
571
 
579
572
  private teardownRioClient() {
580
573
  if (this.rioUnsubscribe) {
@@ -582,15 +575,196 @@ export class RioAssistWidget extends LitElement {
582
575
  this.rioUnsubscribe = null;
583
576
  }
584
577
 
585
- if (this.rioClient) {
586
- this.rioClient.close();
587
- this.rioClient = null;
588
- }
589
- }
590
-
591
- private startLoadingGuard() {
592
- this.clearLoadingGuard();
593
- this.loadingTimer = window.setTimeout(() => {
578
+ if (this.rioClient) {
579
+ this.rioClient.close();
580
+ this.rioClient = null;
581
+ }
582
+ }
583
+
584
+ async requestConversationHistory(conversationId?: string) {
585
+ try {
586
+ const client = this.ensureRioClient();
587
+ const limit = 50;
588
+
589
+ console.info('[RioAssist][history] solicitando historico de conversas', {
590
+ conversationId: conversationId ?? null,
591
+ limit,
592
+ });
593
+
594
+ await client.requestHistory({ conversationId, limit });
595
+ } catch (error) {
596
+ console.error('[RioAssist][history] erro ao solicitar historico', error);
597
+ }
598
+ }
599
+
600
+ private isHistoryPayload(message: RioIncomingMessage) {
601
+ if (
602
+ typeof message.action === 'string' &&
603
+ message.action.toLowerCase().includes('history')
604
+ ) {
605
+ return true;
606
+ }
607
+
608
+ const data = message.data;
609
+ if (data && typeof data === 'object') {
610
+ const action = (data as any).action;
611
+ if (typeof action === 'string' && action.toLowerCase().includes('history')) {
612
+ return true;
613
+ }
614
+
615
+ if (Array.isArray((data as any).history) || Array.isArray((data as any).conversations)) {
616
+ return true;
617
+ }
618
+ }
619
+
620
+ return false;
621
+ }
622
+
623
+ private logHistoryPayload(message: RioIncomingMessage) {
624
+ const label = '[RioAssist][history] payload recebido do websocket';
625
+ if (message.data !== null && message.data !== undefined) {
626
+ console.info(label, message.data);
627
+ return;
628
+ }
629
+
630
+ console.info(label, message.raw);
631
+ }
632
+
633
+ private applyConversationHistory(payload: unknown) {
634
+ const entries = this.extractHistoryEntries(payload);
635
+ if (entries.length === 0) {
636
+ console.info('[RioAssist][history] payload sem itens para montar lista de conversas');
637
+ this.conversations = [];
638
+ return;
639
+ }
640
+
641
+ const map = new Map<string, ConversationItem>();
642
+
643
+ entries.forEach((entry, index) => {
644
+ if (!entry || typeof entry !== 'object') {
645
+ return;
646
+ }
647
+
648
+ const normalized = this.normalizeConversationItem(
649
+ entry as Record<string, unknown>,
650
+ index,
651
+ );
652
+
653
+ if (!normalized) {
654
+ return;
655
+ }
656
+
657
+ const current = map.get(normalized.id);
658
+ if (!current) {
659
+ map.set(normalized.id, normalized);
660
+ return;
661
+ }
662
+
663
+ const currentTime = Date.parse(current.updatedAt);
664
+ const nextTime = Date.parse(normalized.updatedAt);
665
+
666
+ if (Number.isFinite(nextTime) && nextTime > currentTime) {
667
+ map.set(normalized.id, normalized);
668
+ }
669
+ });
670
+
671
+ const conversations = Array.from(map.values()).sort((a, b) => {
672
+ const order = Date.parse(b.updatedAt) - Date.parse(a.updatedAt);
673
+ return Number.isFinite(order) ? order : 0;
674
+ });
675
+
676
+ this.conversations = conversations;
677
+ console.info('[RioAssist][history] conversas normalizadas', conversations);
678
+ }
679
+
680
+ private extractHistoryEntries(payload: unknown): unknown[] {
681
+ if (Array.isArray(payload)) {
682
+ return payload;
683
+ }
684
+
685
+ if (payload && typeof payload === 'object') {
686
+ const record = payload as Record<string, unknown>;
687
+ const candidates = [
688
+ record.history,
689
+ record.conversations,
690
+ record.data,
691
+ record.items,
692
+ record.messages,
693
+ ];
694
+
695
+ for (const candidate of candidates) {
696
+ if (Array.isArray(candidate)) {
697
+ return candidate;
698
+ }
699
+ }
700
+
701
+ if (record.data && typeof record.data === 'object' && !Array.isArray(record.data)) {
702
+ const nested = this.extractHistoryEntries(record.data);
703
+ if (nested.length > 0) {
704
+ return nested;
705
+ }
706
+ }
707
+ }
708
+
709
+ return [];
710
+ }
711
+
712
+ private normalizeConversationItem(
713
+ value: Record<string, unknown>,
714
+ index: number,
715
+ ): ConversationItem | null {
716
+ const rawId =
717
+ value.id ??
718
+ value.conversationId ??
719
+ value.conversationUUID ??
720
+ value.conversationUuid ??
721
+ value.uuid;
722
+
723
+ const id = rawId !== undefined && rawId !== null ? String(rawId) : `history-${index + 1}`;
724
+
725
+ const rawTitle =
726
+ value.title ??
727
+ value.name ??
728
+ value.topic ??
729
+ value.subject ??
730
+ value.question ??
731
+ value.query ??
732
+ value.message;
733
+
734
+ const title =
735
+ typeof rawTitle === 'string' && rawTitle.trim().length > 0
736
+ ? rawTitle.trim()
737
+ : `Conversa ${index + 1}`;
738
+
739
+ const rawUpdated =
740
+ value.updatedAt ??
741
+ value.updated_at ??
742
+ value.lastMessageAt ??
743
+ value.last_message_at ??
744
+ value.createdAt ??
745
+ value.created_at ??
746
+ value.timestamp ??
747
+ value.date;
748
+
749
+ const updatedAt = this.toIsoString(rawUpdated);
750
+
751
+ return { id, title, updatedAt };
752
+ }
753
+
754
+ private toIsoString(value: unknown) {
755
+ if (typeof value === 'string' || typeof value === 'number') {
756
+ const date = new Date(value);
757
+ if (!Number.isNaN(date.getTime())) {
758
+ return date.toISOString();
759
+ }
760
+ }
761
+
762
+ return new Date().toISOString();
763
+ }
764
+
765
+ private startLoadingGuard() {
766
+ this.clearLoadingGuard();
767
+ this.loadingTimer = window.setTimeout(() => {
594
768
  this.loadingTimer = null;
595
769
  this.isLoading = false;
596
770
  }, 15000);
@@ -686,12 +860,7 @@ declare global {
686
860
  'rio-assist-widget': RioAssistWidget;
687
861
  }
688
862
  }
689
-
690
- if (!customElements.get('rio-assist-widget')) {
691
- customElements.define('rio-assist-widget', RioAssistWidget);
692
- }
693
-
694
-
695
-
696
-
697
-
863
+
864
+ if (!customElements.get('rio-assist-widget')) {
865
+ customElements.define('rio-assist-widget', RioAssistWidget);
866
+ }
@@ -1,52 +1,69 @@
1
- const WEBSOCKET_URL = 'wss://ws.volkswagen.latam-sandbox.rio.cloud';
2
- const DEFAULT_AGENT_MODEL = 'eu.amazon.nova-pro-v1:0';
1
+ const WEBSOCKET_URL = 'wss://ws.volkswagen.latam-sandbox.rio.cloud';
2
+ const HEARTBEAT_INTERVAL_MS = 5 * 60_000; // keep-alive before the 10min idle timeout
3
3
 
4
- export type RioIncomingMessage = {
5
- text: string;
6
- raw: string;
7
- data: unknown;
8
- };
4
+ export type RioIncomingMessage = {
5
+ text: string;
6
+ raw: string;
7
+ data: unknown;
8
+ action?: string;
9
+ };
9
10
 
10
11
  export class RioWebsocketClient {
11
12
  readonly token: string;
12
13
 
13
14
  private socket: WebSocket | null = null;
14
15
 
15
- private connectPromise: Promise<void> | null = null;
16
-
17
- private readonly listeners = new Set<(message: RioIncomingMessage) => void>();
18
-
19
- constructor(token: string) {
20
- this.token = token;
21
- }
16
+ private connectPromise: Promise<void> | null = null;
17
+
18
+ private readonly listeners = new Set<(message: RioIncomingMessage) => void>();
19
+
20
+ private heartbeatId: number | null = null;
21
+
22
+ constructor(token: string) {
23
+ this.token = token;
24
+ }
22
25
 
23
26
  matchesToken(value: string) {
24
27
  return this.token === value;
25
28
  }
26
29
 
27
- async sendMessage(message: string) {
28
- const socket = await this.ensureConnection();
29
-
30
- const payload = {
31
- action: 'sendMessage',
32
- message,
33
- agentModel: DEFAULT_AGENT_MODEL,
34
- };
35
-
36
- socket.send(JSON.stringify(payload));
37
- }
38
-
39
- onMessage(listener: (message: RioIncomingMessage) => void) {
40
- this.listeners.add(listener);
41
- return () => this.listeners.delete(listener);
42
- }
43
-
44
- close() {
45
- if (this.socket && this.socket.readyState === WebSocket.OPEN) {
46
- this.socket.close();
47
- }
48
-
49
- this.connectPromise = null;
30
+ async sendMessage(message: string) {
31
+ const socket = await this.ensureConnection();
32
+
33
+ const payload = {
34
+ action: 'sendMessage',
35
+ message,
36
+ };
37
+
38
+ socket.send(JSON.stringify(payload));
39
+ }
40
+
41
+ async requestHistory(options: { conversationId?: string; limit?: number } = {}) {
42
+ const socket = await this.ensureConnection();
43
+ const payload: Record<string, unknown> = {
44
+ action: 'getHistory',
45
+ limit: options.limit ?? 50,
46
+ };
47
+
48
+ if (options.conversationId) {
49
+ payload.conversationId = options.conversationId;
50
+ }
51
+
52
+ socket.send(JSON.stringify(payload));
53
+ }
54
+
55
+ onMessage(listener: (message: RioIncomingMessage) => void) {
56
+ this.listeners.add(listener);
57
+ return () => this.listeners.delete(listener);
58
+ }
59
+
60
+ close() {
61
+ this.stopHeartbeat();
62
+ if (this.socket && this.socket.readyState === WebSocket.OPEN) {
63
+ this.socket.close();
64
+ }
65
+
66
+ this.connectPromise = null;
50
67
  this.socket = null;
51
68
  this.listeners.clear();
52
69
  }
@@ -65,27 +82,32 @@ export class RioWebsocketClient {
65
82
  `${WEBSOCKET_URL}?token=${encodeURIComponent(this.token)}`,
66
83
  );
67
84
 
68
- this.socket.addEventListener('message', (event) => this.handleMessage(event));
69
- this.socket.addEventListener('close', () => {
70
- this.connectPromise = null;
71
- this.socket = null;
72
- });
85
+ this.socket.addEventListener('message', (event) => this.handleMessage(event));
86
+ this.socket.addEventListener('close', () => {
87
+ this.connectPromise = null;
88
+ this.socket = null;
89
+ this.stopHeartbeat();
90
+ });
73
91
 
74
92
  this.connectPromise = new Promise((resolve, reject) => {
75
93
  if (!this.socket) {
76
94
  reject(new Error('Falha ao criar conexão WebSocket.'));
77
95
  return;
78
96
  }
79
-
80
- const handleOpen = () => {
81
- cleanup();
82
- resolve();
83
- };
84
-
85
- const handleError = () => {
86
- cleanup();
87
- this.socket?.close();
88
- this.socket = null;
97
+
98
+ const handleOpen = () => {
99
+ cleanup();
100
+ if (this.socket) {
101
+ this.startHeartbeat(this.socket);
102
+ }
103
+ resolve();
104
+ };
105
+
106
+ const handleError = () => {
107
+ cleanup();
108
+ this.stopHeartbeat();
109
+ this.socket?.close();
110
+ this.socket = null;
89
111
  this.connectPromise = null;
90
112
  reject(
91
113
  new Error(
@@ -109,33 +131,57 @@ export class RioWebsocketClient {
109
131
  throw new Error('Conexão WebSocket do Rio Insight não está pronta.');
110
132
  }
111
133
 
112
- return this.socket;
113
- }
114
-
115
- private async handleMessage(event: MessageEvent) {
116
- const raw = await this.readMessage(event.data);
117
- let parsed: unknown = null;
118
- let text = raw;
119
-
120
- try {
121
- parsed = JSON.parse(raw);
122
- if (typeof parsed === 'object' && parsed !== null) {
123
- const maybeText =
124
- (parsed as any).message ??
125
- (parsed as any).response ??
126
- (parsed as any).text ??
127
- (parsed as any).content;
134
+ return this.socket;
135
+ }
136
+
137
+ private startHeartbeat(socket: WebSocket) {
138
+ this.stopHeartbeat();
139
+ this.heartbeatId = window.setInterval(() => {
140
+ if (socket.readyState === WebSocket.OPEN) {
141
+ socket.send(JSON.stringify({ action: 'ping' }));
142
+ }
143
+ }, HEARTBEAT_INTERVAL_MS);
144
+ }
145
+
146
+ private stopHeartbeat() {
147
+ if (this.heartbeatId !== null) {
148
+ window.clearInterval(this.heartbeatId);
149
+ this.heartbeatId = null;
150
+ }
151
+ }
152
+
153
+ private async handleMessage(event: MessageEvent) {
154
+ const raw = await this.readMessage(event.data);
155
+ let parsed: unknown = null;
156
+ let text = raw;
157
+ let action: string | undefined;
158
+
159
+ try {
160
+ parsed = JSON.parse(raw);
161
+ if (typeof parsed === 'object' && parsed !== null) {
162
+ const maybeAction =
163
+ (parsed as any).action ?? (parsed as any).type ?? (parsed as any).event;
164
+
165
+ if (typeof maybeAction === 'string') {
166
+ action = maybeAction;
167
+ }
168
+
169
+ const maybeText =
170
+ (parsed as any).message ??
171
+ (parsed as any).response ??
172
+ (parsed as any).text ??
173
+ (parsed as any).content;
128
174
 
129
175
  if (typeof maybeText === 'string') {
130
176
  text = maybeText;
131
177
  }
132
178
  }
133
- } catch {
134
- parsed = null;
135
- }
136
-
137
- this.listeners.forEach((listener) => listener({ text, raw, data: parsed }));
138
- }
179
+ } catch {
180
+ parsed = null;
181
+ }
182
+
183
+ this.listeners.forEach((listener) => listener({ text, raw, data: parsed, action }));
184
+ }
139
185
 
140
186
  private async readMessage(
141
187
  data: MessageEvent['data'],