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
package/lib/core/api.js
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The page's client for the dictionary host API.
|
|
5
|
+
*
|
|
6
|
+
* Every call answers a discriminated result instead of throwing, because a
|
|
7
|
+
* deployment without the Web carrier is a normal state rather than a failure:
|
|
8
|
+
* the store treats "host unavailable" as "use local storage only".
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Route namespace owned by this plugin; the host registers the same prefix. */
|
|
12
|
+
const ROUTE_PREFIX = "/dsh-term-dictionary";
|
|
13
|
+
|
|
14
|
+
/** How long one host call may take before it is abandoned. */
|
|
15
|
+
const REQUEST_TIMEOUT_MS = 6000;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The deadline for a model-backed explanation. The host allows its own model call
|
|
19
|
+
* 45 seconds, so the client must outwait it rather than abort a request that is
|
|
20
|
+
* still making progress.
|
|
21
|
+
*/
|
|
22
|
+
const EXPLAIN_TIMEOUT_MS = 60_000;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* How much longer than an ordinary call a source fetch may take.
|
|
26
|
+
*
|
|
27
|
+
* The host is fetching somebody else's static file; it gives that 15 seconds, so the page's own
|
|
28
|
+
* deadline has to be able to outwait it rather than abandon a request that is still working.
|
|
29
|
+
*/
|
|
30
|
+
const FETCH_MARGIN_MS = 20_000;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Build the client.
|
|
34
|
+
*
|
|
35
|
+
* @param options - `base` overrides the origin (default: the page's own origin,
|
|
36
|
+
* which is what the Electron shell serves), `timeoutMs` overrides the deadline,
|
|
37
|
+
* and `fetchImpl` allows tests to inject a transport.
|
|
38
|
+
* @returns `{ load, save, explain, available }`.
|
|
39
|
+
*/
|
|
40
|
+
function createApiClient(options) {
|
|
41
|
+
const explicitBase = typeof options?.base === "string" ? options.base.replace(/\/$/, "") : "";
|
|
42
|
+
const timeoutMs = typeof options?.timeoutMs === "number" ? options.timeoutMs : REQUEST_TIMEOUT_MS;
|
|
43
|
+
/** The model-backed call gets its own deadline; see {@link createApiClient}. */
|
|
44
|
+
const explainTimeoutMs = typeof options?.explainTimeoutMs === "number" ? options.explainTimeoutMs : EXPLAIN_TIMEOUT_MS;
|
|
45
|
+
const injectFetch = options?.fetchImpl;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Resolve the transport for one call.
|
|
49
|
+
* @returns the fetch function and the resolved base URL, or null when the page
|
|
50
|
+
* has no fetch at all.
|
|
51
|
+
*/
|
|
52
|
+
function transport() {
|
|
53
|
+
const fetchImpl = injectFetch ?? (typeof globalThis.fetch === "function" ? globalThis.fetch.bind(globalThis) : null);
|
|
54
|
+
if (fetchImpl === null) return null;
|
|
55
|
+
let base = explicitBase;
|
|
56
|
+
if (base === "" && typeof globalThis.location === "object" && globalThis.location !== null && typeof globalThis.location.origin === "string") {
|
|
57
|
+
base = globalThis.location.origin;
|
|
58
|
+
}
|
|
59
|
+
return { fetchImpl, base };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Perform one JSON round trip.
|
|
64
|
+
* @param path - route path below the plugin prefix.
|
|
65
|
+
* @param init - fetch options; a body is serialized by the caller.
|
|
66
|
+
* @param deadlineMs - how long this call may take, when it is not the default.
|
|
67
|
+
* @returns `{ ok: true, value }` or `{ ok: false, error }`; never throws.
|
|
68
|
+
*/
|
|
69
|
+
async function request(path, init, deadlineMs) {
|
|
70
|
+
const resolved = transport();
|
|
71
|
+
if (resolved === null) return { ok: false, error: "no fetch available" };
|
|
72
|
+
const budget = typeof deadlineMs === "number" ? deadlineMs : timeoutMs;
|
|
73
|
+
const controller = typeof AbortController === "function" ? new AbortController() : null;
|
|
74
|
+
const timer = controller === null ? null : setTimeout(() => controller.abort(), budget);
|
|
75
|
+
try {
|
|
76
|
+
const response = await resolved.fetchImpl(`${resolved.base}${ROUTE_PREFIX}${path}`, {
|
|
77
|
+
...init,
|
|
78
|
+
...(controller === null ? {} : { signal: controller.signal }),
|
|
79
|
+
headers: { accept: "application/json", ...(init?.headers ?? {}) }
|
|
80
|
+
});
|
|
81
|
+
if (response === null || typeof response !== "object") return { ok: false, error: "malformed response" };
|
|
82
|
+
const text = typeof response.text === "function" ? await response.text() : "";
|
|
83
|
+
if (response.ok !== true) {
|
|
84
|
+
const detail = parseError(text);
|
|
85
|
+
return { ok: false, error: detail === "" ? `HTTP ${String(response.status)}` : detail };
|
|
86
|
+
}
|
|
87
|
+
if (text.trim() === "") return { ok: true, value: null };
|
|
88
|
+
try {
|
|
89
|
+
return { ok: true, value: JSON.parse(text) };
|
|
90
|
+
} catch (error) {
|
|
91
|
+
return { ok: false, error: `invalid JSON: ${error instanceof Error ? error.message : String(error)}` };
|
|
92
|
+
}
|
|
93
|
+
} catch (error) {
|
|
94
|
+
const aborted = error !== null && typeof error === "object" && error.name === "AbortError";
|
|
95
|
+
return { ok: false, error: aborted ? "host request timed out" : error instanceof Error ? error.message : String(error) };
|
|
96
|
+
} finally {
|
|
97
|
+
if (timer !== null) clearTimeout(timer);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Pull a server-provided message out of an error body, if it is JSON. */
|
|
102
|
+
function parseError(text) {
|
|
103
|
+
if (typeof text !== "string" || text.trim() === "") return "";
|
|
104
|
+
try {
|
|
105
|
+
const parsed = JSON.parse(text);
|
|
106
|
+
if (parsed !== null && typeof parsed === "object" && typeof parsed.error === "string") return parsed.error;
|
|
107
|
+
} catch {
|
|
108
|
+
/* a non-JSON error body is reported by status alone */
|
|
109
|
+
}
|
|
110
|
+
return text.slice(0, 160);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
/** Whether a transport exists at all. @returns true when calls can be attempted. */
|
|
115
|
+
available() {
|
|
116
|
+
return transport() !== null;
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Read the host's document.
|
|
121
|
+
* @returns `{ ok, state }` on success, `{ ok: false, error }` otherwise.
|
|
122
|
+
*/
|
|
123
|
+
async load() {
|
|
124
|
+
const answer = await request("/state", { method: "GET" });
|
|
125
|
+
if (!answer.ok) return answer;
|
|
126
|
+
const value = answer.value;
|
|
127
|
+
if (value === null || typeof value !== "object" || !Array.isArray(value.entries)) {
|
|
128
|
+
return { ok: false, error: "host returned no dictionary" };
|
|
129
|
+
}
|
|
130
|
+
return { ok: true, state: value };
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Replace the host's document with the page's, which the host merges by
|
|
135
|
+
* term. Used for every mutation, batched by the store.
|
|
136
|
+
* @param state - the document to send.
|
|
137
|
+
* @returns `{ ok }` plus the host's merged document when it answers one.
|
|
138
|
+
*/
|
|
139
|
+
async save(state) {
|
|
140
|
+
const answer = await request("/entries", {
|
|
141
|
+
method: "POST",
|
|
142
|
+
headers: { "content-type": "application/json" },
|
|
143
|
+
body: JSON.stringify({ action: "replace", state })
|
|
144
|
+
});
|
|
145
|
+
if (!answer.ok) return answer;
|
|
146
|
+
const value = answer.value;
|
|
147
|
+
if (value !== null && typeof value === "object" && Array.isArray(value.entries)) return { ok: true, state: value };
|
|
148
|
+
return { ok: true };
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Ask the host to explain one term, using the model when it is configured.
|
|
153
|
+
*
|
|
154
|
+
* This call gets a much longer deadline than the others: the host waits up to
|
|
155
|
+
* its own model timeout, and a generation of a few hundred tokens routinely
|
|
156
|
+
* outlives a short request budget. Aborting it early would look like a
|
|
157
|
+
* failure to the user even though the host finished and stored the entry.
|
|
158
|
+
*
|
|
159
|
+
* @param term - the term to explain.
|
|
160
|
+
* @param context - the sentence, or paragraph, the term appeared in.
|
|
161
|
+
* @param options - `lang`, `depth`, and `retry` for a second attempt at an entry whose
|
|
162
|
+
* explanation the user rejected.
|
|
163
|
+
* @returns `{ ok, definition }` or `{ ok: false, error }`.
|
|
164
|
+
*/
|
|
165
|
+
async explain(term, context, options) {
|
|
166
|
+
const answer = await request(
|
|
167
|
+
"/explain",
|
|
168
|
+
{
|
|
169
|
+
method: "POST",
|
|
170
|
+
headers: { "content-type": "application/json" },
|
|
171
|
+
body: JSON.stringify({
|
|
172
|
+
term,
|
|
173
|
+
context: typeof context === "string" ? context : "",
|
|
174
|
+
// The host builds the prompt, so the preferences travel with the request
|
|
175
|
+
// instead of being applied here — there is one prompt builder, not two.
|
|
176
|
+
lang: typeof options?.lang === "string" ? options.lang : "auto",
|
|
177
|
+
depth: typeof options?.depth === "string" ? options.depth : "normal",
|
|
178
|
+
// A boolean, never the rejected text: the host adds one fixed sentence, so the
|
|
179
|
+
// user's own words cannot become instructions the model follows.
|
|
180
|
+
retry: options?.retry === true
|
|
181
|
+
})
|
|
182
|
+
},
|
|
183
|
+
explainTimeoutMs
|
|
184
|
+
);
|
|
185
|
+
if (!answer.ok) return answer;
|
|
186
|
+
const value = answer.value;
|
|
187
|
+
if (value === null || typeof value !== "object" || value.definition === null || typeof value.definition !== "object") {
|
|
188
|
+
return { ok: false, error: "host returned no explanation" };
|
|
189
|
+
}
|
|
190
|
+
return { ok: true, definition: value.definition, source: value.source ?? "llm" };
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Record one remark into the host's own session feedback log.
|
|
195
|
+
*
|
|
196
|
+
* The host's channel, not this plugin's: the remark lands in THAT machine's session log, never
|
|
197
|
+
* in a model's context and never on a server. It is the product-level half of feedback — the
|
|
198
|
+
* content-level half is the report, which only the user can carry out.
|
|
199
|
+
*
|
|
200
|
+
* @param input - `sessionId`, `text` and `category`.
|
|
201
|
+
* @returns `{ ok: true }`, or `{ ok: false, error }` with `unavailable`, `session-not-found`
|
|
202
|
+
* or `rejected` — three different things, and the page says them differently.
|
|
203
|
+
*/
|
|
204
|
+
async feedback(input) {
|
|
205
|
+
const answer = await request("/feedback", {
|
|
206
|
+
method: "POST",
|
|
207
|
+
headers: { "content-type": "application/json" },
|
|
208
|
+
body: JSON.stringify({
|
|
209
|
+
sessionId: typeof input?.sessionId === "string" ? input.sessionId : "",
|
|
210
|
+
text: typeof input?.text === "string" ? input.text : "",
|
|
211
|
+
category: typeof input?.category === "string" ? input.category : "product-interaction"
|
|
212
|
+
})
|
|
213
|
+
});
|
|
214
|
+
if (!answer.ok) return answer;
|
|
215
|
+
const value = answer.value;
|
|
216
|
+
if (value !== null && typeof value === "object" && value.ok === true) return { ok: true };
|
|
217
|
+
return { ok: false, error: typeof value?.error === "string" ? value.error : "rejected" };
|
|
218
|
+
},
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Build a pack from this dictionary, or read one out of a share code.
|
|
222
|
+
*
|
|
223
|
+
* One method for both directions because the host exposes one route for both: the page's two
|
|
224
|
+
* buttons differ only in which `action` they send.
|
|
225
|
+
*
|
|
226
|
+
* @param input - `action`, plus the build fields (`id`, `name`, `author`, `license`, `scope`,
|
|
227
|
+
* `domains`, `code`) or `code` for a decode.
|
|
228
|
+
* @returns `{ ok: true, pack, summary, sha256, bytes, code? }`, or `{ ok: false, error }`.
|
|
229
|
+
*/
|
|
230
|
+
async pack(input) {
|
|
231
|
+
const answer = await request("/pack", {
|
|
232
|
+
method: "POST",
|
|
233
|
+
headers: { "content-type": "application/json" },
|
|
234
|
+
body: JSON.stringify(input ?? {})
|
|
235
|
+
});
|
|
236
|
+
if (!answer.ok) return answer;
|
|
237
|
+
const value = answer.value;
|
|
238
|
+
if (value !== null && typeof value === "object" && value.ok === true) return value;
|
|
239
|
+
return { ok: false, error: typeof value?.error === "string" ? value.error : "unknown" };
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Ask a source what it offers.
|
|
244
|
+
*
|
|
245
|
+
* The deadline is longer than an ordinary call's: the host is fetching somebody else's file, and
|
|
246
|
+
* abandoning the request would not cancel that fetch.
|
|
247
|
+
*
|
|
248
|
+
* @param url - an https URL of a static index.
|
|
249
|
+
* @param refresh - whether to ignore the cache.
|
|
250
|
+
* @returns `{ ok: true, index, cached, stale?, dropped? }`, or `{ ok: false, error }`.
|
|
251
|
+
*/
|
|
252
|
+
async sourceIndex(url, refresh) {
|
|
253
|
+
const query = `?url=${encodeURIComponent(url)}${refresh === true ? "&refresh=1" : ""}`;
|
|
254
|
+
const answer = await request(`/source/index${query}`, { method: "GET" }, timeoutMs + FETCH_MARGIN_MS);
|
|
255
|
+
if (!answer.ok) return answer;
|
|
256
|
+
const value = answer.value;
|
|
257
|
+
if (value !== null && typeof value === "object" && value.ok === true) return value;
|
|
258
|
+
return { ok: false, error: typeof value?.error === "string" ? value.error : "unreachable" };
|
|
259
|
+
},
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Fetch one pack from a source.
|
|
263
|
+
* @param url - an https URL of a pack file.
|
|
264
|
+
* @param sha256 - the digest the index promised, when it promised one.
|
|
265
|
+
* @returns `{ ok: true, pack, summary, sha256, cached }`, or `{ ok: false, error }`.
|
|
266
|
+
*/
|
|
267
|
+
async sourcePack(url, sha256) {
|
|
268
|
+
const digest = typeof sha256 === "string" && sha256 !== "" ? `&sha256=${encodeURIComponent(sha256)}` : "";
|
|
269
|
+
const answer = await request(`/source/pack?url=${encodeURIComponent(url)}${digest}`, { method: "GET" }, timeoutMs + FETCH_MARGIN_MS);
|
|
270
|
+
if (!answer.ok) return answer;
|
|
271
|
+
const value = answer.value;
|
|
272
|
+
if (value !== null && typeof value === "object" && value.ok === true) return value;
|
|
273
|
+
return { ok: false, error: typeof value?.error === "string" ? value.error : "unreachable" };
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
module.exports = { createApiClient, ROUTE_PREFIX, REQUEST_TIMEOUT_MS, EXPLAIN_TIMEOUT_MS, FETCH_MARGIN_MS };
|
package/lib/core/bus.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A one-slot command bus between the conversation surface and the center panel.
|
|
5
|
+
*
|
|
6
|
+
* A click in the transcript has to open the editor *inside the panel* with the
|
|
7
|
+
* term prefilled, and the panel is a separately mounted React tree. Rather than
|
|
8
|
+
* lifting the editor's state into a shared store — which would couple the panel's
|
|
9
|
+
* layout to the transcript — the shell writes one pending command here and the
|
|
10
|
+
* panel consumes it.
|
|
11
|
+
*
|
|
12
|
+
* The panel acknowledges a command by consuming it, so a command issued while the
|
|
13
|
+
* panel is not mounted (the user never opened it) is not replayed later.
|
|
14
|
+
*
|
|
15
|
+
* There are two commands, and they are deliberately separate slots rather than one
|
|
16
|
+
* queue: `create` fills the editor, `focus` reveals an existing entry. A click on a
|
|
17
|
+
* collected term produces a focus, so the panel must not open its editor for it.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @returns a bus with `requestCreate` / `consumeCreate` and `requestFocus` /
|
|
22
|
+
* `consumeFocus`, plus `subscribe` and `peek`.
|
|
23
|
+
*/
|
|
24
|
+
function createPanelCommandBus() {
|
|
25
|
+
let pending = null;
|
|
26
|
+
let pendingFocus = null;
|
|
27
|
+
const listeners = new Set();
|
|
28
|
+
|
|
29
|
+
/** Notify every subscriber that the pending command changed. */
|
|
30
|
+
function emit() {
|
|
31
|
+
for (const listener of [...listeners]) {
|
|
32
|
+
try {
|
|
33
|
+
listener();
|
|
34
|
+
} catch (error) {
|
|
35
|
+
console.warn("[term-dictionary] command listener failed:", error);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
/**
|
|
42
|
+
* Ask the panel to open its editor on a term.
|
|
43
|
+
* @param term - the term to prefill.
|
|
44
|
+
* @param context - the sentence the term appeared in.
|
|
45
|
+
*/
|
|
46
|
+
requestCreate(term, context) {
|
|
47
|
+
pending = { term: typeof term === "string" ? term : "", context: typeof context === "string" ? context : "", at: Date.now() };
|
|
48
|
+
emit();
|
|
49
|
+
},
|
|
50
|
+
/**
|
|
51
|
+
* Read and clear the pending command.
|
|
52
|
+
* @returns the command, or null when there is none.
|
|
53
|
+
*/
|
|
54
|
+
consumeCreate() {
|
|
55
|
+
const command = pending;
|
|
56
|
+
pending = null;
|
|
57
|
+
return command;
|
|
58
|
+
},
|
|
59
|
+
/**
|
|
60
|
+
* Ask the panel to reveal an entry it already holds.
|
|
61
|
+
*
|
|
62
|
+
* This is the other half of clicking a collected term: the explanation is
|
|
63
|
+
* already on screen under the pointer as a hover hint, so the click's job is
|
|
64
|
+
* to take the user to the entry itself.
|
|
65
|
+
*
|
|
66
|
+
* @param key - the entry's normalized key, or its id.
|
|
67
|
+
*/
|
|
68
|
+
requestFocus(key) {
|
|
69
|
+
if (typeof key !== "string" || key === "") return;
|
|
70
|
+
pendingFocus = { key, at: Date.now() };
|
|
71
|
+
emit();
|
|
72
|
+
},
|
|
73
|
+
/**
|
|
74
|
+
* Read and clear the pending reveal.
|
|
75
|
+
* @returns the reveal request, or null when there is none.
|
|
76
|
+
*/
|
|
77
|
+
consumeFocus() {
|
|
78
|
+
const command = pendingFocus;
|
|
79
|
+
pendingFocus = null;
|
|
80
|
+
return command;
|
|
81
|
+
},
|
|
82
|
+
/** Read the pending command without clearing it. @returns the command, or null. */
|
|
83
|
+
peek() {
|
|
84
|
+
return pending ?? pendingFocus;
|
|
85
|
+
},
|
|
86
|
+
/**
|
|
87
|
+
* Observe command changes.
|
|
88
|
+
* @param listener - the callback.
|
|
89
|
+
* @returns the unsubscribe function.
|
|
90
|
+
*/
|
|
91
|
+
subscribe(listener) {
|
|
92
|
+
listeners.add(listener);
|
|
93
|
+
return () => listeners.delete(listener);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = { createPanelCommandBus };
|