nodebb-plugin-search-agent 0.0.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.
@@ -0,0 +1,125 @@
1
+ 'use strict';
2
+
3
+ // Common stop-words (English + Hebrew) to exclude from TF-IDF vectors
4
+ const STOP_WORDS = new Set([
5
+ // English
6
+ 'a', 'an', 'the', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for',
7
+ 'of', 'with', 'by', 'from', 'is', 'it', 'its', 'be', 'as', 'was',
8
+ 'are', 'were', 'been', 'have', 'has', 'had', 'do', 'does', 'did',
9
+ 'will', 'would', 'could', 'should', 'may', 'might', 'can', 'shall',
10
+ 'not', 'no', 'so', 'if', 'this', 'that', 'these', 'those', 'i', 'we',
11
+ 'you', 'he', 'she', 'they', 'my', 'your', 'his', 'her', 'our', 'their',
12
+ 'how', 'what', 'when', 'where', 'why', 'who', 'which', 'am', 'up',
13
+ 'out', 'about', 'into', 'than', 'more', 'also', 'me', 'him', 'us', 'them',
14
+ // Hebrew
15
+ 'של', 'את', 'אל', 'על', 'עם', 'הם', 'הן', 'זה', 'זו', 'זאת',
16
+ 'כי', 'לא', 'כן', 'יש', 'אם', 'רק', 'גם', 'אבל', 'אנחנו', 'אני',
17
+ 'אתה', 'את', 'הוא', 'היא', 'אנו', 'אתם', 'אתן', 'הם', 'הן',
18
+ 'זה', 'זו', 'אלה', 'אלו', 'כל', 'כך', 'כבר', 'עוד', 'רק', 'כן',
19
+ 'אחד', 'יותר', 'פה', 'שם', 'מה', 'מי', 'איך', 'מתי', 'איפה',
20
+ 'היה', 'הייתה', 'יהיה', 'תהיה', 'הוא', 'היא', 'הם', 'הן',
21
+ 'אסור', 'מותר', 'צריך', 'רוצה', 'יכול', 'יכולה', 'לו', 'לה',
22
+ 'בו', 'בה', 'עליו', 'עליה', 'בין', 'כבר', 'עכשיו', 'היום',
23
+ 'כן', 'לכן', 'כדי', 'כדאי', 'שלי', 'שלך', 'שלו', 'שלה',
24
+ 'שלנו', 'שלכם', 'שלהם', 'שלהן', 'להם', 'להן', 'לנו', 'לכם',
25
+ ]);
26
+
27
+ /**
28
+ * Strip HTML, lowercase, remove punctuation, drop stop-words and short tokens.
29
+ * @param {string|null|undefined} text
30
+ * @returns {string[]}
31
+ */
32
+ function tokenize(text) {
33
+ if (!text) return [];
34
+ return text
35
+ .replace(/<[^>]*>/g, ' ') // strip HTML tags
36
+ .toLowerCase()
37
+ .replace(/[^\p{L}\p{N}\s]/gu, ' ') // keep all Unicode letters & digits (Hebrew, Latin, etc.)
38
+ .split(/\s+/)
39
+ .filter(t => t.length >= 2 && !STOP_WORDS.has(t)); // min 2 chars to keep short Hebrew words
40
+ }
41
+
42
+ /**
43
+ * Build a TF-IDF index from an array of topic objects.
44
+ * Each topic must have: tid, slug, title, mainPostContent (optional).
45
+ * @param {{ tid: number|string, slug: string, title: string, mainPostContent?: string }[]} topics
46
+ * @returns {{ tid: number|string, slug: string, vector: Map<string, number> }[]}
47
+ */
48
+ function buildIndex(topics) {
49
+ if (!topics || topics.length === 0) return [];
50
+
51
+ // Step 1: term frequencies per document
52
+ const docs = topics.map((t) => {
53
+ const tokens = tokenize(`${t.title || ''} ${t.mainPostContent || ''}`);
54
+ const tf = new Map();
55
+ for (const token of tokens) {
56
+ tf.set(token, (tf.get(token) || 0) + 1);
57
+ }
58
+ return { tid: t.tid, slug: t.slug, tf, len: tokens.length };
59
+ });
60
+
61
+ // Step 2: document frequency (how many docs contain each term)
62
+ const df = new Map();
63
+ for (const doc of docs) {
64
+ for (const term of doc.tf.keys()) {
65
+ df.set(term, (df.get(term) || 0) + 1);
66
+ }
67
+ }
68
+
69
+ const N = docs.length;
70
+
71
+ // Step 3: compute TF-IDF vector per document
72
+ return docs.map((doc) => {
73
+ const vector = new Map();
74
+ for (const [term, freq] of doc.tf) {
75
+ const tf = doc.len > 0 ? freq / doc.len : 0;
76
+ // Smoothed IDF to avoid division by zero
77
+ const idf = Math.log((N + 1) / (df.get(term) + 1)) + 1;
78
+ vector.set(term, tf * idf);
79
+ }
80
+ return { tid: doc.tid, slug: doc.slug, vector };
81
+ });
82
+ }
83
+
84
+ /**
85
+ * Rank indexed documents against a query using cosine similarity.
86
+ * @param {string} queryText
87
+ * @param {{ tid: number|string, slug: string, vector: Map<string, number> }[]} index
88
+ * @param {number} [topN=10]
89
+ * @returns {{ tid: number|string, slug: string, score: number }[]}
90
+ */
91
+ function query(queryText, index, topN = 10) {
92
+ if (!index || index.length === 0) return [];
93
+
94
+ const qTokens = tokenize(queryText);
95
+ if (qTokens.length === 0) return [];
96
+
97
+ // Build raw term-count vector for the query
98
+ const qVec = new Map();
99
+ for (const token of qTokens) {
100
+ qVec.set(token, (qVec.get(token) || 0) + 1);
101
+ }
102
+
103
+ const results = [];
104
+ for (const doc of index) {
105
+ let dot = 0;
106
+ let docNormSq = 0;
107
+ let qNormSq = 0;
108
+
109
+ for (const [term, qVal] of qVec) {
110
+ dot += qVal * (doc.vector.get(term) || 0);
111
+ }
112
+ for (const val of doc.vector.values()) docNormSq += val * val;
113
+ for (const val of qVec.values()) qNormSq += val * val;
114
+
115
+ const norm = Math.sqrt(docNormSq) * Math.sqrt(qNormSq);
116
+ const score = norm > 0 ? dot / norm : 0;
117
+ if (score > 0) {
118
+ results.push({ tid: doc.tid, slug: doc.slug, score });
119
+ }
120
+ }
121
+
122
+ return results.sort((a, b) => b.score - a.score).slice(0, topN);
123
+ }
124
+
125
+ module.exports = { tokenize, buildIndex, query };
package/library.js ADDED
@@ -0,0 +1,98 @@
1
+ 'use strict';
2
+
3
+ const winston = require.main.require('winston');
4
+
5
+ const controllers = require('./lib/controllers');
6
+ const { invalidateCache } = require('./lib/searchHandler');
7
+
8
+ const routeHelpers = require.main.require('./src/routes/helpers');
9
+
10
+ const plugin = {};
11
+
12
+ /**
13
+ * static:app.load
14
+ * Register admin page route and set up cache-invalidation on new topic creation.
15
+ */
16
+ plugin.init = async (params) => {
17
+ console.log('[search-agent] plugin.init called');
18
+ const { router } = params;
19
+
20
+ // Admin settings page
21
+ routeHelpers.setupAdminPageRoute(
22
+ router,
23
+ '/admin/plugins/search-agent',
24
+ controllers.renderAdminPage
25
+ );
26
+
27
+ console.log('[search-agent] Admin route registered: /admin/plugins/search-agent');
28
+ winston.info('[plugins/search-agent] Initialised.');
29
+ };
30
+
31
+ /**
32
+ * static:api.routes
33
+ * Register the REST endpoint consumed by the floating chat widget.
34
+ *
35
+ * POST /api/v3/plugins/search-agent/query
36
+ * Body: { "query": "how do I reset my password?" }
37
+ * Returns a ranked list of matching forum topics.
38
+ *
39
+ * Authentication: open to all logged-in users (uses ensureLoggedIn).
40
+ * To allow guests as well, remove the middleware array below.
41
+ */
42
+ plugin.addRoutes = async ({ router, middleware, helpers }) => {
43
+ console.log('[search-agent] plugin.addRoutes called — registering API routes');
44
+ const middlewares = [
45
+ middleware.ensureLoggedIn,
46
+ ];
47
+
48
+ routeHelpers.setupApiRoute(
49
+ router,
50
+ 'post',
51
+ '/search-agent/query',
52
+ middlewares,
53
+ (req, res) => controllers.handleQuery(req, res, helpers)
54
+ );
55
+ console.log('[search-agent] API route registered: POST /api/v3/plugins/search-agent/query');
56
+
57
+ // Public config endpoint — no auth required so guests can read the visibility setting
58
+ routeHelpers.setupApiRoute(
59
+ router,
60
+ 'get',
61
+ '/search-agent/config',
62
+ [],
63
+ (req, res) => controllers.getConfig(req, res, helpers)
64
+ );
65
+ console.log('[search-agent] API route registered: GET /api/v3/plugins/search-agent/config');
66
+
67
+ // Lightweight cache-bust endpoint (admin only)
68
+ routeHelpers.setupApiRoute(
69
+ router,
70
+ 'post',
71
+ '/search-agent/cache/invalidate',
72
+ [middleware.ensureLoggedIn, middleware.admin.checkPrivileges],
73
+ (req, res) => {
74
+ console.log('[search-agent] Cache invalidation requested by uid:', req.uid);
75
+ invalidateCache();
76
+ helpers.formatApiResponse(200, res, { message: 'Cache invalidated.' });
77
+ }
78
+ );
79
+ console.log('[search-agent] API route registered: POST /api/v3/plugins/search-agent/cache/invalidate');
80
+ };
81
+
82
+ /**
83
+ * filter:admin.header.build
84
+ * Add the plugin entry to the ACP sidebar.
85
+ */
86
+ plugin.addAdminNavigation = (header) => {
87
+ console.log('[search-agent] plugin.addAdminNavigation called — adding ACP sidebar entry');
88
+ header.plugins.push({
89
+ route: '/plugins/search-agent',
90
+ icon: 'fa-comments',
91
+ name: 'Search Agent',
92
+ });
93
+
94
+ return header;
95
+ };
96
+
97
+ module.exports = plugin;
98
+
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "nodebb-plugin-search-agent",
3
+ "version": "0.0.1",
4
+ "description": "NodeBB plugin that adds a floating chat assistant to help users find relevant forum topics using TF-IDF text similarity",
5
+ "main": "library.js",
6
+ "author": "Racheli Bayfus",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/racheliK9201/nodebb-plugin-search-agent.git"
10
+ },
11
+ "scripts": {
12
+ "lint": "eslint ."
13
+ },
14
+ "keywords": [
15
+ "nodebb",
16
+ "plugin",
17
+ "search",
18
+ "chat",
19
+ "assistant",
20
+ "tfidf",
21
+ "similarity"
22
+ ],
23
+ "husky": {
24
+ "hooks": {
25
+ "pre-commit": "lint-staged",
26
+ "commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
27
+ }
28
+ },
29
+ "lint-staged": {
30
+ "*.js": [
31
+ "eslint --fix",
32
+ "git add"
33
+ ]
34
+ },
35
+ "license": "MIT",
36
+ "bugs": {
37
+ "url": "https://github.com/nodebb/nodebb-plugin-search-agent/issues"
38
+ },
39
+ "readmeFilename": "README.md",
40
+ "nbbpm": {
41
+ "compatibility": "^3.2.0"
42
+ },
43
+ "devDependencies": {
44
+ "@commitlint/cli": "20.5.0",
45
+ "@commitlint/config-angular": "20.5.0",
46
+ "eslint": "10.0.3",
47
+ "eslint-config-nodebb": "2.0.1",
48
+ "husky": "9.1.7",
49
+ "lint-staged": "16.4.0"
50
+ }
51
+ }
package/plugin.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "id": "nodebb-plugin-search-agent",
3
+ "name": "Search Agent",
4
+ "description": "Floating chat assistant that finds relevant forum topics using TF-IDF similarity",
5
+ "url": "https://github.com/NodeBB/nodebb-plugin-search-agent",
6
+ "library": "./library.js",
7
+ "hooks": [
8
+ { "hook": "static:app.load", "method": "init" },
9
+ { "hook": "static:api.routes", "method": "addRoutes" },
10
+ { "hook": "filter:admin.header.build", "method": "addAdminNavigation" }
11
+ ],
12
+ "staticDirs": {
13
+ "static": "./static"
14
+ },
15
+ "scss": [
16
+ "scss/search-agent.scss"
17
+ ],
18
+ "scripts": [
19
+ "public/lib/main.js"
20
+ ],
21
+ "acpScripts": [
22
+ "public/lib/acp-main.js"
23
+ ],
24
+ "modules": {
25
+ "../admin/plugins/search-agent.js": "./public/lib/admin.js"
26
+ },
27
+ "templates": "templates",
28
+ "languages": "languages"
29
+ }
@@ -0,0 +1,10 @@
1
+ <form>
2
+ <div class="mb-3">
3
+ <label class="form-label" for="name">Name</label>
4
+ <input type="text" id="name" name="name" class="form-control" placeholder="Name" />
5
+ </div>
6
+ <div class="mb-3">
7
+ <label class="form-label" for="description">Description</label>
8
+ <input type="text" id="description" name="description" class="form-control" placeholder="Description" />
9
+ </div>
10
+ </form>
@@ -0,0 +1,12 @@
1
+ <li data-type="item" class="list-group-item">
2
+ <div class="d-flex gap-2 justify-content-between align-items-start">
3
+ <div class="flex-grow-1">
4
+ <strong>{name}</strong><br />
5
+ <small>{description}</small>
6
+ </div>
7
+ <div class="d-flex gap-1 flex-nowrap">
8
+ <button type="button" data-type="edit" class="btn btn-sm btn-info">Edit</button>
9
+ <button type="button" data-type="remove" class="btn btn-sm btn-danger">Delete</button>
10
+ </div>
11
+ </div>
12
+ </li>
@@ -0,0 +1,93 @@
1
+ <div class="acp-page-container">
2
+ <!-- IMPORT admin/partials/settings/header.tpl -->
3
+
4
+ <div class="row m-0">
5
+ <div id="spy-container" class="col-12 col-md-8 px-0 mb-4" tabindex="0">
6
+ <form role="form" class="search-agent-settings">
7
+
8
+ <!-- ── General ─────────────────────────────────────────────── -->
9
+ <div class="mb-4">
10
+ <h5 class="fw-bold tracking-tight settings-header">General</h5>
11
+ <p class="lead">
12
+ Configure the Search Agent plugin behaviour.
13
+ Settings are persisted via <code>meta.settings.get('search-agent')</code>.
14
+ </p>
15
+
16
+ <div class="mb-3">
17
+ <label class="form-label" for="topicLimit">
18
+ Topics indexed (max topics fetched for similarity search)
19
+ </label>
20
+ <input
21
+ type="number"
22
+ min="50"
23
+ max="5000"
24
+ id="topicLimit"
25
+ name="topicLimit"
26
+ class="form-control"
27
+ placeholder="500"
28
+ value="500"
29
+ />
30
+ <div class="form-text">
31
+ Higher values improve recall but increase memory usage.
32
+ The index is rebuilt every 5 minutes.
33
+ </div>
34
+ </div>
35
+
36
+ <div class="mb-3">
37
+ <label class="form-label" for="maxResults">
38
+ Max results returned per query
39
+ </label>
40
+ <input
41
+ type="number"
42
+ min="1"
43
+ max="20"
44
+ id="maxResults"
45
+ name="maxResults"
46
+ class="form-control"
47
+ placeholder="10"
48
+ value="10"
49
+ />
50
+ </div>
51
+
52
+ <div class="form-check form-switch mb-3">
53
+ <input type="checkbox" class="form-check-input" id="guestsAllowed" name="guestsAllowed">
54
+ <label for="guestsAllowed" class="form-check-label">
55
+ Allow guests (non-logged-in users) to use the chat widget
56
+ </label>
57
+ </div>
58
+ </div>
59
+
60
+ <!-- ── Appearance ──────────────────────────────────────────── -->
61
+ <div class="mb-4">
62
+ <h5 class="fw-bold tracking-tight settings-header">Appearance</h5>
63
+
64
+ <div class="mb-3 d-flex gap-2 align-items-center">
65
+ <label class="form-label mb-0" for="primaryColor">Primary colour</label>
66
+ <input
67
+ data-settings="colorpicker"
68
+ type="color"
69
+ id="primaryColor"
70
+ name="primaryColor"
71
+ class="form-control p-1"
72
+ value="#0f7ee8"
73
+ style="width:64px;"
74
+ />
75
+ </div>
76
+ </div>
77
+
78
+ </form>
79
+ </div>
80
+
81
+ <!-- ── Sticky save / table of contents ─────────────────────────────── -->
82
+ <div class="col-12 col-md-4 px-0 mb-4 acp-sidebar">
83
+ <div class="card sticky-top">
84
+ <div class="card-header">Save Changes</div>
85
+ <div class="card-body">
86
+ <button class="btn btn-primary btn-sm fw-semibold" data-action="save">
87
+ Save Settings
88
+ </button>
89
+ </div>
90
+ </div>
91
+ </div>
92
+ </div>
93
+ </div>
@@ -0,0 +1,38 @@
1
+ 'use strict';
2
+
3
+ $(document).ready(function () {
4
+ /*
5
+ * Admin CP frontend for nodebb-plugin-search-agent.
6
+ * Handles saving settings via NodeBB's standard ACP settings API.
7
+ */
8
+ console.log('[search-agent] acp-main.js: DOM ready');
9
+
10
+ $(window).on('action:ajaxify.end', function (ev, data) {
11
+ console.log('[search-agent] acp-main.js: ajaxify.end — url:', data.url);
12
+ if (data.url === 'admin/plugins/search-agent') {
13
+ console.log('[search-agent] acp-main.js: search-agent admin page detected — running setupAdminPage()');
14
+ setupAdminPage();
15
+ }
16
+ });
17
+
18
+ function setupAdminPage() {
19
+ console.log('[search-agent] setupAdminPage: binding save button and loading current settings');
20
+ // NodeBB's settings module is auto-loaded for the ACP.
21
+ // The [data-action="save"] button triggers NodeBB's built-in settings save.
22
+ $('[data-action="save"]').on('click', function () {
23
+ console.log('[search-agent] setupAdminPage: save button clicked');
24
+ require(['settings'], function (Settings) {
25
+ Settings.save('search-agent', $('form.search-agent-settings'), function () {
26
+ console.log('[search-agent] setupAdminPage: settings saved successfully');
27
+ app.alertSuccess('Settings saved!');
28
+ });
29
+ });
30
+ });
31
+
32
+ require(['settings'], function (Settings) {
33
+ console.log('[search-agent] setupAdminPage: loading settings into form');
34
+ Settings.load('search-agent', $('form.search-agent-settings'));
35
+ });
36
+ }
37
+ });
38
+
@@ -0,0 +1,26 @@
1
+ 'use strict';
2
+
3
+ /*
4
+ This module is loaded when the user navigates to /admin/plugins/search-agent.
5
+ It handles loading and saving plugin settings via NodeBB's settings module.
6
+ */
7
+
8
+ import { save, load } from 'settings';
9
+
10
+ export function init() {
11
+ console.log('[search-agent] admin.js: init() called');
12
+ handleSettingsForm();
13
+ }
14
+
15
+ function handleSettingsForm() {
16
+ console.log('[search-agent] admin.js: handleSettingsForm() — loading settings into form');
17
+ // Load current settings into the form fields
18
+ load('search-agent', $('.search-agent-settings'));
19
+
20
+ // Save settings when the button is clicked
21
+ $('[data-action="save"]').on('click', () => {
22
+ console.log('[search-agent] admin.js: save button clicked — persisting settings');
23
+ save('search-agent', $('.search-agent-settings'));
24
+ });
25
+ }
26
+