rushchatbot 1.6.7

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.
Files changed (51) hide show
  1. package/INTEGRATION.md +559 -0
  2. package/index.html +13 -0
  3. package/package/INTEGRATION.md +717 -0
  4. package/package/README.md +717 -0
  5. package/package/package-lock.json +3221 -0
  6. package/package/package.json +101 -0
  7. package/package/src/cli/index.ts +94 -0
  8. package/package/src/client/ChatWidget.tsx +163 -0
  9. package/package/src/client.ts +2 -0
  10. package/package/src/core/RushChatbot.ts +192 -0
  11. package/package/src/index.ts +5 -0
  12. package/package/src/server/guardrails.ts +107 -0
  13. package/package/src/server/index.ts +210 -0
  14. package/package/src/server/matcher.ts +140 -0
  15. package/package/src/shared/types.ts +58 -0
  16. package/package/src/shims-vue.d.ts +5 -0
  17. package/package/src/vanilla/index.ts +302 -0
  18. package/package/src/vanilla.ts +3 -0
  19. package/package/src/vue/ChatWidget.vue +198 -0
  20. package/package/src/vue.ts +3 -0
  21. package/package/src/vue2/ChatWidget.vue +204 -0
  22. package/package/src/vue2.ts +3 -0
  23. package/package/tsconfig.json +19 -0
  24. package/package/vite.config.ts +86 -0
  25. package/package.json +35 -0
  26. package/public/favicon.svg +1 -0
  27. package/public/icons.svg +24 -0
  28. package/server/data/analytics.json +795 -0
  29. package/server/data/chatbot.config.json +26 -0
  30. package/server/data/questions.json +238 -0
  31. package/server/data/questions.tl.json +182 -0
  32. package/server/index.ts +11 -0
  33. package/server/lib/guardrails.ts +107 -0
  34. package/server/lib/matcher.ts +140 -0
  35. package/server/routes/chat.ts +180 -0
  36. package/server/tsconfig.json +11 -0
  37. package/src/App.css +184 -0
  38. package/src/App.tsx +135 -0
  39. package/src/assets/hero.png +0 -0
  40. package/src/assets/react.svg +1 -0
  41. package/src/assets/vite.svg +1 -0
  42. package/src/components/ChatWidget.tsx +417 -0
  43. package/src/components/QuestionTree.tsx +57 -0
  44. package/src/hooks/useChatData.ts +49 -0
  45. package/src/index.css +111 -0
  46. package/src/main.tsx +10 -0
  47. package/src/types/index.ts +40 -0
  48. package/tsconfig.app.json +26 -0
  49. package/tsconfig.json +7 -0
  50. package/tsconfig.node.json +23 -0
  51. package/vite.config.ts +7 -0
