rushchatbot 1.6.7 → 1.6.9
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/README.md +717 -0
- package/package/INTEGRATION.md +1 -1
- package/package/README.md +1 -1
- package/package/package.json +1 -1
- package/package/src/cli/index.ts +23 -4
- package/package/src/server/index.ts +63 -11
- package/package/src/vanilla/index.ts +142 -50
- package/package.json +1 -1
- package/server/data/analytics.json +6 -0
package/README.md
ADDED
|
@@ -0,0 +1,717 @@
|
|
|
1
|
+
# RushChatbot
|
|
2
|
+
|
|
3
|
+
**Version:** 1.6.9
|
|
4
|
+
|
|
5
|
+
Embeddable hybrid chatbot plugin that combines a click-based FAQ tree with a free-text query engine backed by n8n. Ships as an npm package with a Node.js/Express server and widgets for React, Vue 3, and Vanilla JS.
|
|
6
|
+
|
|
7
|
+
**Features:**
|
|
8
|
+
- Click-based FAQ question tree with subquestions
|
|
9
|
+
- Fuzzy + keyword matching against your config
|
|
10
|
+
- n8n webhook fallback for unmatched queries
|
|
11
|
+
- Markdown rendering (bold, lists, headings)
|
|
12
|
+
- Multi-bubble paragraph responses
|
|
13
|
+
- Draggable floating chat button
|
|
14
|
+
- Tagalog (TL) language support
|
|
15
|
+
- Input validation and profanity guardrails
|
|
16
|
+
- Session-based analytics
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Table of Contents
|
|
21
|
+
|
|
22
|
+
1. [Requirements](#1-requirements)
|
|
23
|
+
2. [Installation](#2-installation)
|
|
24
|
+
3. [Scaffold Config Files](#3-scaffold-config-files)
|
|
25
|
+
4. [Configure the Chatbot](#4-configure-the-chatbot)
|
|
26
|
+
5. [Build the FAQ Tree](#5-build-the-faq-tree)
|
|
27
|
+
6. [Start the Server](#6-start-the-server)
|
|
28
|
+
7. [Embed the Widget](#7-embed-the-widget)
|
|
29
|
+
8. [Floating Button Behavior](#8-floating-button-behavior)
|
|
30
|
+
9. [n8n Webhook Setup](#9-n8n-webhook-setup)
|
|
31
|
+
10. [n8n Response Formats](#10-n8n-response-formats)
|
|
32
|
+
11. [Environment Variables](#11-environment-variables)
|
|
33
|
+
12. [API Reference](#12-api-reference)
|
|
34
|
+
13. [Analytics](#13-analytics)
|
|
35
|
+
14. [Troubleshooting](#14-troubleshooting)
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## 1. Requirements
|
|
40
|
+
|
|
41
|
+
| Requirement | Version |
|
|
42
|
+
|---|---|
|
|
43
|
+
| Node.js | >= 18.0.0 |
|
|
44
|
+
| npm | >= 9.0.0 |
|
|
45
|
+
| React (for React widget) | >= 18.0.0 |
|
|
46
|
+
| Vue (for Vue 3 widget) | >= 3.0.0 |
|
|
47
|
+
| Vue (for Vue 2 widget) | >= 2.5.17 |
|
|
48
|
+
| n8n instance | Any hosted or self-hosted |
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## 2. Installation
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
npm install rushchatbot
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## 3. Scaffold Config Files
|
|
61
|
+
|
|
62
|
+
Run the init command to generate the required config and questions files in a directory of your choice:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
npx rushchatbot init ./chatbot
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
This creates three files:
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
chatbot/
|
|
72
|
+
├── chatbot.config.json ← branding, n8n, chat settings
|
|
73
|
+
├── questions.json ← English FAQ tree
|
|
74
|
+
└── questions.tl.json ← Tagalog FAQ tree (optional)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## 4. Configure the Chatbot
|
|
80
|
+
|
|
81
|
+
Edit `chatbot.config.json` with your settings:
|
|
82
|
+
|
|
83
|
+
```json
|
|
84
|
+
{
|
|
85
|
+
"branding": {
|
|
86
|
+
"title": "My Support Bot",
|
|
87
|
+
"subtitle": "How can we help you today?",
|
|
88
|
+
"theme": {
|
|
89
|
+
"primaryColor": "#4F46E5",
|
|
90
|
+
"backgroundColor": "#FFFFFF",
|
|
91
|
+
"userBubbleColor": "#4F46E5",
|
|
92
|
+
"botBubbleColor": "#F3F4F6",
|
|
93
|
+
"textColor": "#111827",
|
|
94
|
+
"fontFamily": "Inter, sans-serif"
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
"n8n": {
|
|
98
|
+
"webhookUrl": "https://your-n8n.com/webhook/your-webhook-id",
|
|
99
|
+
"timeoutMs": 55000
|
|
100
|
+
},
|
|
101
|
+
"chat": {
|
|
102
|
+
"welcomeMessage": "Hi! Choose a question below or type your own.",
|
|
103
|
+
"fallbackMessage": "Let me look that up for you...",
|
|
104
|
+
"matchThreshold": 0.4,
|
|
105
|
+
"showPreQuestionsOnStart": true,
|
|
106
|
+
"language": "en"
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Config field reference
|
|
112
|
+
|
|
113
|
+
| Field | Type | Default | Description |
|
|
114
|
+
|---|---|---|---|
|
|
115
|
+
| `branding.title` | string | — | Chat header title |
|
|
116
|
+
| `branding.subtitle` | string | — | Chat header subtitle |
|
|
117
|
+
| `branding.theme.primaryColor` | string | `#4F46E5` | Header, buttons, user bubble color |
|
|
118
|
+
| `branding.theme.backgroundColor` | string | `#FFFFFF` | Chat window background |
|
|
119
|
+
| `branding.theme.userBubbleColor` | string | `#4F46E5` | User message bubble color |
|
|
120
|
+
| `branding.theme.botBubbleColor` | string | `#F3F4F6` | Bot message bubble color |
|
|
121
|
+
| `branding.theme.textColor` | string | `#111827` | Main text color |
|
|
122
|
+
| `branding.theme.fontFamily` | string | `Inter, sans-serif` | Font stack |
|
|
123
|
+
| `n8n.webhookUrl` | string | — | Your n8n POST webhook URL |
|
|
124
|
+
| `n8n.timeoutMs` | number | `55000` | n8n request timeout in milliseconds |
|
|
125
|
+
| `chat.welcomeMessage` | string | — | First message shown when chat opens |
|
|
126
|
+
| `chat.fallbackMessage` | string | — | Shown when an error occurs |
|
|
127
|
+
| `chat.matchThreshold` | number | `0.4` | Fuzzy match sensitivity (0–1). Lower = stricter |
|
|
128
|
+
| `chat.showPreQuestionsOnStart` | boolean | `true` | Show FAQ buttons when chat opens |
|
|
129
|
+
| `chat.language` | `"en"` \| `"tl"` | `"en"` | Language for displaying FAQ questions |
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## 6. Build the FAQ Tree
|
|
134
|
+
|
|
135
|
+
Edit `questions.json` to define your FAQ categories and answers. The structure supports two levels: main questions and subquestions.
|
|
136
|
+
|
|
137
|
+
```json
|
|
138
|
+
[
|
|
139
|
+
{
|
|
140
|
+
"id": "q1",
|
|
141
|
+
"question": "About Our Company",
|
|
142
|
+
"answer": "We are a company that...",
|
|
143
|
+
"subquestions": [
|
|
144
|
+
{
|
|
145
|
+
"id": "q1a",
|
|
146
|
+
"question": "When was the company founded?",
|
|
147
|
+
"answer": "We were founded in 2010.",
|
|
148
|
+
"subquestions": []
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
"id": "q1b",
|
|
152
|
+
"question": "What is your mission?",
|
|
153
|
+
"answer": "Our mission is to...",
|
|
154
|
+
"subquestions": []
|
|
155
|
+
}
|
|
156
|
+
]
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
"id": "q2",
|
|
160
|
+
"question": "Contact Information",
|
|
161
|
+
"answer": "You can reach us at support@example.com.",
|
|
162
|
+
"subquestions": []
|
|
163
|
+
}
|
|
164
|
+
]
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### Rules
|
|
168
|
+
|
|
169
|
+
- Every question must have a unique `id`
|
|
170
|
+
- `subquestions` must be an array (use `[]` if none)
|
|
171
|
+
- `answer` is shown when the question is clicked directly or matched via text search
|
|
172
|
+
- For Tagalog support, mirror the same structure in `questions.tl.json` with translated text — keep the same `id` values
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
## 6. Start the Server
|
|
177
|
+
|
|
178
|
+
Create a server entry file (e.g. `server.js` or `server.ts`):
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
import { createServer } from 'rushchatbot/server'
|
|
182
|
+
import path from 'path'
|
|
183
|
+
|
|
184
|
+
createServer({
|
|
185
|
+
configPath: path.resolve('./chatbot/chatbot.config.json'),
|
|
186
|
+
questionsPath: path.resolve('./chatbot/questions.json'),
|
|
187
|
+
questionsTagalogPath: path.resolve('./chatbot/questions.tl.json'), // optional
|
|
188
|
+
port: 3001,
|
|
189
|
+
corsOrigin: 'https://your-frontend-domain.com', // or '*' for development
|
|
190
|
+
}).listen()
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### Server options
|
|
194
|
+
|
|
195
|
+
| Option | Type | Default | Description |
|
|
196
|
+
|---|---|---|---|
|
|
197
|
+
| `configPath` | string | — | **Required.** Absolute path to `chatbot.config.json` |
|
|
198
|
+
| `questionsPath` | string | — | **Required.** Absolute path to `questions.json` |
|
|
199
|
+
| `questionsTagalogPath` | string | — | Optional. Absolute path to `questions.tl.json` |
|
|
200
|
+
| `port` | number | `3001` | Port to listen on |
|
|
201
|
+
| `corsOrigin` | string \| string[] | `*` | Allowed CORS origin(s) |
|
|
202
|
+
|
|
203
|
+
Start the server:
|
|
204
|
+
|
|
205
|
+
```bash
|
|
206
|
+
node server.js
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
On successful startup you will see:
|
|
210
|
+
|
|
211
|
+
```
|
|
212
|
+
[RushChatbot] Server running on http://localhost:3001
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
## 7. Embed the Widget
|
|
218
|
+
|
|
219
|
+
RushChatbot ships three separate widget builds. Use the one that matches your frontend stack.
|
|
220
|
+
|
|
221
|
+
---
|
|
222
|
+
|
|
223
|
+
### React
|
|
224
|
+
|
|
225
|
+
Install peer dependencies if not already present:
|
|
226
|
+
|
|
227
|
+
```bash
|
|
228
|
+
npm install react react-dom marked
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Import and render the widget:
|
|
232
|
+
|
|
233
|
+
```tsx
|
|
234
|
+
import { ChatWidget } from 'rushchatbot/client'
|
|
235
|
+
|
|
236
|
+
export default function App() {
|
|
237
|
+
return (
|
|
238
|
+
<div>
|
|
239
|
+
<ChatWidget apiBase="http://localhost:3001/api/chat" />
|
|
240
|
+
</div>
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
The widget renders as a **floating draggable button** fixed to the bottom-right of the screen. Clicking it opens the chat box above the button.
|
|
246
|
+
|
|
247
|
+
#### React widget props
|
|
248
|
+
|
|
249
|
+
| Prop | Type | Default | Description |
|
|
250
|
+
|---|---|---|---|
|
|
251
|
+
| `apiBase` | string | `http://localhost:3001/api/chat` | Base URL of the RushChatbot server |
|
|
252
|
+
| `width` | number | `380` | Chat box width in pixels |
|
|
253
|
+
| `height` | number | `580` | Chat box height in pixels |
|
|
254
|
+
|
|
255
|
+
---
|
|
256
|
+
|
|
257
|
+
### Vue 2
|
|
258
|
+
|
|
259
|
+
Install peer dependency if not already present:
|
|
260
|
+
|
|
261
|
+
```bash
|
|
262
|
+
npm install vue@2
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Register and use the component:
|
|
266
|
+
|
|
267
|
+
```vue
|
|
268
|
+
<script>
|
|
269
|
+
import ChatWidget from 'rushchatbot/vue2'
|
|
270
|
+
|
|
271
|
+
export default {
|
|
272
|
+
components: { ChatWidget }
|
|
273
|
+
}
|
|
274
|
+
</script>
|
|
275
|
+
|
|
276
|
+
<template>
|
|
277
|
+
<ChatWidget api-base="http://localhost:3001/api/chat" />
|
|
278
|
+
</template>
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
Or register it globally in `main.js`:
|
|
282
|
+
|
|
283
|
+
```js
|
|
284
|
+
import Vue from 'vue'
|
|
285
|
+
import App from './App.vue'
|
|
286
|
+
import ChatWidget from 'rushchatbot/vue2'
|
|
287
|
+
|
|
288
|
+
Vue.component('RushChatbot', ChatWidget)
|
|
289
|
+
new Vue({ render: h => h(App) }).$mount('#app')
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
Then use it anywhere:
|
|
293
|
+
|
|
294
|
+
```vue
|
|
295
|
+
<template>
|
|
296
|
+
<RushChatbot api-base="http://localhost:3001/api/chat" />
|
|
297
|
+
</template>
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
#### Vue 2 widget props
|
|
301
|
+
|
|
302
|
+
| Prop | Type | Default | Description |
|
|
303
|
+
|---|---|---|---|
|
|
304
|
+
| `api-base` | string | `http://localhost:3001/api/chat` | Base URL of the RushChatbot server |
|
|
305
|
+
| `width` | number | — | Chat box width in pixels (omit for fluid) |
|
|
306
|
+
| `height` | number | — | Chat box height in pixels (omit for fluid) |
|
|
307
|
+
| `primary-color` | string | from config | Override primary/accent color |
|
|
308
|
+
| `bg-color` | string | from config | Override background color |
|
|
309
|
+
| `border-radius` | number | `16` | Container corner radius in pixels |
|
|
310
|
+
| `show-shadow` | boolean | `true` | Show/hide box shadow |
|
|
311
|
+
| `placeholder` | string | `Type a message...` | Input placeholder text |
|
|
312
|
+
| `send-label` | string | `Send` | Send button label |
|
|
313
|
+
| `hide-input` | boolean | `false` | Hide the text input bar |
|
|
314
|
+
| `hide-end-chat` | boolean | `false` | Hide the "I'm done" end chat button |
|
|
315
|
+
|
|
316
|
+
---
|
|
317
|
+
|
|
318
|
+
### Vue 3
|
|
319
|
+
|
|
320
|
+
Install peer dependency if not already present:
|
|
321
|
+
|
|
322
|
+
```bash
|
|
323
|
+
npm install vue
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
Register and use the component:
|
|
327
|
+
|
|
328
|
+
```vue
|
|
329
|
+
<script setup>
|
|
330
|
+
import ChatWidget from 'rushchatbot/vue'
|
|
331
|
+
</script>
|
|
332
|
+
|
|
333
|
+
<template>
|
|
334
|
+
<ChatWidget api-base="http://localhost:3001/api/chat" />
|
|
335
|
+
</template>
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
Or register it globally in `main.ts`:
|
|
339
|
+
|
|
340
|
+
```ts
|
|
341
|
+
import { createApp } from 'vue'
|
|
342
|
+
import App from './App.vue'
|
|
343
|
+
import ChatWidget from 'rushchatbot/vue'
|
|
344
|
+
|
|
345
|
+
const app = createApp(App)
|
|
346
|
+
app.component('RushChatbot', ChatWidget)
|
|
347
|
+
app.mount('#app')
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
Then use it anywhere:
|
|
351
|
+
|
|
352
|
+
```vue
|
|
353
|
+
<template>
|
|
354
|
+
<RushChatbot api-base="http://localhost:3001/api/chat" />
|
|
355
|
+
</template>
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
#### Vue widget props
|
|
359
|
+
|
|
360
|
+
| Prop | Type | Default | Description |
|
|
361
|
+
|---|---|---|---|
|
|
362
|
+
| `api-base` | string | `http://localhost:3001/api/chat` | Base URL of the RushChatbot server |
|
|
363
|
+
| `width` | number | — | Chat box width in pixels (omit for fluid) |
|
|
364
|
+
| `height` | number | — | Chat box height in pixels (omit for fluid) |
|
|
365
|
+
| `primary-color` | string | from config | Override primary/accent color |
|
|
366
|
+
| `bg-color` | string | from config | Override background color |
|
|
367
|
+
| `border-radius` | number | `16` | Container corner radius in pixels |
|
|
368
|
+
| `show-shadow` | boolean | `true` | Show/hide box shadow |
|
|
369
|
+
| `placeholder` | string | `Type a message...` | Input placeholder text |
|
|
370
|
+
| `send-label` | string | `Send` | Send button label |
|
|
371
|
+
| `hide-input` | boolean | `false` | Hide the text input bar |
|
|
372
|
+
| `hide-end-chat` | boolean | `false` | Hide the "I'm done" end chat button |
|
|
373
|
+
|
|
374
|
+
---
|
|
375
|
+
|
|
376
|
+
### Vanilla JS (Web Component)
|
|
377
|
+
|
|
378
|
+
No framework required. The Vanilla build registers a `<rush-chatbot>` custom element using the Web Components API with Shadow DOM.
|
|
379
|
+
|
|
380
|
+
#### Via npm
|
|
381
|
+
|
|
382
|
+
```ts
|
|
383
|
+
import 'rushchatbot/vanilla'
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
Then add the element anywhere in your HTML:
|
|
387
|
+
|
|
388
|
+
```html
|
|
389
|
+
<rush-chatbot api-base="http://localhost:3001/api/chat"></rush-chatbot>
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
#### Via CDN / script tag
|
|
393
|
+
|
|
394
|
+
```html
|
|
395
|
+
<script type="module" src="/path/to/rushchatbot/dist/vanilla.es.js"></script>
|
|
396
|
+
<!-- or IIFE build for non-module environments -->
|
|
397
|
+
<script src="/path/to/rushchatbot/dist/vanilla.iife.js"></script>
|
|
398
|
+
|
|
399
|
+
<rush-chatbot api-base="http://localhost:3001/api/chat"></rush-chatbot>
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
#### Web Component attributes
|
|
403
|
+
|
|
404
|
+
| Attribute | Type | Default | Description |
|
|
405
|
+
|---|---|---|---|
|
|
406
|
+
| `api-base` | string | `http://localhost:3001/api/chat` | Base URL of the RushChatbot server |
|
|
407
|
+
| `width` | number | — | Chat box width in pixels (omit for fluid) |
|
|
408
|
+
| `height` | number | — | Chat box height in pixels (omit for fluid) |
|
|
409
|
+
| `primary-color` | string | from config | Override primary/accent color |
|
|
410
|
+
| `bg-color` | string | from config | Override background color |
|
|
411
|
+
| `border-radius` | number | `16` | Container corner radius in pixels |
|
|
412
|
+
| `show-shadow` | boolean | `true` | Show/hide box shadow |
|
|
413
|
+
| `placeholder` | string | `Type a message...` | Input placeholder text |
|
|
414
|
+
| `send-label` | string | `Send` | Send button label |
|
|
415
|
+
| `hide-input` | boolean | `false` | Hide the text input bar |
|
|
416
|
+
| `hide-end-chat` | boolean | `false` | Hide the "I'm done" end chat button |
|
|
417
|
+
| `auto-open` | boolean | `false` | Auto-open chat on load |
|
|
418
|
+
| `position` | `bottom-right\|bottom-left` | `bottom-right` | Floating button position |
|
|
419
|
+
|
|
420
|
+
Attributes can be updated at runtime and the widget will reflect the changes:
|
|
421
|
+
|
|
422
|
+
```js
|
|
423
|
+
document.querySelector('rush-chatbot').setAttribute('api-base', 'https://my-server.com/api/chat')
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
---
|
|
427
|
+
|
|
428
|
+
## 8. Floating Button Behavior
|
|
429
|
+
|
|
430
|
+
The chat button is draggable across the entire screen:
|
|
431
|
+
|
|
432
|
+
- **Drag** — click and hold, then move to reposition the button anywhere on screen
|
|
433
|
+
- **Click** (no drag) — toggles the chat box open or closed
|
|
434
|
+
- **Chat box position** — always appears directly above the button, clamped to the viewport so it never goes off-screen
|
|
435
|
+
- **Close** — click the ✕ button inside the chat header, or click the floating button again
|
|
436
|
+
- Works on both desktop (mouse) and mobile (touch)
|
|
437
|
+
|
|
438
|
+
---
|
|
439
|
+
|
|
440
|
+
## 9. n8n Webhook Setup
|
|
441
|
+
|
|
442
|
+
When a user types a query that doesn't match any FAQ answer, RushChatbot forwards it to your n8n webhook.
|
|
443
|
+
|
|
444
|
+
### What RushChatbot sends to n8n
|
|
445
|
+
|
|
446
|
+
```json
|
|
447
|
+
{
|
|
448
|
+
"query": "the user's message",
|
|
449
|
+
"sessionId": "sess_abc123"
|
|
450
|
+
}
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
Headers sent:
|
|
454
|
+
|
|
455
|
+
```
|
|
456
|
+
Content-Type: application/json
|
|
457
|
+
x-session-id: sess_abc123
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
### Setting up the webhook in n8n
|
|
461
|
+
|
|
462
|
+
1. Create a new workflow in n8n
|
|
463
|
+
2. Add a **Webhook** trigger node — set method to `POST`
|
|
464
|
+
3. Copy the webhook URL into `chatbot.config.json` under `n8n.webhookUrl`
|
|
465
|
+
4. Add your AI/logic nodes after the webhook trigger
|
|
466
|
+
5. End with a **Respond to Webhook** node returning one of the supported response formats (see section 11)
|
|
467
|
+
6. Activate the workflow
|
|
468
|
+
|
|
469
|
+
### Session continuity
|
|
470
|
+
|
|
471
|
+
The `sessionId` is generated per browser tab (`sess_` + timestamp + random suffix). Pass it through your n8n workflow to maintain conversation context with your AI agent.
|
|
472
|
+
|
|
473
|
+
---
|
|
474
|
+
|
|
475
|
+
## 10. n8n Response Formats
|
|
476
|
+
|
|
477
|
+
RushChatbot automatically parses all of the following response shapes from n8n:
|
|
478
|
+
|
|
479
|
+
### Single answer
|
|
480
|
+
|
|
481
|
+
```json
|
|
482
|
+
[{ "output": "Your answer text here" }]
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
```json
|
|
486
|
+
[{ "answer": "Your answer text here" }]
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
```json
|
|
490
|
+
[{ "text": "Your answer text here" }]
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
### Multiple bubbles (paragraphs)
|
|
494
|
+
|
|
495
|
+
Return a `paragraphs` array to render each item as a **separate chat bubble** with a short delay between them:
|
|
496
|
+
|
|
497
|
+
```json
|
|
498
|
+
[{
|
|
499
|
+
"paragraphs": [
|
|
500
|
+
"First part of the response.",
|
|
501
|
+
"Second part with **bold** and lists.",
|
|
502
|
+
"Third bubble with a follow-up question."
|
|
503
|
+
]
|
|
504
|
+
}]
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
### Markdown support
|
|
508
|
+
|
|
509
|
+
All responses are rendered as Markdown. Supported formatting:
|
|
510
|
+
|
|
511
|
+
| Syntax | Renders as |
|
|
512
|
+
|---|---|
|
|
513
|
+
| `**bold**` | **bold** |
|
|
514
|
+
| `# Heading` | Large heading |
|
|
515
|
+
| `- item` | Bullet list |
|
|
516
|
+
| `1. item` | Numbered list |
|
|
517
|
+
| `[link](url)` | Hyperlink |
|
|
518
|
+
| `\n` (newline) | Line break |
|
|
519
|
+
|
|
520
|
+
---
|
|
521
|
+
|
|
522
|
+
## 11. Environment Variables
|
|
523
|
+
|
|
524
|
+
| Variable | Required | Description |
|
|
525
|
+
|---|---|---|
|
|
526
|
+
| `PORT` | No | Override the server port (default: `3001`) |
|
|
527
|
+
|
|
528
|
+
### Setting the port
|
|
529
|
+
|
|
530
|
+
```bash
|
|
531
|
+
PORT=4000 node server.js
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
---
|
|
535
|
+
|
|
536
|
+
## 12. API Reference
|
|
537
|
+
|
|
538
|
+
All endpoints are mounted under `/api/chat`.
|
|
539
|
+
|
|
540
|
+
### `GET /api/chat/config`
|
|
541
|
+
|
|
542
|
+
Returns branding and chat settings. Safe to call from the frontend — never exposes n8n credentials or license key.
|
|
543
|
+
|
|
544
|
+
**Response:**
|
|
545
|
+
```json
|
|
546
|
+
{
|
|
547
|
+
"branding": { "title": "...", "subtitle": "...", "theme": { } },
|
|
548
|
+
"chat": { "welcomeMessage": "...", "language": "en" }
|
|
549
|
+
}
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
---
|
|
553
|
+
|
|
554
|
+
### `GET /api/chat/questions`
|
|
555
|
+
|
|
556
|
+
Returns the FAQ question tree in the configured language.
|
|
557
|
+
|
|
558
|
+
**Response:**
|
|
559
|
+
```json
|
|
560
|
+
[
|
|
561
|
+
{
|
|
562
|
+
"id": "q1",
|
|
563
|
+
"question": "About Our Company",
|
|
564
|
+
"answer": "...",
|
|
565
|
+
"subquestions": []
|
|
566
|
+
}
|
|
567
|
+
]
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
---
|
|
571
|
+
|
|
572
|
+
### `GET /api/chat/query?query=your+question&sessionId=sess_abc`
|
|
573
|
+
|
|
574
|
+
Forwards the query **directly to n8n**, bypassing FAQ matching. Useful for testing your n8n workflow from the browser.
|
|
575
|
+
|
|
576
|
+
**Query params:**
|
|
577
|
+
|
|
578
|
+
| Param | Required | Description |
|
|
579
|
+
|---|---|---|
|
|
580
|
+
| `query` | Yes | The user's question |
|
|
581
|
+
| `sessionId` | No | Session identifier |
|
|
582
|
+
|
|
583
|
+
**Response:**
|
|
584
|
+
```json
|
|
585
|
+
{ "answer": "...", "source": "n8n" }
|
|
586
|
+
```
|
|
587
|
+
or
|
|
588
|
+
```json
|
|
589
|
+
{ "paragraphs": ["...", "..."], "source": "n8n" }
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
---
|
|
593
|
+
|
|
594
|
+
### `POST /api/chat/query`
|
|
595
|
+
|
|
596
|
+
Main query endpoint. Runs the full pipeline: input validation → guardrails → FAQ matching → n8n fallback.
|
|
597
|
+
|
|
598
|
+
**Request body:**
|
|
599
|
+
```json
|
|
600
|
+
{
|
|
601
|
+
"query": "How do I apply for membership?",
|
|
602
|
+
"sessionId": "sess_abc123"
|
|
603
|
+
}
|
|
604
|
+
```
|
|
605
|
+
|
|
606
|
+
**Response (FAQ match):**
|
|
607
|
+
```json
|
|
608
|
+
{
|
|
609
|
+
"answer": "You can apply by...",
|
|
610
|
+
"source": "config",
|
|
611
|
+
"matchedQuestion": "How do I apply for membership?",
|
|
612
|
+
"matchedKeywords": ["apply", "membership"],
|
|
613
|
+
"score": 87
|
|
614
|
+
}
|
|
615
|
+
```
|
|
616
|
+
|
|
617
|
+
**Response (n8n fallback):**
|
|
618
|
+
```json
|
|
619
|
+
{ "answer": "...", "source": "n8n" }
|
|
620
|
+
```
|
|
621
|
+
|
|
622
|
+
**Response (validation/guardrail blocked):**
|
|
623
|
+
```json
|
|
624
|
+
{ "answer": "Please rephrase your question...", "source": "validation" }
|
|
625
|
+
```
|
|
626
|
+
|
|
627
|
+
---
|
|
628
|
+
|
|
629
|
+
### `POST /api/chat/click`
|
|
630
|
+
|
|
631
|
+
Tracks when a user clicks a pre-built FAQ question.
|
|
632
|
+
|
|
633
|
+
**Request body:**
|
|
634
|
+
```json
|
|
635
|
+
{
|
|
636
|
+
"questionId": "q1a",
|
|
637
|
+
"sessionId": "sess_abc123"
|
|
638
|
+
}
|
|
639
|
+
```
|
|
640
|
+
|
|
641
|
+
**Response:**
|
|
642
|
+
```json
|
|
643
|
+
{ "ok": true }
|
|
644
|
+
```
|
|
645
|
+
|
|
646
|
+
---
|
|
647
|
+
|
|
648
|
+
### `GET /api/chat/analytics`
|
|
649
|
+
|
|
650
|
+
Returns the full analytics log.
|
|
651
|
+
|
|
652
|
+
**Response:**
|
|
653
|
+
```json
|
|
654
|
+
[
|
|
655
|
+
{
|
|
656
|
+
"sessionId": "sess_abc123",
|
|
657
|
+
"questionId": "q1a",
|
|
658
|
+
"timestamp": "2025-01-01T10:00:00.000Z",
|
|
659
|
+
"type": "click"
|
|
660
|
+
}
|
|
661
|
+
]
|
|
662
|
+
```
|
|
663
|
+
|
|
664
|
+
**Analytics types:**
|
|
665
|
+
|
|
666
|
+
| Type | Description |
|
|
667
|
+
|---|---|
|
|
668
|
+
| `click` | User clicked a pre-built FAQ question |
|
|
669
|
+
| `match` | User query matched a FAQ answer |
|
|
670
|
+
| `n8n` | Query was forwarded to n8n |
|
|
671
|
+
| `error` | Validation, guardrail, or server error |
|
|
672
|
+
|
|
673
|
+
---
|
|
674
|
+
|
|
675
|
+
## 13. Analytics
|
|
676
|
+
|
|
677
|
+
Analytics are written to `analytics.json` in the same directory as your `chatbot.config.json`. Each entry records:
|
|
678
|
+
|
|
679
|
+
- `sessionId` — browser session (resets on new tab)
|
|
680
|
+
- `questionId` — the matched question ID, or `n8n` / `error` / `guardrail`
|
|
681
|
+
- `timestamp` — ISO 8601 UTC
|
|
682
|
+
- `type` — `click`, `match`, `n8n`, or `error`
|
|
683
|
+
|
|
684
|
+
You can query the log at any time via `GET /api/chat/analytics` or read the file directly.
|
|
685
|
+
|
|
686
|
+
---
|
|
687
|
+
|
|
688
|
+
## 14. Troubleshooting
|
|
689
|
+
|
|
690
|
+
### `[RushChatbot] configPath not found`
|
|
691
|
+
|
|
692
|
+
- Use `path.resolve()` to ensure the path is absolute
|
|
693
|
+
- Verify the file exists at the specified location
|
|
694
|
+
|
|
695
|
+
### Chat widget shows nothing / blank
|
|
696
|
+
|
|
697
|
+
- Check the browser console for CORS errors
|
|
698
|
+
- Ensure `corsOrigin` in `createServer()` includes your frontend's origin
|
|
699
|
+
- Confirm the server is running and reachable at the `apiBase` URL
|
|
700
|
+
|
|
701
|
+
### n8n not responding / timeout
|
|
702
|
+
|
|
703
|
+
- Increase `n8n.timeoutMs` in `chatbot.config.json` (default is 55000ms)
|
|
704
|
+
- Test the webhook directly: `GET http://localhost:3001/api/chat/query?query=hello`
|
|
705
|
+
- Check your n8n workflow is activated (not just saved)
|
|
706
|
+
|
|
707
|
+
### Port already in use
|
|
708
|
+
|
|
709
|
+
```bash
|
|
710
|
+
kill $(lsof -ti :3001)
|
|
711
|
+
```
|
|
712
|
+
|
|
713
|
+
### Queries always fall through to n8n (no FAQ matches)
|
|
714
|
+
|
|
715
|
+
- Lower `chat.matchThreshold` in config (e.g. `0.3`)
|
|
716
|
+
- Ensure your `questions.json` answers contain keywords that appear in user queries
|
|
717
|
+
- Check that question `id` values are unique across the entire tree
|
package/package/INTEGRATION.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# RushChatbot
|
|
2
2
|
|
|
3
|
-
**Version:** 1.6.
|
|
3
|
+
**Version:** 1.6.9
|
|
4
4
|
|
|
5
5
|
Embeddable hybrid chatbot plugin that combines a click-based FAQ tree with a free-text query engine backed by n8n. Ships as an npm package with a Node.js/Express server and widgets for React, Vue 3, and Vanilla JS.
|
|
6
6
|
|
package/package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# RushChatbot
|
|
2
2
|
|
|
3
|
-
**Version:** 1.6.
|
|
3
|
+
**Version:** 1.6.9
|
|
4
4
|
|
|
5
5
|
Embeddable hybrid chatbot plugin that combines a click-based FAQ tree with a free-text query engine backed by n8n. Ships as an npm package with a Node.js/Express server and widgets for React, Vue 3, and Vanilla JS.
|
|
6
6
|
|
package/package/package.json
CHANGED
package/package/src/cli/index.ts
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
import fs from 'fs'
|
|
3
3
|
import path from 'path'
|
|
4
4
|
|
|
5
|
+
function sanitizeLog(value: string): string {
|
|
6
|
+
return value.replace(/[\r\n\t]/g, ' ').slice(0, 500)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function safeResolvePath(inputArg: string): string {
|
|
10
|
+
const resolved = path.resolve(inputArg)
|
|
11
|
+
// Reject paths containing traversal sequences
|
|
12
|
+
if (inputArg.includes('..') || inputArg.includes('\0')) {
|
|
13
|
+
throw new Error(`Path traversal detected: ${sanitizeLog(inputArg)}`)
|
|
14
|
+
}
|
|
15
|
+
return resolved
|
|
16
|
+
}
|
|
17
|
+
|
|
5
18
|
const args = process.argv.slice(2)
|
|
6
19
|
const command = args[0]
|
|
7
20
|
|
|
@@ -48,18 +61,24 @@ const DEFAULT_QUESTIONS = [
|
|
|
48
61
|
|
|
49
62
|
function writeIfNotExists(filePath: string, content: unknown) {
|
|
50
63
|
if (fs.existsSync(filePath)) {
|
|
51
|
-
console.log(` [skip] ${filePath} already exists`)
|
|
64
|
+
console.log(` [skip] ${sanitizeLog(filePath)} already exists`)
|
|
52
65
|
return
|
|
53
66
|
}
|
|
54
67
|
fs.writeFileSync(filePath, JSON.stringify(content, null, 2))
|
|
55
|
-
console.log(` [created] ${filePath}`)
|
|
68
|
+
console.log(` [created] ${sanitizeLog(filePath)}`)
|
|
56
69
|
}
|
|
57
70
|
|
|
58
71
|
if (command === 'init') {
|
|
59
|
-
|
|
72
|
+
let targetDir: string
|
|
73
|
+
try {
|
|
74
|
+
targetDir = args[1] ? safeResolvePath(args[1]) : process.cwd()
|
|
75
|
+
} catch (e) {
|
|
76
|
+
console.error(sanitizeLog((e as Error).message))
|
|
77
|
+
process.exit(1)
|
|
78
|
+
}
|
|
60
79
|
fs.mkdirSync(targetDir, { recursive: true })
|
|
61
80
|
|
|
62
|
-
console.log(`\nInitializing RushChatbot config in: ${targetDir}\n`)
|
|
81
|
+
console.log(`\nInitializing RushChatbot config in: ${sanitizeLog(targetDir)}\n`)
|
|
63
82
|
writeIfNotExists(path.join(targetDir, 'chatbot.config.json'), DEFAULT_CONFIG)
|
|
64
83
|
writeIfNotExists(path.join(targetDir, 'questions.json'), DEFAULT_QUESTIONS)
|
|
65
84
|
writeIfNotExists(path.join(targetDir, 'questions.tl.json'), DEFAULT_QUESTIONS)
|
|
@@ -3,10 +3,50 @@ import cors from 'cors'
|
|
|
3
3
|
import axios from 'axios'
|
|
4
4
|
import fs from 'fs'
|
|
5
5
|
import path from 'path'
|
|
6
|
+
import crypto from 'crypto'
|
|
6
7
|
import { findMatch, MatchResult, Question } from './matcher'
|
|
7
8
|
import { checkGuardrails, validateInput } from './guardrails'
|
|
8
9
|
import type { ChatbotConfig } from '../shared/types'
|
|
9
10
|
|
|
11
|
+
function sanitizeLog(value: string): string {
|
|
12
|
+
return value.replace(/[\r\n\t]/g, ' ').slice(0, 500)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function validatePath(inputPath: string, allowedBase: string): string {
|
|
16
|
+
const resolved = path.resolve(inputPath)
|
|
17
|
+
if (!resolved.startsWith(path.resolve(allowedBase) + path.sep) && resolved !== path.resolve(allowedBase)) {
|
|
18
|
+
throw new Error(`[RushChatbot] Path outside allowed directory: ${resolved}`)
|
|
19
|
+
}
|
|
20
|
+
return resolved
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const csrfTokens = new Map<string, number>()
|
|
24
|
+
|
|
25
|
+
function generateCsrfToken(): string {
|
|
26
|
+
const token = crypto.randomBytes(32).toString('hex')
|
|
27
|
+
csrfTokens.set(token, Date.now())
|
|
28
|
+
return token
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function validateCsrfToken(token: string | undefined): boolean {
|
|
32
|
+
if (!token || !csrfTokens.has(token)) return false
|
|
33
|
+
const age = Date.now() - (csrfTokens.get(token) ?? 0)
|
|
34
|
+
if (age > 3600000) { csrfTokens.delete(token); return false }
|
|
35
|
+
return true
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function csrfMiddleware(req: express.Request, res: express.Response, next: express.NextFunction) {
|
|
39
|
+
const origin = req.headers.origin
|
|
40
|
+
const referer = req.headers.referer
|
|
41
|
+
// Allow requests with no origin (server-to-server, curl, mobile apps)
|
|
42
|
+
if (!origin && !referer) return next()
|
|
43
|
+
const token = req.headers['x-csrf-token'] as string | undefined
|
|
44
|
+
if (!validateCsrfToken(token)) {
|
|
45
|
+
return res.status(403).json({ error: 'Invalid or missing CSRF token' })
|
|
46
|
+
}
|
|
47
|
+
next()
|
|
48
|
+
}
|
|
49
|
+
|
|
10
50
|
export interface RushChatbotServerOptions {
|
|
11
51
|
/** Absolute path to your chatbot.config.json */
|
|
12
52
|
configPath: string
|
|
@@ -63,30 +103,31 @@ export function createServer(options: RushChatbotServerOptions) {
|
|
|
63
103
|
if (!fs.existsSync(configPath)) throw new Error(`[RushChatbot] configPath not found: ${configPath}`)
|
|
64
104
|
if (!fs.existsSync(questionsPath)) throw new Error(`[RushChatbot] questionsPath not found: ${questionsPath}`)
|
|
65
105
|
|
|
66
|
-
const
|
|
106
|
+
const allowedBase = path.dirname(path.resolve(configPath))
|
|
107
|
+
const analyticsPath = path.join(allowedBase, 'analytics.json')
|
|
67
108
|
|
|
68
109
|
function getConfig(): ChatbotConfig {
|
|
69
|
-
return JSON.parse(fs.readFileSync(configPath, 'utf-8'))
|
|
110
|
+
return JSON.parse(fs.readFileSync(validatePath(configPath, allowedBase), 'utf-8'))
|
|
70
111
|
}
|
|
71
112
|
|
|
72
113
|
function getQuestions(lang: 'en' | 'tl'): Question[] {
|
|
73
114
|
if (lang === 'tl') {
|
|
74
115
|
if (!questionsTagalogPath || !fs.existsSync(questionsTagalogPath)) return []
|
|
75
|
-
return JSON.parse(fs.readFileSync(questionsTagalogPath, 'utf-8'))
|
|
116
|
+
return JSON.parse(fs.readFileSync(validatePath(questionsTagalogPath, allowedBase), 'utf-8'))
|
|
76
117
|
}
|
|
77
|
-
return JSON.parse(fs.readFileSync(questionsPath, 'utf-8'))
|
|
118
|
+
return JSON.parse(fs.readFileSync(validatePath(questionsPath, allowedBase), 'utf-8'))
|
|
78
119
|
}
|
|
79
120
|
|
|
80
121
|
function getAnalytics(): AnalyticsEntry[] {
|
|
81
122
|
if (!fs.existsSync(analyticsPath)) return []
|
|
82
|
-
return JSON.parse(fs.readFileSync(analyticsPath, 'utf-8'))
|
|
123
|
+
return JSON.parse(fs.readFileSync(validatePath(analyticsPath, allowedBase), 'utf-8'))
|
|
83
124
|
}
|
|
84
125
|
|
|
85
126
|
function saveAnalytics(entry: AnalyticsEntry) {
|
|
86
127
|
try {
|
|
87
128
|
const analytics = getAnalytics()
|
|
88
129
|
analytics.push(entry)
|
|
89
|
-
fs.writeFileSync(analyticsPath, JSON.stringify(analytics, null, 2))
|
|
130
|
+
fs.writeFileSync(validatePath(analyticsPath, allowedBase), JSON.stringify(analytics, null, 2))
|
|
90
131
|
} catch (e) {
|
|
91
132
|
console.error('[RushChatbot analytics error]', e)
|
|
92
133
|
}
|
|
@@ -99,10 +140,21 @@ export function createServer(options: RushChatbotServerOptions) {
|
|
|
99
140
|
const app = express()
|
|
100
141
|
app.use(cors({ origin: corsOrigin }))
|
|
101
142
|
app.use(express.json())
|
|
143
|
+
// Apply CSRF protection globally to all state-changing methods
|
|
144
|
+
app.use((req, res, next) => {
|
|
145
|
+
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) {
|
|
146
|
+
return csrfMiddleware(req, res, next)
|
|
147
|
+
}
|
|
148
|
+
next()
|
|
149
|
+
})
|
|
102
150
|
|
|
103
151
|
const router = express.Router()
|
|
104
152
|
|
|
105
|
-
router.
|
|
153
|
+
router.get('/csrf-token', (_req, res) => {
|
|
154
|
+
res.json({ csrfToken: generateCsrfToken() })
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
router.post('/click', csrfMiddleware, (req, res) => {
|
|
106
158
|
const { questionId } = req.body
|
|
107
159
|
const sid = getSid(req)
|
|
108
160
|
if (!questionId || typeof questionId !== 'string')
|
|
@@ -126,12 +178,12 @@ export function createServer(options: RushChatbotServerOptions) {
|
|
|
126
178
|
return res.json({ ...parseN8nResponse(data), source: 'n8n' })
|
|
127
179
|
} catch (err: unknown) {
|
|
128
180
|
const message = err instanceof Error ? err.message : String(err)
|
|
129
|
-
console.error('[RushChatbot query GET error]', message)
|
|
130
|
-
return res.status(500).json({ error:
|
|
181
|
+
console.error('[RushChatbot query GET error]', sanitizeLog(message))
|
|
182
|
+
return res.status(500).json({ error: 'Internal server error' })
|
|
131
183
|
}
|
|
132
184
|
})
|
|
133
185
|
|
|
134
|
-
router.post('/query', async (req, res) => {
|
|
186
|
+
router.post('/query', csrfMiddleware, async (req, res) => {
|
|
135
187
|
const sid = getSid(req)
|
|
136
188
|
const cfg = getConfig()
|
|
137
189
|
try {
|
|
@@ -181,7 +233,7 @@ export function createServer(options: RushChatbotServerOptions) {
|
|
|
181
233
|
|
|
182
234
|
} catch (err: unknown) {
|
|
183
235
|
const message = err instanceof Error ? err.message : String(err)
|
|
184
|
-
console.error('[RushChatbot query error]', message)
|
|
236
|
+
console.error('[RushChatbot query error]', sanitizeLog(message))
|
|
185
237
|
saveAnalytics({ sessionId: sid, questionId: 'error', timestamp: new Date().toISOString(), type: 'error' })
|
|
186
238
|
return res.status(500).json({ answer: cfg.chat.fallbackMessage, source: 'error' })
|
|
187
239
|
}
|
|
@@ -2,6 +2,15 @@ import { RushChatbot, parseMarkdown } from '../core/RushChatbot'
|
|
|
2
2
|
import type { RushChatbotState, WidgetSettings } from '../core/RushChatbot'
|
|
3
3
|
import type { Question, Message } from '../shared/types'
|
|
4
4
|
|
|
5
|
+
function escapeHtml(str: string): string {
|
|
6
|
+
return str
|
|
7
|
+
.replace(/&/g, '&')
|
|
8
|
+
.replace(/</g, '<')
|
|
9
|
+
.replace(/>/g, '>')
|
|
10
|
+
.replace(/"/g, '"')
|
|
11
|
+
.replace(/'/g, ''')
|
|
12
|
+
}
|
|
13
|
+
|
|
5
14
|
const STYLES = `
|
|
6
15
|
@keyframes rushchatbot-bounce {
|
|
7
16
|
0%, 80%, 100% { transform: translateY(0); opacity: 0.4; }
|
|
@@ -137,42 +146,100 @@ class RushChatbotElement extends HTMLElement {
|
|
|
137
146
|
const msgsEl = this.shadow.querySelector('.messages') as HTMLElement | null
|
|
138
147
|
const wasAtBottom = msgsEl ? msgsEl.scrollHeight - msgsEl.scrollTop - msgsEl.clientHeight < 60 : true
|
|
139
148
|
|
|
140
|
-
|
|
149
|
+
// Build static shell with no user data in innerHTML
|
|
150
|
+
const shellHtml = `
|
|
141
151
|
<style>${STYLES}</style>
|
|
142
|
-
<div class="container"
|
|
143
|
-
<div class="header"
|
|
144
|
-
<div class="header-title"
|
|
145
|
-
<div class="header-subtitle"
|
|
152
|
+
<div class="container" id="container">
|
|
153
|
+
<div class="header" id="header">
|
|
154
|
+
<div class="header-title" id="header-title"></div>
|
|
155
|
+
<div class="header-subtitle" id="header-subtitle"></div>
|
|
146
156
|
</div>
|
|
147
157
|
<div class="messages-wrap">
|
|
148
|
-
${this.showScrollBtn ? `
|
|
149
|
-
<button class="scroll-btn" id="scrollBtn" style="background:${theme.primaryColor}" aria-label="Scroll to latest">
|
|
150
|
-
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M8 3v10M3 9l5 5 5-5" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
151
|
-
</button>` : ''}
|
|
152
158
|
<div class="messages" id="msgs">
|
|
153
|
-
|
|
154
|
-
${state.loading ? `<div class="thinking" style="background:${theme.botBubbleColor}">
|
|
155
|
-
${[0,1,2].map(i => `<span class="dot" style="background:${theme.primaryColor};animation:rushchatbot-bounce 1.2s ease-in-out ${i*0.2}s infinite"></span>`).join('')}
|
|
156
|
-
</div>` : ''}
|
|
157
|
-
${state.showMain && state.questions.length > 0 ? `
|
|
158
|
-
<div class="main-list">
|
|
159
|
-
${state.questions.map(q => `
|
|
160
|
-
<button class="main-btn" data-action="main" data-id="${q.id}"
|
|
161
|
-
style="border:1px solid ${theme.primaryColor};color:${theme.primaryColor}">
|
|
162
|
-
${q.question}
|
|
163
|
-
</button>`).join('')}
|
|
164
|
-
${settings.hideEndChat ? '' : `<button class="end-btn" data-action="endchat">👋 I'm done, thanks!</button>`}
|
|
165
|
-
</div>` : ''}
|
|
159
|
+
<div id="msgs-content"></div>
|
|
166
160
|
<div id="bottom"></div>
|
|
167
161
|
</div>
|
|
168
162
|
</div>
|
|
169
163
|
${settings.hideInput ? '' : `
|
|
170
164
|
<form class="input-row" id="form">
|
|
171
|
-
<input id="input"
|
|
172
|
-
<button type="submit"
|
|
165
|
+
<input id="input" />
|
|
166
|
+
<button type="submit" id="send-btn"></button>
|
|
173
167
|
</form>`}
|
|
174
168
|
</div>
|
|
175
169
|
`
|
|
170
|
+
this.shadow.innerHTML = shellHtml
|
|
171
|
+
|
|
172
|
+
// Populate dynamic values via DOM (safe from XSS)
|
|
173
|
+
const container = this.shadow.getElementById('container') as HTMLElement
|
|
174
|
+
container.style.cssText = `background:${escapeHtml(theme.backgroundColor)};color:${escapeHtml(theme.textColor)};width:${this.w};height:${this.h};font-family:${escapeHtml(theme.fontFamily)};border-radius:${borderRadius}px;box-shadow:${shadow}`
|
|
175
|
+
|
|
176
|
+
const header = this.shadow.getElementById('header') as HTMLElement
|
|
177
|
+
header.style.background = escapeHtml(theme.primaryColor)
|
|
178
|
+
;(this.shadow.getElementById('header-title') as HTMLElement).textContent = state.config!.branding.title
|
|
179
|
+
;(this.shadow.getElementById('header-subtitle') as HTMLElement).textContent = state.config!.branding.subtitle
|
|
180
|
+
|
|
181
|
+
const msgsContent = this.shadow.getElementById('msgs-content') as HTMLElement
|
|
182
|
+
msgsContent.textContent = ''
|
|
183
|
+
|
|
184
|
+
state.messages.forEach((msg, idx) => {
|
|
185
|
+
msgsContent.appendChild(this.renderMsg(msg, idx))
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
if (state.loading) {
|
|
189
|
+
const thinking = document.createElement('div')
|
|
190
|
+
thinking.className = 'thinking'
|
|
191
|
+
thinking.style.background = escapeHtml(theme.botBubbleColor)
|
|
192
|
+
;[0,1,2].forEach(i => {
|
|
193
|
+
const dot = document.createElement('span')
|
|
194
|
+
dot.className = 'dot'
|
|
195
|
+
dot.style.cssText = `background:${escapeHtml(theme.primaryColor)};animation:rushchatbot-bounce 1.2s ease-in-out ${i*0.2}s infinite`
|
|
196
|
+
thinking.appendChild(dot)
|
|
197
|
+
})
|
|
198
|
+
msgsContent.appendChild(thinking)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (state.showMain && state.questions.length > 0) {
|
|
202
|
+
const mainList = document.createElement('div')
|
|
203
|
+
mainList.className = 'main-list'
|
|
204
|
+
state.questions.forEach(q => {
|
|
205
|
+
const btn = document.createElement('button')
|
|
206
|
+
btn.className = 'main-btn'
|
|
207
|
+
btn.dataset.action = 'main'
|
|
208
|
+
btn.dataset.id = q.id
|
|
209
|
+
btn.style.cssText = `border:1px solid ${escapeHtml(theme.primaryColor)};color:${escapeHtml(theme.primaryColor)}`
|
|
210
|
+
btn.textContent = q.question
|
|
211
|
+
mainList.appendChild(btn)
|
|
212
|
+
})
|
|
213
|
+
if (!settings.hideEndChat) {
|
|
214
|
+
const endBtn = document.createElement('button')
|
|
215
|
+
endBtn.className = 'end-btn'
|
|
216
|
+
endBtn.dataset.action = 'endchat'
|
|
217
|
+
endBtn.textContent = "\uD83D\uDC4B I'm done, thanks!"
|
|
218
|
+
mainList.appendChild(endBtn)
|
|
219
|
+
}
|
|
220
|
+
msgsContent.appendChild(mainList)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (this.showScrollBtn) {
|
|
224
|
+
const scrollBtn = document.createElement('button')
|
|
225
|
+
scrollBtn.className = 'scroll-btn'
|
|
226
|
+
scrollBtn.id = 'scrollBtn'
|
|
227
|
+
scrollBtn.style.background = escapeHtml(theme.primaryColor)
|
|
228
|
+
scrollBtn.setAttribute('aria-label', 'Scroll to latest')
|
|
229
|
+
scrollBtn.innerHTML = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M8 3v10M3 9l5 5 5-5" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>`
|
|
230
|
+
this.shadow.querySelector('.messages-wrap')!.prepend(scrollBtn)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (!settings.hideInput) {
|
|
234
|
+
const inputEl = this.shadow.getElementById('input') as HTMLInputElement
|
|
235
|
+
inputEl.placeholder = placeholder
|
|
236
|
+
inputEl.value = state.input
|
|
237
|
+
if (state.loading) inputEl.disabled = true
|
|
238
|
+
const sendBtn = this.shadow.getElementById('send-btn') as HTMLButtonElement
|
|
239
|
+
sendBtn.textContent = sendLabel
|
|
240
|
+
sendBtn.style.background = escapeHtml(theme.primaryColor)
|
|
241
|
+
if (state.loading) sendBtn.disabled = true
|
|
242
|
+
}
|
|
176
243
|
|
|
177
244
|
const newMsgsEl = this.shadow.getElementById('msgs') as HTMLElement
|
|
178
245
|
if (wasAtBottom) {
|
|
@@ -257,41 +324,66 @@ class RushChatbotElement extends HTMLElement {
|
|
|
257
324
|
})
|
|
258
325
|
}
|
|
259
326
|
|
|
260
|
-
private renderMsg(msg: Message, idx: number):
|
|
327
|
+
private renderMsg(msg: Message, idx: number): HTMLElement {
|
|
261
328
|
const { theme } = this
|
|
262
329
|
const isUser = msg.role === 'user'
|
|
263
330
|
const bgColor = isUser ? theme.userBubbleColor : theme.botBubbleColor
|
|
264
331
|
const color = isUser ? '#fff' : theme.textColor
|
|
265
|
-
|
|
332
|
+
|
|
333
|
+
const row = document.createElement('div')
|
|
334
|
+
row.className = `msg-row ${isUser ? 'user' : 'bot'}`
|
|
335
|
+
if (idx === 0) row.style.marginTop = '48px'
|
|
336
|
+
|
|
337
|
+
const bubble = document.createElement('div')
|
|
338
|
+
bubble.className = 'bubble'
|
|
339
|
+
bubble.style.cssText = `background:${escapeHtml(bgColor)};color:${escapeHtml(color)}`
|
|
266
340
|
|
|
267
341
|
if (msg.subquestions && msg.subquestions.length > 0) {
|
|
268
342
|
const siblingIds = msg.subquestions.map((s: Question) => s.id).join(',')
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
343
|
+
const mdDiv = document.createElement('div')
|
|
344
|
+
mdDiv.className = 'rushchatbot-md'
|
|
345
|
+
mdDiv.style.marginBottom = '8px'
|
|
346
|
+
mdDiv.innerHTML = parseMarkdown(msg.text)
|
|
347
|
+
bubble.appendChild(mdDiv)
|
|
348
|
+
|
|
349
|
+
const subList = document.createElement('div')
|
|
350
|
+
subList.className = 'sub-list'
|
|
351
|
+
msg.subquestions.forEach((sub: Question) => {
|
|
352
|
+
const btn = document.createElement('button')
|
|
353
|
+
btn.className = 'btn'
|
|
354
|
+
btn.dataset.action = 'sub'
|
|
355
|
+
btn.dataset.id = sub.id
|
|
356
|
+
btn.dataset.siblings = siblingIds
|
|
357
|
+
btn.style.cssText = `border:1px solid ${escapeHtml(theme.primaryColor)};color:${escapeHtml(theme.primaryColor)}`
|
|
358
|
+
btn.textContent = sub.question
|
|
359
|
+
subList.appendChild(btn)
|
|
360
|
+
})
|
|
361
|
+
const backBtn = document.createElement('button')
|
|
362
|
+
backBtn.className = 'btn'
|
|
363
|
+
backBtn.dataset.action = 'back'
|
|
364
|
+
backBtn.style.cssText = 'border:1px solid #6B7280;color:#6B7280'
|
|
365
|
+
backBtn.textContent = '\u2190 Back to main menu'
|
|
366
|
+
subList.appendChild(backBtn)
|
|
367
|
+
bubble.appendChild(subList)
|
|
368
|
+
} else if (isUser) {
|
|
369
|
+
bubble.textContent = msg.text
|
|
370
|
+
} else {
|
|
371
|
+
const mdDiv = document.createElement('div')
|
|
372
|
+
mdDiv.className = 'rushchatbot-md'
|
|
373
|
+
mdDiv.innerHTML = parseMarkdown(msg.text)
|
|
374
|
+
bubble.appendChild(mdDiv)
|
|
375
|
+
if (msg.isEndChat) {
|
|
376
|
+
const newChatBtn = document.createElement('button')
|
|
377
|
+
newChatBtn.className = 'new-chat-btn'
|
|
378
|
+
newChatBtn.dataset.action = 'newchat'
|
|
379
|
+
newChatBtn.style.background = escapeHtml(theme.primaryColor)
|
|
380
|
+
newChatBtn.textContent = '\uD83D\uDD04 Start a new chat'
|
|
381
|
+
bubble.appendChild(newChatBtn)
|
|
382
|
+
}
|
|
283
383
|
}
|
|
284
384
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
<div class="bubble" style="background:${bgColor};color:${color}">
|
|
288
|
-
${isUser
|
|
289
|
-
? msg.text
|
|
290
|
-
: `<div class="rushchatbot-md">${parseMarkdown(msg.text)}</div>
|
|
291
|
-
${msg.isEndChat ? `<button class="new-chat-btn" data-action="newchat" style="background:${theme.primaryColor}">🔄 Start a new chat</button>` : ''}`
|
|
292
|
-
}
|
|
293
|
-
</div>
|
|
294
|
-
</div>`
|
|
385
|
+
row.appendChild(bubble)
|
|
386
|
+
return row
|
|
295
387
|
}
|
|
296
388
|
}
|
|
297
389
|
|
package/package.json
CHANGED