Confirm before wiping the index on a Best-Match mode change

Changing the mode -- including disabling -- silently dropped the
stored embeddings and any other downloaded model, costing a full
reindex. Prompt first, since the menu click gives no hint of the cost.
This commit is contained in:
Dan Stillman 2026-07-21 14:54:30 -04:00 • committed by Bogdan Abaev
parent ff0c1325ac
commit 449243f3e9
4 changed files with 97 additions and 6 deletions

View file

@ -63,23 +63,33 @@ Zotero_Preferences.Advanced = {
initSemanticSearch: function () {
// Populate the model menu from the model registry. The preferences
// code watches bound menulists for added items and selects the one
// matching the current pref value.
let modelPopup = document.querySelector('#semantic-search-model > menupopup');
// Populate the model menu from the model registry. The menu isn't
// bound to the pref, since a mode change needs a confirmation prompt
// before the pref -- and with it the stored index -- is touched.
let modelMenu = document.getElementById('semantic-search-model');
let modelPopup = modelMenu.querySelector('menupopup');
for (let { name, l10nID } of Zotero.Embeddings.getAvailableModels()) {
let menuitem = document.createXULElement('menuitem');
menuitem.setAttribute('value', name);
document.l10n.setAttributes(menuitem, l10nID);
modelPopup.append(menuitem);
}
modelMenu.value = Zotero.Embeddings.getModelName();
modelMenu.addEventListener('command', () => this.handleSemanticSearchModeChange());
// Live progress updates from the background indexer
this._semanticSearchListener = status => this.updateSemanticSearchUI(status);
Zotero.Embeddings.Indexing.addProgressListener(this._semanticSearchListener);
let modelPrefObserverID = Zotero.Prefs.registerObserver(
'embeddings.model',
() => {
modelMenu.value = Zotero.Embeddings.getModelName();
}
);
document.getElementById('zotero-prefpane-advanced').addEventListener('unload', () => {
Zotero.Embeddings.Indexing.removeProgressListener(this._semanticSearchListener);
Zotero.Prefs.unregisterObserver(modelPrefObserverID);
});
document.getElementById('semantic-search-resume').addEventListener('command', () => {
@ -96,6 +106,42 @@ Zotero_Preferences.Advanced = {
},
handleSemanticSearchModeChange: async function () {
let modelMenu = document.getElementById('semantic-search-model');
let oldValue = Zotero.Embeddings.getModelName();
let newValue = modelMenu.value;
if (newValue === oldValue) {
return;
}
// Leaving an enabled mode wipes the stored index (and, when disabling,
// the downloaded data), so confirm first. Enabling from Disabled just
// starts indexing.
if (Zotero.Embeddings.isEnabled()) {
let [title, text, button] = await document.l10n.formatValues(
(newValue
? ['switch-title', 'switch-text', 'switch-button']
: ['disable-title', 'disable-text', 'disable-button'])
.map(id => ({ id: `preferences-advanced-semantic-search-${id}` }))
);
let ps = Services.prompt;
let index = ps.confirmEx(
window,
title,
text,
ps.BUTTON_POS_0 * ps.BUTTON_TITLE_IS_STRING
+ ps.BUTTON_POS_1 * ps.BUTTON_TITLE_CANCEL
+ ps.BUTTON_POS_1_DEFAULT,
button, null, null, null, {}
);
if (index !== 0) {
modelMenu.value = oldValue;
return;
}
}
Zotero.Prefs.set('embeddings.model', newValue);
},
updateSemanticSearchUI: function (status) {
let statusBox = document.getElementById('semantic-search-status');
statusBox.hidden = !status.enabled;

View file

@ -298,8 +298,7 @@
<label><html:h2 data-l10n-id="preferences-advanced-semantic-search-title"/></label>
<hbox align="center">
<label data-l10n-id="preferences-advanced-semantic-search-model" control="semantic-search-model"/>
<menulist id="semantic-search-model"
preference="extensions.zotero.embeddings.model" native="true">
<menulist id="semantic-search-model" native="true">
<menupopup>
<menuitem value="" data-l10n-id="preferences-advanced-semantic-search-disabled"/>
<!-- Model items are populated from Zotero.Embeddings.getAvailableModels() -->

View file

@ -111,6 +111,12 @@ preferences-advanced-semantic-search-resume =
.label = Resume
preferences-advanced-semantic-search-stop =
.label = Stop
preferences-advanced-semantic-search-switch-title = Change Mode?
preferences-advanced-semantic-search-switch-text = Changing the mode will rebuild the search index, which can take a long time for large libraries.
preferences-advanced-semantic-search-switch-button = Change Mode
preferences-advanced-semantic-search-disable-title = Disable Best-Match Search?
preferences-advanced-semantic-search-disable-text = Disabling will delete the search index and downloaded data.
preferences-advanced-semantic-search-disable-button = Disable
preferences-advanced-language-and-region-title = Language and Region
preferences-advanced-enable-bidi-ui =
.label = Enable bidirectional text editing utilities

View file

@ -154,4 +154,44 @@ describe("Advanced Preferences", function () {
});
})
})
describe("Best-Match Search", function () {
it("should confirm mode changes only when leaving an enabled mode", async function () {
var stubs = [
sinon.stub(Zotero.Embeddings.Indexing, 'startIndexing').resolves(),
sinon.stub(Zotero.Embeddings, 'pruneModels').resolves()
];
var win = await loadPrefPane('advanced');
var menu = win.document.getElementById('semantic-search-model');
try {
// Enabling from Disabled prompts nothing (the test would hang on
// an unexpected modal prompt)
menu.value = 'bge-small-en-v1.5';
await win.Zotero_Preferences.Advanced.handleSemanticSearchModeChange();
assert.equal(Zotero.Prefs.get('embeddings.model'), 'bge-small-en-v1.5');
// Cancelling a switch restores the menu and leaves the pref alone
var promise = waitForDialog(null, 'cancel');
menu.value = 'multilingual-e5-small';
await win.Zotero_Preferences.Advanced.handleSemanticSearchModeChange();
await promise;
assert.equal(Zotero.Prefs.get('embeddings.model'), 'bge-small-en-v1.5');
assert.equal(menu.value, 'bge-small-en-v1.5');
// Confirming applies the change
promise = waitForDialog();
menu.value = '';
await win.Zotero_Preferences.Advanced.handleSemanticSearchModeChange();
await promise;
assert.equal(Zotero.Prefs.get('embeddings.model'), '');
}
finally {
win.close();
Zotero.Prefs.set('embeddings.model', '');
await Zotero.Embeddings.Indexing.waitForPendingModelSwitch();
Zotero.Prefs.clear('embeddings.indexingPaused');
stubs.forEach(stub => stub.restore());
}
});
})
})