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.
- package/.gitattributes +22 -0
- package/LICENSE +21 -0
- package/README.md +205 -0
- package/commitlint.config.js +26 -0
- package/eslint.config.mjs +10 -0
- package/languages/de/search-agent.json +15 -0
- package/languages/en-GB/search-agent.json +15 -0
- package/languages/en-US/search-agent.json +15 -0
- package/languages/he/search-agent.json +15 -0
- package/lib/controllers.js +69 -0
- package/lib/searchHandler.js +225 -0
- package/lib/similarity.js +125 -0
- package/library.js +98 -0
- package/nodebb-plugin-search-agent-0.0.1.tgz +0 -0
- package/package.json +51 -0
- package/plugin.json +29 -0
- package/plugins/quickstart/partials/sorted-list/form.tpl +10 -0
- package/plugins/quickstart/partials/sorted-list/item.tpl +12 -0
- package/plugins/search-agent.tpl +93 -0
- package/public/lib/acp-main.js +38 -0
- package/public/lib/admin.js +26 -0
- package/public/lib/main.js +281 -0
- package/renovate.json +5 -0
- package/scss/search-agent.scss +374 -0
- package/static/samplefile.html +5 -0
- package/templates/admin/plugins/search-agent.tpl +186 -0
- package/test/.eslintrc +9 -0
- package/test/index.js +128 -0
|
@@ -0,0 +1,186 @@
|
|
|
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
|
+
<!-- ── AI (OpenAI) ─────────────────────────────────────────── -->
|
|
61
|
+
<div class="mb-4">
|
|
62
|
+
<h5 class="fw-bold tracking-tight settings-header">AI-Powered Re-ranking (OpenAI)</h5>
|
|
63
|
+
<p class="lead">
|
|
64
|
+
When enabled, TF-IDF finds candidate topics and OpenAI selects the most
|
|
65
|
+
relevant ones for a smarter, semantically-aware response.
|
|
66
|
+
Leave the API key blank to use TF-IDF ranking only.
|
|
67
|
+
</p>
|
|
68
|
+
|
|
69
|
+
<div class="form-check form-switch mb-3">
|
|
70
|
+
<input type="checkbox" class="form-check-input" id="aiEnabled" name="aiEnabled">
|
|
71
|
+
<label for="aiEnabled" class="form-check-label">
|
|
72
|
+
Enable AI re-ranking
|
|
73
|
+
</label>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<div class="mb-3">
|
|
77
|
+
<label class="form-label" for="openaiApiKey">OpenAI API Key</label>
|
|
78
|
+
<input
|
|
79
|
+
type="password"
|
|
80
|
+
id="openaiApiKey"
|
|
81
|
+
name="openaiApiKey"
|
|
82
|
+
class="form-control"
|
|
83
|
+
placeholder="sk-…"
|
|
84
|
+
autocomplete="new-password"
|
|
85
|
+
/>
|
|
86
|
+
<div class="form-text">
|
|
87
|
+
Your secret key from
|
|
88
|
+
<a href="https://platform.openai.com/api-keys" target="_blank" rel="noopener noreferrer">
|
|
89
|
+
platform.openai.com/api-keys
|
|
90
|
+
</a>.
|
|
91
|
+
Stored server-side only; never sent to the client.
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<div class="mb-3">
|
|
96
|
+
<label class="form-label" for="openaiModel">OpenAI Model</label>
|
|
97
|
+
<input
|
|
98
|
+
type="text"
|
|
99
|
+
id="openaiModel"
|
|
100
|
+
name="openaiModel"
|
|
101
|
+
class="form-control"
|
|
102
|
+
placeholder="gpt-4o-mini"
|
|
103
|
+
value="gpt-4o-mini"
|
|
104
|
+
/>
|
|
105
|
+
<div class="form-text">
|
|
106
|
+
Recommended: <code>gpt-4o-mini</code> (fast and inexpensive).
|
|
107
|
+
Use <code>gpt-4o</code> for higher accuracy.
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
<div class="mb-3">
|
|
112
|
+
<label class="form-label" for="aiCandidates">
|
|
113
|
+
TF-IDF candidates sent to AI
|
|
114
|
+
</label>
|
|
115
|
+
<input
|
|
116
|
+
type="number"
|
|
117
|
+
min="5"
|
|
118
|
+
max="30"
|
|
119
|
+
id="aiCandidates"
|
|
120
|
+
name="aiCandidates"
|
|
121
|
+
class="form-control"
|
|
122
|
+
placeholder="20"
|
|
123
|
+
value="20"
|
|
124
|
+
/>
|
|
125
|
+
<div class="form-text">
|
|
126
|
+
Number of TF-IDF results passed to the AI for final selection.
|
|
127
|
+
Higher = more context for the AI, but slightly more tokens used.
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
<!-- ── Visibility ─────────────────────────────────────────── -->
|
|
133
|
+
<div class="mb-4">
|
|
134
|
+
<h5 class="fw-bold tracking-tight settings-header">Visibility</h5>
|
|
135
|
+
<p class="lead">
|
|
136
|
+
Control which users can see and interact with the Search Agent widget.
|
|
137
|
+
</p>
|
|
138
|
+
|
|
139
|
+
<div class="mb-3">
|
|
140
|
+
<label class="form-label" for="visibleTo">Show widget to</label>
|
|
141
|
+
<select id="visibleTo" name="visibleTo" class="form-select">
|
|
142
|
+
<option value="all">All logged-in users</option>
|
|
143
|
+
<option value="admins">Administrators only</option>
|
|
144
|
+
</select>
|
|
145
|
+
<div class="form-text">
|
|
146
|
+
When set to "Administrators only", the widget will only be shown to
|
|
147
|
+
users with administrator privileges. Non-admin requests to the API
|
|
148
|
+
will also be rejected.
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
<!-- ── Appearance ──────────────────────────────────────────── -->
|
|
154
|
+
<div class="mb-4">
|
|
155
|
+
<h5 class="fw-bold tracking-tight settings-header">Appearance</h5>
|
|
156
|
+
|
|
157
|
+
<div class="mb-3 d-flex gap-2 align-items-center">
|
|
158
|
+
<label class="form-label mb-0" for="primaryColor">Primary colour</label>
|
|
159
|
+
<input
|
|
160
|
+
data-settings="colorpicker"
|
|
161
|
+
type="color"
|
|
162
|
+
id="primaryColor"
|
|
163
|
+
name="primaryColor"
|
|
164
|
+
class="form-control p-1"
|
|
165
|
+
value="#0f7ee8"
|
|
166
|
+
style="width:64px;"
|
|
167
|
+
/>
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
</form>
|
|
172
|
+
</div>
|
|
173
|
+
|
|
174
|
+
<!-- ── Sticky save / table of contents ─────────────────────────────── -->
|
|
175
|
+
<div class="col-12 col-md-4 px-0 mb-4 acp-sidebar">
|
|
176
|
+
<div class="card sticky-top">
|
|
177
|
+
<div class="card-header">Save Changes</div>
|
|
178
|
+
<div class="card-body">
|
|
179
|
+
<button class="btn btn-primary btn-sm fw-semibold" data-action="save">
|
|
180
|
+
Save Settings
|
|
181
|
+
</button>
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
package/test/.eslintrc
ADDED
package/test/index.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run these tests from the NodeBB root:
|
|
3
|
+
* npx mocha test/plugins-installed.js
|
|
4
|
+
*
|
|
5
|
+
* Or run just the similarity unit tests (no NodeBB runtime needed):
|
|
6
|
+
* npx mocha node_modules/nodebb-plugin-search-agent/test/index.js
|
|
7
|
+
*
|
|
8
|
+
* To include integration tests that exercise routes / hooks, add the plugin
|
|
9
|
+
* to config.json:
|
|
10
|
+
* { "test_plugins": ["nodebb-plugin-search-agent"] }
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
/* globals describe, it, before */
|
|
16
|
+
|
|
17
|
+
const assert = require('assert');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
|
|
20
|
+
// The similarity module has no NodeBB dependencies – safe to require directly.
|
|
21
|
+
const { tokenize, buildIndex, query } = require(path.join(__dirname, '../lib/similarity'));
|
|
22
|
+
|
|
23
|
+
// ─── Unit: tokeniser ─────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
describe('similarity.tokenize', () => {
|
|
26
|
+
it('lowercases input', () => {
|
|
27
|
+
assert.deepStrictEqual(tokenize('Hello World'), ['hello', 'world']);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('strips HTML tags', () => {
|
|
31
|
+
const tokens = tokenize('<p>Some <b>bold</b> text</p>');
|
|
32
|
+
assert.ok(tokens.includes('some'));
|
|
33
|
+
assert.ok(tokens.includes('bold'));
|
|
34
|
+
assert.ok(tokens.includes('text'));
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('removes stop-words', () => {
|
|
38
|
+
const tokens = tokenize('how do I reset my password');
|
|
39
|
+
assert.ok(!tokens.includes('how'));
|
|
40
|
+
assert.ok(!tokens.includes('do'));
|
|
41
|
+
assert.ok(!tokens.includes('my'));
|
|
42
|
+
assert.ok(tokens.includes('reset'));
|
|
43
|
+
assert.ok(tokens.includes('password'));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('drops tokens shorter than 3 characters', () => {
|
|
47
|
+
assert.deepStrictEqual(tokenize('a ab abc abcd'), ['abc', 'abcd']);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('returns empty array for empty string', () => {
|
|
51
|
+
assert.deepStrictEqual(tokenize(''), []);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('returns empty array for null/undefined', () => {
|
|
55
|
+
assert.deepStrictEqual(tokenize(null), []);
|
|
56
|
+
assert.deepStrictEqual(tokenize(undefined), []);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// ─── Unit: index building ────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
describe('similarity.buildIndex', () => {
|
|
63
|
+
it('returns empty array for empty input', () => {
|
|
64
|
+
assert.deepStrictEqual(buildIndex([]), []);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('builds an entry per topic', () => {
|
|
68
|
+
const topics = [
|
|
69
|
+
{ tid: 1, title: 'Password reset guide', slug: '1/password-reset', mainPostContent: 'How to reset your password.' },
|
|
70
|
+
{ tid: 2, title: 'Account settings', slug: '2/account-settings', mainPostContent: 'Manage your account.' },
|
|
71
|
+
];
|
|
72
|
+
const index = buildIndex(topics);
|
|
73
|
+
assert.strictEqual(index.length, 2);
|
|
74
|
+
assert.ok(index[0].vector instanceof Map);
|
|
75
|
+
assert.ok(index[0].vector.size > 0);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('preserves tid and slug', () => {
|
|
79
|
+
const topics = [{ tid: 42, title: 'Test topic', slug: '42/test-topic', mainPostContent: '' }];
|
|
80
|
+
const [entry] = buildIndex(topics);
|
|
81
|
+
assert.strictEqual(entry.tid, 42);
|
|
82
|
+
assert.strictEqual(entry.slug, '42/test-topic');
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// ─── Unit: query ranking ──────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
describe('similarity.query', () => {
|
|
89
|
+
const topics = [
|
|
90
|
+
{ tid: 1, title: 'Password reset guide', slug: '1/password-reset', mainPostContent: 'Steps to reset a forgotten password.' },
|
|
91
|
+
{ tid: 2, title: 'How to change email', slug: '2/change-email', mainPostContent: 'Update your account email address.' },
|
|
92
|
+
{ tid: 3, title: 'NodeBB installation guide', slug: '3/nodebb-installation', mainPostContent: 'Install NodeBB on Ubuntu server.' },
|
|
93
|
+
];
|
|
94
|
+
const index = buildIndex(topics);
|
|
95
|
+
|
|
96
|
+
it('returns an array', () => {
|
|
97
|
+
const results = query('password reset', index);
|
|
98
|
+
assert.ok(Array.isArray(results));
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('returns the most relevant topic first', () => {
|
|
102
|
+
const results = query('password reset forgotten', index);
|
|
103
|
+
assert.ok(results.length > 0);
|
|
104
|
+
assert.strictEqual(results[0].tid, 1, 'Password reset topic should rank first');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('returns empty array for blank query', () => {
|
|
108
|
+
assert.deepStrictEqual(query('', index), []);
|
|
109
|
+
assert.deepStrictEqual(query(' ', index), []);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('returns empty array when index is empty', () => {
|
|
113
|
+
assert.deepStrictEqual(query('anything', []), []);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('respects topN limit', () => {
|
|
117
|
+
const all = query('guide', index, 1);
|
|
118
|
+
assert.ok(all.length <= 1);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('scores are between 0 and 1', () => {
|
|
122
|
+
const results = query('installation guide', index, 10);
|
|
123
|
+
for (const r of results) {
|
|
124
|
+
assert.ok(r.score >= 0 && r.score <= 1, `score ${r.score} out of range`);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|