@jasonwch/nodebb-plugin-meilisearch-r 1.1.1 → 1.2.0
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 +29 -28
- package/lib/alerts.js +24 -0
- package/lib/chat-search-global.js +221 -0
- package/lib/client.js +445 -0
- package/lib/constants.js +42 -0
- package/lib/defaults.js +73 -0
- package/lib/embedder.js +297 -0
- package/lib/hooks/messages.js +49 -0
- package/lib/hooks/posts.js +180 -0
- package/lib/hooks/topics.js +210 -0
- package/lib/pending-queue.js +69 -0
- package/lib/reindex.js +311 -0
- package/lib/routes.js +129 -0
- package/lib/search.js +230 -0
- package/lib/settings-helpers.js +15 -0
- package/lib/settings.js +119 -0
- package/lib/state.js +69 -0
- package/library.js +17 -1555
- package/package.json +1 -1
- package/static/languages/en-GB/meilisearch.json +112 -79
- package/static/languages/he/meilisearch.json +113 -0
- package/static/languages/pl/meilisearch.json +34 -1
- package/static/languages/zh-CN/meilisearch.json +34 -1
- package/static/lib/admin.js +317 -2
- package/static/lib/client/quick-search.js +1 -1
- package/static/templates/admin/plugins/meilisearch.tpl +289 -222
package/lib/routes.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const winston = nodebb.require('winston');
|
|
4
|
+
const routeHelpers = nodebb.require('./src/routes/helpers');
|
|
5
|
+
const settings = nodebb.require('./src/meta/settings');
|
|
6
|
+
|
|
7
|
+
// Plugin lifecycle + HTTP surface: static:app.load init, the reindex REST API,
|
|
8
|
+
// the ACP nav entry, and the admin-notices health line.
|
|
9
|
+
module.exports = function attachRoutes(plugin) {
|
|
10
|
+
plugin.init = async function (params) {
|
|
11
|
+
const { router } = params;
|
|
12
|
+
winston.debug('[plugin/meilisearch] Initializing MeiliSearch plugin');
|
|
13
|
+
routeHelpers.setupAdminPageRoute(router, '/admin/plugins/meilisearch', [], async (req, res) => {
|
|
14
|
+
const topicTotal = Number(plugin.indexing.topic_progress.total) || 0;
|
|
15
|
+
const topicCurrent = Number(plugin.indexing.topic_progress.current) || 0;
|
|
16
|
+
const postTotal = Number(plugin.indexing.post_progress.total) || 0;
|
|
17
|
+
const postCurrent = Number(plugin.indexing.post_progress.current) || 0;
|
|
18
|
+
const messageTotal = Number(plugin.indexing.message_progress.total) || 0;
|
|
19
|
+
const messageCurrent = Number(plugin.indexing.message_progress.current) || 0;
|
|
20
|
+
const topicPercent = topicTotal > 0 ? Math.min(100, Math.max(0, Math.round(100 * topicCurrent / topicTotal))) : 0;
|
|
21
|
+
const postPercent = postTotal > 0 ? Math.min(100, Math.max(0, Math.round(100 * postCurrent / postTotal))) : 0;
|
|
22
|
+
const messagePercent = messageTotal > 0 ? Math.min(100, Math.max(0, Math.round(100 * messageCurrent / messageTotal))) : 0;
|
|
23
|
+
const lastReindexResult = await settings.getOne(plugin.id, 'lastReindexResult') || {};
|
|
24
|
+
res.render('admin/plugins/meilisearch', {
|
|
25
|
+
title: '[[meilisearch:admin.settings]]',
|
|
26
|
+
indexing: plugin.indexing,
|
|
27
|
+
topicPercent,
|
|
28
|
+
postPercent,
|
|
29
|
+
messagePercent,
|
|
30
|
+
lastReindexResult,
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
await settings.setOnEmpty(plugin.id, plugin.defaults);
|
|
34
|
+
await plugin.prepareSearch();
|
|
35
|
+
await plugin.ensureChatMessageIndex();
|
|
36
|
+
plugin.patchTopicsSearch();
|
|
37
|
+
plugin.initialized = true;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
plugin.addRoutes = async function ({ router, middleware, helpers }) {
|
|
41
|
+
const middlewares = [
|
|
42
|
+
middleware.ensureLoggedIn,
|
|
43
|
+
middleware.admin.checkPrivileges,
|
|
44
|
+
];
|
|
45
|
+
routeHelpers.setupApiRoute(router, 'get', '/meilisearch/reindex', middlewares, async (req, res) => {
|
|
46
|
+
helpers.formatApiResponse(200, res, {
|
|
47
|
+
indexing: plugin.indexing,
|
|
48
|
+
healthy: plugin.healthy,
|
|
49
|
+
lastReindexResult: await settings.getOne(plugin.id, 'lastReindexResult') || {},
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
routeHelpers.setupApiRoute(router, 'post', '/meilisearch/reindex', middlewares, async (req, res) => {
|
|
54
|
+
await settings.set(plugin.id, { indexed: false }, true);
|
|
55
|
+
plugin.reindex(false);
|
|
56
|
+
helpers.formatApiResponse(202, res, plugin.indexing);
|
|
57
|
+
});
|
|
58
|
+
routeHelpers.setupApiRoute(router, 'delete', '/meilisearch/reindex', middlewares, async (req, res) => {
|
|
59
|
+
await settings.set(plugin.id, { indexed: false }, true);
|
|
60
|
+
plugin.reindex(true);
|
|
61
|
+
helpers.formatApiResponse(202, res, plugin.indexing);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Pure A: Ollama reachability probe. NodeBB container makes a 5s HTTP GET to
|
|
65
|
+
// <url>/api/version (or http://localhost:11434/api/version if URL is blank — the
|
|
66
|
+
// lib/embedder.js default). Used by static/lib/admin.js saveSettings to show a
|
|
67
|
+
// modal when Ollama is unreachable from NodeBB. Test runs from NodeBB's container,
|
|
68
|
+
// not Meilisearch's — admin is informed of this in the modal text so they can
|
|
69
|
+
// override with "Save anyway" if they're confident Meilisearch can reach Ollama
|
|
70
|
+
// on a different network path (e.g. Meilisearch runs with --network=host).
|
|
71
|
+
routeHelpers.setupApiRoute(router, 'post', '/meilisearch/test-ollama', middlewares, async (req, res) => {
|
|
72
|
+
const rawUrl = (req.body && typeof req.body.url === 'string') ? req.body.url : '';
|
|
73
|
+
// Default URL mirrors lib/embedder.js ollama fallback
|
|
74
|
+
const ollamaUrl = rawUrl || 'http://localhost:11434/api/embeddings';
|
|
75
|
+
// Strip /api/embeddings suffix if admin included it, then append /api/version probe path
|
|
76
|
+
const ollamaBase = ollamaUrl.replace(/\/api\/embeddings\/?$/, '').replace(/\/$/, '');
|
|
77
|
+
const probeUrl = `${ollamaBase}/api/version`;
|
|
78
|
+
// Declare controller/timeout above the try block so the finally clause can always
|
|
79
|
+
// clear the timer — even if the synchronous URL parsing or fetch setup throws
|
|
80
|
+
// before the await resolves (e.g. malformed URL → `new URL()` throws inside fetch).
|
|
81
|
+
const controller = new AbortController();
|
|
82
|
+
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
83
|
+
try {
|
|
84
|
+
const response = await fetch(probeUrl, { signal: controller.signal });
|
|
85
|
+
if (!response.ok) {
|
|
86
|
+
return helpers.formatApiResponse(200, res, {
|
|
87
|
+
reachable: false,
|
|
88
|
+
error: `Ollama responded HTTP ${response.status} ${response.statusText}`,
|
|
89
|
+
probeUrl,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
const data = await response.json().catch(() => ({}));
|
|
93
|
+
return helpers.formatApiResponse(200, res, {
|
|
94
|
+
reachable: true,
|
|
95
|
+
version: data.version,
|
|
96
|
+
probeUrl,
|
|
97
|
+
});
|
|
98
|
+
} catch (err) {
|
|
99
|
+
return helpers.formatApiResponse(200, res, {
|
|
100
|
+
reachable: false,
|
|
101
|
+
error: err.name === 'AbortError' ? 'Timeout after 5s' : err.message,
|
|
102
|
+
probeUrl,
|
|
103
|
+
});
|
|
104
|
+
} finally {
|
|
105
|
+
clearTimeout(timeout);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
plugin.addAdminNavigation = function (header) {
|
|
111
|
+
header.plugins.push({
|
|
112
|
+
route: '/plugins/meilisearch',
|
|
113
|
+
icon: 'fa-tint',
|
|
114
|
+
name: 'Meilisearch',
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
return header;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
plugin.getNotices = async function (notices) {
|
|
121
|
+
const checkHealth = await plugin.checkHealth();
|
|
122
|
+
notices.push({
|
|
123
|
+
done: checkHealth,
|
|
124
|
+
doneText: 'MeiliSearch connection OK',
|
|
125
|
+
notDoneText: 'Could not connect to MeiliSearch',
|
|
126
|
+
});
|
|
127
|
+
return notices;
|
|
128
|
+
};
|
|
129
|
+
};
|
package/lib/search.js
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const winston = nodebb.require('winston');
|
|
4
|
+
const settings = nodebb.require('./src/meta/settings');
|
|
5
|
+
const plugins = nodebb.require('./src/plugins');
|
|
6
|
+
const { resolveMinTermLength } = require('./settings-helpers');
|
|
7
|
+
const { EMBEDDER_NAME, isSemanticSearchEnabled, getSemanticRatio, getSemanticScoreThreshold } = require('./embedder');
|
|
8
|
+
|
|
9
|
+
// rankingScoreThreshold is a top-level search parameter (a sibling of "hybrid", not nested
|
|
10
|
+
// inside it) - Meilisearch defaults it to 0 (no filtering) when omitted, which is why
|
|
11
|
+
// unfiltered vector search returns its full `limit` regardless of how unrelated the tail is.
|
|
12
|
+
async function buildSemanticSearchExtras(plugin) {
|
|
13
|
+
if (!(await isSemanticSearchEnabled(plugin))) return {};
|
|
14
|
+
return {
|
|
15
|
+
hybrid: { embedder: EMBEDDER_NAME, semanticRatio: await getSemanticRatio(plugin) },
|
|
16
|
+
rankingScoreThreshold: await getSemanticScoreThreshold(plugin),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Runs a Meilisearch query and never lets it throw past this point: a broken/misconfigured
|
|
21
|
+
// embedder (e.g. "Cannot find embedder with name `default`") must not take down normal
|
|
22
|
+
// keyword search or callers like Topics.getSuggestedTopics, which propagate rejections
|
|
23
|
+
// straight up through NodeBB's hook chain. If hybrid search fails, retries once as a plain
|
|
24
|
+
// keyword search so users still get results instead of nothing; if that also fails (e.g.
|
|
25
|
+
// Meilisearch itself is down), logs + alerts once (throttled) and returns null so the
|
|
26
|
+
// caller can fall back to its original, unmodified data.
|
|
27
|
+
async function safeSearch(plugin, indexName, term, opts) {
|
|
28
|
+
try {
|
|
29
|
+
return await plugin.client.index(indexName).search(term, opts);
|
|
30
|
+
} catch (err) {
|
|
31
|
+
const reason = err?.message || String(err);
|
|
32
|
+
if (opts.hybrid) {
|
|
33
|
+
winston.error(`[plugin/meilisearch] Hybrid search on "${indexName}" failed (${reason}); falling back to keyword-only search`);
|
|
34
|
+
plugin.notifyAdmins(`search:${indexName}`, {
|
|
35
|
+
type: 'danger',
|
|
36
|
+
titleKey: '[[meilisearch:admin.semanticSearchDegraded]]',
|
|
37
|
+
message: `${indexName}: ${reason}`,
|
|
38
|
+
});
|
|
39
|
+
// rankingScoreThreshold is tuned for hybrid/vector scores - drop it too, or a
|
|
40
|
+
// keyword-only fallback could wrongly filter out valid BM25-style matches.
|
|
41
|
+
const { hybrid, rankingScoreThreshold, ...keywordOnly } = opts;
|
|
42
|
+
try {
|
|
43
|
+
return await plugin.client.index(indexName).search(term, keywordOnly);
|
|
44
|
+
} catch (fallbackErr) {
|
|
45
|
+
winston.error(`[plugin/meilisearch] Keyword-only fallback search on "${indexName}" also failed: ${fallbackErr.message}`);
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
winston.error(`[plugin/meilisearch] Search on "${indexName}" failed: ${reason}`);
|
|
50
|
+
plugin.notifyAdmins(`search:${indexName}`, {
|
|
51
|
+
type: 'danger',
|
|
52
|
+
titleKey: '[[meilisearch:admin.semanticSearchDegraded]]',
|
|
53
|
+
message: `${indexName}: ${reason}`,
|
|
54
|
+
});
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Core search integration: filter:search.query / filter:topic.search / filter:messaging.searchMessages.
|
|
60
|
+
module.exports = function attachSearch(plugin) {
|
|
61
|
+
plugin.checkConflict = function () {
|
|
62
|
+
const hooksToCheck = [
|
|
63
|
+
'filter:search.query',
|
|
64
|
+
'filter:topic.search',
|
|
65
|
+
'filter:messaging.searchMessages',
|
|
66
|
+
];
|
|
67
|
+
// blacklist, in case someone makes a plugin using these hooks that doesn't conflict.
|
|
68
|
+
// also, outside of dbsearch the user is expected to realize they installed two search plugins.
|
|
69
|
+
const conflictingPlugins = [
|
|
70
|
+
'nodebb-plugin-dbsearch',
|
|
71
|
+
'nodebb-plugin-solr',
|
|
72
|
+
'nodebb-plugin-elasticsearch',
|
|
73
|
+
'nodebb-plugin-search-elasticsearch',
|
|
74
|
+
];
|
|
75
|
+
for (const hook of hooksToCheck) {
|
|
76
|
+
if ((plugins.loadedHooks[hook] || []).filter(hookData => conflictingPlugins.includes(hookData.id)).length >= 1) {
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
plugin.searchMessages = async function (data) {
|
|
84
|
+
if (!data || !data.content) {
|
|
85
|
+
return data;
|
|
86
|
+
}
|
|
87
|
+
if (plugin.checkConflict()) {
|
|
88
|
+
winston.warn('[plugin/meilisearch] Another search plugin (most likely dbsearch) is enabled, so chat search via Meilisearch was aborted.');
|
|
89
|
+
return data;
|
|
90
|
+
}
|
|
91
|
+
if (!plugin.healthy && !(await plugin.checkHealth())) {
|
|
92
|
+
winston.warn('[plugin/meilisearch] Meilisearch instance did not return a healthy response, so chat search via Meilisearch was aborted.');
|
|
93
|
+
return data;
|
|
94
|
+
}
|
|
95
|
+
const rawQuery = String(data.content).trim();
|
|
96
|
+
const minTermLength = await resolveMinTermLength(plugin);
|
|
97
|
+
if (!rawQuery || !rawQuery.split(' ').some(word => word.length >= minTermLength)) {
|
|
98
|
+
winston.debug(`[plugin/meilisearch] Skipping chat search: no query word >= ${minTermLength} char(s)`);
|
|
99
|
+
return data;
|
|
100
|
+
}
|
|
101
|
+
const num = (v) => {
|
|
102
|
+
const n = parseInt(v, 10);
|
|
103
|
+
return Number.isFinite(n) ? n : null;
|
|
104
|
+
};
|
|
105
|
+
const filter = [];
|
|
106
|
+
// Core passes roomId/uid as arrays (filter:messaging.searchMessages payload in src/api/search.js).
|
|
107
|
+
const roomIds = Array.isArray(data.roomId) ? data.roomId : [data.roomId];
|
|
108
|
+
const roomNums = roomIds.map(num).filter(r => r !== null);
|
|
109
|
+
// Fail closed: if no valid roomId, return no results rather than searching globally across all rooms.
|
|
110
|
+
if (!roomNums.length) {
|
|
111
|
+
winston.warn('[plugin/meilisearch] searchMessages called with no valid roomId; returning no results');
|
|
112
|
+
return data;
|
|
113
|
+
}
|
|
114
|
+
filter.push(roomNums.map(rid => `roomId = ${rid}`));
|
|
115
|
+
// uid is an optional sender filter (message author, not the searcher); never populated by core callers.
|
|
116
|
+
const uids = Array.isArray(data.uid) ? data.uid : [data.uid];
|
|
117
|
+
const uidNums = uids.map(num).filter(u => u !== null);
|
|
118
|
+
if (uidNums.length) {
|
|
119
|
+
filter.push(uidNums.map(u => `uid = ${u}`));
|
|
120
|
+
}
|
|
121
|
+
const limit = parseInt(await settings.getOne(plugin.id, 'maxDocuments') || 100, 10);
|
|
122
|
+
winston.debug(`[plugin/meilisearch] Searching chat messages for "${rawQuery}" in room ${roomNums.join(',')}`);
|
|
123
|
+
const result = await safeSearch(plugin, 'chat_message', rawQuery, {
|
|
124
|
+
attributesToRetrieve: ['mid'],
|
|
125
|
+
limit: Math.min(limit, 100),
|
|
126
|
+
filter: filter.length ? filter : undefined,
|
|
127
|
+
matchingStrategy: data.matchWords === 'all' ? 'all' : 'last',
|
|
128
|
+
...(await buildSemanticSearchExtras(plugin)),
|
|
129
|
+
});
|
|
130
|
+
if (!result) return data;
|
|
131
|
+
data.ids = data.ids.concat(result.hits.map(hit => hit.mid));
|
|
132
|
+
return data;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
plugin.search = async function (data) {
|
|
136
|
+
if (plugin.checkConflict()) {
|
|
137
|
+
// The dbsearch plugin was detected, abort search!
|
|
138
|
+
winston.warn(
|
|
139
|
+
'[plugin/meilisearch] Another search plugin (most likely dbsearch) is enabled, so search via Meilisearch was aborted.',
|
|
140
|
+
);
|
|
141
|
+
return data;
|
|
142
|
+
}
|
|
143
|
+
if (!plugin.healthy && !(await plugin.checkHealth())) {
|
|
144
|
+
winston.warn(
|
|
145
|
+
'[plugin/meilisearch] Meilisearch instance did not return a healthy response, so search via Meilisearch was aborted.',
|
|
146
|
+
);
|
|
147
|
+
return data;
|
|
148
|
+
}
|
|
149
|
+
const rawQuery = (data.term || data.content || '').trim();
|
|
150
|
+
const minTermLength = await resolveMinTermLength(plugin);
|
|
151
|
+
if (!rawQuery || !rawQuery.split(' ').some(word => word.length >= minTermLength)) {
|
|
152
|
+
winston.debug(`[plugin/meilisearch] Skipping search: no query word >= ${minTermLength} char(s)`);
|
|
153
|
+
return data;
|
|
154
|
+
}
|
|
155
|
+
// #6: Use local variables instead of mutating the shared payload.
|
|
156
|
+
const content = data.term || data.content;
|
|
157
|
+
const searchData = data.term ? { tid: data.tid } : data?.searchData;
|
|
158
|
+
const rawIndex = Array.isArray(data?.index) ? data.index[0] : data.index;
|
|
159
|
+
const ALLOWED_INDEXES = { post: 'pid', topic: 'tid' };
|
|
160
|
+
const index = ALLOWED_INDEXES[rawIndex] ? rawIndex : 'post';
|
|
161
|
+
const id = ALLOWED_INDEXES[index];
|
|
162
|
+
winston.debug(`[plugin/meilisearch] Searching for ${content} in ${index}`);
|
|
163
|
+
const result = await safeSearch(plugin, index, content, {
|
|
164
|
+
attributesToRetrieve: [id],
|
|
165
|
+
limit: parseInt(await settings.getOne(plugin.id, 'maxDocuments') || 500, 10),
|
|
166
|
+
filter: plugin.buildFilter(
|
|
167
|
+
data.cid,
|
|
168
|
+
data.uid,
|
|
169
|
+
searchData?.timeFilter,
|
|
170
|
+
searchData?.timeRange,
|
|
171
|
+
searchData?.tid,
|
|
172
|
+
),
|
|
173
|
+
sort: plugin.buildSort(searchData?.sortBy, searchData?.sortDirection),
|
|
174
|
+
matchingStrategy: data.matchWords === 'all' ? 'all' : 'last',
|
|
175
|
+
...(await buildSemanticSearchExtras(plugin)),
|
|
176
|
+
});
|
|
177
|
+
if (!result) return data;
|
|
178
|
+
data.ids = result.hits.map(hit => hit[id]);
|
|
179
|
+
return data;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
plugin.buildFilter = function (categories, postedBy, timeFilter, timeRange, tid) {
|
|
183
|
+
const num = (v) => {
|
|
184
|
+
const n = parseInt(v, 10);
|
|
185
|
+
return Number.isFinite(n) ? n : null;
|
|
186
|
+
};
|
|
187
|
+
const filter = [];
|
|
188
|
+
if (categories?.length) {
|
|
189
|
+
const cids = categories.map(num).filter(c => c !== null);
|
|
190
|
+
if (cids.length) filter.push(cids.map(cid => `cid = ${cid}`));
|
|
191
|
+
}
|
|
192
|
+
if (postedBy?.length) {
|
|
193
|
+
const uids = postedBy.map(num).filter(u => u !== null);
|
|
194
|
+
if (uids.length) filter.push(uids.map(uid => `uid = ${uid}`));
|
|
195
|
+
}
|
|
196
|
+
if (timeFilter && timeRange) {
|
|
197
|
+
const range = num(timeRange);
|
|
198
|
+
if (range !== null) {
|
|
199
|
+
filter.push(`timestamp ${timeFilter === 'newer' ? '>' : '<'} ${Date.now() - (range * 1000)}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const numericTid = num(tid);
|
|
203
|
+
if (numericTid !== null) {
|
|
204
|
+
filter.push(`tid = ${numericTid}`);
|
|
205
|
+
}
|
|
206
|
+
return filter.length ? filter : undefined;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
plugin.buildSort = function (sortBy, sortDirection) {
|
|
210
|
+
let field = '';
|
|
211
|
+
switch (sortBy) {
|
|
212
|
+
case 'timestamp':
|
|
213
|
+
field = 'timestamp';
|
|
214
|
+
break;
|
|
215
|
+
case 'topic.title':
|
|
216
|
+
field = 'title';
|
|
217
|
+
break;
|
|
218
|
+
case 'category':
|
|
219
|
+
field = 'cid';
|
|
220
|
+
break;
|
|
221
|
+
default:
|
|
222
|
+
return undefined;
|
|
223
|
+
}
|
|
224
|
+
return [`${field}:${sortDirection === 'ascending' ? 'asc' : 'desc'}`];
|
|
225
|
+
};
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
// Exposed so other direct-search call sites (lib/chat-search-global.js) get the same
|
|
229
|
+
// crash-proofing/degrade-and-alert behavior instead of duplicating or, worse, skipping it.
|
|
230
|
+
module.exports.safeSearch = safeSearch;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const settings = nodebb.require('./src/meta/settings');
|
|
4
|
+
|
|
5
|
+
async function resolveMinTermLength(plugin) {
|
|
6
|
+
const v = parseInt(await settings.getOne(plugin.id, 'searchMinTermLength'), 10);
|
|
7
|
+
return Number.isFinite(v) && v >= 2 ? v : 2;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function clampMinTermLength(value) {
|
|
11
|
+
const v = parseInt(value, 10);
|
|
12
|
+
return Number.isFinite(v) && v >= 2 ? v : 2;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
module.exports = { resolveMinTermLength, clampMinTermLength };
|
package/lib/settings.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const winston = nodebb.require('winston');
|
|
4
|
+
const settings = nodebb.require('./src/meta/settings');
|
|
5
|
+
const { clampMinTermLength } = require('./settings-helpers');
|
|
6
|
+
const { SEMANTIC_SETTING_KEYS, resetAppliedConfigs } = require('./embedder');
|
|
7
|
+
|
|
8
|
+
function deepCompare(a, b) {
|
|
9
|
+
if (a === null || b === null) return a === b;
|
|
10
|
+
if (typeof a !== typeof b) return false;
|
|
11
|
+
switch (typeof a) {
|
|
12
|
+
case 'object':
|
|
13
|
+
return Object.keys(a).length === Object.keys(b).length &&
|
|
14
|
+
Object.keys(a).every(key => deepCompare(a[key], b[key]));
|
|
15
|
+
case 'string':
|
|
16
|
+
case 'number':
|
|
17
|
+
case 'boolean':
|
|
18
|
+
default:
|
|
19
|
+
return a === b;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// filter:settings.set — reacts to ACP settings saves: reconnects on connection-setting
|
|
24
|
+
// changes and pushes breaking settings (ranking rules, typo tolerance, etc.) to MeiliSearch.
|
|
25
|
+
module.exports = function attachSettings(plugin) {
|
|
26
|
+
async function connectionSettingsChanged(newSettings) {
|
|
27
|
+
if (!newSettings) return false;
|
|
28
|
+
const keys = ['host', 'apiKey', 'healthCheckInterval'];
|
|
29
|
+
for (const k of keys) {
|
|
30
|
+
if (!Object.prototype.hasOwnProperty.call(newSettings, k)) continue;
|
|
31
|
+
const stored = await settings.getOne(plugin.id, k);
|
|
32
|
+
if (String(stored || '') !== String(newSettings[k] || '')) return true;
|
|
33
|
+
}
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function hostSettingChanged(newSettings) {
|
|
38
|
+
if (!newSettings || !Object.prototype.hasOwnProperty.call(newSettings, 'host')) return false;
|
|
39
|
+
const stored = await settings.getOne(plugin.id, 'host');
|
|
40
|
+
return String(stored || '') !== String(newSettings.host || '');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function isBreaking([setting, value]) {
|
|
44
|
+
if (!plugin.breakingSettings.includes(setting)) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
const stored = await settings.getOne(plugin.id, setting);
|
|
48
|
+
const changed = !deepCompare(stored, value);
|
|
49
|
+
if (changed) {
|
|
50
|
+
winston.info(`[plugin/meilisearch] ${setting} changed: ${JSON.stringify(stored)} -> ${JSON.stringify(value)}`);
|
|
51
|
+
}
|
|
52
|
+
return changed;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Deliberately separate from isBreaking()/updateIndexSettings: embedder config
|
|
56
|
+
// changes are the only thing that can make Meilisearch re-embed every document
|
|
57
|
+
// through a (often paid) embedding API, so this must only fire when a semantic
|
|
58
|
+
// search field was itself part of the diff - never as a side effect of saving
|
|
59
|
+
// unrelated settings like ranking rules or stop words.
|
|
60
|
+
async function semanticSettingsChanged(newSettings) {
|
|
61
|
+
if (!newSettings) return false;
|
|
62
|
+
const results = await Promise.all(SEMANTIC_SETTING_KEYS.map(async (key) => {
|
|
63
|
+
if (!Object.prototype.hasOwnProperty.call(newSettings, key)) return false;
|
|
64
|
+
const stored = await settings.getOne(plugin.id, key);
|
|
65
|
+
return !deepCompare(stored, newSettings[key]);
|
|
66
|
+
}));
|
|
67
|
+
return results.some(Boolean);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
plugin.saveSettings = async (data) => {
|
|
71
|
+
if (data.plugin === plugin.id && !data.quiet && plugin.initialized) {
|
|
72
|
+
try {
|
|
73
|
+
if (data.settings && Object.prototype.hasOwnProperty.call(data.settings, 'searchMinTermLength')) {
|
|
74
|
+
data.settings.searchMinTermLength = clampMinTermLength(data.settings.searchMinTermLength);
|
|
75
|
+
}
|
|
76
|
+
// #8: Only re-connect when connection settings changed.
|
|
77
|
+
const connChanged = await connectionSettingsChanged(data.settings);
|
|
78
|
+
if (connChanged) {
|
|
79
|
+
// Fix 10: If host changed, clear indexed so prepareSearch auto-reindexes the new (empty) host.
|
|
80
|
+
const hostChanged = await hostSettingChanged(data.settings);
|
|
81
|
+
if (hostChanged) {
|
|
82
|
+
await settings.set(plugin.id, { indexed: false }, true);
|
|
83
|
+
// The new host has no memory of what embedder config this plugin last
|
|
84
|
+
// applied to the OLD host - without this, an unchanged semantic search
|
|
85
|
+
// setting would look "already applied" and never get pushed to the new host.
|
|
86
|
+
await resetAppliedConfigs(plugin);
|
|
87
|
+
}
|
|
88
|
+
await plugin.prepareSearch(data.settings, true);
|
|
89
|
+
}
|
|
90
|
+
const changed = (await Promise.all(
|
|
91
|
+
Object.entries(data.settings).map(([k, v]) => isBreaking([k, v])),
|
|
92
|
+
)).some(Boolean);
|
|
93
|
+
if (changed) {
|
|
94
|
+
winston.info('settings changed, updating index');
|
|
95
|
+
await plugin.updateIndexSettings(data.settings);
|
|
96
|
+
}
|
|
97
|
+
if (await semanticSettingsChanged(data.settings)) {
|
|
98
|
+
winston.info('[plugin/meilisearch] semantic search settings changed, updating embedders');
|
|
99
|
+
await plugin.updateEmbedders(data.settings);
|
|
100
|
+
}
|
|
101
|
+
} catch (err) {
|
|
102
|
+
// #7: Don't throw — let settings persist to DB even when Meili is unreachable.
|
|
103
|
+
winston.error(`[plugin/meilisearch] Error while saving settings: ${err.message}`);
|
|
104
|
+
plugin.healthy = false;
|
|
105
|
+
// Surface the failure to admins in the ACP — without this, the client sees the
|
|
106
|
+
// "Settings Saved" success toast even when Meilisearch silently rejected the
|
|
107
|
+
// settings/embedder update. Client-side validation (admin.js) is the primary
|
|
108
|
+
// UX path for invalid configs; this catches connection-level failures (e.g.
|
|
109
|
+
// Meilisearch host unreachable after Fix B's 30s HTTP timeout fires).
|
|
110
|
+
plugin.notifyAdmins('settings:save', {
|
|
111
|
+
type: 'danger',
|
|
112
|
+
titleKey: '[[meilisearch:admin.meilisearchError]]',
|
|
113
|
+
message: err.message,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return data;
|
|
118
|
+
};
|
|
119
|
+
};
|
package/lib/state.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const winston = nodebb.require('winston');
|
|
4
|
+
const Sockets = nodebb.require('./src/socket.io');
|
|
5
|
+
const pubsub = nodebb.require('./src/pubsub');
|
|
6
|
+
const user = nodebb.require('./src/user');
|
|
7
|
+
const { defaults, breakingSettings } = require('./defaults');
|
|
8
|
+
|
|
9
|
+
// Sockets currently joined to 'admin/plugins/meilisearch' (set by app.enterRoom in ACP).
|
|
10
|
+
// NodeBB 4.15.0 SocketMeta.rooms.enter only guards uid_/chat_/topic_/category_ prefixes
|
|
11
|
+
// (verified in NodeBB v4.15.0 src/socket.io/meta.js) — arbitrary room names like
|
|
12
|
+
// 'admin/plugins/meilisearch' are freely joinable by any authenticated user. Filter by
|
|
13
|
+
// isAdministrator before emitting, so alert payloads (which may contain embedder endpoint
|
|
14
|
+
// URLs / model names from Meilisearch task error messages) don't reach authenticated
|
|
15
|
+
// non-admins who manually joined the room. isAdministrator is Redis-cached for 60s by
|
|
16
|
+
// NodeBB core (src/user.js), so for low-frequency alert emissions the overhead is sub-ms.
|
|
17
|
+
async function emitToAdmins(eventName, payload) {
|
|
18
|
+
const sockets = await Sockets.server.in('admin/plugins/meilisearch').fetchSockets();
|
|
19
|
+
if (!sockets.length) return;
|
|
20
|
+
const adminChecks = await Promise.all(
|
|
21
|
+
// Use s.data.uid (cluster-safe — socket.data is shared across nodes via fetchSockets,
|
|
22
|
+
// verified in NodeBB v4.15.0 src/socket.io/index.js:87-88). s.uid only exists on the
|
|
23
|
+
// local node, so in a multi-node cluster, s.uid would be undefined for remote sockets,
|
|
24
|
+
// silently defeating the admin-only filter.
|
|
25
|
+
sockets.map(s => (s.data && s.data.uid ? user.isAdministrator(s.data.uid) : Promise.resolve(false))),
|
|
26
|
+
);
|
|
27
|
+
sockets.forEach((s, i) => {
|
|
28
|
+
if (adminChecks[i]) s.emit(eventName, payload);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Sets up the plugin object's mutable state and cross-instance sync via pubsub.
|
|
33
|
+
// Must run before any other lib/* attach step since everything else reads/writes these fields.
|
|
34
|
+
module.exports = function initState(plugin) {
|
|
35
|
+
plugin.id = 'meilisearch';
|
|
36
|
+
plugin.healthy = false;
|
|
37
|
+
|
|
38
|
+
plugin.indexing = {
|
|
39
|
+
running: false,
|
|
40
|
+
topic_progress: { total: null, current: null },
|
|
41
|
+
post_progress: { total: null, current: null },
|
|
42
|
+
message_progress: { total: null, current: null },
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
plugin.reindexingForced = false;
|
|
46
|
+
plugin.pendingDuringReindex = [];
|
|
47
|
+
plugin.reindexLockRefresh = null;
|
|
48
|
+
|
|
49
|
+
/** @type {import('meilisearch').Meilisearch} */
|
|
50
|
+
plugin.client = undefined;
|
|
51
|
+
plugin.defaults = defaults;
|
|
52
|
+
plugin.breakingSettings = breakingSettings;
|
|
53
|
+
plugin.initialized = false;
|
|
54
|
+
plugin.initializingOnAnotherInstance = false;
|
|
55
|
+
plugin.healthCheckTask = null;
|
|
56
|
+
|
|
57
|
+
pubsub.on('meilisearch:reindex', (indexing) => {
|
|
58
|
+
plugin.indexing = indexing;
|
|
59
|
+
emitToAdmins('plugins.meilisearch.reindex', plugin.indexing)
|
|
60
|
+
.catch(err => winston.error(`[plugin/meilisearch] emitToAdmins reindex failed: ${err.message}`));
|
|
61
|
+
});
|
|
62
|
+
pubsub.on('meilisearch:init', (initializing) => {
|
|
63
|
+
plugin.initializingOnAnotherInstance = initializing;
|
|
64
|
+
});
|
|
65
|
+
pubsub.on('meilisearch:alert', (alert) => {
|
|
66
|
+
emitToAdmins('plugins.meilisearch.alert', alert)
|
|
67
|
+
.catch(err => winston.error(`[plugin/meilisearch] emitToAdmins alert failed: ${err.message}`));
|
|
68
|
+
});
|
|
69
|
+
};
|