nodebb-plugin-search-agent 0.0.7 → 0.0.9
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/lib/controllers.js +2 -2
- package/lib/searchHandler.js +33 -12
- package/package.json +1 -1
- package/public/lib/main.js +4 -4
- package/scss/search-agent.scss +4 -2
- package/services/vectorSearchService.js +1 -1
package/lib/controllers.js
CHANGED
|
@@ -38,8 +38,8 @@ controllers.handleQuery = async function (req, res, helpers) {
|
|
|
38
38
|
if (!queryText) {
|
|
39
39
|
return helpers.formatApiResponse(400, res, new Error('Missing or empty "query" field.'));
|
|
40
40
|
}
|
|
41
|
-
if (queryText.length >
|
|
42
|
-
return helpers.formatApiResponse(400, res, new Error('Query exceeds maximum length of
|
|
41
|
+
if (queryText.length > 2000) {
|
|
42
|
+
return helpers.formatApiResponse(400, res, new Error('Query exceeds maximum length of 2000 characters.'));
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
try {
|
package/lib/searchHandler.js
CHANGED
|
@@ -24,12 +24,12 @@ async function getSettings() {
|
|
|
24
24
|
const raw = (await meta.settings.get('search-agent')) || {};
|
|
25
25
|
return {
|
|
26
26
|
topicLimit: Math.max(50, parseInt(raw.topicLimit, 10) || 500),
|
|
27
|
-
maxResults: Math.min(
|
|
27
|
+
maxResults: Math.min(50, Math.max(1, parseInt(raw.maxResults, 10) || 10)),
|
|
28
28
|
aiEnabled: raw.aiEnabled === 'on',
|
|
29
29
|
openaiApiKey: (raw.openaiApiKey || '').trim(),
|
|
30
30
|
openaiModel: (raw.openaiModel || 'gpt-4o-mini').trim(),
|
|
31
31
|
// How many TF-IDF candidates to send to AI for re-ranking
|
|
32
|
-
aiCandidates: Math.min(
|
|
32
|
+
aiCandidates: Math.min(100, Math.max(5, parseInt(raw.aiCandidates, 10) || 30)),
|
|
33
33
|
// Visibility: 'all' = all logged-in users, 'admins' = administrators only
|
|
34
34
|
visibleTo: raw.visibleTo || 'all',
|
|
35
35
|
// Whether guests (non-logged-in users) may use the widget
|
|
@@ -84,9 +84,18 @@ async function getIndex(topicLimit) {
|
|
|
84
84
|
|
|
85
85
|
// ─── OpenAI helper ────────────────────────────────────────────────────────────
|
|
86
86
|
|
|
87
|
+
// Reasoning models (o1, o3, o4 series) do not support temperature != default.
|
|
88
|
+
function isReasoningModel(model) {
|
|
89
|
+
return /^o\d/i.test(model);
|
|
90
|
+
}
|
|
91
|
+
|
|
87
92
|
function callOpenAI(apiKey, model, messages) {
|
|
88
93
|
return new Promise((resolve, reject) => {
|
|
89
|
-
const
|
|
94
|
+
const payload = { model, messages };
|
|
95
|
+
if (!isReasoningModel(model)) {
|
|
96
|
+
payload.temperature = 0;
|
|
97
|
+
}
|
|
98
|
+
const body = JSON.stringify(payload);
|
|
90
99
|
const options = {
|
|
91
100
|
hostname: 'api.openai.com',
|
|
92
101
|
path: '/v1/chat/completions',
|
|
@@ -157,8 +166,8 @@ async function reRankWithAI(queryText, candidates, topicMap, apiKey, model, maxR
|
|
|
157
166
|
const candidateList = candidates
|
|
158
167
|
.map((c) => {
|
|
159
168
|
const title = (topicMap[String(c.tid)] || {}).title || '';
|
|
160
|
-
const raw = (snippetByTid[String(c.tid)] || '').replace(/<[^>]*>/g, ' ').replace(
|
|
161
|
-
const snippet = raw.length > 0 ? `\n תוכן: "${raw.slice(0,
|
|
169
|
+
const raw = (snippetByTid[String(c.tid)] || '').replace(/<[^>]*>/g, ' ').replace(/[ \t]+/g, ' ').trim();
|
|
170
|
+
const snippet = raw.length > 0 ? `\n תוכן: "${raw.slice(0, 1500)}"` : '';
|
|
162
171
|
return `[tid:${c.tid}] ${title}${snippet}`;
|
|
163
172
|
})
|
|
164
173
|
.join('\n\n');
|
|
@@ -243,14 +252,17 @@ async function searchTopics(queryText) {
|
|
|
243
252
|
topics.filter(t => t && t.tid && !t.deleted).map(t => [String(t.tid), t])
|
|
244
253
|
);
|
|
245
254
|
|
|
246
|
-
// Build snippet map:
|
|
247
|
-
//
|
|
248
|
-
const
|
|
255
|
+
// Build snippet map: collect ALL matched post contents per topic so the AI
|
|
256
|
+
// sees full-topic context, not just the first hit.
|
|
257
|
+
const contentsByTid = {}; // tid -> ordered array of matched post contents
|
|
249
258
|
for (const r of vectorResults) {
|
|
250
259
|
const key = String(r.topic_id);
|
|
251
|
-
if (!
|
|
260
|
+
if (!contentsByTid[key]) contentsByTid[key] = [];
|
|
261
|
+
if (r.content) contentsByTid[key].push(r.content);
|
|
252
262
|
}
|
|
253
|
-
|
|
263
|
+
// Prepend the main post so the AI sees the topic's opening question first,
|
|
264
|
+
// then the matched reply posts follow (which may contain the answer).
|
|
265
|
+
const snippetByTid = {};
|
|
254
266
|
const topicsWithMainPid = topics.filter(t => t && t.tid && !t.deleted && t.mainPid);
|
|
255
267
|
if (topicsWithMainPid.length > 0) {
|
|
256
268
|
const mainContents = await Posts.getPostsFields(
|
|
@@ -258,8 +270,17 @@ async function searchTopics(queryText) {
|
|
|
258
270
|
['pid', 'content']
|
|
259
271
|
);
|
|
260
272
|
for (let i = 0; i < topicsWithMainPid.length; i++) {
|
|
261
|
-
const
|
|
262
|
-
|
|
273
|
+
const mainContent = mainContents[i] && mainContents[i].content;
|
|
274
|
+
const tid = String(topicsWithMainPid[i].tid);
|
|
275
|
+
const matched = (contentsByTid[tid] || []).filter(s => s !== mainContent);
|
|
276
|
+
const parts = [mainContent, ...matched].filter(Boolean);
|
|
277
|
+
if (parts.length > 0) snippetByTid[tid] = parts.join('\n\n');
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
// Topics without a mainPid — use matched posts only
|
|
281
|
+
for (const [tid, contents] of Object.entries(contentsByTid)) {
|
|
282
|
+
if (!snippetByTid[tid] && contents.length > 0) {
|
|
283
|
+
snippetByTid[tid] = contents.join('\n\n');
|
|
263
284
|
}
|
|
264
285
|
}
|
|
265
286
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nodebb-plugin-search-agent",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.9",
|
|
4
4
|
"description": "NodeBB plugin that adds a floating chat assistant to help users find relevant forum topics using TF-IDF text similarity",
|
|
5
5
|
"main": "library.js",
|
|
6
6
|
"author": "Racheli Bayfus",
|
package/public/lib/main.js
CHANGED
|
@@ -102,14 +102,14 @@ function buildPanelHtml() {
|
|
|
102
102
|
</div>
|
|
103
103
|
|
|
104
104
|
<div class="search-agent-panel__footer">
|
|
105
|
-
<
|
|
105
|
+
<textarea
|
|
106
106
|
id="search-agent-input"
|
|
107
107
|
class="search-agent-input"
|
|
108
|
-
|
|
108
|
+
rows="2"
|
|
109
109
|
placeholder="[[search-agent:input-placeholder]]"
|
|
110
|
-
maxlength="
|
|
110
|
+
maxlength="2000"
|
|
111
111
|
autocomplete="off"
|
|
112
|
-
|
|
112
|
+
></textarea>
|
|
113
113
|
<button id="search-agent-send" class="search-agent-send" aria-label="[[search-agent:send-label]]">
|
|
114
114
|
<i class="fa fa-paper-plane"></i>
|
|
115
115
|
</button>
|
package/scss/search-agent.scss
CHANGED
|
@@ -174,7 +174,7 @@ $sa-transition: 0.25s ease;
|
|
|
174
174
|
|
|
175
175
|
&__footer {
|
|
176
176
|
display: flex;
|
|
177
|
-
align-items:
|
|
177
|
+
align-items: flex-end;
|
|
178
178
|
gap: 8px;
|
|
179
179
|
padding: 10px 12px;
|
|
180
180
|
border-top: 1px solid $sa-border;
|
|
@@ -187,11 +187,13 @@ $sa-transition: 0.25s ease;
|
|
|
187
187
|
.search-agent-input {
|
|
188
188
|
flex: 1;
|
|
189
189
|
border: 1px solid $sa-border;
|
|
190
|
-
border-radius:
|
|
190
|
+
border-radius: 12px;
|
|
191
191
|
padding: 8px 14px;
|
|
192
192
|
font-size: 14px;
|
|
193
193
|
color: $sa-text-dark;
|
|
194
194
|
outline: none;
|
|
195
|
+
resize: none;
|
|
196
|
+
line-height: 1.4;
|
|
195
197
|
transition: border-color $sa-transition, box-shadow $sa-transition;
|
|
196
198
|
|
|
197
199
|
&:focus {
|
|
@@ -9,7 +9,7 @@ function winston() {
|
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
// Fetch this many candidates from Orama — cast a wide net so the AI has enough to choose from
|
|
12
|
-
const TOP_K =
|
|
12
|
+
const TOP_K = 50;
|
|
13
13
|
// Absolute minimum cosine similarity — only filters pure noise (near-zero similarity).
|
|
14
14
|
// Do NOT raise this: the relevant result often scores lower than irrelevant ones.
|
|
15
15
|
// The AI re-ranker (which reads content) is the precision gate, not this floor.
|