minor cleanup of prefs UI
Some checks are pending
CI / Test (shard 1) (push) Waiting to run
CI / Test (shard 2) (push) Waiting to run
CI / Test (shard 3) (push) Waiting to run
CI / Test (shard 4) (push) Waiting to run
CI / Utilities Tests (push) Waiting to run
CI / Build, Upload (push) Waiting to run

- hide the diagnostics by default
- do not show the chunks count next to progress bars to avoid confusion
- shorten labels to be more concise
This commit is contained in:
Bogdan Abaev 2026-09-14 16:09:56 -07:00
parent 267e779c8f
commit 3e2f7fb33c
7 changed files with 237 additions and 175 deletions

View file

@ -33,7 +33,7 @@
xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
xmlns:html="http://www.w3.org/1999/xhtml"
data-l10n-id="preferences-advanced-semantic-search-endpoint-dialog"
onload="Zotero_EmbeddingsEndpointDialog.init()">
onload="init()">
<dialog
id="zotero-embeddings-endpoint"
orient="vertical"
@ -50,7 +50,7 @@
<separator class="thin"/>
<label data-l10n-id="preferences-advanced-semantic-search-endpoint-url" control="endpoint-url"/>
<html:input type="text" id="endpoint-url" placeholder="http://localhost:8080/v1/embeddings"/>
<html:input type="text" id="endpoint-url"/>
<description class="endpoint-note"
data-l10n-id="preferences-advanced-semantic-search-endpoint-privacy"/>
<separator class="thin"/>
@ -82,98 +82,101 @@
</vbox>
<script>
<![CDATA[
var Zotero_EmbeddingsEndpointDialog = {
init() {
this.io = window.arguments[0];
this.urlInput = document.getElementById('endpoint-url');
this.dialog = document.getElementById('zotero-embeddings-endpoint');
this.urlInput.value = Zotero.Prefs.get('embeddings.endpoint') || '';
document.l10n.setAttributes(this.dialog.getButton('accept'),
'preferences-advanced-semantic-search-endpoint-accept');
// Offered only while there is an endpoint to remove
let remove = this.dialog.getButton('extra2');
document.l10n.setAttributes(remove, 'preferences-advanced-semantic-search-endpoint-remove');
remove.hidden = !this.urlInput.value;
document.addEventListener('dialogextra2', () => this.remove());
let io;
let urlInput;
let dialog;
let serving = Zotero.Embeddings.getServing();
document.getElementById('endpoint-model').value = Zotero.Embeddings.getModelName();
document.getElementById('endpoint-pooling').value = Zotero.Embeddings.getPooling();
document.getElementById('endpoint-file').value = `${serving.gguf} (${serving.quant})`;
let { command, url } = Zotero.Embeddings.Endpoint.getCommand();
document.getElementById('endpoint-command').value = command;
document.l10n.setAttributes(document.getElementById('endpoint-command-url'),
'preferences-advanced-semantic-search-endpoint-then-use', { url });
document.getElementById('endpoint-copy').addEventListener('command', () => {
Zotero.Utilities.Internal.copyTextToClipboard(command);
});
function init() {
io = window.arguments[0];
urlInput = document.getElementById('endpoint-url');
dialog = document.getElementById('zotero-embeddings-endpoint');
let { command, url } = Zotero.Embeddings.Endpoint.getCommand();
let stored = Zotero.Prefs.get('embeddings.endpoint');
// Where the suggested command serves, so a server started as
// suggested is accepted as is
urlInput.value = stored || url;
document.l10n.setAttributes(dialog.getButton('accept'),
'preferences-advanced-semantic-search-endpoint-accept');
// Offered only while there is an endpoint to remove
let remove = dialog.getButton('extra2');
document.l10n.setAttributes(remove, 'preferences-advanced-semantic-search-endpoint-remove');
remove.hidden = !stored;
document.addEventListener('dialogextra2', removeEndpoint);
// Verification is asynchronous: hold the dialog open until it
// has an answer
document.addEventListener('dialogaccept', (event) => {
event.preventDefault();
this.accept();
});
this.urlInput.addEventListener('input', () => this.showError(null));
this.urlInput.focus();
},
let serving = Zotero.Embeddings.getServing();
document.getElementById('endpoint-model').value = Zotero.Embeddings.getModelName();
document.getElementById('endpoint-pooling').value = Zotero.Embeddings.getPooling();
document.getElementById('endpoint-file').value = `${serving.gguf} (${serving.quant})`;
document.getElementById('endpoint-command').value = command;
document.l10n.setAttributes(document.getElementById('endpoint-command-url'),
'preferences-advanced-semantic-search-endpoint-then-use', { url });
document.getElementById('endpoint-copy').addEventListener('command', () => {
Zotero.Utilities.Internal.copyTextToClipboard(command);
});
// Stop using the endpoint. Nothing stored is affected: the vectors
// it produced match the model's, and a later endpoint verifies anew.
remove() {
Zotero.Prefs.clear('embeddings.endpoint');
this.io.ok = true;
window.close();
},
// Verification is asynchronous: hold the dialog open until it
// has an answer
document.addEventListener('dialogaccept', (event) => {
event.preventDefault();
accept();
});
urlInput.addEventListener('input', () => showError(null));
urlInput.focus();
}
async accept() {
let url = this.urlInput.value.trim();
// An emptied URL turns the endpoint off; nothing to verify
if (!url) {
this.remove();
return;
}
this.setBusy(true);
this.showError(null);
let verdict;
try {
verdict = await Zotero.Embeddings.Endpoint.verify(url);
}
catch (e) {
Zotero.logError(e);
verdict = { state: 'unreachable' };
}
this.setBusy(false);
if (verdict.state != 'ok') {
this.showError(verdict);
return;
}
Zotero.Prefs.set('embeddings.endpoint', url);
this.io.ok = true;
window.close();
},
// Stop using the endpoint. Nothing stored is affected: the vectors
// it produced match the model's, and a later endpoint verifies anew.
function removeEndpoint() {
Zotero.Prefs.clear('embeddings.endpoint');
io.ok = true;
window.close();
}
setBusy(busy) {
document.getElementById('endpoint-progress').hidden = !busy;
this.dialog.getButton('accept').disabled = busy;
this.dialog.getButton('cancel').disabled = busy;
this.dialog.getButton('extra2').disabled = busy;
this.urlInput.disabled = busy;
},
showError(verdict) {
let box = document.getElementById('endpoint-error');
box.hidden = !verdict;
if (!verdict) {
return;
}
document.l10n.setAttributes(box,
`preferences-advanced-semantic-search-endpoint-error-${verdict.state}`,
{ pooling: Zotero.Embeddings.getPooling() });
async function accept() {
let url = urlInput.value.trim();
// An emptied URL turns the endpoint off; nothing to verify
if (!url) {
removeEndpoint();
return;
}
};
]]>
setBusy(true);
showError(null);
let verdict;
try {
verdict = await Zotero.Embeddings.Endpoint.verify(url);
}
catch (e) {
Zotero.logError(e);
verdict = { state: 'unreachable' };
}
setBusy(false);
if (verdict.state != 'ok') {
showError(verdict);
return;
}
Zotero.Prefs.set('embeddings.endpoint', url);
io.ok = true;
window.close();
}
function setBusy(busy) {
document.getElementById('endpoint-progress').hidden = !busy;
dialog.getButton('accept').disabled = busy;
dialog.getButton('cancel').disabled = busy;
dialog.getButton('extra2').disabled = busy;
urlInput.disabled = busy;
}
function showError(verdict) {
let box = document.getElementById('endpoint-error');
box.hidden = !verdict;
if (!verdict) {
return;
}
document.l10n.setAttributes(box,
`preferences-advanced-semantic-search-endpoint-error-${verdict.state}`,
{ pooling: Zotero.Embeddings.getPooling() });
}
</script>
</dialog>
</window>

View file

@ -113,6 +113,15 @@ Zotero_Preferences.Advanced = {
document.getElementById('semantic-search-endpoint-configure').addEventListener('command', () => {
this.openSemanticSearchEndpointDialog();
});
let diagnostics = document.getElementById('semantic-search-diagnostics');
let toggle = document.getElementById('semantic-search-diagnostics-toggle');
toggle.addEventListener('command', () => {
diagnostics.hidden = !diagnostics.hidden;
document.l10n.setAttributes(toggle, diagnostics.hidden
? 'preferences-advanced-semantic-search-diagnostics-show'
: 'preferences-advanced-semantic-search-diagnostics-hide');
});
// The endpoint's stored verdict is read lazily; have it in memory
// before the status line first renders
Zotero.Embeddings.Endpoint.load().then(() => {
@ -166,12 +175,11 @@ Zotero_Preferences.Advanced = {
statusBox.hidden = !status.enabled;
// Fulltext indexing only means something with a model selected
document.getElementById('semantic-search-index-fulltext').disabled = !status.enabled;
this.updateSemanticSearchEndpointUI(status.endpoint, status.enabled);
if (!status.enabled) {
return;
}
this.updateSemanticSearchEndpointUI(status.endpoint);
// Phase / status message
let phaseLabel = document.getElementById('semantic-search-phase');
let hasRemaining = status.items.done < status.items.total
@ -242,15 +250,16 @@ Zotero_Preferences.Advanced = {
document.getElementById('semantic-search-attachments-row').hidden
= !Zotero.Prefs.get('embeddings.indexFulltext');
this._updateSemanticSearchBar('attachments', status.chunks);
this._updateSemanticSearchDiagnostics(status.diagnostics, status.eta);
this._updateSemanticSearchDiagnostics(status);
},
// Whether the active model can be served at all, and how the configured
// server stands (see Zotero.Embeddings.Endpoint.getStatus())
updateSemanticSearchEndpointUI: function (endpoint) {
updateSemanticSearchEndpointUI: function (endpoint, enabled) {
let row = document.getElementById('semantic-search-endpoint-row');
row.hidden = !Zotero.Embeddings.Endpoint.isSupported();
// Nothing can be said about serving without a model
row.hidden = !enabled || !Zotero.Embeddings.Endpoint.isSupported();
if (row.hidden) {
return;
}
@ -281,7 +290,7 @@ Zotero_Preferences.Advanced = {
// Key/value rows of pipeline diagnostics for developers, so the labels
// are plain English rather than localized
_updateSemanticSearchDiagnostics: function (diagnostics, eta) {
_updateSemanticSearchDiagnostics: function ({ items, chunks: indexed, diagnostics, eta }) {
let n = (value, digits = 0) => (value ?? 0).toLocaleString(undefined, {
maximumFractionDigits: digits, minimumFractionDigits: digits
});
@ -301,6 +310,8 @@ Zotero_Preferences.Advanced = {
let rows = [];
let { window, run, engine, processes, slice, chunks } = diagnostics;
rows.push(['Items indexed', `${n(items.done)} / ${n(items.total)}`]);
rows.push(['Attachment chunks indexed', `${n(indexed.done)} / ${n(indexed.total)}`]);
rows.push(['ETA', eta === null ? '—' : duration(eta)]);
rows.push(['Throughput (2 min)', window ? speed(window) : '—']);
rows.push(['Inference speed (run)', run ? speed(run) : '—']);
@ -341,8 +352,8 @@ Zotero_Preferences.Advanced = {
},
// Fill one progress row with done out of total. The percentage is
// rounded down, so it reads 100% only when everything is done.
// Fill one progress bar. The percentage is rounded down, so it reads
// 100% only when everything is done.
_updateSemanticSearchBar: function (name, { done, total }) {
let bar = document.getElementById(`semantic-search-${name}-progress`);
bar.max = Math.max(total, 1);
@ -350,7 +361,7 @@ Zotero_Preferences.Advanced = {
document.l10n.setAttributes(
document.getElementById(`semantic-search-${name}-value`),
'preferences-advanced-semantic-search-progress-value',
{ done, total, percent: total ? Math.floor(done / total * 100) : 0 }
{ percent: total ? Math.floor(done / total * 100) : 0 }
);
},

View file

@ -309,37 +309,27 @@
</menupopup>
</menulist>
</hbox>
<description id="semantic-search-model-description"
data-l10n-id="preferences-advanced-semantic-search-model-description"/>
<!-- Temporary, for testing: pin best-match search to one engine -->
<hbox align="center">
<label data-l10n-id="preferences-advanced-best-match-engine"
control="best-match-engine"/>
<menulist id="best-match-engine"
preference="extensions.zotero.search.bestMatchEngine"
native="true">
<menupopup>
<menuitem value="hybrid"
data-l10n-id="preferences-advanced-best-match-engine-hybrid"/>
<menuitem value="lexical"
data-l10n-id="preferences-advanced-best-match-engine-lexical"/>
<menuitem value="semantic"
data-l10n-id="preferences-advanced-best-match-engine-semantic"/>
</menupopup>
</menulist>
</hbox>
<hbox align="center">
<label data-l10n-id="preferences-advanced-best-match-margin"
control="best-match-margin"/>
<html:input id="best-match-margin" class="html-input" type="number"
min="1" max="100" step="1" size="3"
data-preference="search.bestMatchMargin"/>
</hbox>
<checkbox id="semantic-search-index-fulltext"
data-l10n-id="preferences-advanced-semantic-search-index-fulltext"
preference="extensions.zotero.embeddings.indexFulltext"
native="true"/>
<hbox align="center">
<label data-l10n-id="preferences-advanced-best-match-margin"
control="best-match-margin"/>
<html:input id="best-match-margin" class="html-input" type="number"
min="0" max="100" step="1" size="3"
data-preference="search.bestMatchMargin"/>
</hbox>
<description class="semantic-search-hint"
data-l10n-id="preferences-advanced-best-match-margin-description"/>
<hbox id="semantic-search-endpoint-row" align="center" hidden="true">
<label id="semantic-search-endpoint-status"/>
<button id="semantic-search-endpoint-configure"
data-l10n-id="preferences-advanced-semantic-search-endpoint-configure"/>
</hbox>
<vbox id="semantic-search-status" hidden="true">
<separator class="thin"/>
<hbox align="center">
<label data-l10n-id="preferences-advanced-semantic-search-status"/>
<label id="semantic-search-phase"/>
@ -350,11 +340,6 @@
data-l10n-id="preferences-advanced-semantic-search-stop"
hidden="true"/>
</hbox>
<hbox id="semantic-search-endpoint-row" align="center" hidden="true">
<label id="semantic-search-endpoint-status"/>
<button id="semantic-search-endpoint-configure"
data-l10n-id="preferences-advanced-semantic-search-endpoint-configure"/>
</hbox>
<html:div id="semantic-search-progress">
<html:div id="semantic-search-items-row" class="semantic-search-progress-row">
<label data-l10n-id="preferences-advanced-semantic-search-items"/>
@ -367,7 +352,30 @@
<label id="semantic-search-attachments-value"/>
</html:div>
</html:div>
<html:div id="semantic-search-diagnostics"/>
<button id="semantic-search-diagnostics-toggle"
data-l10n-id="preferences-advanced-semantic-search-diagnostics-show"/>
<html:div id="semantic-search-diagnostics" hidden="true"/>
</vbox>
<!-- Temporary, for testing: pin best-match search to one engine -->
<vbox>
<separator class="thin"/>
<hbox align="center">
<label data-l10n-id="preferences-advanced-best-match-engine"
control="best-match-engine"/>
<menulist id="best-match-engine"
preference="extensions.zotero.search.bestMatchEngine"
native="true">
<menupopup>
<menuitem value="hybrid"
data-l10n-id="preferences-advanced-best-match-engine-hybrid"/>
<menuitem value="lexical"
data-l10n-id="preferences-advanced-best-match-engine-lexical"/>
<menuitem value="semantic"
data-l10n-id="preferences-advanced-best-match-engine-semantic"/>
</menupopup>
</menulist>
</hbox>
</vbox>
</groupbox>
</vbox>

View file

@ -3926,7 +3926,8 @@ Zotero.Embeddings.Endpoint = new function () {
this.getStatus = function () {
let url = Zotero.Prefs.get('embeddings.endpoint') || '';
let status = { url, state: 'off', serverModel: null };
if (!url) {
// A verdict is about a model, so there is none to report without one
if (!url || !Zotero.Embeddings.isEnabled()) {
return status;
}
if (_verdict === undefined) {

View file

@ -102,7 +102,6 @@ preferences-advanced-semantic-search-chinese =
.label = Chinese
preferences-advanced-semantic-search-multilingual =
.label = Multilingual
preferences-advanced-semantic-search-model-description = “{ preferences-advanced-semantic-search-english.label }” and “{ preferences-advanced-semantic-search-chinese.label }” give the best results for libraries in those languages. “{ preferences-advanced-semantic-search-multilingual.label }” supports searching in and across many languages.
preferences-advanced-semantic-search-index-fulltext =
.label = Search full text of attachment files
preferences-advanced-best-match-engine = Engine (temporary, for testing):
@ -112,7 +111,8 @@ preferences-advanced-best-match-engine-lexical =
.label = Lexical
preferences-advanced-best-match-engine-semantic =
.label = Semantic
preferences-advanced-best-match-margin = Drop results scoring more than this far below the best result (%):
preferences-advanced-best-match-margin = Quality cutoff below top (%):
preferences-advanced-best-match-margin-description = At 30%, results scoring up to 30% below the best one are kept; 100% keeps every result, 0 drops everything but the top match.
preferences-advanced-semantic-search-downloading = Downloading…
preferences-advanced-semantic-search-downloading-progress = Downloading… { $percent }%
preferences-advanced-semantic-search-extracting = Preparing documents…
@ -127,9 +127,13 @@ preferences-advanced-semantic-search-resume =
.label = Resume
preferences-advanced-semantic-search-stop =
.label = Stop
preferences-advanced-semantic-search-items = Items and notes
preferences-advanced-semantic-search-items = Metadata, notes, annotations
preferences-advanced-semantic-search-attachments = Attachments
preferences-advanced-semantic-search-progress-value = { $percent }% ({ $done } / { $total })
preferences-advanced-semantic-search-progress-value = { $percent }%
preferences-advanced-semantic-search-diagnostics-show =
.label = Show Diagnostics
preferences-advanced-semantic-search-diagnostics-hide =
.label = Hide Diagnostics
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

View file

@ -98,40 +98,78 @@
margin-inline-start: 6px;
}
#semantic-search-progress {
display: grid;
grid-template-columns: max-content 1fr max-content;
align-items: center;
column-gap: 8px;
row-gap: 4px;
margin-top: 4px;
#semantic-search {
// One gap between rows; the controls' own block margins would make
// the gap depend on which control a row holds
row-gap: 6px;
.semantic-search-progress-row {
display: contents;
> hbox > :is(menulist, button, .html-input),
> vbox > hbox > :is(menulist, button) {
margin-block: 0;
}
// Sized to its first item at construction, before the models are added
#semantic-search-model {
min-width: 150px;
}
#best-match-margin {
width: 5em;
}
.semantic-search-hint {
opacity: 0.7;
font-size: 0.9em;
}
#semantic-search-status {
row-gap: 6px;
}
#semantic-search-diagnostics-toggle {
align-self: flex-start;
margin-inline: 0;
}
// Bars and figures share one label column
#semantic-search-progress,
#semantic-search-diagnostics {
display: grid;
align-items: center;
column-gap: 12px;
row-gap: 4px;
}
#semantic-search-progress {
grid-template-columns: max-content 1fr max-content;
.semantic-search-progress-row {
display: contents;
&[hidden] {
display: none;
}
}
progress {
width: 100%;
}
}
#semantic-search-diagnostics {
grid-template-columns: max-content 1fr;
&[hidden] {
display: none;
}
}
progress {
width: 100%;
}
}
#semantic-search-diagnostics {
display: grid;
grid-template-columns: max-content 1fr;
column-gap: 12px;
row-gap: 2px;
margin-top: 10px;
label:nth-child(odd) {
color: var(--fill-secondary);
}
label:nth-child(even) {
white-space: normal;
label:nth-child(odd) {
color: var(--fill-secondary);
}
label:nth-child(even) {
white-space: normal;
}
}
}
@ -148,18 +186,6 @@
}
}
#semantic-search-model-description {
margin-bottom: 10px;
}
#semantic-search-libraries {
padding-top: 10px;
}
#semantic-search-endpoint-row {
margin: 4px 0;
}
#zotero-embeddings-endpoint-container {
.endpoint-note {
opacity: 0.7;

View file

@ -1389,6 +1389,7 @@ describe("Zotero.Embeddings", function () {
sinon.stub(Zotero.Embeddings, 'getModelVersion').returns('test-endpoint/1'),
sinon.stub(Zotero.Embeddings, 'embedMany').callsFake(async texts => texts.map(text => vectorFor(text)))
];
await Zotero.Embeddings.initDB();
await Zotero.DB.queryAsync("DELETE FROM embeddings.itemEmbeddingsMeta WHERE key='endpoint'");
Zotero.Embeddings.Endpoint.reset();
});
@ -1399,6 +1400,14 @@ describe("Zotero.Embeddings", function () {
await Zotero.DB.queryAsync("DELETE FROM embeddings.itemEmbeddingsMeta WHERE key='endpoint'");
});
it("should report a configured endpoint as off while no model is selected", async function () {
Zotero.Prefs.set('embeddings.endpoint', URL);
Zotero.Embeddings.getModelName.returns('');
Zotero.Embeddings.getModelVersion.throws(new Error("Unknown embeddings model ''"));
assert.equal(Zotero.Embeddings.Endpoint.getStatus().state, 'off');
assert.doesNotThrow(() => Zotero.Embeddings.Indexing.getStatus());
});
it("should describe how to serve the model", function () {
let { command, url } = Zotero.Embeddings.Endpoint.getCommand();
assert.include(command, 'hotchpotch/bekko-embedding-v1-a8m-GGUF:F16');