rio-assist-widget 0.1.8 → 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.8",
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": {
@@ -26,9 +26,13 @@
26
26
  "README.md"
27
27
  ],
28
28
  "dependencies": {
29
- "lit": "^3.1.2"
29
+ "dompurify": "^3.3.0",
30
+ "lit": "^3.1.2",
31
+ "markdown-it": "^14.1.0",
32
+ "markdown-it-task-lists": "^2.1.1"
30
33
  },
31
34
  "devDependencies": {
35
+ "@types/dompurify": "^3.0.5",
32
36
  "playwright-chromium": "^1.56.1",
33
37
  "typescript": "^5.4.5",
34
38
  "vite": "^5.2.0"
@@ -169,25 +169,58 @@ export const miniPanelStyles = css`
169
169
  gap: 12px;
170
170
  }
171
171
 
172
- .message {
173
- border-radius: 16px;
174
- border: 1px solid #e4eaee;
175
- padding: 10px 16px;
176
- max-width: 90%;
172
+ .message {
173
+ border-radius: 16px;
174
+ border: 1px solid #e4eaee;
175
+ padding: 10px 16px;
176
+ max-width: 90%;
177
177
  background: #fff;
178
178
  color: #1f2f36;
179
- font-size: 15px;
180
- }
181
-
182
- .message p {
183
- margin: 0;
184
- line-height: 1.35;
185
- }
186
-
187
- .message--user {
188
- align-self: flex-end;
189
- background: #e5ebf0;
190
- border-color: #cfd6dc;
179
+ font-size: 15px;
180
+ }
181
+
182
+ .message__content {
183
+ line-height: 1.35;
184
+ }
185
+
186
+ .message__content p,
187
+ .message__content ul,
188
+ .message__content ol {
189
+ margin: 4px 0;
190
+ }
191
+
192
+ .message__content pre {
193
+ background: #0d161b;
194
+ color: #f3f7fb;
195
+ border-radius: 8px;
196
+ padding: 10px;
197
+ overflow-x: auto;
198
+ margin: 6px 0;
199
+ font-size: 14px;
200
+ }
201
+
202
+ .message__content code {
203
+ background: #f1f4f7;
204
+ padding: 2px 6px;
205
+ border-radius: 6px;
206
+ }
207
+
208
+ .message__content blockquote {
209
+ border-left: 3px solid #cfd6dc;
210
+ margin: 6px 0;
211
+ padding-left: 10px;
212
+ color: #4b5a65;
213
+ }
214
+
215
+ .message__content ul,
216
+ .message__content ol {
217
+ padding-left: 20px;
218
+ }
219
+
220
+ .message--user {
221
+ align-self: flex-end;
222
+ background: #e5ebf0;
223
+ border-color: #cfd6dc;
191
224
  color: #1f2f36;
192
225
  padding: 8px 10px;
193
226
  }
@@ -1,4 +1,5 @@
1
1
  import { html } from 'lit';
2
+ import { unsafeHTML } from 'lit/directives/unsafe-html.js';
2
3
  import { classMap } from 'lit/directives/class-map.js';
3
4
  import type { RioAssistWidget } from '../rio-assist/rio-assist';
4
5
  import { renderConversationsPanel } from '../conversations-panel/conversations-panel.template';
@@ -30,7 +31,9 @@ export const renderChatSurface = (component: RioAssistWidget) => {
30
31
  'message--assistant': message.role === 'assistant',
31
32
  })}
32
33
  >
33
- <p>${message.text}</p>
34
+ <div class="message__content">
35
+ ${unsafeHTML(message.html ?? message.text)}
36
+ </div>
34
37
  <time>
35
38
  ${new Date(message.timestamp).toLocaleTimeString('pt-BR', {
36
39
  hour: '2-digit',
@@ -1,19 +1,23 @@
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';
1
+ import { LitElement, type PropertyValues } from 'lit';
2
+ import { widgetStyles } from './rio-assist.styles';
3
+ import { renderRioAssist } from './rio-assist.template';
4
+ import {
5
+ RioWebsocketClient,
6
+ type RioIncomingMessage,
7
+ } from '../../services/rioWebsocket';
8
+ import MarkdownIt from 'markdown-it';
9
+ import markdownItTaskLists from 'markdown-it-task-lists';
10
+ import DOMPurify from 'dompurify';
8
11
 
9
12
  type ChatRole = 'user' | 'assistant';
10
13
 
11
- export type ChatMessage = {
12
- id: string;
13
- role: ChatRole;
14
- text: string;
15
- timestamp: number;
16
- };
14
+ export type ChatMessage = {
15
+ id: string;
16
+ role: ChatRole;
17
+ text: string;
18
+ html?: string;
19
+ timestamp: number;
20
+ };
17
21
 
18
22
  type ConversationItem = {
19
23
  id: string;
@@ -43,8 +47,9 @@ export class RioAssistWidget extends LitElement {
43
47
  conversationMenuPlacement: { state: true },
44
48
  isFullscreen: { type: Boolean, state: true },
45
49
  conversationScrollbar: { state: true },
46
- showNewConversationShortcut: { type: Boolean, state: true },
47
- };
50
+ showNewConversationShortcut: { type: Boolean, state: true },
51
+ conversations: { state: true },
52
+ };
48
53
 
49
54
  open = false;
50
55
 
@@ -98,33 +103,21 @@ export class RioAssistWidget extends LitElement {
98
103
 
99
104
  private conversationScrollbarDraggingId: number | null = null;
100
105
 
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
- );
106
+ private conversationScrollbarDragState: {
107
+ startY: number;
108
+ startThumbTop: number;
109
+ trackHeight: number;
110
+ thumbHeight: number;
111
+ list: HTMLElement;
112
+ } | null = null;
113
+
114
+ private markdownRenderer = new MarkdownIt({
115
+ html: false,
116
+ linkify: true,
117
+ breaks: true,
118
+ }).use(markdownItTaskLists);
119
+
120
+ conversations: ConversationItem[] = [];
128
121
 
129
122
  get suggestions(): string[] {
130
123
  if (!this.suggestionsSource) {
@@ -211,21 +204,25 @@ export class RioAssistWidget extends LitElement {
211
204
  }
212
205
  }
213
206
 
214
- openConversationsPanel() {
215
- this.showConversations = true;
216
- }
217
-
218
- closeConversationsPanel() {
219
- this.showConversations = false;
220
- this.conversationMenuId = null;
221
- }
222
-
223
- toggleConversationsPanel() {
224
- this.showConversations = !this.showConversations;
225
- if (!this.showConversations) {
226
- this.conversationMenuId = null;
227
- }
228
- }
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
+ }
229
226
 
230
227
  toggleNewConversationShortcut() {
231
228
  this.showNewConversationShortcut = !this.showNewConversationShortcut;
@@ -479,23 +476,24 @@ export class RioAssistWidget extends LitElement {
479
476
  await this.processMessage(suggestion);
480
477
  }
481
478
 
482
- async handleSubmit(event: SubmitEvent) {
483
- event.preventDefault();
484
- await this.processMessage(this.message);
485
- }
486
-
487
- private createMessage(role: ChatRole, text: string): ChatMessage {
488
- const id = typeof crypto !== 'undefined' && 'randomUUID' in crypto
489
- ? crypto.randomUUID()
490
- : `${Date.now()}-${Math.random()}`;
491
-
492
- return {
493
- id,
494
- role,
495
- text,
496
- timestamp: Date.now(),
497
- };
498
- }
479
+ async handleSubmit(event: SubmitEvent) {
480
+ event.preventDefault();
481
+ await this.processMessage(this.message);
482
+ }
483
+
484
+ private createMessage(role: ChatRole, text: string): ChatMessage {
485
+ const id = typeof crypto !== 'undefined' && 'randomUUID' in crypto
486
+ ? crypto.randomUUID()
487
+ : `${Date.now()}-${Math.random()}`;
488
+
489
+ return {
490
+ id,
491
+ role,
492
+ text,
493
+ html: this.renderMarkdown(text),
494
+ timestamp: Date.now(),
495
+ };
496
+ }
499
497
 
500
498
  private async processMessage(rawValue: string) {
501
499
  const content = rawValue.trim();
@@ -539,11 +537,11 @@ export class RioAssistWidget extends LitElement {
539
537
  }
540
538
  }
541
539
 
542
- private ensureRioClient() {
543
- const token = this.rioToken.trim();
544
- if (!token) {
545
- throw new Error(
546
- '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.',
547
545
  );
548
546
  }
549
547
 
@@ -554,16 +552,22 @@ export class RioAssistWidget extends LitElement {
554
552
  this.handleIncomingMessage(incoming);
555
553
  });
556
554
  }
557
-
558
- return this.rioClient;
559
- }
560
-
561
- private handleIncomingMessage(message: RioIncomingMessage) {
562
- const assistantMessage = this.createMessage('assistant', message.text);
563
- this.messages = [...this.messages, assistantMessage];
564
- this.clearLoadingGuard();
565
- this.isLoading = false;
566
- }
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
+ }
567
571
 
568
572
  private teardownRioClient() {
569
573
  if (this.rioUnsubscribe) {
@@ -571,15 +575,196 @@ export class RioAssistWidget extends LitElement {
571
575
  this.rioUnsubscribe = null;
572
576
  }
573
577
 
574
- if (this.rioClient) {
575
- this.rioClient.close();
576
- this.rioClient = null;
577
- }
578
- }
579
-
580
- private startLoadingGuard() {
581
- this.clearLoadingGuard();
582
- 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(() => {
583
768
  this.loadingTimer = null;
584
769
  this.isLoading = false;
585
770
  }, 15000);
@@ -604,6 +789,67 @@ export class RioAssistWidget extends LitElement {
604
789
  });
605
790
  }
606
791
 
792
+ private renderMarkdown(content: string) {
793
+ const rendered = this.markdownRenderer.render(content);
794
+ const clean = DOMPurify.sanitize(rendered, {
795
+ ALLOWED_TAGS: [
796
+ 'a',
797
+ 'p',
798
+ 'ul',
799
+ 'ol',
800
+ 'li',
801
+ 'code',
802
+ 'pre',
803
+ 'strong',
804
+ 'em',
805
+ 'blockquote',
806
+ 'table',
807
+ 'thead',
808
+ 'tbody',
809
+ 'tr',
810
+ 'th',
811
+ 'td',
812
+ 'del',
813
+ 'hr',
814
+ 'br',
815
+ 'img',
816
+ 'span',
817
+ 'input',
818
+ ],
819
+ ALLOWED_ATTR: [
820
+ 'href',
821
+ 'title',
822
+ 'target',
823
+ 'rel',
824
+ 'src',
825
+ 'alt',
826
+ 'class',
827
+ 'type',
828
+ 'checked',
829
+ 'disabled',
830
+ 'aria-label',
831
+ ],
832
+ ALLOW_DATA_ATTR: false,
833
+ FORBID_TAGS: ['style', 'script'],
834
+ USE_PROFILES: { html: true },
835
+ });
836
+
837
+ const container = document.createElement('div');
838
+ container.innerHTML = clean;
839
+
840
+ container.querySelectorAll('a').forEach((anchor) => {
841
+ anchor.setAttribute('target', '_blank');
842
+ anchor.setAttribute('rel', 'noopener noreferrer');
843
+ });
844
+
845
+ container.querySelectorAll('input[type="checkbox"]').forEach((checkbox) => {
846
+ checkbox.setAttribute('disabled', '');
847
+ checkbox.setAttribute('tabindex', '-1');
848
+ });
849
+
850
+ return container.innerHTML;
851
+ }
852
+
607
853
  render() {
608
854
  return renderRioAssist(this);
609
855
  }
@@ -614,12 +860,7 @@ declare global {
614
860
  'rio-assist-widget': RioAssistWidget;
615
861
  }
616
862
  }
617
-
618
- if (!customElements.get('rio-assist-widget')) {
619
- customElements.define('rio-assist-widget', RioAssistWidget);
620
- }
621
-
622
-
623
-
624
-
625
-
863
+
864
+ if (!customElements.get('rio-assist-widget')) {
865
+ customElements.define('rio-assist-widget', RioAssistWidget);
866
+ }
package/src/playground.ts CHANGED
@@ -1,24 +1,25 @@
1
- import './main';
2
-
3
- const boot = () => {
4
- window.RioAssist?.init({
5
- title: 'Rio Insight',
6
- buttonLabel: 'Rio Insight',
7
- accentColor: '#c02267',
8
- rioToken: 'SEU_TOKEN_RIO_AQUI',
9
- suggestions: [
10
- 'Veículos com problemas',
11
- 'Valor das peças',
12
- 'Planos de manutenção',
13
- ],
14
- });
15
- };
16
-
17
- if (window.RioAssist) {
18
- boot();
19
- } else {
20
- window.addEventListener('rio-assist-ready', boot, { once: true });
21
- }
22
-
23
-
24
-
1
+ import './main';
2
+
3
+ const rioToken = import.meta.env.VITE_RIO_TOKEN || 'SEU_TOKEN_RIO_AQUI';
4
+ const apiBaseUrl = import.meta.env.VITE_RIO_API_BASE_URL || '';
5
+
6
+ const boot = () => {
7
+ window.RioAssist?.init({
8
+ title: 'Rio Insight',
9
+ buttonLabel: 'Rio Insight',
10
+ accentColor: '#c02267',
11
+ rioToken,
12
+ apiBaseUrl,
13
+ suggestions: [
14
+ 'Veículos com problemas',
15
+ 'Valor das peças',
16
+ 'Planos de manutenção',
17
+ ],
18
+ });
19
+ };
20
+
21
+ if (window.RioAssist) {
22
+ boot();
23
+ } else {
24
+ window.addEventListener('rio-assist-ready', boot, { once: true });
25
+ }