@@ -0,0 +1,210 @@
1
+ import express from 'express'
2
+ import cors from 'cors'
3
+ import axios from 'axios'
4
+ import fs from 'fs'
5
+ import path from 'path'
6
+ import { findMatch, MatchResult, Question } from './matcher'
7
+ import { checkGuardrails, validateInput } from './guardrails'
8
+ import type { ChatbotConfig } from '../shared/types'
9
+
10
+ export interface RushChatbotServerOptions {
11
+ /** Absolute path to your chatbot.config.json */
12
+ configPath: string
13
+ /** Absolute path to your questions.json (English) */
14
+ questionsPath: string
15
+ /** Absolute path to your questions.tl.json (Tagalog, optional) */
16
+ questionsTagalogPath?: string
17
+ /** Port to listen on. Defaults to 3001 */
18
+ port?: number
19
+ /** CORS origin(s). Defaults to * */
20
+ corsOrigin?: string | string[]
21
+ }
22
+
23
+ type AnalyticsEntry = {
24
+ sessionId: string
25
+ questionId: string
26
+ timestamp: string
27
+ type: 'click' | 'match' | 'n8n' | 'error'
28
+ }
29
+
30
+ function parseN8nResponse(data: unknown): { paragraphs: string[] } | { answer: string } {
31
+ if (Array.isArray(data) && data.length > 0) {
32
+ const first = data[0]
33
+ if (Array.isArray(first?.paragraphs) && first.paragraphs.length > 0)
34
+ return { paragraphs: first.paragraphs.map(String) }
35
+ if (typeof first === 'string') return { answer: first }
36
+ if (first?.output) return { answer: String(first.output) }
37
+ if (first?.text) return { answer: String(first.text) }
38
+ if (first?.answer) return { answer: String(first.answer) }
39
+ if (first?.message) return { answer: String(first.message) }
40
+ }
41
+ if (typeof data === 'object' && data !== null) {
42
+ const obj = data as Record<string, unknown>
43
+ if (Array.isArray(obj.paragraphs)) return { paragraphs: obj.paragraphs.map(String) }
44
+ if (obj.output) return { answer: String(obj.output) }
45
+ if (obj.answer) return { answer: String(obj.answer) }
46
+ if (obj.text) return { answer: String(obj.text) }
47
+ if (obj.message) return { answer: String(obj.message) }
48
+ }
49
+ if (typeof data === 'string') return { answer: data }
50
+ return { answer: JSON.stringify(data) }
51
+ }
52
+
53
+ export function createServer(options: RushChatbotServerOptions) {
54
+ const {
55
+ configPath,
56
+ questionsPath,
57
+ questionsTagalogPath,
58
+ port = 3001,
59
+ corsOrigin = '*',
60
+ } = options
61
+
62
+ // Validate paths exist
63
+ if (!fs.existsSync(configPath)) throw new Error(`[RushChatbot] configPath not found: ${configPath}`)
64
+ if (!fs.existsSync(questionsPath)) throw new Error(`[RushChatbot] questionsPath not found: ${questionsPath}`)
65
+
66
+ const analyticsPath = path.join(path.dirname(configPath), 'analytics.json')
67
+
68
+ function getConfig(): ChatbotConfig {
69
+ return JSON.parse(fs.readFileSync(configPath, 'utf-8'))
70
+ }
71
+
72
+ function getQuestions(lang: 'en' | 'tl'): Question[] {
73
+ if (lang === 'tl') {
74
+ if (!questionsTagalogPath || !fs.existsSync(questionsTagalogPath)) return []
75
+ return JSON.parse(fs.readFileSync(questionsTagalogPath, 'utf-8'))
76
+ }
77
+ return JSON.parse(fs.readFileSync(questionsPath, 'utf-8'))
78
+ }
79
+
80
+ function getAnalytics(): AnalyticsEntry[] {
81
+ if (!fs.existsSync(analyticsPath)) return []
82
+ return JSON.parse(fs.readFileSync(analyticsPath, 'utf-8'))
83
+ }
84
+
85
+ function saveAnalytics(entry: AnalyticsEntry) {
86
+ try {
87
+ const analytics = getAnalytics()
88
+ analytics.push(entry)
89
+ fs.writeFileSync(analyticsPath, JSON.stringify(analytics, null, 2))
90
+ } catch (e) {
91
+ console.error('[RushChatbot analytics error]', e)
92
+ }
93
+ }
94
+
95
+ function getSid(req: express.Request): string {
96
+ return String(req.body?.sessionId ?? req.query?.sessionId ?? req.headers['x-session-id'] ?? 'unknown')
97
+ }
98
+
99
+ const app = express()
100
+ app.use(cors({ origin: corsOrigin }))
101
+ app.use(express.json())
102
+
103
+ const router = express.Router()
104
+
105
+ router.post('/click', (req, res) => {
106
+ const { questionId } = req.body
107
+ const sid = getSid(req)
108
+ if (!questionId || typeof questionId !== 'string')
109
+ return res.status(400).json({ error: 'questionId is required' })
110
+ saveAnalytics({ sessionId: sid, questionId, timestamp: new Date().toISOString(), type: 'click' })
111
+ res.json({ ok: true })
112
+ })
113
+
114
+ router.get('/query', async (req, res) => {
115
+ const cfg = getConfig()
116
+ const query = String(req.query.query ?? '').trim()
117
+ const sid = getSid(req)
118
+ if (!query) return res.status(400).json({ error: 'query param is required' })
119
+ try {
120
+ const { webhookUrl, timeoutMs = 55000 } = cfg.n8n
121
+ const { data } = await axios.post(
122
+ webhookUrl,
123
+ { query, sessionId: sid },
124
+ { timeout: timeoutMs, headers: { 'Content-Type': 'application/json', 'x-session-id': sid } }
125
+ )
126
+ return res.json({ ...parseN8nResponse(data), source: 'n8n' })
127
+ } catch (err: unknown) {
128
+ 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 })
131
+ }
132
+ })
133
+
134
+ router.post('/query', async (req, res) => {
135
+ const sid = getSid(req)
136
+ const cfg = getConfig()
137
+ try {
138
+ const { query } = req.body
139
+ if (!query || typeof query !== 'string' || !query.trim())
140
+ return res.status(400).json({ error: 'query is required' })
141
+
142
+ const validation = validateInput(query.trim())
143
+ if (!validation.valid) {
144
+ saveAnalytics({ sessionId: sid, questionId: 'invalid_input', timestamp: new Date().toISOString(), type: 'error' })
145
+ return res.json({ answer: validation.response, source: 'validation' })
146
+ }
147
+
148
+ const guardrail = checkGuardrails(query.trim())
149
+ if (guardrail.flagged) {
150
+ saveAnalytics({ sessionId: sid, questionId: 'guardrail', timestamp: new Date().toISOString(), type: 'error' })
151
+ return res.json({ answer: guardrail.response, source: 'guardrail' })
152
+ }
153
+
154
+ let result: MatchResult | null = findMatch(query.trim(), getQuestions('en'), cfg.chat.matchThreshold ?? 0.4)
155
+ if (!result) result = findMatch(query.trim(), getQuestions('tl'), cfg.chat.matchThreshold ?? 0.4)
156
+
157
+ if (result) {
158
+ saveAnalytics({ sessionId: sid, questionId: result.question.id, timestamp: new Date().toISOString(), type: 'match' })
159
+ return res.json({
160
+ answer: result.question.answer,
161
+ source: 'config',
162
+ matchedQuestion: result.question.question,
163
+ matchedKeywords: result.matchedKeywords,
164
+ score: Math.round(result.score * 100),
165
+ })
166
+ }
167
+
168
+ const { webhookUrl, timeoutMs = 55000 } = cfg.n8n
169
+ if (!webhookUrl || webhookUrl.includes('your-n8n.com')) {
170
+ saveAnalytics({ sessionId: sid, questionId: 'n8n_not_configured', timestamp: new Date().toISOString(), type: 'error' })
171
+ return res.json({ answer: cfg.chat.fallbackMessage, source: 'error' })
172
+ }
173
+
174
+ 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' })
181
+
182
+ } catch (err: unknown) {
183
+ const message = err instanceof Error ? err.message : String(err)
184
+ console.error('[RushChatbot query error]', message)
185
+ saveAnalytics({ sessionId: sid, questionId: 'error', timestamp: new Date().toISOString(), type: 'error' })
186
+ return res.status(500).json({ answer: cfg.chat.fallbackMessage, source: 'error' })
187
+ }
188
+ })
189
+
190
+ router.get('/questions', (_req, res) => {
191
+ const { chat } = getConfig()
192
+ res.json(getQuestions(chat.language === 'tl' ? 'tl' : 'en'))
193
+ })
194
+
195
+ router.get('/config', (_req, res) => {
196
+ const { branding, chat } = getConfig()
197
+ res.json({ branding, chat })
198
+ })
199
+
200
+ router.get('/analytics', (_req, res) => {
201
+ res.json(getAnalytics())
202
+ })
203
+
204
+ app.use('/api/chat', router)
205
+
206
+ return {
207
+ app,
208
+ listen: () => app.listen(port, () => console.log(`[RushChatbot] Server running on http://localhost:${port}`)),
209
+ }
210
+ }
@@ -0,0 +1,140 @@
1
+ import Fuse from 'fuse.js'
2
+
3
+ export interface Question {
4
+ id: string
5
+ question: string
6
+ answer: string
7
+ subquestions: Question[]
8
+ }
9
+
10
+ // Common stop words to ignore during keyword extraction
11
+ const STOP_WORDS = new Set([
12
+ 'a', 'an', 'the', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
13
+ 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could',
14
+ 'should', 'may', 'might', 'shall', 'can', 'need', 'dare', 'ought',
15
+ 'i', 'me', 'my', 'we', 'our', 'you', 'your', 'he', 'she', 'it', 'they',
16
+ 'what', 'where', 'when', 'who', 'how', 'why', 'which',
17
+ 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'about', 'by', 'from',
18
+ 'and', 'or', 'but', 'if', 'so', 'yet', 'nor',
19
+ 'this', 'that', 'these', 'those', 'there', 'here',
20
+ 'tell', 'give', 'show', 'know', 'want', 'like', 'get', 'find', 'look'
21
+ ])
22
+
23
+ // Synonym map — only domain-specific terms, not generic words
24
+ const SYNONYMS: Record<string, string[]> = {
25
+ website: ['site', 'web', 'url', 'online', 'portal', 'link'],
26
+ email: ['mail', 'e-mail'],
27
+ address: ['location', 'situated'],
28
+ fee: ['cost', 'price', 'charge', 'dues', 'rate', 'much'],
29
+ membership: ['member', 'join', 'enroll', 'apply'],
30
+ contact: ['reach', 'call', 'phone', 'inquire'],
31
+ benefit: ['advantage', 'perk', 'privilege'],
32
+ program: ['course', 'seminar', 'workshop', 'training'],
33
+ academy: ['school', 'learning', 'education'],
34
+ award: ['recognition', 'prize', 'honor', 'trophy'],
35
+ event: ['conference', 'summit', 'forum', 'gathering'],
36
+ publication: ['journal', 'digest', 'magazine'],
37
+ mission: ['goal', 'purpose', 'objective'],
38
+ founded: ['established', 'started', 'created', 'history', 'origin'],
39
+ hours: ['schedule', 'open', 'available'],
40
+ nominate: ['nomination', 'recommend', 'propose'],
41
+ contribute: ['write', 'author', 'publish'],
42
+ }
43
+
44
+ function extractKeywords(text: string): string[] {
45
+ return text
46
+ .toLowerCase()
47
+ .replace(/[^a-z0-9\s]/g, ' ')
48
+ .split(/\s+/)
49
+ .filter(w => w.length > 1 && !STOP_WORDS.has(w))
50
+ }
51
+
52
+ function expandWithSynonyms(keywords: string[]): string[] {
53
+ const expanded = new Set(keywords)
54
+ for (const [canonical, synonymList] of Object.entries(SYNONYMS)) {
55
+ for (const kw of keywords) {
56
+ if (synonymList.includes(kw) || kw === canonical) {
57
+ expanded.add(canonical)
58
+ synonymList.forEach(s => expanded.add(s))
59
+ }
60
+ }
61
+ }
62
+ return Array.from(expanded)
63
+ }
64
+
65
+ function keywordScore(rawKeywords: string[], expandedKeywords: string[], question: Question): { score: number; matched: string[] } {
66
+ const target = `${question.question} ${question.answer}`.toLowerCase()
67
+ const targetWords = new Set(target.replace(/[^a-z0-9\s]/g, ' ').split(/\s+/))
68
+
69
+ // Check raw keywords first for direct matches
70
+ const rawMatched = rawKeywords.filter(kw => targetWords.has(kw))
71
+
72
+ // Check expanded keywords but only count them if the canonical term hits
73
+ const expandedMatched = expandedKeywords.filter(kw => targetWords.has(kw))
74
+
75
+ // Deduplicate and use the larger matched set
76
+ const matched = Array.from(new Set([...rawMatched, ...expandedMatched]))
77
+
78
+ // Score based on raw keywords so expansion doesn't inflate the score
79
+ const score = rawMatched.length > 0
80
+ ? rawMatched.length / Math.max(rawKeywords.length, 1)
81
+ : expandedMatched.length > 0 ? 0.3 : 0 // small boost if only synonyms matched
82
+
83
+ return { score, matched }
84
+ }
85
+
86
+ function flattenQuestions(questions: Question[]): Question[] {
87
+ return questions.reduce<Question[]>((acc, q) => {
88
+ acc.push(q)
89
+ if (q.subquestions?.length) acc.push(...flattenQuestions(q.subquestions))
90
+ return acc
91
+ }, [])
92
+ }
93
+
94
+ export interface MatchResult {
95
+ question: Question
96
+ matchedKeywords: string[]
97
+ score: number
98
+ }
99
+
100
+ export function findMatch(
101
+ query: string,
102
+ questions: Question[],
103
+ threshold: number
104
+ ): MatchResult | null {
105
+ const flat = flattenQuestions(questions)
106
+
107
+ const rawKeywords = extractKeywords(query)
108
+ const expandedKeywords = expandWithSynonyms(rawKeywords)
109
+
110
+ const fuse = new Fuse(flat, {
111
+ keys: [
112
+ { name: 'question', weight: 0.7 },
113
+ { name: 'answer', weight: 0.3 }
114
+ ],
115
+ threshold: 0.2,
116
+ includeScore: true,
117
+ minMatchCharLength: 3,
118
+ ignoreLocation: true,
119
+ })
120
+
121
+ const fuseResults = fuse.search(query)
122
+
123
+ const candidates = flat.map(q => {
124
+ const fuseResult = fuseResults.find(r => r.item.id === q.id)
125
+ const fuseScore = fuseResult ? 1 - (fuseResult.score ?? 1) : 0
126
+ const { score: kwScore, matched } = keywordScore(rawKeywords, expandedKeywords, q)
127
+
128
+ // Require at least one keyword match AND a reasonable combined score
129
+ if (kwScore === 0) return { q, combined: 0, matchedKeywords: [] }
130
+
131
+ const combined = fuseScore * 0.4 + kwScore * 0.6
132
+ return { q, combined, matchedKeywords: matched }
133
+ })
134
+
135
+ const best = candidates
136
+ .filter(c => c.combined > 0.3)
137
+ .sort((a, b) => b.combined - a.combined)[0]
138
+
139
+ return best ? { question: best.q, matchedKeywords: best.matchedKeywords, score: best.combined } : null
140
+ }
@@ -0,0 +1,58 @@
1
+ export interface Question {
2
+ id: string
3
+ question: string
4
+ answer: string
5
+ subquestions: Question[]
6
+ }
7
+
8
+ export interface Theme {
9
+ primaryColor: string
10
+ backgroundColor: string
11
+ userBubbleColor: string
12
+ botBubbleColor: string
13
+ textColor: string
14
+ fontFamily: string
15
+ }
16
+
17
+ export interface ChatbotConfig {
18
+ branding: {
19
+ title: string
20
+ subtitle: string
21
+ avatarUrl?: string
22
+ theme: Theme
23
+ }
24
+ n8n: {
25
+ webhookUrl: string
26
+ timeoutMs?: number
27
+ }
28
+ chat: {
29
+ welcomeMessage: string
30
+ fallbackMessage: string
31
+ matchThreshold?: number
32
+ showPreQuestionsOnStart?: boolean
33
+ language?: 'en' | 'tl'
34
+ }
35
+ }
36
+
37
+ export interface Message {
38
+ id: string
39
+ role: 'user' | 'bot'
40
+ text: string
41
+ source?: 'config' | 'n8n' | 'error'
42
+ subquestions?: Question[]
43
+ isFollowUp?: boolean
44
+ isEndChat?: boolean
45
+ }
46
+
47
+ export interface WidgetSettings {
48
+ primaryColor?: string
49
+ bgColor?: string
50
+ borderRadius?: number
51
+ showShadow?: boolean
52
+ placeholder?: string
53
+ sendLabel?: string
54
+ hideInput?: boolean
55
+ hideEndChat?: boolean
56
+ autoOpen?: boolean
57
+ position?: 'bottom-right' | 'bottom-left'
58
+ }
@@ -0,0 +1,5 @@
1
+ declare module '*.vue' {
2
+ import type { DefineComponent } from 'vue'
3
+ const component: DefineComponent<{}, {}, any>
4
+ export default component
5
+ }