rushchatbot 1.6.8 → 1.7.1
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 +1 -1
- 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/client/ChatWidget.tsx +3 -0
- package/package/src/core/RushChatbot.ts +26 -1
- package/package/src/server/index.ts +82 -17
- package/package/src/shared/types.ts +2 -1
- package/package/src/vanilla/index.ts +149 -50
- package/package/src/vue/ChatWidget.vue +3 -0
- package/package/src/vue2/ChatWidget.vue +3 -0
- package/package.json +1 -1
- package/server/data/analytics.json +36 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# RushChatbot
|
|
2
2
|
|
|
3
|
-
**Version:** 1.
|
|
3
|
+
**Version:** 1.7.1
|
|
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/INTEGRATION.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# RushChatbot
|
|
2
2
|
|
|
3
|
-
**Version:** 1.
|
|
3
|
+
**Version:** 1.7.0
|
|
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.
|
|
3
|
+
**Version:** 1.7.1
|
|
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)
|
|
@@ -44,6 +44,9 @@ function ThinkingDots({ color }: { color: string }) {
|
|
|
44
44
|
.rushchatbot-md h1 { font-size: 1.1em; }
|
|
45
45
|
.rushchatbot-md h2 { font-size: 1em; }
|
|
46
46
|
.rushchatbot-md h3 { font-size: 0.95em; }
|
|
47
|
+
.rushchatbot-md code { font-family: monospace; font-size: 0.9em; background: rgba(0,0,0,0.08); padding: 1px 5px; border-radius: 4px; }
|
|
48
|
+
.rushchatbot-md pre { background: rgba(0,0,0,0.08); border-radius: 6px; padding: 10px 12px; overflow-x: auto; margin: 6px 0; }
|
|
49
|
+
.rushchatbot-md pre code { background: none; padding: 0; font-size: 0.85em; white-space: pre; }
|
|
47
50
|
`}</style>
|
|
48
51
|
</span>
|
|
49
52
|
)
|
|
@@ -1,7 +1,26 @@
|
|
|
1
1
|
import { marked } from 'marked'
|
|
2
2
|
import type { Message, Question, WidgetSettings } from '../shared/types'
|
|
3
3
|
|
|
4
|
-
marked.
|
|
4
|
+
marked.use({
|
|
5
|
+
breaks: true,
|
|
6
|
+
renderer: {
|
|
7
|
+
code({ text, lang }: { text: string; lang?: string }): string {
|
|
8
|
+
const escaped = text
|
|
9
|
+
.replace(/&/g, '&')
|
|
10
|
+
.replace(/</g, '<')
|
|
11
|
+
.replace(/>/g, '>')
|
|
12
|
+
const langAttr = lang ? ` data-lang="${lang}"` : ''
|
|
13
|
+
return `<pre${langAttr}><code>${escaped}</code></pre>`
|
|
14
|
+
},
|
|
15
|
+
codespan({ text }: { text: string }): string {
|
|
16
|
+
const escaped = text
|
|
17
|
+
.replace(/&/g, '&')
|
|
18
|
+
.replace(/</g, '<')
|
|
19
|
+
.replace(/>/g, '>')
|
|
20
|
+
return `<code>${escaped}</code>`
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
})
|
|
5
24
|
|
|
6
25
|
export interface RushChatbotConfig {
|
|
7
26
|
apiBase?: string
|
|
@@ -170,6 +189,12 @@ export class RushChatbot {
|
|
|
170
189
|
|
|
171
190
|
this.setState({ loading: false })
|
|
172
191
|
|
|
192
|
+
if (res.source === 'n8n_error' || res.source === 'error') {
|
|
193
|
+
this.addMessage({ role: 'bot', text: res.answer ?? "I'm having trouble right now. Please try again in a moment. ⏳", source: res.source, isError: true })
|
|
194
|
+
this.setState({ showMain: true })
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
|
|
173
198
|
if (res.paragraphs?.length > 0) {
|
|
174
199
|
for (const p of res.paragraphs) {
|
|
175
200
|
this.addMessage({ role: 'bot', text: p, source: res.source })
|
|
@@ -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 {
|
|
@@ -172,16 +224,29 @@ export function createServer(options: RushChatbotServerOptions) {
|
|
|
172
224
|
}
|
|
173
225
|
|
|
174
226
|
saveAnalytics({ sessionId: sid, questionId: 'n8n', timestamp: new Date().toISOString(), type: 'n8n' })
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
227
|
+
try {
|
|
228
|
+
const { data } = await axios.post(
|
|
229
|
+
webhookUrl,
|
|
230
|
+
{ query: query.trim(), sessionId: sid },
|
|
231
|
+
{ timeout: timeoutMs, headers: { 'Content-Type': 'application/json', 'x-session-id': sid } }
|
|
232
|
+
)
|
|
233
|
+
return res.json({ ...parseN8nResponse(data), source: 'n8n' })
|
|
234
|
+
} catch (n8nErr: unknown) {
|
|
235
|
+
const n8nMsg = n8nErr instanceof Error ? n8nErr.message : String(n8nErr)
|
|
236
|
+
const isTimeout = n8nMsg.includes('timeout') || n8nMsg.includes('ECONNREFUSED') || n8nMsg.includes('ENOTFOUND')
|
|
237
|
+
console.error('[RushChatbot n8n error]', sanitizeLog(n8nMsg))
|
|
238
|
+
saveAnalytics({ sessionId: sid, questionId: 'n8n_error', timestamp: new Date().toISOString(), type: 'error' })
|
|
239
|
+
return res.status(502).json({
|
|
240
|
+
answer: isTimeout
|
|
241
|
+
? "I'm having trouble reaching my knowledge base right now. Please try again in a moment. ⏳"
|
|
242
|
+
: cfg.chat.fallbackMessage,
|
|
243
|
+
source: 'n8n_error',
|
|
244
|
+
})
|
|
245
|
+
}
|
|
181
246
|
|
|
182
247
|
} catch (err: unknown) {
|
|
183
248
|
const message = err instanceof Error ? err.message : String(err)
|
|
184
|
-
console.error('[RushChatbot query error]', message)
|
|
249
|
+
console.error('[RushChatbot query error]', sanitizeLog(message))
|
|
185
250
|
saveAnalytics({ sessionId: sid, questionId: 'error', timestamp: new Date().toISOString(), type: 'error' })
|
|
186
251
|
return res.status(500).json({ answer: cfg.chat.fallbackMessage, source: 'error' })
|
|
187
252
|
}
|
|
@@ -38,10 +38,11 @@ export interface Message {
|
|
|
38
38
|
id: string
|
|
39
39
|
role: 'user' | 'bot'
|
|
40
40
|
text: string
|
|
41
|
-
source?: 'config' | 'n8n' | 'error'
|
|
41
|
+
source?: 'config' | 'n8n' | 'n8n_error' | 'error'
|
|
42
42
|
subquestions?: Question[]
|
|
43
43
|
isFollowUp?: boolean
|
|
44
44
|
isEndChat?: boolean
|
|
45
|
+
isError?: boolean
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
export interface WidgetSettings {
|
|
@@ -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; }
|
|
@@ -47,6 +56,9 @@ const STYLES = `
|
|
|
47
56
|
.rushchatbot-md h1 { font-size: 1.1em; }
|
|
48
57
|
.rushchatbot-md h2 { font-size: 1em; }
|
|
49
58
|
.rushchatbot-md h3 { font-size: 0.95em; }
|
|
59
|
+
.rushchatbot-md code { font-family: monospace; font-size: 0.9em; background: rgba(0,0,0,0.08); padding: 1px 5px; border-radius: 4px; }
|
|
60
|
+
.rushchatbot-md pre { background: rgba(0,0,0,0.08); border-radius: 6px; padding: 10px 12px; overflow-x: auto; margin: 6px 0; }
|
|
61
|
+
.rushchatbot-md pre code { background: none; padding: 0; font-size: 0.85em; white-space: pre; }
|
|
50
62
|
`
|
|
51
63
|
|
|
52
64
|
const OBSERVED = [
|
|
@@ -137,42 +149,100 @@ class RushChatbotElement extends HTMLElement {
|
|
|
137
149
|
const msgsEl = this.shadow.querySelector('.messages') as HTMLElement | null
|
|
138
150
|
const wasAtBottom = msgsEl ? msgsEl.scrollHeight - msgsEl.scrollTop - msgsEl.clientHeight < 60 : true
|
|
139
151
|
|
|
140
|
-
|
|
152
|
+
// Build static shell with no user data in innerHTML
|
|
153
|
+
const shellHtml = `
|
|
141
154
|
<style>${STYLES}</style>
|
|
142
|
-
<div class="container"
|
|
143
|
-
<div class="header"
|
|
144
|
-
<div class="header-title"
|
|
145
|
-
<div class="header-subtitle"
|
|
155
|
+
<div class="container" id="container">
|
|
156
|
+
<div class="header" id="header">
|
|
157
|
+
<div class="header-title" id="header-title"></div>
|
|
158
|
+
<div class="header-subtitle" id="header-subtitle"></div>
|
|
146
159
|
</div>
|
|
147
160
|
<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
161
|
<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>` : ''}
|
|
162
|
+
<div id="msgs-content"></div>
|
|
166
163
|
<div id="bottom"></div>
|
|
167
164
|
</div>
|
|
168
165
|
</div>
|
|
169
166
|
${settings.hideInput ? '' : `
|
|
170
167
|
<form class="input-row" id="form">
|
|
171
|
-
<input id="input"
|
|
172
|
-
<button type="submit"
|
|
168
|
+
<input id="input" />
|
|
169
|
+
<button type="submit" id="send-btn"></button>
|
|
173
170
|
</form>`}
|
|
174
171
|
</div>
|
|
175
172
|
`
|
|
173
|
+
this.shadow.innerHTML = shellHtml
|
|
174
|
+
|
|
175
|
+
// Populate dynamic values via DOM (safe from XSS)
|
|
176
|
+
const container = this.shadow.getElementById('container') as HTMLElement
|
|
177
|
+
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}`
|
|
178
|
+
|
|
179
|
+
const header = this.shadow.getElementById('header') as HTMLElement
|
|
180
|
+
header.style.background = escapeHtml(theme.primaryColor)
|
|
181
|
+
;(this.shadow.getElementById('header-title') as HTMLElement).textContent = state.config!.branding.title
|
|
182
|
+
;(this.shadow.getElementById('header-subtitle') as HTMLElement).textContent = state.config!.branding.subtitle
|
|
183
|
+
|
|
184
|
+
const msgsContent = this.shadow.getElementById('msgs-content') as HTMLElement
|
|
185
|
+
msgsContent.textContent = ''
|
|
186
|
+
|
|
187
|
+
state.messages.forEach((msg, idx) => {
|
|
188
|
+
msgsContent.appendChild(this.renderMsg(msg, idx))
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
if (state.loading) {
|
|
192
|
+
const thinking = document.createElement('div')
|
|
193
|
+
thinking.className = 'thinking'
|
|
194
|
+
thinking.style.background = escapeHtml(theme.botBubbleColor)
|
|
195
|
+
;[0,1,2].forEach(i => {
|
|
196
|
+
const dot = document.createElement('span')
|
|
197
|
+
dot.className = 'dot'
|
|
198
|
+
dot.style.cssText = `background:${escapeHtml(theme.primaryColor)};animation:rushchatbot-bounce 1.2s ease-in-out ${i*0.2}s infinite`
|
|
199
|
+
thinking.appendChild(dot)
|
|
200
|
+
})
|
|
201
|
+
msgsContent.appendChild(thinking)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (state.showMain && state.questions.length > 0) {
|
|
205
|
+
const mainList = document.createElement('div')
|
|
206
|
+
mainList.className = 'main-list'
|
|
207
|
+
state.questions.forEach(q => {
|
|
208
|
+
const btn = document.createElement('button')
|
|
209
|
+
btn.className = 'main-btn'
|
|
210
|
+
btn.dataset.action = 'main'
|
|
211
|
+
btn.dataset.id = q.id
|
|
212
|
+
btn.style.cssText = `border:1px solid ${escapeHtml(theme.primaryColor)};color:${escapeHtml(theme.primaryColor)}`
|
|
213
|
+
btn.textContent = q.question
|
|
214
|
+
mainList.appendChild(btn)
|
|
215
|
+
})
|
|
216
|
+
if (!settings.hideEndChat) {
|
|
217
|
+
const endBtn = document.createElement('button')
|
|
218
|
+
endBtn.className = 'end-btn'
|
|
219
|
+
endBtn.dataset.action = 'endchat'
|
|
220
|
+
endBtn.textContent = "\uD83D\uDC4B I'm done, thanks!"
|
|
221
|
+
mainList.appendChild(endBtn)
|
|
222
|
+
}
|
|
223
|
+
msgsContent.appendChild(mainList)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (this.showScrollBtn) {
|
|
227
|
+
const scrollBtn = document.createElement('button')
|
|
228
|
+
scrollBtn.className = 'scroll-btn'
|
|
229
|
+
scrollBtn.id = 'scrollBtn'
|
|
230
|
+
scrollBtn.style.background = escapeHtml(theme.primaryColor)
|
|
231
|
+
scrollBtn.setAttribute('aria-label', 'Scroll to latest')
|
|
232
|
+
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>`
|
|
233
|
+
this.shadow.querySelector('.messages-wrap')!.prepend(scrollBtn)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (!settings.hideInput) {
|
|
237
|
+
const inputEl = this.shadow.getElementById('input') as HTMLInputElement
|
|
238
|
+
inputEl.placeholder = placeholder
|
|
239
|
+
inputEl.value = state.input
|
|
240
|
+
if (state.loading) inputEl.disabled = true
|
|
241
|
+
const sendBtn = this.shadow.getElementById('send-btn') as HTMLButtonElement
|
|
242
|
+
sendBtn.textContent = sendLabel
|
|
243
|
+
sendBtn.style.background = escapeHtml(theme.primaryColor)
|
|
244
|
+
if (state.loading) sendBtn.disabled = true
|
|
245
|
+
}
|
|
176
246
|
|
|
177
247
|
const newMsgsEl = this.shadow.getElementById('msgs') as HTMLElement
|
|
178
248
|
if (wasAtBottom) {
|
|
@@ -257,41 +327,70 @@ class RushChatbotElement extends HTMLElement {
|
|
|
257
327
|
})
|
|
258
328
|
}
|
|
259
329
|
|
|
260
|
-
private renderMsg(msg: Message, idx: number):
|
|
330
|
+
private renderMsg(msg: Message, idx: number): HTMLElement {
|
|
261
331
|
const { theme } = this
|
|
262
332
|
const isUser = msg.role === 'user'
|
|
263
333
|
const bgColor = isUser ? theme.userBubbleColor : theme.botBubbleColor
|
|
264
334
|
const color = isUser ? '#fff' : theme.textColor
|
|
265
|
-
|
|
335
|
+
|
|
336
|
+
const row = document.createElement('div')
|
|
337
|
+
row.className = `msg-row ${isUser ? 'user' : 'bot'}`
|
|
338
|
+
if (idx === 0) row.style.marginTop = '48px'
|
|
339
|
+
|
|
340
|
+
const bubble = document.createElement('div')
|
|
341
|
+
bubble.className = 'bubble'
|
|
342
|
+
bubble.style.cssText = `background:${escapeHtml(bgColor)};color:${escapeHtml(color)}`
|
|
266
343
|
|
|
267
344
|
if (msg.subquestions && msg.subquestions.length > 0) {
|
|
268
345
|
const siblingIds = msg.subquestions.map((s: Question) => s.id).join(',')
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
346
|
+
const mdDiv = document.createElement('div')
|
|
347
|
+
mdDiv.className = 'rushchatbot-md'
|
|
348
|
+
mdDiv.style.marginBottom = '8px'
|
|
349
|
+
mdDiv.innerHTML = parseMarkdown(msg.text)
|
|
350
|
+
bubble.appendChild(mdDiv)
|
|
351
|
+
|
|
352
|
+
const subList = document.createElement('div')
|
|
353
|
+
subList.className = 'sub-list'
|
|
354
|
+
msg.subquestions.forEach((sub: Question) => {
|
|
355
|
+
const btn = document.createElement('button')
|
|
356
|
+
btn.className = 'btn'
|
|
357
|
+
btn.dataset.action = 'sub'
|
|
358
|
+
btn.dataset.id = sub.id
|
|
359
|
+
btn.dataset.siblings = siblingIds
|
|
360
|
+
btn.style.cssText = `border:1px solid ${escapeHtml(theme.primaryColor)};color:${escapeHtml(theme.primaryColor)}`
|
|
361
|
+
btn.textContent = sub.question
|
|
362
|
+
subList.appendChild(btn)
|
|
363
|
+
})
|
|
364
|
+
const backBtn = document.createElement('button')
|
|
365
|
+
backBtn.className = 'btn'
|
|
366
|
+
backBtn.dataset.action = 'back'
|
|
367
|
+
backBtn.style.cssText = 'border:1px solid #6B7280;color:#6B7280'
|
|
368
|
+
backBtn.textContent = '\u2190 Back to main menu'
|
|
369
|
+
subList.appendChild(backBtn)
|
|
370
|
+
bubble.appendChild(subList)
|
|
371
|
+
} else if (isUser) {
|
|
372
|
+
bubble.textContent = msg.text
|
|
373
|
+
} else {
|
|
374
|
+
const mdDiv = document.createElement('div')
|
|
375
|
+
mdDiv.className = 'rushchatbot-md'
|
|
376
|
+
mdDiv.innerHTML = parseMarkdown(msg.text)
|
|
377
|
+
bubble.appendChild(mdDiv)
|
|
378
|
+
if (msg.isError) {
|
|
379
|
+
bubble.style.border = '1px solid #EF4444'
|
|
380
|
+
bubble.style.color = '#B91C1C'
|
|
381
|
+
}
|
|
382
|
+
if (msg.isEndChat) {
|
|
383
|
+
const newChatBtn = document.createElement('button')
|
|
384
|
+
newChatBtn.className = 'new-chat-btn'
|
|
385
|
+
newChatBtn.dataset.action = 'newchat'
|
|
386
|
+
newChatBtn.style.background = escapeHtml(theme.primaryColor)
|
|
387
|
+
newChatBtn.textContent = '\uD83D\uDD04 Start a new chat'
|
|
388
|
+
bubble.appendChild(newChatBtn)
|
|
389
|
+
}
|
|
283
390
|
}
|
|
284
391
|
|
|
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>`
|
|
392
|
+
row.appendChild(bubble)
|
|
393
|
+
return row
|
|
295
394
|
}
|
|
296
395
|
}
|
|
297
396
|
|
|
@@ -195,4 +195,7 @@ const sendBtnStyle = computed(() => ({
|
|
|
195
195
|
.rushchatbot-md h1 { font-size: 1.1em; }
|
|
196
196
|
.rushchatbot-md h2 { font-size: 1em; }
|
|
197
197
|
.rushchatbot-md h3 { font-size: 0.95em; }
|
|
198
|
+
.rushchatbot-md code { font-family: monospace; font-size: 0.9em; background: rgba(0,0,0,0.08); padding: 1px 5px; border-radius: 4px; }
|
|
199
|
+
.rushchatbot-md pre { background: rgba(0,0,0,0.08); border-radius: 6px; padding: 10px 12px; overflow-x: auto; margin: 6px 0; }
|
|
200
|
+
.rushchatbot-md pre code { background: none; padding: 0; font-size: 0.85em; white-space: pre; }
|
|
198
201
|
</style>
|
|
@@ -201,4 +201,7 @@ export default {
|
|
|
201
201
|
0%, 80%, 100% { transform: translateY(0); opacity: 0.4; }
|
|
202
202
|
40% { transform: translateY(-6px); opacity: 1; }
|
|
203
203
|
}
|
|
204
|
+
.rushchatbot-md code { font-family: monospace; font-size: 0.9em; background: rgba(0,0,0,0.08); padding: 1px 5px; border-radius: 4px; }
|
|
205
|
+
.rushchatbot-md pre { background: rgba(0,0,0,0.08); border-radius: 6px; padding: 10px 12px; overflow-x: auto; margin: 6px 0; }
|
|
206
|
+
.rushchatbot-md pre code { background: none; padding: 0; font-size: 0.85em; white-space: pre; }
|
|
204
207
|
</style>
|
package/package.json
CHANGED
|
@@ -791,5 +791,41 @@
|
|
|
791
791
|
"questionId": "q4d",
|
|
792
792
|
"timestamp": "2026-09-24T05:33:53.688Z",
|
|
793
793
|
"type": "click"
|
|
794
|
+
},
|
|
795
|
+
{
|
|
796
|
+
"sessionId": "sess_mugkfhe5_nsw8z9",
|
|
797
|
+
"questionId": "q1",
|
|
798
|
+
"timestamp": "2026-09-25T06:14:13.722Z",
|
|
799
|
+
"type": "click"
|
|
800
|
+
},
|
|
801
|
+
{
|
|
802
|
+
"sessionId": "sess_mugn4uve_9yrh57",
|
|
803
|
+
"questionId": "q3",
|
|
804
|
+
"timestamp": "2026-09-25T07:29:56.881Z",
|
|
805
|
+
"type": "click"
|
|
806
|
+
},
|
|
807
|
+
{
|
|
808
|
+
"sessionId": "sess_mugn4uve_9yrh57",
|
|
809
|
+
"questionId": "q3h",
|
|
810
|
+
"timestamp": "2026-09-25T07:29:59.276Z",
|
|
811
|
+
"type": "click"
|
|
812
|
+
},
|
|
813
|
+
{
|
|
814
|
+
"sessionId": "sess_mugn4uve_9yrh57",
|
|
815
|
+
"questionId": "q6",
|
|
816
|
+
"timestamp": "2026-09-25T07:30:05.858Z",
|
|
817
|
+
"type": "click"
|
|
818
|
+
},
|
|
819
|
+
{
|
|
820
|
+
"sessionId": "sess_mugn4uve_9yrh57",
|
|
821
|
+
"questionId": "q8",
|
|
822
|
+
"timestamp": "2026-09-25T07:31:58.988Z",
|
|
823
|
+
"type": "click"
|
|
824
|
+
},
|
|
825
|
+
{
|
|
826
|
+
"sessionId": "sess_mugn4uve_9yrh57",
|
|
827
|
+
"questionId": "q8c",
|
|
828
|
+
"timestamp": "2026-09-25T07:32:01.577Z",
|
|
829
|
+
"type": "click"
|
|
794
830
|
}
|
|
795
831
|
]
|