@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.
@@ -10,7 +10,8 @@ define('admin/plugins/meilisearch', [
10
10
  'api',
11
11
  'alerts',
12
12
  'modals',
13
- ], (settings, api, alerts, modals) => {
13
+ 'translator',
14
+ ], (settings, api, alerts, modals, translator) => {
14
15
  const ACP = {};
15
16
  let reindexCompleteShown = false;
16
17
 
@@ -67,23 +68,212 @@ define('admin/plugins/meilisearch', [
67
68
  }
68
69
  }
69
70
 
71
+ // Suggestions only - the model field is always free text so any model name/tag works.
72
+ // First entry in each list doubles as the input's placeholder.
73
+ const SEMANTIC_MODEL_SUGGESTIONS = {
74
+ openAi: ['text-embedding-3-small', 'text-embedding-3-large', 'text-embedding-ada-002'],
75
+ huggingFace: [
76
+ 'BAAI/bge-base-en-v1.5', 'BAAI/bge-small-en-v1.5', 'BAAI/bge-large-en-v1.5',
77
+ 'sentence-transformers/all-MiniLM-L6-v2', 'sentence-transformers/all-mpnet-base-v2',
78
+ ],
79
+ ollama: ['nomic-embed-text', 'mxbai-embed-large', 'all-minilm', 'bge-m3'],
80
+ rest: [],
81
+ };
82
+ const SEMANTIC_URL_PLACEHOLDERS = {
83
+ // Leave OpenAI's own default endpoint as an empty placeholder - it's optional there,
84
+ // only needed to point at an OpenAI-compatible provider (OpenRouter, Azure, LocalAI...).
85
+ openAi: 'https://api.openai.com/v1/embeddings',
86
+ ollama: 'http://localhost:11434/api/embeddings',
87
+ rest: 'https://api.example.com/embeddings',
88
+ };
89
+ // The literal {{text}}/{{embedding}}/{{..}} tokens below are Meilisearch's own REST
90
+ // embedder template syntax, not benchpress - kept in JS (not the .tpl) so they aren't
91
+ // mistaken for template expressions by the admin page's own renderer. "{{..}}" as the
92
+ // 2nd array element (in BOTH templates) enables batching - Meilisearch sends multiple
93
+ // documents' texts in one HTTP call during indexing; omitting it causes
94
+ // "response has a single embedding, but request has multiple texts to embed".
95
+ const SEMANTIC_REST_REQUEST_PLACEHOLDER = '{"input": ["{{text}}", "{{..}}"], "model": "text-embedding-3-small"}';
96
+ const SEMANTIC_REST_RESPONSE_PLACEHOLDER = '{"data": [{"embedding": "{{embedding}}"}, "{{..}}"]}';
97
+ const SEMANTIC_FIELDS_BY_PROVIDER = {
98
+ openAi: ['apiKey', 'model', 'url'],
99
+ huggingFace: ['model'],
100
+ ollama: ['apiKey', 'model', 'url'],
101
+ rest: ['apiKey', 'url', 'rest'],
102
+ };
103
+ // Mirrors the server's SEMANTIC_SETTING_KEYS (lib/embedder.js) minus "enabled" (handled
104
+ // separately below, since off->on matters regardless of these) and "ratio" (confirmed
105
+ // cost-free - buildEmbedderConfig() never reads it). Any of these changing while semantic
106
+ // search is enabled makes the next Save re-embed every document through the configured
107
+ // (often paid) API - see admin.semanticSearchSaveNotice.
108
+ const SEMANTIC_COST_FIELDS = [
109
+ 'semanticSearchProvider', 'semanticSearchApiKey', 'semanticSearchModel',
110
+ 'semanticSearchUrl', 'semanticSearchDimensions', 'semanticSearchRestRequest', 'semanticSearchRestResponse',
111
+ ];
112
+ // Snapshotted right after the form is populated (on load, and again after every
113
+ // successful save) so saveSettings() can tell "did a cost-triggering field actually
114
+ // change" apart from "the form just happens to hold these values" (e.g. re-saving
115
+ // unrelated settings like ranking rules should never prompt a confirm).
116
+ let semanticSnapshot = null;
117
+
118
+ function captureSemanticSnapshot() {
119
+ semanticSnapshot = { enabled: $('#semanticSearchEnabled').is(':checked'), fields: {} };
120
+ SEMANTIC_COST_FIELDS.forEach((id) => {
121
+ semanticSnapshot.fields[id] = $(`#${id}`).val();
122
+ });
123
+ }
124
+
125
+ // True only when semantic search is (or is about to be) enabled AND something that
126
+ // feeds buildEmbedderConfig() actually changed since the last load/save - never fires
127
+ // for a plain re-save of the same values, or for disabling semantic search (free).
128
+ function semanticCostChangeDetected() {
129
+ if (!semanticSnapshot) return false;
130
+ const enabledNow = $('#semanticSearchEnabled').is(':checked');
131
+ if (!enabledNow) return false;
132
+ if (!semanticSnapshot.enabled) return true;
133
+ return SEMANTIC_COST_FIELDS.some(id => $(`#${id}`).val() !== semanticSnapshot.fields[id]);
134
+ }
135
+
136
+ // Mirrors lib/embedder.js validateEmbedderConfig. Reads form values via jQuery
137
+ // selectors (not nodebb.require — runs in browser) and applies the same per-provider
138
+ // rules: openAi requires apiKey iff url blank; huggingFace/ollama need nothing;
139
+ // rest requires url, warns on non-empty non-JSON request/response templates.
140
+ //
141
+ // Returns null if valid, or an error string (will be displayed via alerts.alert).
142
+ function validateEmbedderForm() {
143
+ const enabled = $('#semanticSearchEnabled').is(':checked');
144
+ if (!enabled) return null;
145
+ const provider = $('#semanticSearchProvider').val();
146
+ // .trim() all text inputs — admin may paste with trailing whitespace/newline, which
147
+ // would pass client-side "non-empty" check but fail at the embedding API (e.g. OpenAI
148
+ // returns 401 for an apiKey with trailing whitespace).
149
+ const apiKey = ($('#semanticSearchApiKey').val() || '').trim();
150
+ const url = ($('#semanticSearchUrl').val() || '').trim();
151
+ const restRequest = ($('#semanticSearchRestRequest').val() || '').trim();
152
+ const restResponse = ($('#semanticSearchRestResponse').val() || '').trim();
153
+ const missing = [];
154
+ const invalid = [];
155
+ switch (provider) {
156
+ case 'openAi':
157
+ // apiKey required only when url is blank (custom OpenAI-compatible endpoint may not need auth)
158
+ if (!apiKey && !url) missing.push('apiKey');
159
+ break;
160
+ case 'huggingFace':
161
+ // model + nothing required (HF runs in Meilisearch, defaults work)
162
+ break;
163
+ case 'ollama':
164
+ // url + model + apiKey all optional (defaults work for local install)
165
+ break;
166
+ case 'rest':
167
+ if (!url) missing.push('url');
168
+ if (restRequest.trim() && !isValidJson(restRequest)) invalid.push('request');
169
+ if (restResponse.trim() && !isValidJson(restResponse)) invalid.push('response');
170
+ break;
171
+ default:
172
+ return `Unknown provider: ${provider}`;
173
+ }
174
+ if (!missing.length && !invalid.length) return null;
175
+ const parts = [];
176
+ if (missing.length) parts.push(`Required field(s) missing for "${provider}" provider: ${missing.join(', ')}`);
177
+ if (invalid.length) parts.push(`Invalid JSON in field(s): ${invalid.join(', ')}`);
178
+ return parts.join('; ');
179
+ }
180
+
181
+ function isValidJson(str) {
182
+ try { JSON.parse(str); return true; } catch (e) { return false; }
183
+ }
184
+
70
185
  ACP.init = function () {
71
186
  app.enterRoom('admin/plugins/meilisearch');
72
187
  settings.load('meilisearch', $('.meilisearch-settings'), () => {
73
188
  toggleLimitNotice();
189
+ initSemanticSearchFields();
190
+ toggleSemanticSearch();
191
+ toggleForceReindexCostNotice();
192
+ captureSemanticSnapshot();
74
193
  });
75
194
  $('#save').on('click', saveSettings);
76
195
  $('#reindex').on('click', reindex);
77
196
  formatLocalTimes();
78
197
  $('#globalChatSearchEnabled').on('change', toggleLimitNotice);
198
+ $('#semanticSearchEnabled').on('change', () => { toggleSemanticSearch(); toggleForceReindexCostNotice(); });
199
+ $('#semanticSearchProvider').on('change', toggleSemanticSearch);
200
+ $('#semanticSearchRatio').on('input', updateSemanticRatioLabel);
201
+ $('#semanticSearchScoreThreshold').on('input', updateSemanticScoreThresholdLabel);
202
+ $('#force-reindex').on('change', toggleForceReindexCostNotice);
79
203
  socket.removeListener('plugins.meilisearch.reindex', onReindex);
80
204
  socket.on('plugins.meilisearch.reindex', onReindex);
205
+ socket.removeListener('plugins.meilisearch.alert', onAlert);
206
+ socket.on('plugins.meilisearch.alert', onAlert);
81
207
  };
82
208
 
209
+ // Real-time server-side failures (embedder push rejected, search degraded because the
210
+ // configured embedder/endpoint is broken, ...) - surfaced here instead of only in the
211
+ // server log, since admins reading the ACP won't necessarily be tailing logs.
212
+ function onAlert(data) {
213
+ if (!data) return;
214
+ alerts.alert({
215
+ type: data.type || 'danger',
216
+ title: data.titleKey || '[[meilisearch:admin.meilisearchError]]',
217
+ message: data.message || '',
218
+ timeout: 15000,
219
+ });
220
+ }
221
+
83
222
  function toggleLimitNotice() {
84
223
  $('#globalChatSearchLimitNotice').toggle($('#globalChatSearchEnabled').is(':checked'));
85
224
  }
86
225
 
226
+ function toggleForceReindexCostNotice() {
227
+ const semanticEnabled = $('#semanticSearchEnabled').is(':checked');
228
+ const forced = $('#force-reindex').is(':checked');
229
+ $('#semanticForceReindexCostNotice').toggle(semanticEnabled && forced);
230
+ }
231
+
232
+ function initSemanticSearchFields() {
233
+ updateModelSuggestions($('#semanticSearchProvider').val());
234
+ $('#semanticSearchUrl').attr('placeholder', SEMANTIC_URL_PLACEHOLDERS[$('#semanticSearchProvider').val()] || '');
235
+ $('#semanticSearchRestRequest').attr('placeholder', SEMANTIC_REST_REQUEST_PLACEHOLDER);
236
+ $('#semanticSearchRestResponse').attr('placeholder', SEMANTIC_REST_RESPONSE_PLACEHOLDER);
237
+ updateSemanticRatioLabel();
238
+ updateSemanticScoreThresholdLabel();
239
+ }
240
+
241
+ // Model stays a free-text input for every provider - the datalist is only
242
+ // suggestions, so any custom model name/tag can still be typed in directly.
243
+ function updateModelSuggestions(provider) {
244
+ const suggestions = SEMANTIC_MODEL_SUGGESTIONS[provider] || [];
245
+ const $list = $('#semanticSearchModelList');
246
+ $list.empty();
247
+ suggestions.forEach((model) => {
248
+ $list.append($('<option></option>').attr('value', model));
249
+ });
250
+ $('#semanticSearchModel').attr('placeholder', suggestions[0] || '');
251
+ }
252
+
253
+ function updateSemanticRatioLabel() {
254
+ const val = parseFloat($('#semanticSearchRatio').val());
255
+ $('#semanticSearchRatioValue').text(Number.isFinite(val) ? val.toFixed(2) : '0.50');
256
+ }
257
+
258
+ function updateSemanticScoreThresholdLabel() {
259
+ const val = parseFloat($('#semanticSearchScoreThreshold').val());
260
+ $('#semanticSearchScoreThresholdValue').text(Number.isFinite(val) ? val.toFixed(2) : '0.20');
261
+ }
262
+
263
+ function toggleSemanticSearch() {
264
+ const enabled = $('#semanticSearchEnabled').is(':checked');
265
+ $('#semantic-search-options').toggle(enabled);
266
+ if (!enabled) return;
267
+ const provider = $('#semanticSearchProvider').val();
268
+ const visibleFields = SEMANTIC_FIELDS_BY_PROVIDER[provider] || [];
269
+ updateModelSuggestions(provider);
270
+ $('#semanticSearchUrl').attr('placeholder', SEMANTIC_URL_PLACEHOLDERS[provider] || '');
271
+ $('[data-semantic-field]').each(function toggleField() {
272
+ const $field = $(this);
273
+ $field.toggle(visibleFields.includes($field.attr('data-semantic-field')));
274
+ });
275
+ }
276
+
87
277
  function setProgress(element, current, total) {
88
278
  current = Number(current) || 0;
89
279
  total = Number(total) || 0;
@@ -104,12 +294,133 @@ define('admin/plugins/meilisearch', [
104
294
  }
105
295
 
106
296
  function saveSettings() {
297
+ // (A) Pre-flight validation: block save on missing required fields (config invalid).
298
+ const validationError = validateEmbedderForm();
299
+ if (validationError) {
300
+ alerts.alert({
301
+ type: 'danger',
302
+ alert_id: 'meilisearch-validation',
303
+ title: '[[meilisearch:admin.semanticConfigInvalid]]',
304
+ message: validationError,
305
+ timeout: 10000,
306
+ });
307
+ return; // block save — doSaveSettings not called
308
+ }
309
+ // (Pure A — NEW) Ollama reachability probe. When semantic is enabled AND provider is
310
+ // ollama, asynchronously test <url>/api/version from NodeBB's container (5s timeout).
311
+ // On unreachable, show modal: "Ollama is not reachable from this NodeBB. If you are
312
+ // sure the Ollama is up and reachable from Meilisearch instance, click OK to save anyway."
313
+ // Admin can override because the test runs from NodeBB's network, not Meilisearch's
314
+ // (different networks possible if Meilisearch is host-networked or different Docker net).
315
+ // Other providers skip the probe and go straight to cost-confirm / save.
316
+ const semanticEnabled = $('#semanticSearchEnabled').is(':checked');
317
+ const provider = $('#semanticSearchProvider').val();
318
+ if (semanticEnabled && provider === 'ollama') {
319
+ proceedWithOllamaProbe();
320
+ return;
321
+ }
322
+ // Non-ollama providers: cost-change confirm (if applicable) then save
323
+ if (semanticCostChangeDetected()) {
324
+ modals.confirm('[[meilisearch:admin.confirmSemanticSave]]', (confirm) => {
325
+ if (confirm) doSaveSettings();
326
+ });
327
+ return;
328
+ }
329
+ doSaveSettings();
330
+ }
331
+
332
+ // Pure A: Async Ollama reachability probe. Shows "Testing Ollama..." info toast, then
333
+ // calls POST /api/v3/meilisearch/test-ollama which makes a 5s HTTP GET to <url>/api/version
334
+ // from NodeBB's container. On success, proceeds with the normal cost-confirm → save chain.
335
+ // On unreachable, shows modals.confirm() with the admin-specified text — admin's "OK"
336
+ // click means "Save anyway" (override the probe failure), "Cancel" aborts the save.
337
+ async function proceedWithOllamaProbe() {
338
+ const url = ($('#semanticSearchUrl').val() || '').trim();
339
+ // Disable save button during the async probe to prevent double-click stacking modals.
340
+ // Re-enabled in every exit path (success → doSaveSettings, failure → modal, error → save).
341
+ const $saveBtn = $('#save');
342
+ $saveBtn.prop('disabled', true);
343
+ const reEnableSave = () => { $saveBtn.prop('disabled', false); };
344
+ alerts.alert({
345
+ type: 'info',
346
+ alert_id: 'meilisearch-ollama-probe',
347
+ message: '[[meilisearch:admin.ollamaTesting]]',
348
+ timeout: 6000,
349
+ });
350
+ try {
351
+ const response = await api.post('/plugins/meilisearch/test-ollama', { url });
352
+ if (response && response.reachable) {
353
+ alerts.alert({
354
+ type: 'success',
355
+ alert_id: 'meilisearch-ollama-reachable',
356
+ message: response.version
357
+ ? `[[meilisearch:admin.ollamaReachable, ${response.version}]]`
358
+ : '[[meilisearch:admin.ollamaReachableNoVersion]]',
359
+ timeout: 3000,
360
+ });
361
+ reEnableSave();
362
+ // Probe passed — continue with cost-confirm if applicable, else save directly
363
+ if (semanticCostChangeDetected()) {
364
+ modals.confirm('[[meilisearch:admin.confirmSemanticSave]]', (confirm) => {
365
+ if (confirm) doSaveSettings();
366
+ });
367
+ return;
368
+ }
369
+ doSaveSettings();
370
+ return;
371
+ }
372
+ // Unreachable — show confirm modal with override option.
373
+ // modals.confirm(message, callback): callback(true) = OK/Save anyway,
374
+ // callback(false) = Cancel/abort. Use translator.translateKey + args as an ARRAY
375
+ // to substitute %1, %2 — bypasses NodeBB's `[[key, arg1, arg2]]` syntax which
376
+ // mis-splits args when an arg contains a comma (e.g. errorDetail
377
+ // "Network error: foo, bar"). translator.compile is NOT a translator — it only
378
+ // BUILDS [[...]] tokens (and would also work here via its escapeArg comma-escaping,
379
+ // but translateKey is more direct).
380
+ const probeUrl = (response && response.probeUrl) || (url || 'http://localhost:11434/api/embeddings');
381
+ const errorDetail = (response && response.error) || 'Unknown error';
382
+ const message = await translator.translateKey(
383
+ 'meilisearch:admin.ollamaUnreachableBody',
384
+ [probeUrl, errorDetail],
385
+ translator.getLanguage(),
386
+ );
387
+ reEnableSave();
388
+ modals.confirm(message, (confirm) => {
389
+ if (!confirm) return; // admin cancelled — save aborted, form preserved
390
+ // Admin chose "Save anyway" — continue with cost-confirm if applicable
391
+ if (semanticCostChangeDetected()) {
392
+ modals.confirm('[[meilisearch:admin.confirmSemanticSave]]', (costConfirm) => {
393
+ if (costConfirm) doSaveSettings();
394
+ });
395
+ return;
396
+ }
397
+ doSaveSettings();
398
+ });
399
+ } catch (err) {
400
+ // NodeBB's own API route unreachable (shouldn't normally happen — server down?)
401
+ // Fall through to save so admin isn't blocked by an unrelated server error
402
+ reEnableSave();
403
+ alerts.error(err.message || 'Ollama probe failed unexpectedly');
404
+ if (semanticCostChangeDetected()) {
405
+ modals.confirm('[[meilisearch:admin.confirmSemanticSave]]', (confirm) => {
406
+ if (confirm) doSaveSettings();
407
+ });
408
+ return;
409
+ }
410
+ doSaveSettings();
411
+ }
412
+ }
413
+
414
+ function doSaveSettings() {
107
415
  settings.save('meilisearch', $('.meilisearch-settings'), () => {
108
416
  const saveBtn = $('#save').get(0);
109
417
  if (saveBtn) {
110
418
  saveBtn.classList.toggle('saved', true);
111
419
  setTimeout(() => { saveBtn.classList.toggle('saved', false); }, 1500);
112
420
  }
421
+ // Re-baseline: this save's values are now "current", so the next save only
422
+ // prompts again if something changes relative to what was JUST saved.
423
+ captureSemanticSnapshot();
113
424
  alerts.alert({
114
425
  type: 'success',
115
426
  alert_id: 'meilisearch-saved',
@@ -132,7 +443,11 @@ define('admin/plugins/meilisearch', [
132
443
  }
133
444
  function reindex() {
134
445
  const forceReindex = document.getElementById('force-reindex').checked;
135
- modals.confirm('[[meilisearch:admin.confirmReindex]]', (confirm) => {
446
+ const semanticEnabled = $('#semanticSearchEnabled').is(':checked');
447
+ const confirmMessage = (forceReindex && semanticEnabled)
448
+ ? '[[meilisearch:admin.confirmReindex]]<br><br><strong>[[meilisearch:admin.semanticForceReindexCostNotice]]</strong>'
449
+ : '[[meilisearch:admin.confirmReindex]]';
450
+ modals.confirm(confirmMessage, (confirm) => {
136
451
  if (!confirm) {
137
452
  return;
138
453
  }
@@ -47,7 +47,7 @@
47
47
  function bindResultClick(resultEl) {
48
48
  resultEl
49
49
  .off('click.meili-minimize')
50
- .on('click.meili-minimize', '#quick-search-results a', function () {
50
+ .on('click.meili-minimize', '.quick-search-results-container a', function () {
51
51
  var composerEl = resultEl.closest('.composer');
52
52
  if (!composerEl.length) { return; }
53
53
  var hideBtn = composerEl.find('[data-action="hide"]').first();