dsh-plugin-term-dictionary 0.0.0-stage → 1.1.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/CHANGELOG.md +189 -0
- package/LICENSE +21 -0
- package/README.md +776 -2
- package/cordis.patch.yml +14 -0
- package/icon.svg +13 -0
- package/lib/ROADMAP-lexicon.md +52 -0
- package/lib/client.js +13354 -0
- package/lib/core/api.js +278 -0
- package/lib/core/bus.js +98 -0
- package/lib/core/copy.js +614 -0
- package/lib/core/core.js +309 -0
- package/lib/core/dictionary.js +1187 -0
- package/lib/core/entries.js +454 -0
- package/lib/core/highlight.js +282 -0
- package/lib/core/hover.js +470 -0
- package/lib/core/hovercard.js +173 -0
- package/lib/core/interact.js +1802 -0
- package/lib/core/lexicon.en.js +872 -0
- package/lib/core/lexicon.zh.js +249 -0
- package/lib/core/overlay.js +239 -0
- package/lib/core/pack.js +372 -0
- package/lib/core/package.json +4 -0
- package/lib/core/selection.js +83 -0
- package/lib/core/settings.js +366 -0
- package/lib/core/shell.js +1003 -0
- package/lib/core/stopwords.js +147 -0
- package/lib/core/store.js +397 -0
- package/lib/core/styles.js +574 -0
- package/lib/core/terms.js +398 -0
- package/lib/core/transfer.js +382 -0
- package/lib/core/views.js +2428 -0
- package/lib/index.js +1110 -0
- package/lib/pack-code.js +84 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +71 -3
|
@@ -0,0 +1,1003 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The plugin shell: wiring for the browser half.
|
|
5
|
+
*
|
|
6
|
+
* It owns the four moving parts and nothing else —
|
|
7
|
+
* - the dictionary store, hydrated from local storage and the host;
|
|
8
|
+
* - the detector, rebuilt whenever the dictionary changes;
|
|
9
|
+
* - the conversation interaction layer (auto-collection, click, selection);
|
|
10
|
+
* - the three registered surfaces: the sidebar panel row, the center panel, and
|
|
11
|
+
* the overlay that carries the popup, the selection affordance and the toast.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const React = require("react");
|
|
15
|
+
const core = require("./core.js");
|
|
16
|
+
const copy = require("./copy.js");
|
|
17
|
+
const { TermDetector } = require("./terms.js");
|
|
18
|
+
const { createDictionaryStore } = require("./store.js");
|
|
19
|
+
const { createBrowserStore } = require("./dictionary.js");
|
|
20
|
+
const { createApiClient } = require("./api.js");
|
|
21
|
+
const { MAX_CONTEXT_CHARS } = require("./entries.js");
|
|
22
|
+
const { createSettingsStore } = require("./settings.js");
|
|
23
|
+
const { createInteractionLayer, readRegion } = require("./interact.js");
|
|
24
|
+
const { createTermHighlighter } = require("./highlight.js");
|
|
25
|
+
const { createPanelCommandBus } = require("./bus.js");
|
|
26
|
+
const { DictionaryGlyph, DictionaryPanel, DeletedList, FlaggedList, PacksPage, SettingsPage, TransferPage } = require("./views.js");
|
|
27
|
+
const { TermPopup, SelectionAffordance, Toast, HighlighterStyle } = require("./overlay.js");
|
|
28
|
+
const { createHoverLayer } = require("./hover.js");
|
|
29
|
+
const { HoverCard, HoverStyle } = require("./hovercard.js");
|
|
30
|
+
|
|
31
|
+
/** Built-in glossaries. A missing pair degrades the detector, never breaks it. */
|
|
32
|
+
const LEXICON_EN = safeRequire("./lexicon.en.js");
|
|
33
|
+
/** Chinese jargon the detector knows with no dictionary entry. */
|
|
34
|
+
const LEXICON_ZH = safeRequire("./lexicon.zh.js");
|
|
35
|
+
/** Words the detector must never treat as jargon on their own. */
|
|
36
|
+
const STOPWORDS = (() => {
|
|
37
|
+
const loaded = safeRequire("./stopwords.js");
|
|
38
|
+
return { EN: Array.isArray(loaded.EN) ? loaded.EN : [], ZH: Array.isArray(loaded.ZH) ? loaded.ZH : [] };
|
|
39
|
+
})();
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Require an optional data module.
|
|
43
|
+
* @param spec - a relative specifier.
|
|
44
|
+
* @returns the module's exports, or an empty object.
|
|
45
|
+
*/
|
|
46
|
+
function safeRequire(spec) {
|
|
47
|
+
try {
|
|
48
|
+
return require(spec) ?? {};
|
|
49
|
+
} catch (error) {
|
|
50
|
+
console.error(`[term-dictionary] optional data module ${spec} is unavailable:`, error);
|
|
51
|
+
return {};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const h = React.createElement;
|
|
56
|
+
|
|
57
|
+
/** Locale namespace owned by this plugin. */
|
|
58
|
+
const NS = "termDictionary";
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Services required by the browser half.
|
|
62
|
+
*
|
|
63
|
+
* `layout` is what selects the Dictionary panel, so it is declared rather than
|
|
64
|
+
* probed: a Cordis context throws on reading an undeclared service, and declaring
|
|
65
|
+
* it keeps the plugin inactive in a composition that has no layout owner instead
|
|
66
|
+
* of failing at the first click.
|
|
67
|
+
*/
|
|
68
|
+
const inject = ["slots", "locale", "layout"];
|
|
69
|
+
|
|
70
|
+
/** The panel id: the sidebar row's id, the `main` slot key and the layout's selection key. */
|
|
71
|
+
const PANEL_ID = "term-dictionary";
|
|
72
|
+
|
|
73
|
+
/** Panel row order in the sidebar list, before the host's own panels. */
|
|
74
|
+
const PANEL_ORDER = 40;
|
|
75
|
+
|
|
76
|
+
/** How long a notice stays on screen. */
|
|
77
|
+
const TOAST_MS = 6000;
|
|
78
|
+
|
|
79
|
+
/** How many explanations may be in flight at once. */
|
|
80
|
+
const MAX_PARALLEL_EXPLAINS = 2;
|
|
81
|
+
|
|
82
|
+
/** How many explanations may wait in the queue before new ones are dropped. */
|
|
83
|
+
const MAX_EXPLAIN_QUEUE = 6;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Identifies the build in the diagnostic output.
|
|
87
|
+
*
|
|
88
|
+
* Bumped by hand on every change to the browser half while the pointer path is being
|
|
89
|
+
* diagnosed. It is also what the sidebar row shows, because that is the one report the
|
|
90
|
+
* page can make about its own bundle without using any of the channels under test.
|
|
91
|
+
*/
|
|
92
|
+
const BUILD_TAG = "td-beacon-2";
|
|
93
|
+
|
|
94
|
+
/** Sequence number behind the report names, so two reports in one millisecond still land. */
|
|
95
|
+
let reportSeq = 0;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A term name no earlier report could have used.
|
|
99
|
+
*
|
|
100
|
+
* `recordSighting` never rewrites the context of an entry that already exists, and refuses
|
|
101
|
+
* one that was deleted outright. Both rules make a working report indistinguishable from a
|
|
102
|
+
* missing one, so every report has to arrive under a name nobody has filed anything under.
|
|
103
|
+
*
|
|
104
|
+
* @param kind - which report this is.
|
|
105
|
+
* @returns the term to file it under.
|
|
106
|
+
*/
|
|
107
|
+
function reportTerm(kind) {
|
|
108
|
+
reportSeq++;
|
|
109
|
+
return `zz-${kind}-${BUILD_TAG}-${String(Date.now()).slice(-6)}-${String(reportSeq)}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Characters a beacon may occupy.
|
|
114
|
+
*
|
|
115
|
+
* Derived from the store's own limit rather than written out, because the number is the fact
|
|
116
|
+
* here: an entry's `context` is clamped by `clampText` inside `createEntry`, on a path both
|
|
117
|
+
* halves share, and the route answers `created` either way. So anything longer arrives as JSON
|
|
118
|
+
* cut mid-string — unparseable, with the decisive head kept and the reason for the failure gone.
|
|
119
|
+
* Five of the reports already on disk are exactly that length and end in an ellipsis. The margin
|
|
120
|
+
* is for the `dropped` list itself growing while the payload shrinks.
|
|
121
|
+
*/
|
|
122
|
+
const MAX_BEACON_CHARS = MAX_CONTEXT_CHARS - 20;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Beacon fields least useful for the pointer question, dropped earliest.
|
|
126
|
+
*
|
|
127
|
+
* `probe` goes first despite being the most detailed field: it is the largest by a wide margin,
|
|
128
|
+
* and the page already holds it — `diagnose` writes it to `__termDictionary.last` and prints it
|
|
129
|
+
* before this runs. Dropping it last would instead cost `blocks`, which is the one field that
|
|
130
|
+
* separates "the settled pass never read a transcript" (`null`) from "it read one with no terms"
|
|
131
|
+
* (`0`) — the distinction every report on disk was too short to make.
|
|
132
|
+
*/
|
|
133
|
+
const BEACON_DROP_ORDER = ["probe", "origin", "ready", "style", "sw", "hints", "transport", "attached", "caret", "hl"];
|
|
134
|
+
|
|
135
|
+
/** Fields a report must keep: without these a shortened report says nothing about the pointer. */
|
|
136
|
+
const BEACON_KEEP = new Set([
|
|
137
|
+
"b",
|
|
138
|
+
"w",
|
|
139
|
+
"at",
|
|
140
|
+
"seen",
|
|
141
|
+
"inR",
|
|
142
|
+
"out",
|
|
143
|
+
"blocked",
|
|
144
|
+
"adopts",
|
|
145
|
+
"clicks",
|
|
146
|
+
"hasRegion",
|
|
147
|
+
"chats",
|
|
148
|
+
"blocks",
|
|
149
|
+
"trace",
|
|
150
|
+
"budget",
|
|
151
|
+
"dropped"
|
|
152
|
+
]);
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The hit-test stage that refuses most, as `name:count`, or `ok:n` when nothing does.
|
|
156
|
+
*
|
|
157
|
+
* A gesture report can say the pointer was inside the transcript hundreds of times while no hint
|
|
158
|
+
* ever appeared, and the reason lives in ten separate stages. Carrying all ten would cost the
|
|
159
|
+
* report its own limit, so this carries the one that answers the question — the tally it is read
|
|
160
|
+
* against is `__termDictionary.stats().hitStages`, which is on the page.
|
|
161
|
+
*
|
|
162
|
+
* @param stages - the layer's stage tally, or undefined.
|
|
163
|
+
* @returns the compact trace, or null when there is no layer yet.
|
|
164
|
+
*/
|
|
165
|
+
function topStage(stages) {
|
|
166
|
+
if (stages === null || stages === undefined) return null;
|
|
167
|
+
let name = "";
|
|
168
|
+
let count = 0;
|
|
169
|
+
for (const [key, value] of Object.entries(stages)) {
|
|
170
|
+
if (key === "ok" || !(value > count)) continue;
|
|
171
|
+
name = key;
|
|
172
|
+
count = value;
|
|
173
|
+
}
|
|
174
|
+
return count === 0 ? `ok:${stages.ok ?? 0}` : `${name}:${count}`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Trim a beacon until the entry store will keep all of it.
|
|
179
|
+
*
|
|
180
|
+
* The payload declares its fields decisive-first, so every drop costs real information; that is
|
|
181
|
+
* why this records what went rather than silently shrinking. A payload that still cannot fit
|
|
182
|
+
* after everything droppable is gone returns the smallest honest report instead of a truncated
|
|
183
|
+
* one, so an oversized probe cannot put the whole heartbeat out of reach.
|
|
184
|
+
*
|
|
185
|
+
* @param payload - the beacon's fields, mutated in place.
|
|
186
|
+
* @returns JSON that fits {@link MAX_BEACON_CHARS}.
|
|
187
|
+
*/
|
|
188
|
+
function fitBeacon(payload) {
|
|
189
|
+
payload.dropped = [];
|
|
190
|
+
const render = () => JSON.stringify(payload);
|
|
191
|
+
if (render().length <= MAX_BEACON_CHARS) return render();
|
|
192
|
+
for (const key of BEACON_DROP_ORDER) {
|
|
193
|
+
if (!(key in payload)) continue;
|
|
194
|
+
delete payload[key];
|
|
195
|
+
payload.dropped.push(key);
|
|
196
|
+
if (render().length <= MAX_BEACON_CHARS) return render();
|
|
197
|
+
}
|
|
198
|
+
// Whatever a later report bolted on that the drop list does not name.
|
|
199
|
+
for (const key of Object.keys(payload)) {
|
|
200
|
+
if (BEACON_KEEP.has(key)) continue;
|
|
201
|
+
delete payload[key];
|
|
202
|
+
payload.dropped.push(key);
|
|
203
|
+
if (render().length <= MAX_BEACON_CHARS) return render();
|
|
204
|
+
}
|
|
205
|
+
return JSON.stringify({ b: payload.b, w: payload.w, at: payload.at, seen: payload.seen, inR: payload.inR, over: true });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Build a detector over the current dictionary.
|
|
210
|
+
* @param state - the dictionary document.
|
|
211
|
+
* @returns a detector.
|
|
212
|
+
*/
|
|
213
|
+
function buildDetector(state) {
|
|
214
|
+
return new TermDetector({
|
|
215
|
+
lexicon: Object.assign({}, LEXICON_EN, LEXICON_ZH),
|
|
216
|
+
entries: state.entries,
|
|
217
|
+
stopwords: STOPWORDS
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** The bundle's entry point. */
|
|
222
|
+
function apply(ctx) {
|
|
223
|
+
const t = ctx.locale.bind(NS);
|
|
224
|
+
const storage = globalThis.localStorage;
|
|
225
|
+
const api = createApiClient({});
|
|
226
|
+
|
|
227
|
+
const store = createDictionaryStore({
|
|
228
|
+
local: createBrowserStore(storage, console),
|
|
229
|
+
remote: api.available() ? { load: () => api.load(), save: (state) => api.save(state) } : null,
|
|
230
|
+
logger: console
|
|
231
|
+
});
|
|
232
|
+
const settings = createSettingsStore({ storage, logger: console });
|
|
233
|
+
|
|
234
|
+
/** The live detector generation, replaced after every dictionary change. */
|
|
235
|
+
let detector = buildDetector(store.getState());
|
|
236
|
+
let interaction = null;
|
|
237
|
+
/** The conversation region the last settled pass walked, for the annotation layer. */
|
|
238
|
+
let transcriptRegion = null;
|
|
239
|
+
const highlighter = createTermHighlighter({ document: globalThis.document, detector, logger: console });
|
|
240
|
+
const bus = createPanelCommandBus();
|
|
241
|
+
|
|
242
|
+
const panelProps = {
|
|
243
|
+
store,
|
|
244
|
+
bus,
|
|
245
|
+
settings,
|
|
246
|
+
explain: (term, context, options) => api.explain(term, explainContext(term, context), explainOptions(options)),
|
|
247
|
+
/**
|
|
248
|
+
* The id of the conversation on screen.
|
|
249
|
+
*
|
|
250
|
+
* The host's feedback channel takes one, and the transcript that carries it is unmounted while
|
|
251
|
+
* this panel holds the main region — so the id comes from the pointer layer, which reads it
|
|
252
|
+
* whenever there IS a transcript to read it from. Empty means "never seen one", which the panel
|
|
253
|
+
* reports rather than sending a remark the host would refuse.
|
|
254
|
+
*/
|
|
255
|
+
sessionId: () => (typeof interaction?.sessionId === "function" ? interaction.sessionId() : ""),
|
|
256
|
+
recordFeedback: (input) => api.feedback(input),
|
|
257
|
+
/**
|
|
258
|
+
* The pack layer's four calls, grouped.
|
|
259
|
+
*
|
|
260
|
+
* The page asks for a pack, a share code, an index or one pack from a source — and never touches
|
|
261
|
+
* the transport itself, which is what keeps the https rule, the disk cache and the checksum on
|
|
262
|
+
* the host's side of the boundary.
|
|
263
|
+
*/
|
|
264
|
+
packApi: {
|
|
265
|
+
build: (input) => api.pack({ action: "build", ...input }),
|
|
266
|
+
decode: (value) => api.pack({ action: "decode", code: value }),
|
|
267
|
+
index: (url, refresh) => api.sourceIndex(url, refresh),
|
|
268
|
+
fetch: (url, sha256) => api.sourcePack(url, sha256)
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
/** The overlay's view state, kept outside React so the interaction layer can drive it. */
|
|
273
|
+
let overlayState = { kind: "closed" };
|
|
274
|
+
const overlayListeners = new Set();
|
|
275
|
+
const setOverlay = (next) => {
|
|
276
|
+
overlayState = next;
|
|
277
|
+
for (const listener of [...overlayListeners]) listener();
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
/** The transient notice's view state. */
|
|
281
|
+
let toastState = null;
|
|
282
|
+
let toastTimer = null;
|
|
283
|
+
const toastListeners = new Set();
|
|
284
|
+
const setToast = (next) => {
|
|
285
|
+
toastState = next;
|
|
286
|
+
if (toastTimer !== null) clearTimeout(toastTimer);
|
|
287
|
+
if (next !== null) {
|
|
288
|
+
toastTimer = setTimeout(() => {
|
|
289
|
+
toastState = null;
|
|
290
|
+
toastTimer = null;
|
|
291
|
+
for (const listener of [...toastListeners]) listener();
|
|
292
|
+
}, TOAST_MS);
|
|
293
|
+
}
|
|
294
|
+
for (const listener of [...toastListeners]) listener();
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
/** The selection affordance's view state. */
|
|
298
|
+
let selectionState = null;
|
|
299
|
+
const selectionListeners = new Set();
|
|
300
|
+
const setSelection = (next) => {
|
|
301
|
+
selectionState = next;
|
|
302
|
+
for (const listener of [...selectionListeners]) listener();
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Open the explanation popup for one term at one point.
|
|
307
|
+
* @param hit - `{ term, key, context, known }` from the interaction layer.
|
|
308
|
+
* @param point - viewport coordinates of the pointer.
|
|
309
|
+
*/
|
|
310
|
+
const openPopup = (hit, point) => {
|
|
311
|
+
// A click that lands on a selection must not fight the affordance.
|
|
312
|
+
setSelection(null);
|
|
313
|
+
setOverlay({ kind: "entry", term: hit.term, key: hit.key, context: hit.context, point });
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Open the explanation popup for an unknown term, or take the user to the entry
|
|
318
|
+
* that a known one already has.
|
|
319
|
+
*
|
|
320
|
+
* The hint already carries the explanation, so a click on a collected term is a
|
|
321
|
+
* navigation: select the panel and let it reveal the row. A glossary term has no
|
|
322
|
+
* row to reveal, so it keeps the popup.
|
|
323
|
+
*
|
|
324
|
+
* @param hit - the clicked term.
|
|
325
|
+
* @param point - viewport coordinates of the pointer.
|
|
326
|
+
*/
|
|
327
|
+
const activate = (hit, point) => {
|
|
328
|
+
const entry = store.find(hit.key);
|
|
329
|
+
if (entry === null) {
|
|
330
|
+
openPopup(hit, point);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
setSelection(null);
|
|
334
|
+
setOverlay({ kind: "closed" });
|
|
335
|
+
ctx.layout.selectPanel(PANEL_ID);
|
|
336
|
+
bus.requestFocus(entry.key);
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
/** Open the editor in the panel, prefilled, and select the panel. */
|
|
340
|
+
const startCreate = (term, context) => {
|
|
341
|
+
setSelection(null);
|
|
342
|
+
setOverlay({ kind: "closed" });
|
|
343
|
+
// `layout` is declared in `inject`, so this throws rather than silently
|
|
344
|
+
// no-ops if the panel selection service is genuinely absent — and in a
|
|
345
|
+
// deployment without ui-layout the plugin never activates at all.
|
|
346
|
+
ctx.layout.selectPanel(PANEL_ID);
|
|
347
|
+
bus.requestCreate(term, context);
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
/** Persist a sighting and refresh the detector when the dictionary grew. */
|
|
351
|
+
const noteSighting = async (sighting) => {
|
|
352
|
+
const key = typeof sighting.key === "string" && sighting.key !== "" ? sighting.key : core.normalizeTerm(sighting.term);
|
|
353
|
+
const result = await store.noteSighting({ ...sighting, glossary: detector.glossaryOf(key) });
|
|
354
|
+
if (result.created) rebuildDetector();
|
|
355
|
+
return result;
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
/** Rebuild the detector from the current dictionary. */
|
|
359
|
+
function rebuildDetector() {
|
|
360
|
+
detector = buildDetector(store.getState());
|
|
361
|
+
interaction?.setDetector(detector);
|
|
362
|
+
// The annotation layer scans with the same generation, so a new or deleted
|
|
363
|
+
// entry appears or disappears in the transcript at the same moment it does in
|
|
364
|
+
// the panel.
|
|
365
|
+
highlighter.setDetector(detector);
|
|
366
|
+
syncAnnotation();
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Paint or clear the transcript marking, according to the switch. */
|
|
370
|
+
function syncAnnotation() {
|
|
371
|
+
if (settings.getSnapshot().autoAnnotate) highlighter.refresh(transcriptRegion);
|
|
372
|
+
else highlighter.clear();
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
//#region diagnostic beacon
|
|
376
|
+
|
|
377
|
+
/** The last report's outcome, for the console and for `__termDictionary`. */
|
|
378
|
+
let beaconOutcome = { state: "pending", at: 0, detail: "" };
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Tell the host what this page can see, without waiting for anyone to interact.
|
|
382
|
+
*
|
|
383
|
+
* TEMPORARY, and CONSOLE-ONLY. It used to file itself into the dictionary, which filled the
|
|
384
|
+
* user's term list with `zz-hb-*` rows on every reload — a diagnostic writing into the data
|
|
385
|
+
* it diagnoses. The outcome is still kept where it can be read: `console.info` below,
|
|
386
|
+
* `globalThis.__termDictionary` and `globalThis.__termDictionaryBeacon`.
|
|
387
|
+
*
|
|
388
|
+
* The fields that measured the hover gesture are gone with it: `seen`, `inR`, `out`,
|
|
389
|
+
* `blocked` and `hints` counted a pointer path this plugin no longer has, and a field that
|
|
390
|
+
* can only ever read `null` is worse than no field — it reads as "the counters are broken"
|
|
391
|
+
* rather than "the counters are gone".
|
|
392
|
+
*
|
|
393
|
+
* @param label - which call this is.
|
|
394
|
+
* @param extra - fields only a later report can carry.
|
|
395
|
+
* @returns the report's outcome.
|
|
396
|
+
*/
|
|
397
|
+
async function beacon(label, extra) {
|
|
398
|
+
const term = reportTerm(`hb-${label}`);
|
|
399
|
+
let answer;
|
|
400
|
+
let text = "";
|
|
401
|
+
try {
|
|
402
|
+
const stats = typeof interaction?.stats === "function" ? interaction.stats() : {};
|
|
403
|
+
const current = settings.getSnapshot();
|
|
404
|
+
const payload = {
|
|
405
|
+
b: BUILD_TAG,
|
|
406
|
+
w: label,
|
|
407
|
+
at: Date.now(),
|
|
408
|
+
adopts: stats.regionAdoptions ?? null,
|
|
409
|
+
clicks: stats.clickSeen ?? null,
|
|
410
|
+
budget: stats.budget ?? null,
|
|
411
|
+
hasRegion: typeof interaction?.hasRegion === "function" ? interaction.hasRegion() : null,
|
|
412
|
+
chats: typeof interaction?.regionCount === "function" ? interaction.regionCount() : null,
|
|
413
|
+
blocks: transcriptRegion === null ? null : readRegion(transcriptRegion).blocks.length,
|
|
414
|
+
// Which hit-test stage a CLICK resolved at. The reason a gesture produced
|
|
415
|
+
// nothing is one of several exits inside `termAtPoint`, and the counters alone
|
|
416
|
+
// cannot tell them apart — they say "the pointer was there and nothing
|
|
417
|
+
// happened" and nothing more.
|
|
418
|
+
trace: topStage(stats.hitStages),
|
|
419
|
+
hl: highlighter.count(),
|
|
420
|
+
attached: stats.attached ?? null,
|
|
421
|
+
caret: stats.caretApi ?? null,
|
|
422
|
+
transport: api.available(),
|
|
423
|
+
style:
|
|
424
|
+
typeof globalThis.document?.querySelector === "function"
|
|
425
|
+
? globalThis.document.querySelector('style[data-term-dictionary="highlight-style"]') !== null
|
|
426
|
+
: null,
|
|
427
|
+
sw: [current.autoCollect, current.autoAnnotate, current.autoExplain],
|
|
428
|
+
ready: globalThis.document?.readyState ?? null,
|
|
429
|
+
origin: globalThis.location?.origin ?? null,
|
|
430
|
+
...extra
|
|
431
|
+
};
|
|
432
|
+
text = fitBeacon(payload);
|
|
433
|
+
} catch (error) {
|
|
434
|
+
// Assembling the report used to be outside the try, so an error there was thrown
|
|
435
|
+
// into a `void beacon(...)` with no rejection handler: the heartbeat stopped
|
|
436
|
+
// without a trace, in exactly the generation whose region had finally been found.
|
|
437
|
+
// A failure now belongs to the report rather than replacing it.
|
|
438
|
+
answer = { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
439
|
+
}
|
|
440
|
+
// Console-only ON PURPOSE.
|
|
441
|
+
//
|
|
442
|
+
// This report used to be filed as a dictionary entry, which is what filled the user's
|
|
443
|
+
// term list with `zz-hb-*` rows on every reload — a diagnostic that writes into the
|
|
444
|
+
// data it is diagnosing. The readouts that actually matter survive: this console line,
|
|
445
|
+
// `globalThis.__termDictionary` and `globalThis.__termDictionaryBeacon`. The user's
|
|
446
|
+
// dictionary stays theirs.
|
|
447
|
+
const detail = "console-only";
|
|
448
|
+
beaconOutcome = { state: "ok", at: Date.now(), detail };
|
|
449
|
+
globalThis.__termDictionaryBeacon = beaconOutcome;
|
|
450
|
+
console.info(`[term-dictionary ${BUILD_TAG}] ${label} report (console only, not filed)`, text);
|
|
451
|
+
return beaconOutcome;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
//#endregion
|
|
455
|
+
|
|
456
|
+
//#region auto-explain
|
|
457
|
+
|
|
458
|
+
/** Explanations waiting for a model call. */
|
|
459
|
+
const explainQueue = [];
|
|
460
|
+
/** How many model calls are in flight. */
|
|
461
|
+
let explaining = 0;
|
|
462
|
+
/**
|
|
463
|
+
* Set once the host says it cannot explain anything.
|
|
464
|
+
*
|
|
465
|
+
* A failure is not per-term: the usual cause is a provider route with no credentials,
|
|
466
|
+
* which will fail identically for the next term. Without this, every collected term
|
|
467
|
+
* would retry the same broken route for the rest of the session.
|
|
468
|
+
*/
|
|
469
|
+
let explainDisabled = false;
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Queue an explanation for a term automatic collection just created.
|
|
473
|
+
*
|
|
474
|
+
* Bounded on purpose: one settled message can create several entries, and firing a
|
|
475
|
+
* model call per entry at once would stack requests the user never asked for.
|
|
476
|
+
*
|
|
477
|
+
* @param item - `{ term, key, context }`.
|
|
478
|
+
*/
|
|
479
|
+
/**
|
|
480
|
+
* The user's explanation preferences, with `auto` resolved to a concrete language.
|
|
481
|
+
*
|
|
482
|
+
* The host builds the prompt, so it is told the language rather than asked to guess it:
|
|
483
|
+
* `auto` means "match the interface", and only the page knows which one that is.
|
|
484
|
+
*
|
|
485
|
+
* @param options - `retry` for a second attempt at an explanation the user rejected.
|
|
486
|
+
* @returns `{ lang, depth, retry }`.
|
|
487
|
+
*/
|
|
488
|
+
function explainOptions(options) {
|
|
489
|
+
const current = settings.getSnapshot();
|
|
490
|
+
let lang = current.explainLang;
|
|
491
|
+
if (lang === "auto") {
|
|
492
|
+
let active = "";
|
|
493
|
+
try {
|
|
494
|
+
active = ctx.locale?.getSnapshot?.()?.active ?? "";
|
|
495
|
+
} catch (error) {
|
|
496
|
+
void error;
|
|
497
|
+
}
|
|
498
|
+
lang = String(active).toLowerCase().startsWith("en") ? "en" : "zh";
|
|
499
|
+
}
|
|
500
|
+
return { lang, depth: current.explainDepth, retry: options?.retry === true };
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* The context to hand the model: the term's sentence, or the whole paragraph it sat in.
|
|
505
|
+
*
|
|
506
|
+
* The layer hands over one sentence, which is right for the popup and thin for a model
|
|
507
|
+
* that is being asked what a term MEANS: a definition often lives in the sentences
|
|
508
|
+
* around it. When the setting is on, the containing block is used instead — it is the
|
|
509
|
+
* same text the annotation layer already reads, so nothing new is measured, and the host
|
|
510
|
+
* clamps it to its own prompt budget.
|
|
511
|
+
*
|
|
512
|
+
* @param term - the term being explained.
|
|
513
|
+
* @param sentence - the layer's context.
|
|
514
|
+
* @returns the context to send.
|
|
515
|
+
*/
|
|
516
|
+
function explainContext(term, sentence) {
|
|
517
|
+
const base = typeof sentence === "string" ? sentence : "";
|
|
518
|
+
if (settings.getSnapshot().explainParagraph !== true) return base;
|
|
519
|
+
const region = transcriptRegion;
|
|
520
|
+
if (region === null || region === undefined) return base;
|
|
521
|
+
const needle = typeof term === "string" ? term.trim().toLowerCase() : "";
|
|
522
|
+
if (needle === "") return base;
|
|
523
|
+
try {
|
|
524
|
+
for (const block of readRegion(region).blocks) {
|
|
525
|
+
const text = typeof block.text === "string" ? block.text : "";
|
|
526
|
+
if (text === "" || !text.toLowerCase().includes(needle)) continue;
|
|
527
|
+
// Only ever widen: a block that reads shorter than the sentence is not a
|
|
528
|
+
// paragraph the model benefits from.
|
|
529
|
+
return text.length > base.length ? text : base;
|
|
530
|
+
}
|
|
531
|
+
} catch (error) {
|
|
532
|
+
warn("reading the paragraph for the explanation failed", error);
|
|
533
|
+
}
|
|
534
|
+
return base;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function requestExplanation(item) {
|
|
538
|
+
if (explainDisabled || !settings.getSnapshot().autoExplain) return;
|
|
539
|
+
const entry = store.find(item.key);
|
|
540
|
+
// Already explained — by the glossary, by the user, or by an earlier pass.
|
|
541
|
+
if (entry !== null && (entry.definition.gloss !== "" || entry.definition.zh !== "")) return;
|
|
542
|
+
// The user said this entry's explanation is not to be trusted, so do not spend a model call on
|
|
543
|
+
// one that would be thrown away. The RULE itself lives in `recordSighting` — this is only the
|
|
544
|
+
// cheaper half of it, and the data layer stays the authority because every automatic writer
|
|
545
|
+
// goes through there.
|
|
546
|
+
if (entry !== null && entry.untrusted === true) return;
|
|
547
|
+
if (explainQueue.length >= MAX_EXPLAIN_QUEUE) return;
|
|
548
|
+
explainQueue.push(item);
|
|
549
|
+
void pumpExplanations();
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** Run queued explanations, at most {@link MAX_PARALLEL_EXPLAINS} at a time. */
|
|
553
|
+
async function pumpExplanations() {
|
|
554
|
+
if (explaining >= MAX_PARALLEL_EXPLAINS) return;
|
|
555
|
+
const next = explainQueue.shift();
|
|
556
|
+
if (next === undefined) return;
|
|
557
|
+
explaining++;
|
|
558
|
+
try {
|
|
559
|
+
const answer = await api.explain(next.term, explainContext(next.term, next.context), explainOptions());
|
|
560
|
+
if (answer.ok === true && answer.definition !== null && typeof answer.definition === "object") {
|
|
561
|
+
// The same path the popup's "用模型生成解释" uses, so a generated explanation
|
|
562
|
+
// carries the same provenance and the same revival authority.
|
|
563
|
+
await noteSighting({
|
|
564
|
+
term: next.term,
|
|
565
|
+
key: next.key,
|
|
566
|
+
context: next.context,
|
|
567
|
+
definition: answer.definition,
|
|
568
|
+
source: "llm",
|
|
569
|
+
confidence: 0.9
|
|
570
|
+
});
|
|
571
|
+
} else {
|
|
572
|
+
explainDisabled = true;
|
|
573
|
+
explainQueue.length = 0;
|
|
574
|
+
setToast({ kind: "auto", text: t("toastExplainUnavailable", { message: answer.error ?? "" }), undoable: false });
|
|
575
|
+
}
|
|
576
|
+
} catch (error) {
|
|
577
|
+
explainDisabled = true;
|
|
578
|
+
explainQueue.length = 0;
|
|
579
|
+
console.warn("[term-dictionary] auto-explanation failed:", error);
|
|
580
|
+
} finally {
|
|
581
|
+
explaining--;
|
|
582
|
+
void pumpExplanations();
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
//#endregion
|
|
587
|
+
|
|
588
|
+
// The store is the single source of dictionary truth, so the detector follows
|
|
589
|
+
// it rather than being invalidated by each mutation site.
|
|
590
|
+
ctx.effect(() => store.subscribe(() => rebuildDetector()), "term-dictionary: detector follows the dictionary");
|
|
591
|
+
|
|
592
|
+
// The switches reach the two layers that act on them. `interaction` may not exist yet
|
|
593
|
+
// when this first runs (it is registered further down), which is why its own effect
|
|
594
|
+
// pushes the current value in as soon as it is built.
|
|
595
|
+
ctx.effect(() => {
|
|
596
|
+
const apply = () => {
|
|
597
|
+
const current = settings.getSnapshot();
|
|
598
|
+
interaction?.setAutoCollect(current.autoCollect);
|
|
599
|
+
syncAnnotation();
|
|
600
|
+
};
|
|
601
|
+
apply();
|
|
602
|
+
return settings.subscribe(apply);
|
|
603
|
+
}, "term-dictionary: the switches reach the layers");
|
|
604
|
+
|
|
605
|
+
// Hydration is what loads the dictionary at all: local storage first, then the
|
|
606
|
+
// host's file when the Web carrier is reachable. Nothing else reads storage, so
|
|
607
|
+
// a plugin that never calls this shows an empty panel forever.
|
|
608
|
+
ctx.effect(() => {
|
|
609
|
+
let alive = true;
|
|
610
|
+
void store
|
|
611
|
+
.hydrate()
|
|
612
|
+
.then(() => {
|
|
613
|
+
if (alive) rebuildDetector();
|
|
614
|
+
})
|
|
615
|
+
.catch((error) => {
|
|
616
|
+
console.error("[term-dictionary] hydration failed:", error);
|
|
617
|
+
});
|
|
618
|
+
return () => {
|
|
619
|
+
alive = false;
|
|
620
|
+
};
|
|
621
|
+
}, "term-dictionary: hydrate the dictionary");
|
|
622
|
+
|
|
623
|
+
ctx.effect(
|
|
624
|
+
() =>
|
|
625
|
+
ctx.locale.register(NS, {
|
|
626
|
+
zh: copy.zh,
|
|
627
|
+
en: copy.en
|
|
628
|
+
}),
|
|
629
|
+
"term-dictionary: dictionaries"
|
|
630
|
+
);
|
|
631
|
+
|
|
632
|
+
// Rebuild the detector after a locale change so the panel's own copy and any
|
|
633
|
+
// locale-dependent label read fresh values; the lexicons themselves are fixed.
|
|
634
|
+
ctx.effect(() => {
|
|
635
|
+
const off = ctx.locale.subscribe(() => rebuildDetector());
|
|
636
|
+
return () => off();
|
|
637
|
+
}, "term-dictionary: detector follows the locale");
|
|
638
|
+
|
|
639
|
+
//#region the pointer layer
|
|
640
|
+
|
|
641
|
+
/** Listeners and timers the hover layer owns, so one effect can dispose of them. */
|
|
642
|
+
const hoverDisposers = [];
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* What the hover card shows for one marked term.
|
|
646
|
+
*
|
|
647
|
+
* The user's own entry wins; a term with an entry but no explanation yet falls back to the
|
|
648
|
+
* built-in glossary, so hovering a half-finished entry still says something rather than
|
|
649
|
+
* nothing.
|
|
650
|
+
*
|
|
651
|
+
* @param key - the entry key.
|
|
652
|
+
* @param term - the term as it appears in the text.
|
|
653
|
+
* @returns `{ key, term, zh, gloss, usage, domain }`.
|
|
654
|
+
*/
|
|
655
|
+
function hoverEntryFor(key, term) {
|
|
656
|
+
const entry = typeof store?.find === "function" ? store.find(key) : null;
|
|
657
|
+
const glossary = typeof detector?.glossaryOf === "function" ? detector.glossaryOf(key) : null;
|
|
658
|
+
if (entry !== null && entry !== undefined) {
|
|
659
|
+
return {
|
|
660
|
+
key,
|
|
661
|
+
term: entry.term,
|
|
662
|
+
zh: entry.definition.zh !== "" ? entry.definition.zh : (glossary?.zh ?? ""),
|
|
663
|
+
gloss: entry.definition.gloss !== "" ? entry.definition.gloss : (glossary?.gloss ?? ""),
|
|
664
|
+
usage: entry.definition.usage ?? "",
|
|
665
|
+
domain: entry.domain ?? ""
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
if (glossary === undefined || glossary === null) return { key, term, zh: "", gloss: "", usage: "", domain: "" };
|
|
669
|
+
return { key, term, zh: glossary.zh ?? "", gloss: glossary.gloss ?? "", usage: glossary.usage ?? "", domain: "" };
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* The pointer layer.
|
|
674
|
+
*
|
|
675
|
+
* Its INPUT is the annotation layer's own segment list — the occurrences that are already
|
|
676
|
+
* marked — so the hit test cannot disagree with the marking about what a marked term is.
|
|
677
|
+
*/
|
|
678
|
+
const hover = createHoverLayer({
|
|
679
|
+
document: globalThis.document,
|
|
680
|
+
logger: console,
|
|
681
|
+
segments: () => highlighter.segments(),
|
|
682
|
+
// The two hover delays, read fresh per gesture so a knob takes effect on the next hover
|
|
683
|
+
// rather than on the next reload.
|
|
684
|
+
delays: () => {
|
|
685
|
+
const current = settings.getSnapshot();
|
|
686
|
+
return { inMs: current.hoverInMs, outMs: current.hoverOutMs };
|
|
687
|
+
},
|
|
688
|
+
// A click on a marked term is a navigation: open its entry in the panel this plugin owns.
|
|
689
|
+
onActivate: (segment) => {
|
|
690
|
+
try {
|
|
691
|
+
activate({ term: segment.term, key: segment.key, id: segment.id, context: "" });
|
|
692
|
+
} catch (error) {
|
|
693
|
+
console.warn("term-dictionary: opening the hovered entry failed:", error instanceof Error ? error.message : error);
|
|
694
|
+
}
|
|
695
|
+
},
|
|
696
|
+
disposers: hoverDisposers
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
ctx.effect(() => {
|
|
700
|
+
hover.attach();
|
|
701
|
+
return () => {
|
|
702
|
+
hover.detach();
|
|
703
|
+
for (const dispose of hoverDisposers.splice(0)) {
|
|
704
|
+
try {
|
|
705
|
+
dispose();
|
|
706
|
+
} catch (error) {
|
|
707
|
+
void error;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
};
|
|
711
|
+
}, "term-dictionary: the pointer layer");
|
|
712
|
+
|
|
713
|
+
//#endregion
|
|
714
|
+
|
|
715
|
+
//#region surfaces
|
|
716
|
+
|
|
717
|
+
ctx.slots.inject("sidebar.panellist", () =>
|
|
718
|
+
ctx.slots.register(
|
|
719
|
+
{
|
|
720
|
+
name: "sidebar.panellist",
|
|
721
|
+
id: PANEL_ID,
|
|
722
|
+
order: PANEL_ORDER,
|
|
723
|
+
label: () => t("nav"),
|
|
724
|
+
locale: NS
|
|
725
|
+
},
|
|
726
|
+
DictionaryGlyph
|
|
727
|
+
)
|
|
728
|
+
);
|
|
729
|
+
|
|
730
|
+
ctx.slots.inject("main", () =>
|
|
731
|
+
ctx.slots.register(
|
|
732
|
+
{
|
|
733
|
+
name: "main",
|
|
734
|
+
key: PANEL_ID,
|
|
735
|
+
locale: NS,
|
|
736
|
+
inject: () => panelProps
|
|
737
|
+
},
|
|
738
|
+
DictionaryPanel
|
|
739
|
+
)
|
|
740
|
+
);
|
|
741
|
+
|
|
742
|
+
ctx.slots.inject("shell.overlay", () =>
|
|
743
|
+
ctx.slots.register(
|
|
744
|
+
{
|
|
745
|
+
name: "shell.overlay",
|
|
746
|
+
id: "term-dictionary-overlay",
|
|
747
|
+
order: 60,
|
|
748
|
+
locale: NS,
|
|
749
|
+
inject: () => ({
|
|
750
|
+
store,
|
|
751
|
+
api,
|
|
752
|
+
t,
|
|
753
|
+
hover: hover.view,
|
|
754
|
+
hoverEntry: hoverEntryFor,
|
|
755
|
+
overlay: {
|
|
756
|
+
getSnapshot: () => overlayState,
|
|
757
|
+
subscribe: (listener) => {
|
|
758
|
+
overlayListeners.add(listener);
|
|
759
|
+
return () => overlayListeners.delete(listener);
|
|
760
|
+
}
|
|
761
|
+
},
|
|
762
|
+
selection: {
|
|
763
|
+
getSnapshot: () => selectionState,
|
|
764
|
+
subscribe: (listener) => {
|
|
765
|
+
selectionListeners.add(listener);
|
|
766
|
+
return () => selectionListeners.delete(listener);
|
|
767
|
+
}
|
|
768
|
+
},
|
|
769
|
+
toast: {
|
|
770
|
+
getSnapshot: () => toastState,
|
|
771
|
+
subscribe: (listener) => {
|
|
772
|
+
toastListeners.add(listener);
|
|
773
|
+
return () => toastListeners.delete(listener);
|
|
774
|
+
}
|
|
775
|
+
},
|
|
776
|
+
close: () => setOverlay({ kind: "closed" }),
|
|
777
|
+
startCreate,
|
|
778
|
+
setToast,
|
|
779
|
+
noteSighting,
|
|
780
|
+
rebuildDetector
|
|
781
|
+
})
|
|
782
|
+
},
|
|
783
|
+
TermOverlay
|
|
784
|
+
)
|
|
785
|
+
);
|
|
786
|
+
|
|
787
|
+
//#endregion
|
|
788
|
+
|
|
789
|
+
//#region interaction
|
|
790
|
+
|
|
791
|
+
ctx.effect(() => {
|
|
792
|
+
interaction = createInteractionLayer({
|
|
793
|
+
detector,
|
|
794
|
+
store,
|
|
795
|
+
isWordCharacter: core.isWordCharacter,
|
|
796
|
+
// The layer collects; the shell owns the switches. Handed over as a thunk so a flip
|
|
797
|
+
// takes effect on the next settled message rather than on the next page load.
|
|
798
|
+
collectSettings: () => settings.getSnapshot(),
|
|
799
|
+
logger: console,
|
|
800
|
+
onExplain: (hit, point) => openPopup(hit, point),
|
|
801
|
+
onActivate: (hit, point) => activate(hit, point),
|
|
802
|
+
onSettled: (region) => {
|
|
803
|
+
transcriptRegion = region;
|
|
804
|
+
syncAnnotation();
|
|
805
|
+
},
|
|
806
|
+
// Automatic collection reports what it created, so the shell can ask the model
|
|
807
|
+
// for the explanations. The layer collects; the shell is what owns the API.
|
|
808
|
+
onCollected: (items) => {
|
|
809
|
+
for (const item of items) requestExplanation(item);
|
|
810
|
+
},
|
|
811
|
+
onSelection: (next) => setSelection(next === null ? null : { term: next.text, context: next.context, rect: next.rect }),
|
|
812
|
+
// An explanation card sits over the transcript with nothing else to dismiss it:
|
|
813
|
+
// the pointer moving away only closes a hint, and the card is in the layer's own
|
|
814
|
+
// blocked set, so hovering it is refused and hovering past it reaches nothing behind
|
|
815
|
+
// it. Pressing anywhere that is not ours closes it, which is what makes the next hover
|
|
816
|
+
// reachable again. A selection affordance is left alone — it belongs to the selection.
|
|
817
|
+
onPressOutside: () => {
|
|
818
|
+
if (overlayState.kind === "entry") setOverlay({ kind: "closed" });
|
|
819
|
+
},
|
|
820
|
+
// Diagnostic: a page where the pointer gestures do nothing cannot say why from
|
|
821
|
+
// the outside. This prints the pipeline's own reading for the first few gestures of
|
|
822
|
+
// each kind — hover, click, and the rejections that used to be silent — and files it
|
|
823
|
+
// under the same transport as the heartbeat. It is not part of the feature and is
|
|
824
|
+
// removed once the pointer path is confirmed.
|
|
825
|
+
diagnose: (kind, record) => {
|
|
826
|
+
try {
|
|
827
|
+
globalThis.__termDictionary.last = { kind, record };
|
|
828
|
+
console.info(`[term-dictionary ${BUILD_TAG}] ${kind}`, JSON.stringify(record));
|
|
829
|
+
// The same channel as the boot report, so a probe that cannot be delivered
|
|
830
|
+
// says so instead of going quiet.
|
|
831
|
+
void beacon(kind, { probe: record });
|
|
832
|
+
} catch (error) {
|
|
833
|
+
console.warn("[term-dictionary] the diagnostic failed:", error);
|
|
834
|
+
}
|
|
835
|
+
},
|
|
836
|
+
onToast: (notice) => {
|
|
837
|
+
if (notice?.count > 0) {
|
|
838
|
+
setToast({ kind: "auto", text: t("toastAutoAdded", { count: String(notice.count) }), undoable: false });
|
|
839
|
+
rebuildDetector();
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
});
|
|
843
|
+
// The console handle is installed here rather than from inside `diagnose`, so it exists
|
|
844
|
+
// whether or not a gesture has ever been reported. When it lived behind the diagnostic
|
|
845
|
+
// gate, the page that was failing had no `__termDictionary` to inspect at all: the tool
|
|
846
|
+
// that would have answered "is the layer attached" was itself one of the things under
|
|
847
|
+
// test, and `stats()` is precisely the answer that separates "no events reached us" from
|
|
848
|
+
// "events reached us, there was no region".
|
|
849
|
+
globalThis.__termDictionary = {
|
|
850
|
+
build: BUILD_TAG,
|
|
851
|
+
last: null,
|
|
852
|
+
probe: (x, y) => interaction?.probe?.(x, y) ?? null,
|
|
853
|
+
stats: () => interaction?.stats?.() ?? null,
|
|
854
|
+
hasRegion: () => interaction?.hasRegion?.() ?? null,
|
|
855
|
+
regionCount: () => interaction?.regionCount?.() ?? null,
|
|
856
|
+
beacon: () => beaconOutcome
|
|
857
|
+
};
|
|
858
|
+
interaction.attach();
|
|
859
|
+
// The layer is built with collection ON, so a switch that is already off has to be
|
|
860
|
+
// pushed into it here — the settings effect ran before the layer existed.
|
|
861
|
+
interaction.setAutoCollect(settings.getSnapshot().autoCollect);
|
|
862
|
+
// Diagnostic: the layer's own counters, reported the moment it exists rather than on a
|
|
863
|
+
// timer that a hidden page or a re-run of this effect could cancel.
|
|
864
|
+
void beacon("layer");
|
|
865
|
+
return () => {
|
|
866
|
+
interaction?.detach();
|
|
867
|
+
interaction = null;
|
|
868
|
+
transcriptRegion = null;
|
|
869
|
+
// The registry entry outlives the region it pointed at, so it is dropped with
|
|
870
|
+
// the layer rather than left marking whatever the page shows next.
|
|
871
|
+
highlighter.clear();
|
|
872
|
+
};
|
|
873
|
+
}, "term-dictionary: conversation interaction");
|
|
874
|
+
|
|
875
|
+
//#endregion
|
|
876
|
+
|
|
877
|
+
// Diagnostic: the page says it is here, before anything it depends on has a chance to
|
|
878
|
+
// stay quiet. This is the last statement of activation, so if `apply` ran at all the
|
|
879
|
+
// report was attempted — and `beacon` makes the attempt's own failure visible.
|
|
880
|
+
void beacon("boot");
|
|
881
|
+
|
|
882
|
+
ctx.effect(() => () => {
|
|
883
|
+
store.dispose();
|
|
884
|
+
settings.dispose();
|
|
885
|
+
explainQueue.length = 0;
|
|
886
|
+
if (toastTimer !== null) clearTimeout(toastTimer);
|
|
887
|
+
}, "term-dictionary: teardown");
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
exports.apply = apply;
|
|
891
|
+
exports.inject = inject;
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* The single overlay surface: the explanation popup, the selection affordance and
|
|
895
|
+
* the transient notice. They share one registration because they are one layer —
|
|
896
|
+
* all three are fixed-position elements owned by the page rather than by the
|
|
897
|
+
* conversation's own tree.
|
|
898
|
+
*
|
|
899
|
+
* @param props - injected stores, the API client, and the callbacks the layer.
|
|
900
|
+
* @returns the layer's elements.
|
|
901
|
+
*/
|
|
902
|
+
function TermOverlay(props) {
|
|
903
|
+
const { store, api, t } = props;
|
|
904
|
+
const view = React.useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
|
|
905
|
+
const overlay = React.useSyncExternalStore(props.overlay.subscribe, props.overlay.getSnapshot, props.overlay.getSnapshot);
|
|
906
|
+
const selection = React.useSyncExternalStore(props.selection.subscribe, props.selection.getSnapshot, props.selection.getSnapshot);
|
|
907
|
+
const toast = React.useSyncExternalStore(props.toast.subscribe, props.toast.getSnapshot, props.toast.getSnapshot);
|
|
908
|
+
const hover = React.useSyncExternalStore(props.hover.subscribe, props.hover.getSnapshot, props.hover.getSnapshot);
|
|
909
|
+
const [pending, setPending] = React.useState(null);
|
|
910
|
+
const entry = overlay.kind === "entry" ? store.find(overlay.term) : null;
|
|
911
|
+
// What the hover card says, resolved from the pointer layer's own state: the segment names the
|
|
912
|
+
// term, this plugin's store and glossary name the explanation.
|
|
913
|
+
const hovered = hover.kind === "open" ? props.hoverEntry(hover.segment.key, hover.segment.term) : null;
|
|
914
|
+
|
|
915
|
+
return h(
|
|
916
|
+
React.Fragment,
|
|
917
|
+
null,
|
|
918
|
+
// The annotation layer's stylesheet. Mounted with the overlay so it cannot
|
|
919
|
+
// outlive the plugin.
|
|
920
|
+
h(HighlighterStyle, { key: "highlight-style" }),
|
|
921
|
+
// The pointer layer's stylesheet: the hovered term's emphasis and the pointer cursor.
|
|
922
|
+
h(HoverStyle, { key: "hover-style" }),
|
|
923
|
+
hovered === null ? null : h(HoverCard, { key: "hover-card", entry: hovered, anchor: hover.anchor, t }),
|
|
924
|
+
selection === null
|
|
925
|
+
? null
|
|
926
|
+
: h(SelectionAffordance, {
|
|
927
|
+
selection,
|
|
928
|
+
label: t("selectAdd"),
|
|
929
|
+
title: t("selectAddTitle", { term: selection.term }),
|
|
930
|
+
onAdd: () => props.startCreate(selection.term, selection.context),
|
|
931
|
+
onClose: () => props.setSelection?.(null)
|
|
932
|
+
}),
|
|
933
|
+
overlay.kind === "entry"
|
|
934
|
+
? h(TermPopup, {
|
|
935
|
+
term: overlay.term,
|
|
936
|
+
entry,
|
|
937
|
+
context: overlay.context,
|
|
938
|
+
point: overlay.point,
|
|
939
|
+
t,
|
|
940
|
+
pending,
|
|
941
|
+
onCreate: () => props.startCreate(overlay.term, overlay.context),
|
|
942
|
+
onClose: () => props.close(),
|
|
943
|
+
onGenerate: async () => {
|
|
944
|
+
setPending({ phase: "running" });
|
|
945
|
+
const answer = await api.explain(overlay.term, explainContext(overlay.term, overlay.context ?? ""), explainOptions());
|
|
946
|
+
if (answer.ok) {
|
|
947
|
+
await props.noteSighting({
|
|
948
|
+
term: overlay.term,
|
|
949
|
+
key: core.normalizeTerm(overlay.term),
|
|
950
|
+
context: overlay.context ?? "",
|
|
951
|
+
definition: answer.definition,
|
|
952
|
+
source: "llm",
|
|
953
|
+
confidence: 0.9,
|
|
954
|
+
// The user pressed the button, so this write is allowed to land on an entry they
|
|
955
|
+
// had called untrustworthy — the one way past the data layer's refusal.
|
|
956
|
+
overridesUntrusted: true
|
|
957
|
+
});
|
|
958
|
+
setPending(null);
|
|
959
|
+
props.rebuildDetector();
|
|
960
|
+
} else {
|
|
961
|
+
setPending({ phase: "failed", message: answer.error });
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
})
|
|
965
|
+
: null,
|
|
966
|
+
toast === null
|
|
967
|
+
? null
|
|
968
|
+
: h(Toast, {
|
|
969
|
+
text: toast.text,
|
|
970
|
+
actionLabel: toast.undoable ? t("toastUndo") : null,
|
|
971
|
+
onAction: toast.onAction,
|
|
972
|
+
onClose: () => props.setToast(null)
|
|
973
|
+
})
|
|
974
|
+
);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
exports.apply = apply;
|
|
978
|
+
exports.inject = inject;
|
|
979
|
+
// Exported so the check on a beacon's size can call it. The entry store clamps an entry's
|
|
980
|
+
// context without saying so, which makes "this report was trimmed" a fact about this function
|
|
981
|
+
// rather than about anything observable at the call site.
|
|
982
|
+
exports.fitBeacon = fitBeacon;
|
|
983
|
+
// Not part of the platform contract either. The preferences moved behind the panel's own page
|
|
984
|
+
// state, so a check that only saw the bundle's surfaces could no longer reach them by rendering
|
|
985
|
+
// the panel — and a check that has to simulate a click first is a check that tests the click.
|
|
986
|
+
exports.settingsPage = SettingsPage;
|
|
987
|
+
// And the packs page: reached from the header as well, and it is where the pack layer's four routes,
|
|
988
|
+
// the source list and the shared import path meet.
|
|
989
|
+
exports.packsPage = PacksPage;
|
|
990
|
+
// And the deleted view, for the same reason: it is reached through a tab, so a check that could only
|
|
991
|
+
// render the panel would have to simulate the tab click to see a single tombstone row.
|
|
992
|
+
exports.deletedList = DeletedList;
|
|
993
|
+
// And the flagged view, for the same reason once more: it is reached through a tab, and its rows are
|
|
994
|
+
// about a remark rather than about the entry.
|
|
995
|
+
exports.flaggedList = FlaggedList;
|
|
996
|
+
// And the import/export page. Its decisions are pure (`transfer.js`) and checked there, but the page
|
|
997
|
+
// is where the two halves meet: the category checklist the user ticks, the file they pick, and the
|
|
998
|
+
// report they read. Reached only through the header button, so a check that rendered just the panel
|
|
999
|
+
// could not see any of it.
|
|
1000
|
+
exports.transferPage = TransferPage;
|
|
1001
|
+
// Exported for the same reason as `fitBeacon`: the trace a report carries is the one field that
|
|
1002
|
+
// names the refusing stage, and a check cannot reach it through a page that has no layout.
|
|
1003
|
+
exports.topStage = topStage;
|