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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # RushChatbot
2
2
 
3
- **Version:** 1.6.8
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
 
@@ -1,6 +1,6 @@
1
1
  # RushChatbot
2
2
 
3
- **Version:** 1.6.8
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.6.8
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
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rushchatbot",
3
- "version": "1.6.8",
3
+ "version": "1.7.1",
4
4
  "description": "Embeddable hybrid chatbot with n8n fallback, license key protection, and configurable FAQ tree",
5
5
  "keywords": [
6
6
  "chatbot",
@@ -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
- const targetDir = args[1] ? path.resolve(args[1]) : process.cwd()
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.setOptions({ breaks: true })
4
+ marked.use({
5
+ breaks: true,
6
+ renderer: {
7
+ code({ text, lang }: { text: string; lang?: string }): string {
8
+ const escaped = text
9
+ .replace(/&/g, '&amp;')
10
+ .replace(/</g, '&lt;')
11
+ .replace(/>/g, '&gt;')
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, '&amp;')
18
+ .replace(/</g, '&lt;')
19
+ .replace(/>/g, '&gt;')
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 analyticsPath = path.join(path.dirname(configPath), 'analytics.json')
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.post('/click', (req, res) => {
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: message })
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
- const { data } = await axios.post(
176
- webhookUrl,
177
- { query: query.trim(), sessionId: sid },
178
- { timeout: timeoutMs, headers: { 'Content-Type': 'application/json', 'x-session-id': sid } }
179
- )
180
- return res.json({ ...parseN8nResponse(data), source: 'n8n' })
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, '&amp;')
8
+ .replace(/</g, '&lt;')
9
+ .replace(/>/g, '&gt;')
10
+ .replace(/"/g, '&quot;')
11
+ .replace(/'/g, '&#39;')
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
- this.shadow.innerHTML = `
152
+ // Build static shell with no user data in innerHTML
153
+ const shellHtml = `
141
154
  <style>${STYLES}</style>
142
- <div class="container" style="background:${theme.backgroundColor};color:${theme.textColor};width:${this.w};height:${this.h};font-family:${theme.fontFamily};border-radius:${borderRadius}px;box-shadow:${shadow}">
143
- <div class="header" style="background:${theme.primaryColor}">
144
- <div class="header-title">${state.config!.branding.title}</div>
145
- <div class="header-subtitle">${state.config!.branding.subtitle}</div>
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
- ${state.messages.map((msg, idx) => this.renderMsg(msg, idx)).join('')}
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" placeholder="${placeholder}" value="${state.input}" ${state.loading ? 'disabled' : ''} />
172
- <button type="submit" style="background:${theme.primaryColor}" ${state.loading ? 'disabled' : ''}>${sendLabel}</button>
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): string {
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
- const firstStyle = idx === 0 ? 'margin-top:48px;' : ''
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
- return `
270
- <div class="msg-row bot" style="${firstStyle}">
271
- <div class="bubble" style="background:${bgColor};color:${color}">
272
- <div class="rushchatbot-md" style="margin-bottom:8px">${parseMarkdown(msg.text)}</div>
273
- <div class="sub-list">
274
- ${msg.subquestions.map((sub: Question) => `
275
- <button class="btn" data-action="sub" data-id="${sub.id}" data-siblings="${siblingIds}"
276
- style="border:1px solid ${theme.primaryColor};color:${theme.primaryColor}">
277
- ${sub.question}
278
- </button>`).join('')}
279
- <button class="btn" data-action="back" style="border:1px solid #6B7280;color:#6B7280">← Back to main menu</button>
280
- </div>
281
- </div>
282
- </div>`
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
- return `
286
- <div class="msg-row ${isUser ? 'user' : 'bot'}" style="${firstStyle}">
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rushchatbot",
3
- "version": "1.6.8",
3
+ "version": "1.7.1",
4
4
  "scripts": {
5
5
  "dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
6
6
  "dev:client": "vite",
@@ -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
  ]