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/index.js
ADDED
|
@@ -0,0 +1,1110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host half of the term dictionary.
|
|
3
|
+
*
|
|
4
|
+
* It owns three things the page cannot own by itself:
|
|
5
|
+
* 1. the durable dictionary file, under `$DSH_HOME/dsh-plugin-term-dictionary/`;
|
|
6
|
+
* 2. an HTTP surface on the Web carrier's server, which the page reads and
|
|
7
|
+
* writes through relative `fetch` calls;
|
|
8
|
+
* 3. an optional model call that writes a term's explanation.
|
|
9
|
+
*
|
|
10
|
+
* The merge rules are not re-implemented here: `lib/dictionary.js` is loaded
|
|
11
|
+
* through `createRequire` so the host and the page apply exactly the same
|
|
12
|
+
* normalization and merge, and an edit made offline in the page cannot be
|
|
13
|
+
* mangled when it reaches the file.
|
|
14
|
+
*
|
|
15
|
+
* Only `node:` built-ins are imported. A workspace bundle is installed as a link
|
|
16
|
+
* with no `node_modules` of its own, so a bare `@deepseek-ai/*` import would fail
|
|
17
|
+
* to resolve — and the model, when it is needed, is reached through the
|
|
18
|
+
* injected `llm` service rather than through a package import.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { createRequire } from "node:module";
|
|
22
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
23
|
+
import { homedir, tmpdir } from "node:os";
|
|
24
|
+
import { dirname, join } from "node:path";
|
|
25
|
+
import { decodePackCode, encodePackCode, sha256Hex } from "./pack-code.js";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The package's own CommonJS core, shared verbatim with the browser half.
|
|
29
|
+
*
|
|
30
|
+
* Every core file lives under `lib/core/`, whose own package.json declares
|
|
31
|
+
* `"type": "commonjs"`. That scope marker is load-bearing twice over: the parent
|
|
32
|
+
* package is ESM (so a plain `.js` here would be parsed as a module and this
|
|
33
|
+
* host half's `import` would fail), and the browser bundle's module table needs
|
|
34
|
+
* callable `module.exports` values. The host half stays ESM, so it reaches the
|
|
35
|
+
* core through `createRequire`.
|
|
36
|
+
*/
|
|
37
|
+
const require = createRequire(import.meta.url);
|
|
38
|
+
const dictionary = require("./core/dictionary.js");
|
|
39
|
+
const entries = require("./core/entries.js");
|
|
40
|
+
const packCore = require("./core/pack.js");
|
|
41
|
+
|
|
42
|
+
/** Route prefix owned by this plugin. The browser half spells the same string. */
|
|
43
|
+
const ROUTE_PREFIX = "/dsh-term-dictionary";
|
|
44
|
+
|
|
45
|
+
/** Largest accepted request body. A dictionary is small; anything larger is a bug. */
|
|
46
|
+
const MAX_BODY_BYTES = 4 * 1024 * 1024;
|
|
47
|
+
|
|
48
|
+
/** Deadline for one explanation call. */
|
|
49
|
+
const EXPLAIN_TIMEOUT_MS = 45_000;
|
|
50
|
+
|
|
51
|
+
/** Upper bound on the explanation the model may return. */
|
|
52
|
+
const EXPLAIN_MAX_TOKENS = 700;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The categories the host's feedback API accepts, spelled exactly as the host declares them.
|
|
56
|
+
*
|
|
57
|
+
* A closed set here too: the host validates its own, and passing an id it does not know would fail a
|
|
58
|
+
* remark for a reason the user cannot see. `product-interaction` is the default because that is what a
|
|
59
|
+
* dictionary entry's remark is about.
|
|
60
|
+
*/
|
|
61
|
+
const FEEDBACK_CATEGORIES = [
|
|
62
|
+
"other",
|
|
63
|
+
"task-result",
|
|
64
|
+
"instruction-following",
|
|
65
|
+
"product-interaction",
|
|
66
|
+
"service-stability",
|
|
67
|
+
"resource-cost",
|
|
68
|
+
"security-privacy-permission"
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
/** Upper bound on one feedback remark: it is a line or two, not a document. */
|
|
72
|
+
const MAX_FEEDBACK_CHARS = 500;
|
|
73
|
+
|
|
74
|
+
/** How long a fetched index is reused before a source is asked again. */
|
|
75
|
+
const INDEX_TTL_MS = 60 * 60 * 1000;
|
|
76
|
+
|
|
77
|
+
/** How long a fetched pack is reused. Longer, because a pack is content rather than a listing. */
|
|
78
|
+
const PACK_TTL_MS = 24 * 60 * 60 * 1000;
|
|
79
|
+
|
|
80
|
+
/** Size caps on what a source may hand back, so one URL cannot fill the disk or the heap. */
|
|
81
|
+
const MAX_INDEX_BYTES = 1024 * 1024;
|
|
82
|
+
const MAX_PACK_BYTES = 4 * 1024 * 1024;
|
|
83
|
+
|
|
84
|
+
/** How long one fetch may take. Shorter than the model call: a static file is not thinking. */
|
|
85
|
+
const FETCH_TIMEOUT_MS = 15_000;
|
|
86
|
+
|
|
87
|
+
/** Test seam: injected by the tests, absent in the real host. */
|
|
88
|
+
let runtimeOverrides = null;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The plugin's own version, for the packs it publishes.
|
|
92
|
+
*
|
|
93
|
+
* Read from the package rather than written here, so a published pack names the generation that built
|
|
94
|
+
* it without a second place to keep the number in step. Best effort: a profile that cannot read its
|
|
95
|
+
* own package.json still builds packs, they just do not name a build.
|
|
96
|
+
*
|
|
97
|
+
* @returns the version, or "".
|
|
98
|
+
*/
|
|
99
|
+
let cachedVersion = null;
|
|
100
|
+
function pluginVersion() {
|
|
101
|
+
if (cachedVersion !== null) return cachedVersion;
|
|
102
|
+
cachedVersion = "";
|
|
103
|
+
try {
|
|
104
|
+
const parsed = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
105
|
+
if (typeof parsed?.version === "string") cachedVersion = parsed.version;
|
|
106
|
+
} catch (error) {
|
|
107
|
+
void error;
|
|
108
|
+
}
|
|
109
|
+
return cachedVersion;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Load the shared dictionary core. Indirection through `createRequire` keeps the
|
|
114
|
+
* browser-facing file free of build tooling while giving the host the same code.
|
|
115
|
+
* @returns the dictionary module.
|
|
116
|
+
*/
|
|
117
|
+
function core() {
|
|
118
|
+
return runtimeOverrides?.dictionary ?? dictionary;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Resolve the directory plugin-owned data lives in.
|
|
123
|
+
*
|
|
124
|
+
* The configured path wins. Otherwise the first candidate that can actually be
|
|
125
|
+
* created and written to is used, because `$DSH_HOME` is not always writable —
|
|
126
|
+
* an installation directory under a protected root, or a sandboxed process, can
|
|
127
|
+
* refuse it. Falling back keeps the dictionary working instead of failing every
|
|
128
|
+
* save, and the chosen path is reported to the page so the user can see where the
|
|
129
|
+
* data went.
|
|
130
|
+
*
|
|
131
|
+
* @param configured - a path from the plugin config, when the user set one.
|
|
132
|
+
* @param logger - optional warning sink.
|
|
133
|
+
* @returns `{ directory, writable, candidates }`.
|
|
134
|
+
*/
|
|
135
|
+
function resolveDataDirectory(configured, logger) {
|
|
136
|
+
const home = (process.env.DSH_HOME ?? "").trim() || join(homedir(), ".dsh");
|
|
137
|
+
const primary = typeof configured === "string" && configured.trim() !== "" ? configured.trim() : join(home, "dsh-plugin-term-dictionary");
|
|
138
|
+
const candidates = configured !== undefined && configured !== null && configured !== ""
|
|
139
|
+
? [primary]
|
|
140
|
+
: [primary, join(tmpdir(), "dsh-plugin-term-dictionary")];
|
|
141
|
+
for (const directory of candidates) {
|
|
142
|
+
try {
|
|
143
|
+
mkdirSync(directory, { recursive: true });
|
|
144
|
+
const probe = join(directory, ".write-probe");
|
|
145
|
+
writeFileSync(probe, "", "utf8");
|
|
146
|
+
rmSync(probe, { force: true });
|
|
147
|
+
return { directory, writable: true, candidates };
|
|
148
|
+
} catch (error) {
|
|
149
|
+
logger?.warn?.(
|
|
150
|
+
`term-dictionary: ${directory} is not writable (${error instanceof Error ? error.message : String(error)}); trying the next location`
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// Nothing was writable: report it and keep the dictionary in memory so the
|
|
155
|
+
// session still works, rather than throwing during activation.
|
|
156
|
+
logger?.warn?.(`term-dictionary: no writable data directory among ${candidates.join(", ")}; the dictionary will not persist`);
|
|
157
|
+
return { directory: primary, writable: false, candidates };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The file system surface `lib/dictionary.js` expects. */
|
|
161
|
+
const fileSystem = { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync };
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Read a request body with a hard size ceiling.
|
|
165
|
+
* @param req - the incoming request.
|
|
166
|
+
* @returns the decoded UTF-8 body.
|
|
167
|
+
* @throws {Error} when the body exceeds {@link MAX_BODY_BYTES}.
|
|
168
|
+
*/
|
|
169
|
+
async function readBody(req) {
|
|
170
|
+
const chunks = [];
|
|
171
|
+
let size = 0;
|
|
172
|
+
for await (const chunk of req) {
|
|
173
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
174
|
+
size += buffer.length;
|
|
175
|
+
if (size > MAX_BODY_BYTES) throw new Error("request body too large");
|
|
176
|
+
chunks.push(buffer);
|
|
177
|
+
}
|
|
178
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Write one JSON response.
|
|
183
|
+
* @param res - the response to own.
|
|
184
|
+
* @param status - HTTP status code.
|
|
185
|
+
* @param payload - a JSON-serializable value.
|
|
186
|
+
*/
|
|
187
|
+
function sendJson(res, status, payload) {
|
|
188
|
+
res.statusCode = status;
|
|
189
|
+
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
190
|
+
res.setHeader("cache-control", "no-store");
|
|
191
|
+
res.end(JSON.stringify(payload));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Parse a JSON request body.
|
|
196
|
+
* @param req - the incoming request.
|
|
197
|
+
* @returns the parsed value, or null when the body is not a JSON object.
|
|
198
|
+
*/
|
|
199
|
+
async function readJson(req) {
|
|
200
|
+
const text = await readBody(req);
|
|
201
|
+
if (text.trim() === "") return null;
|
|
202
|
+
const value = JSON.parse(text);
|
|
203
|
+
return value !== null && typeof value === "object" ? value : null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Ask the model service for one term's explanation.
|
|
208
|
+
*
|
|
209
|
+
* The prompt is deliberately narrow: the model answers with a single JSON object,
|
|
210
|
+
* and every field is re-validated here, so a chatty or malformed answer degrades
|
|
211
|
+
* to "no explanation" instead of polluting the dictionary.
|
|
212
|
+
*
|
|
213
|
+
* @param llm - the model service; the caller proves it is mounted before calling.
|
|
214
|
+
* @param config - the resolved plugin config (`provider`, `model`).
|
|
215
|
+
* @param selection - the profile's default model service, when mounted.
|
|
216
|
+
* @param term - the term to explain.
|
|
217
|
+
* @param context - the sentence the term appeared in.
|
|
218
|
+
* @param logger - optional warning sink.
|
|
219
|
+
* @returns the definition fields, or throws with a readable message.
|
|
220
|
+
*/
|
|
221
|
+
async function explainTerm(llm, config, selection, term, context, logger, preferences) {
|
|
222
|
+
const targets = await resolveTargets(llm, config, selection, logger);
|
|
223
|
+
let lastError = null;
|
|
224
|
+
for (let at = 0; at < targets.length; at++) {
|
|
225
|
+
const target = targets[at];
|
|
226
|
+
try {
|
|
227
|
+
return await streamExplanation(llm, target, term, context, logger, preferences);
|
|
228
|
+
} catch (error) {
|
|
229
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
230
|
+
// A route that cannot authenticate is not a dead end. The next candidate is
|
|
231
|
+
// precisely what the user means by "the model I am already signed in to", and
|
|
232
|
+
// reporting the credential error instead is what produced a failure message that
|
|
233
|
+
// named a route the user never chose.
|
|
234
|
+
if (!isCredentialFailure(message) || at === targets.length - 1) throw error;
|
|
235
|
+
logger?.warn?.(`term-dictionary: route ${target.provider} cannot authenticate (${message}); trying the next route`);
|
|
236
|
+
lastError = error;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
throw lastError ?? new Error("no model route could be used");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Stream one explanation through one already-chosen route.
|
|
244
|
+
*
|
|
245
|
+
* Split out of {@link explainTerm} so that a credential failure can be caught per route:
|
|
246
|
+
* the retry has to wrap the whole call, including stream iteration, because the adapter
|
|
247
|
+
* reports a missing key as a terminal `finish` chunk rather than by throwing at dispatch.
|
|
248
|
+
*
|
|
249
|
+
* @param llm - the model service.
|
|
250
|
+
* @param target - the route, from {@link resolveTargets}.
|
|
251
|
+
* @param term - the term to explain.
|
|
252
|
+
* @param context - the text the term appeared in.
|
|
253
|
+
* @param logger - optional warning sink.
|
|
254
|
+
* @param preferences - the user's `{ lang, depth, retry }`.
|
|
255
|
+
* @returns the definition fields, or throws with a readable message.
|
|
256
|
+
*/
|
|
257
|
+
async function streamExplanation(llm, target, term, context, logger, preferences) {
|
|
258
|
+
const controller = typeof AbortController === "function" ? new AbortController() : null;
|
|
259
|
+
const timer = controller === null ? null : setTimeout(() => controller.abort(), EXPLAIN_TIMEOUT_MS);
|
|
260
|
+
try {
|
|
261
|
+
const stream = llm.stream({
|
|
262
|
+
provider: target.provider,
|
|
263
|
+
model: target.model,
|
|
264
|
+
messages: [
|
|
265
|
+
{
|
|
266
|
+
role: "user",
|
|
267
|
+
content: [{ type: "text", text: buildPrompt(term, context) }]
|
|
268
|
+
}
|
|
269
|
+
],
|
|
270
|
+
system: buildSystemPrompt(preferences),
|
|
271
|
+
temperature: 0.2,
|
|
272
|
+
maxTokens: EXPLAIN_MAX_TOKENS,
|
|
273
|
+
...(controller === null ? {} : { signal: controller.signal })
|
|
274
|
+
});
|
|
275
|
+
let text = "";
|
|
276
|
+
let failure = null;
|
|
277
|
+
for await (const chunk of stream) {
|
|
278
|
+
if (chunk === null || typeof chunk !== "object") continue;
|
|
279
|
+
if (chunk.type === "text-delta" && typeof chunk.text === "string") text += chunk.text;
|
|
280
|
+
else if (chunk.type === "finish") failure = finishFailure(chunk.reason);
|
|
281
|
+
}
|
|
282
|
+
if (failure !== null) throw new Error(failure);
|
|
283
|
+
const parsed = parseExplanation(text);
|
|
284
|
+
if (parsed === null) throw new Error("the model did not return a usable explanation");
|
|
285
|
+
return parsed;
|
|
286
|
+
} finally {
|
|
287
|
+
if (timer !== null) clearTimeout(timer);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Decide which provider/model pair to call.
|
|
293
|
+
*
|
|
294
|
+
* The order of preference is the whole point of this function:
|
|
295
|
+
*
|
|
296
|
+
* 1. the plugin's own `config.provider` / `config.model`, when set;
|
|
297
|
+
* 2. **the profile's own default model selection** (`agentDefaultModel`), which is
|
|
298
|
+
* the route the agent in this window is already talking through — so it is a route
|
|
299
|
+
* that is known to be configured and credentialed in *this* deployment;
|
|
300
|
+
* 3. the first registered provider, with the first model it advertises.
|
|
301
|
+
*
|
|
302
|
+
* Step 2 exists because step 3 alone picked the wrong adapter. `listProviders()`
|
|
303
|
+
* returns providers in registration order, and in this profile the official
|
|
304
|
+
* API-key route (`deepseek-official`) is registered before the signed-in account
|
|
305
|
+
* route (`deepseek-account`). Generating an explanation therefore failed with
|
|
306
|
+
*
|
|
307
|
+
* llm-deepseek: no API key for provider route "deepseek-official";
|
|
308
|
+
* store DEEPSEEK_API_KEY through the credentials service
|
|
309
|
+
*
|
|
310
|
+
* while the window itself was happily using `deepseek-account` — the profile's
|
|
311
|
+
* configured provider. Asking the deployment what it uses is the only way to be right
|
|
312
|
+
* about which route can actually answer.
|
|
313
|
+
*
|
|
314
|
+
* @param llm - the model service.
|
|
315
|
+
* @param config - resolved plugin config.
|
|
316
|
+
* @param selection - the profile's default model service, when one is mounted.
|
|
317
|
+
* @param logger - optional warning sink.
|
|
318
|
+
* @returns every route to try, best first.
|
|
319
|
+
* @throws {Error} when nothing can be routed.
|
|
320
|
+
*/
|
|
321
|
+
async function resolveTargets(llm, config, selection, logger) {
|
|
322
|
+
const configured = typeof config.provider === "string" ? config.provider.trim() : "";
|
|
323
|
+
const preferred = readSelection(selection, logger);
|
|
324
|
+
const providers = typeof llm.listProviders === "function" ? llm.listProviders() : [];
|
|
325
|
+
const order = [];
|
|
326
|
+
/** Add a route once, ignoring blanks and duplicates. */
|
|
327
|
+
const push = (provider) => {
|
|
328
|
+
const name = typeof provider === "string" ? provider.trim() : "";
|
|
329
|
+
if (name !== "" && !order.includes(name)) order.push(name);
|
|
330
|
+
};
|
|
331
|
+
if (configured !== "") {
|
|
332
|
+
// An explicit configuration tries ONE route. Falling through to another would ignore
|
|
333
|
+
// the operator's choice, and its failure is theirs to see.
|
|
334
|
+
push(configured);
|
|
335
|
+
} else {
|
|
336
|
+
// The selection is tried even when the catalog does not list it: a route this half
|
|
337
|
+
// cannot see is still the best available answer to "which model is this window using",
|
|
338
|
+
// and the old visibility test is exactly what threw it away and picked the API-key
|
|
339
|
+
// route instead.
|
|
340
|
+
if (preferred !== null) push(preferred.provider);
|
|
341
|
+
if (Array.isArray(providers)) for (const entry of providers) push(entry?.id);
|
|
342
|
+
}
|
|
343
|
+
if (order.length === 0) throw new Error("no model provider is available");
|
|
344
|
+
const targets = [];
|
|
345
|
+
for (const provider of order) {
|
|
346
|
+
const own = preferred !== null && preferred.provider === provider ? preferred.model : "";
|
|
347
|
+
targets.push(await targetFor(llm, config, provider, own));
|
|
348
|
+
}
|
|
349
|
+
return targets;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Whether a failure says "this route has no credential".
|
|
354
|
+
*
|
|
355
|
+
* Only this kind of failure is worth another route. A missing API key is a property of the
|
|
356
|
+
* ROUTE, not of the request, so the next candidate can legitimately succeed; every other
|
|
357
|
+
* failure (a refused request, an unknown model, a timeout) would be a real error sent to a
|
|
358
|
+
* second provider for no reason, so it is reported as it is.
|
|
359
|
+
*
|
|
360
|
+
* @param message - the failure text.
|
|
361
|
+
* @returns true when trying the next route is justified.
|
|
362
|
+
*/
|
|
363
|
+
function isCredentialFailure(message) {
|
|
364
|
+
return /no api key|api key|apikey|credential|unauthoriz|unauthoris|unauthorized|not authenticated/i.test(String(message ?? ""));
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Read the profile's default model selection, defensively.
|
|
369
|
+
*
|
|
370
|
+
* An unmounted owner, a service that throws, or a half-filled selection all yield null so the
|
|
371
|
+
* caller falls through to the catalog rather than failing the call.
|
|
372
|
+
*
|
|
373
|
+
* @param selection - the `agentDefaultModel` service, when mounted.
|
|
374
|
+
* @param logger - optional warning sink.
|
|
375
|
+
* @returns `{ provider, model }`, or null.
|
|
376
|
+
*/
|
|
377
|
+
function readSelection(selection, logger) {
|
|
378
|
+
if (selection === null || selection === undefined) return null;
|
|
379
|
+
try {
|
|
380
|
+
const current = typeof selection.currentSelection === "function" ? selection.currentSelection() : null;
|
|
381
|
+
if (current !== null && typeof current === "object" && typeof current.provider === "string" && current.provider.trim() !== "") {
|
|
382
|
+
return {
|
|
383
|
+
provider: current.provider.trim(),
|
|
384
|
+
model: typeof current.model === "string" && current.model.trim() !== "" ? current.model.trim() : ""
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
} catch (error) {
|
|
388
|
+
logger?.warn?.(`term-dictionary: reading the default model selection failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
389
|
+
}
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Choose a model for one provider.
|
|
395
|
+
*
|
|
396
|
+
* @param llm - the model service.
|
|
397
|
+
* @param config - the resolved plugin config.
|
|
398
|
+
* @param provider - the route.
|
|
399
|
+
* @param preferredModel - the selection's model, when this IS the selection's route.
|
|
400
|
+
* @returns `{ provider, model }`.
|
|
401
|
+
*/
|
|
402
|
+
async function targetFor(llm, config, provider, preferredModel) {
|
|
403
|
+
if (typeof config.model === "string" && config.model.trim() !== "") return { provider, model: config.model.trim() };
|
|
404
|
+
// A selection's model applies only to the selection's own provider: taking it across
|
|
405
|
+
// providers would ask one adapter for another's model id.
|
|
406
|
+
if (typeof preferredModel === "string" && preferredModel !== "") return { provider, model: preferredModel };
|
|
407
|
+
let models = [];
|
|
408
|
+
try {
|
|
409
|
+
models = await llm.listModels(provider);
|
|
410
|
+
} catch (error) {
|
|
411
|
+
models = [];
|
|
412
|
+
}
|
|
413
|
+
const model = Array.isArray(models) && typeof models[0]?.id === "string" ? models[0].id : "deepseek-flash";
|
|
414
|
+
return { provider, model };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** The first route to try, for callers that take a single target. @returns the route. */
|
|
418
|
+
async function resolveTarget(llm, config, selection, logger) {
|
|
419
|
+
return (await resolveTargets(llm, config, selection, logger))[0];
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** The instruction the model answers. */
|
|
423
|
+
const SYSTEM_PROMPT = [
|
|
424
|
+
"You are building a glossary for a Chinese reader who is a software engineer.",
|
|
425
|
+
"Explain the given term as it is used in the given context.",
|
|
426
|
+
"Answer with ONE JSON object and nothing else, with exactly these keys:",
|
|
427
|
+
'{"zh": "...", "gloss": "...", "usage": "...", "domain": "..."}',
|
|
428
|
+
"- zh: the term's Chinese name, 2-8 Chinese characters.",
|
|
429
|
+
'- gloss: one or two Chinese sentences explaining what it means and why it matters.',
|
|
430
|
+
"- usage: one short Chinese sentence showing it in use, or an empty string.",
|
|
431
|
+
"- domain: one Chinese domain label such as 软件工程 / 人工智能 / 数据 / 网络 / 安全 / 运维.",
|
|
432
|
+
"Do not use markdown fences. Do not add keys. Do not answer in English.",
|
|
433
|
+
].join("\n");
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* The system instruction for one explanation, given the user's preferences.
|
|
437
|
+
*
|
|
438
|
+
* The base prompt is kept and only narrowed or widened by a clause, because it is the text
|
|
439
|
+
* whose output shape the parser and the tests already agree on — rewriting it per depth would
|
|
440
|
+
* be four prompts to keep in sync. The English case is the exception: the base prompt ends with
|
|
441
|
+
* "Do not answer in English", so it cannot be reused, and English gets its own instruction
|
|
442
|
+
* while the JSON KEYS stay the same (they are field names, not prose).
|
|
443
|
+
*
|
|
444
|
+
* @param preferences - `{ lang, depth, retry }` as sent by the page.
|
|
445
|
+
* @returns the instruction.
|
|
446
|
+
*/
|
|
447
|
+
function buildSystemPrompt(preferences) {
|
|
448
|
+
const depth = preferences?.depth === "brief" ? "brief" : preferences?.depth === "detailed" ? "detailed" : "normal";
|
|
449
|
+
// A second attempt at an entry the user called wrong. The sentence is FIXED and says nothing about
|
|
450
|
+
// what they objected to: their note is their own text, and feeding it to a model would turn a
|
|
451
|
+
// remark into an instruction. What the model can act on is simply that the last answer was
|
|
452
|
+
// rejected — which is the one thing the user's verdict does establish.
|
|
453
|
+
const retry = preferences?.retry === true;
|
|
454
|
+
const retryZh = retry ? "\n注意:上一个解释被使用者否掉了,请换一个更具体的说法,不要重复套话。" : "";
|
|
455
|
+
// `lang` is a CLOSED set here: exactly `en` selects the English instruction, and everything
|
|
456
|
+
// else — `zh`, `auto`, absent, unrecognized — produces the Chinese one.
|
|
457
|
+
//
|
|
458
|
+
// `auto` deliberately still means Chinese rather than "follow the UI": the page resolves
|
|
459
|
+
// `auto` against the active locale BEFORE sending, because only the page knows which locale
|
|
460
|
+
// is active. A request arriving with `auto` therefore comes from something other than this
|
|
461
|
+
// plugin's own client, and an interface language cannot be guessed at this end. Spelling
|
|
462
|
+
// that out is the point: the old `!== "en"` test made the same decision but read as an
|
|
463
|
+
// accident, which is how a closed set turns into a silent default nobody can find.
|
|
464
|
+
if (preferences?.lang !== "en") {
|
|
465
|
+
if (depth === "brief") return `${SYSTEM_PROMPT}\n再简短些:gloss 只写一句不超过 20 字的话,usage 留空。${retryZh}`;
|
|
466
|
+
if (depth === "detailed") return `${SYSTEM_PROMPT}\n再充实些:gloss 写两到三句,说明它是什么、怎么工作、为什么重要;usage 给一个完整例句。${retryZh}`;
|
|
467
|
+
return `${SYSTEM_PROMPT}${retryZh}`;
|
|
468
|
+
}
|
|
469
|
+
const gloss =
|
|
470
|
+
depth === "brief"
|
|
471
|
+
? "one English sentence, at most 20 words."
|
|
472
|
+
: depth === "detailed"
|
|
473
|
+
? "two or three English sentences covering what it is, how it works, and why it matters."
|
|
474
|
+
: "one or two English sentences explaining what it means and why it matters.";
|
|
475
|
+
const usage = depth === "brief" ? "an empty string." : "one short English sentence showing it in use, or an empty string.";
|
|
476
|
+
return [
|
|
477
|
+
"Explain the given term as it is used in the given context.",
|
|
478
|
+
"Answer with ONE JSON object and nothing else, with exactly these keys:",
|
|
479
|
+
'{"zh": "...", "gloss": "...", "usage": "...", "domain": "..."}',
|
|
480
|
+
"- zh: the term's short Chinese name, 2-8 Chinese characters. This field stays Chinese.",
|
|
481
|
+
`- gloss: ${gloss}`,
|
|
482
|
+
`- usage: ${usage}`,
|
|
483
|
+
"- domain: one short English label such as software engineering / AI / data / networking / security / operations.",
|
|
484
|
+
"Do not use markdown fences. Do not add keys.",
|
|
485
|
+
// Same fixed sentence as the Chinese branch, in the language the answer is written in.
|
|
486
|
+
...(retry ? ["Note: the previous explanation was rejected by the user. Take a different, more specific angle rather than restating the obvious."] : [])
|
|
487
|
+
].join("\n");
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** The per-term instruction. */
|
|
491
|
+
function buildPrompt(term, context) {
|
|
492
|
+
const trimmed = typeof context === "string" ? context.trim() : "";
|
|
493
|
+
return trimmed === ""
|
|
494
|
+
? `术语:${term}`
|
|
495
|
+
: `术语:${term}\n它出现的上下文:${trimmed.slice(0, 600)}`;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Translate a terminal `finish` reason into a failure message.
|
|
500
|
+
* @param reason - the finish payload.
|
|
501
|
+
* @returns a message, or null when the stream ended normally.
|
|
502
|
+
*/
|
|
503
|
+
function finishFailure(reason) {
|
|
504
|
+
if (reason === null || typeof reason !== "object") return null;
|
|
505
|
+
if (reason.kind === "error" || reason.kind === "aborted") {
|
|
506
|
+
const message = reason.failure !== null && typeof reason.failure === "object" && typeof reason.failure.message === "string"
|
|
507
|
+
? reason.failure.message
|
|
508
|
+
: "the model call failed";
|
|
509
|
+
return message;
|
|
510
|
+
}
|
|
511
|
+
return null;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Pull the explanation object out of a model answer.
|
|
516
|
+
*
|
|
517
|
+
* The answer is untrusted: it may carry fences, prose, or a truncated object, so
|
|
518
|
+
* the first balanced `{...}` run is tried and every field is validated.
|
|
519
|
+
*
|
|
520
|
+
* @param text - the model's raw answer.
|
|
521
|
+
* @returns the four definition fields, or null when nothing usable is present.
|
|
522
|
+
*/
|
|
523
|
+
function parseExplanation(text) {
|
|
524
|
+
if (typeof text !== "string" || text.trim() === "") return null;
|
|
525
|
+
const cleaned = text.replace(/```(?:json)?/gi, "").trim();
|
|
526
|
+
for (const candidate of balancedObjects(cleaned)) {
|
|
527
|
+
let parsed;
|
|
528
|
+
try {
|
|
529
|
+
parsed = JSON.parse(candidate);
|
|
530
|
+
} catch {
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
if (parsed === null || typeof parsed !== "object") continue;
|
|
534
|
+
const gloss = typeof parsed.gloss === "string" ? parsed.gloss.trim() : "";
|
|
535
|
+
if (gloss === "") continue;
|
|
536
|
+
return {
|
|
537
|
+
zh: typeof parsed.zh === "string" ? parsed.zh.trim().slice(0, 60) : "",
|
|
538
|
+
gloss: gloss.slice(0, entries.MAX_DEFINITION_CHARS),
|
|
539
|
+
usage: typeof parsed.usage === "string" ? parsed.usage.trim().slice(0, entries.MAX_DEFINITION_CHARS) : "",
|
|
540
|
+
domain: typeof parsed.domain === "string" ? parsed.domain.trim().slice(0, 24) : ""
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
return null;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Every top-level `{...}` run in a string, longest first.
|
|
548
|
+
* @param text - the text to scan.
|
|
549
|
+
* @returns candidate JSON objects as text.
|
|
550
|
+
*/
|
|
551
|
+
function balancedObjects(text) {
|
|
552
|
+
const found = [];
|
|
553
|
+
let depth = 0;
|
|
554
|
+
let start = -1;
|
|
555
|
+
let inString = false;
|
|
556
|
+
let escaped = false;
|
|
557
|
+
for (let index = 0; index < text.length; index++) {
|
|
558
|
+
const character = text[index];
|
|
559
|
+
if (inString) {
|
|
560
|
+
if (escaped) escaped = false;
|
|
561
|
+
else if (character === "\\") escaped = true;
|
|
562
|
+
else if (character === '"') inString = false;
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
if (character === '"') {
|
|
566
|
+
inString = true;
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
if (character === "{") {
|
|
570
|
+
if (depth === 0) start = index;
|
|
571
|
+
depth++;
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
if (character === "}" && depth > 0) {
|
|
575
|
+
depth--;
|
|
576
|
+
if (depth === 0 && start >= 0) {
|
|
577
|
+
found.push(text.slice(start, index + 1));
|
|
578
|
+
start = -1;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
return found.sort((left, right) => right.length - left.length);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Plugin entry. Registers the dictionary file store and the HTTP routes.
|
|
587
|
+
*
|
|
588
|
+
* @param ctx - the host plugin context.
|
|
589
|
+
* @param config - the resolved plugin config.
|
|
590
|
+
*/
|
|
591
|
+
export function apply(ctx, config) {
|
|
592
|
+
const resolved = config ?? {};
|
|
593
|
+
const logger = ctx.logger;
|
|
594
|
+
const location = resolveDataDirectory(resolved.dataDir, logger);
|
|
595
|
+
const file = join(location.directory, "dictionary.json");
|
|
596
|
+
const store = dictionary.createFileStore(fileSystem, file, location.writable ? logger : null);
|
|
597
|
+
let state = store.load();
|
|
598
|
+
|
|
599
|
+
/** Persist the current document, in the background. */
|
|
600
|
+
function persist() {
|
|
601
|
+
void store.save(state).catch((error) => {
|
|
602
|
+
logger?.warn?.(`term-dictionary: saving failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Run one mutating operation and persist only when it changed something.
|
|
608
|
+
* @param operation - a function from document to `{ state, ... }`.
|
|
609
|
+
* @returns the operation's result.
|
|
610
|
+
*/
|
|
611
|
+
function mutate(operation) {
|
|
612
|
+
const result = operation(state);
|
|
613
|
+
if (result !== null && typeof result === "object" && result.state !== undefined && result.state !== state) {
|
|
614
|
+
state = result.state;
|
|
615
|
+
persist();
|
|
616
|
+
}
|
|
617
|
+
return result;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
ctx.effect(() => () => {
|
|
621
|
+
// Nothing owns an open handle, but persisting on unload keeps a queued write
|
|
622
|
+
// from being lost when the fiber is disposed mid-edit.
|
|
623
|
+
persist();
|
|
624
|
+
}, "term-dictionary: final flush");
|
|
625
|
+
|
|
626
|
+
// The model is optional. Declaring it in the root `inject` would keep the whole
|
|
627
|
+
// plugin dormant in a profile with no adapter — including the dictionary routes,
|
|
628
|
+
// which need no model at all. A child context gets the service when it appears
|
|
629
|
+
// and is torn down when it leaves, so the buttons that need a model simply
|
|
630
|
+
// report that none is mounted.
|
|
631
|
+
let model = null;
|
|
632
|
+
ctx.inject(["llm"], (llmCtx) => {
|
|
633
|
+
model = llmCtx.llm;
|
|
634
|
+
ctx.effect(() => () => {
|
|
635
|
+
model = null;
|
|
636
|
+
}, "term-dictionary: model service detached");
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
// The profile's own default model route, used to pick a provider that is actually
|
|
640
|
+
// configured and credentialed here. Optional for the same reason `llm` is: a
|
|
641
|
+
// composition without it must still serve the dictionary.
|
|
642
|
+
let defaultModel = null;
|
|
643
|
+
ctx.inject(["agentDefaultModel"], (selectionCtx) => {
|
|
644
|
+
defaultModel = selectionCtx.agentDefaultModel;
|
|
645
|
+
ctx.effect(() => () => {
|
|
646
|
+
defaultModel = null;
|
|
647
|
+
}, "term-dictionary: default model service detached");
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
// The host's own feedback channel, optional for the same reason `llm` is.
|
|
651
|
+
//
|
|
652
|
+
// `remote.sessionFeedback` is the CLIENT-side projection of this service, and asking the page for
|
|
653
|
+
// it would mean listing it in the client half's `inject` — where a service that never arrives parks
|
|
654
|
+
// the WHOLE package (the panel, the underlines, every gesture) waiting for it. A live desktop
|
|
655
|
+
// profile was measured with no `remote` service at all, so that inject would have killed the
|
|
656
|
+
// plugin on the machine it was written on. The optional child context is the runtime's own answer:
|
|
657
|
+
// the page talks to its own route, and a composition without the channel reports that it has none.
|
|
658
|
+
let feedback = null;
|
|
659
|
+
ctx.inject(["sessionFeedback"], (feedbackCtx) => {
|
|
660
|
+
feedback = feedbackCtx.sessionFeedback;
|
|
661
|
+
ctx.effect(() => () => {
|
|
662
|
+
feedback = null;
|
|
663
|
+
}, "term-dictionary: feedback channel detached");
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
const prefix = { kind: "prefix", path: ROUTE_PREFIX };
|
|
667
|
+
|
|
668
|
+
ctx.effect(
|
|
669
|
+
() =>
|
|
670
|
+
ctx.webServer.register({
|
|
671
|
+
...prefix,
|
|
672
|
+
handler: async (req, res) => {
|
|
673
|
+
try {
|
|
674
|
+
await route(req, res);
|
|
675
|
+
} catch (error) {
|
|
676
|
+
sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}),
|
|
680
|
+
`term-dictionary: routes under ${ROUTE_PREFIX}`
|
|
681
|
+
);
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Dispatch one request inside the plugin's prefix.
|
|
685
|
+
* @param req - the incoming request.
|
|
686
|
+
* @param res - the response to own.
|
|
687
|
+
*/
|
|
688
|
+
async function route(req, res) {
|
|
689
|
+
const path = new URL(req.url ?? "/", "http://dsh.invalid").pathname.slice(ROUTE_PREFIX.length) || "/";
|
|
690
|
+
if (path === "/state" || path === "/") {
|
|
691
|
+
if (req.method !== "GET") return sendJson(res, 405, { error: "method not allowed" });
|
|
692
|
+
// `serializeState` emits only what a reader may see: live entries, the
|
|
693
|
+
// keys of deleted ones, and no tombstone bodies. The page needs the keys
|
|
694
|
+
// so a window that never held the tombstone still learns about the
|
|
695
|
+
// deletion, and `location` so a fallback directory or a read-only profile
|
|
696
|
+
// is visible rather than silent.
|
|
697
|
+
return sendJson(res, 200, {
|
|
698
|
+
...dictionary.serializeState(dictionary.seal(state)),
|
|
699
|
+
location: { directory: location.directory, writable: location.writable, model: model !== null }
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
if (path === "/entries") {
|
|
703
|
+
if (req.method !== "POST") return sendJson(res, 405, { error: "method not allowed" });
|
|
704
|
+
const body = await readJson(req);
|
|
705
|
+
if (body === null) return sendJson(res, 400, { error: "a JSON object body is required" });
|
|
706
|
+
return handleEntries(res, body);
|
|
707
|
+
}
|
|
708
|
+
if (path === "/explain") {
|
|
709
|
+
if (req.method !== "POST") return sendJson(res, 405, { error: "method not allowed" });
|
|
710
|
+
const body = await readJson(req);
|
|
711
|
+
if (body === null) return sendJson(res, 400, { error: "a JSON object body is required" });
|
|
712
|
+
return handleExplain(res, body);
|
|
713
|
+
}
|
|
714
|
+
if (path === "/feedback") {
|
|
715
|
+
if (req.method !== "POST") return sendJson(res, 405, { error: "method not allowed" });
|
|
716
|
+
const body = await readJson(req);
|
|
717
|
+
if (body === null) return sendJson(res, 400, { error: "a JSON object body is required" });
|
|
718
|
+
return handleFeedback(res, body);
|
|
719
|
+
}
|
|
720
|
+
if (path === "/pack") {
|
|
721
|
+
if (req.method !== "POST") return sendJson(res, 405, { error: "method not allowed" });
|
|
722
|
+
const body = await readJson(req);
|
|
723
|
+
if (body === null) return sendJson(res, 400, { error: "a JSON object body is required" });
|
|
724
|
+
return handlePack(res, body);
|
|
725
|
+
}
|
|
726
|
+
if (path === "/source/index") {
|
|
727
|
+
if (req.method !== "GET") return sendJson(res, 405, { error: "method not allowed" });
|
|
728
|
+
return handleSourceIndex(res, new URL(req.url ?? "/", "http://dsh.invalid").searchParams);
|
|
729
|
+
}
|
|
730
|
+
if (path === "/source/pack") {
|
|
731
|
+
if (req.method !== "GET") return sendJson(res, 405, { error: "method not allowed" });
|
|
732
|
+
return handleSourcePack(res, new URL(req.url ?? "/", "http://dsh.invalid").searchParams);
|
|
733
|
+
}
|
|
734
|
+
return sendJson(res, 404, { error: `unknown route ${path}` });
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* `POST /entries` — the page's write path.
|
|
739
|
+
*
|
|
740
|
+
* Three actions: `replace` (the page's whole document, merged term by term),
|
|
741
|
+
* `record` (one sighting), and `remove`.
|
|
742
|
+
*
|
|
743
|
+
* @param res - the response to own.
|
|
744
|
+
* @param body - the parsed request body.
|
|
745
|
+
*/
|
|
746
|
+
function handleEntries(res, body) {
|
|
747
|
+
const action = typeof body.action === "string" ? body.action : "replace";
|
|
748
|
+
if (action === "replace") {
|
|
749
|
+
if (body.state === undefined) return sendJson(res, 400, { error: "action replace requires state" });
|
|
750
|
+
state = core().mergeState(state, body.state);
|
|
751
|
+
persist();
|
|
752
|
+
return sendJson(res, 200, core().serializeState(core().seal(state))); }
|
|
753
|
+
if (action === "record") {
|
|
754
|
+
if (typeof body.term !== "string" || body.term.trim() === "") return sendJson(res, 400, { error: "action record requires term" });
|
|
755
|
+
const result = mutate((current) =>
|
|
756
|
+
core().recordSighting(
|
|
757
|
+
current,
|
|
758
|
+
{
|
|
759
|
+
term: body.term,
|
|
760
|
+
context: typeof body.context === "string" ? body.context : "",
|
|
761
|
+
glossary: body.glossary !== null && typeof body.glossary === "object" ? body.glossary : undefined,
|
|
762
|
+
sessionId: typeof body.sessionId === "string" ? body.sessionId : ""
|
|
763
|
+
},
|
|
764
|
+
{ now: Date.now() }
|
|
765
|
+
)
|
|
766
|
+
);
|
|
767
|
+
return sendJson(res, 200, { ok: true, created: result.created === true, entry: result.entry ?? null });
|
|
768
|
+
}
|
|
769
|
+
if (action === "remove") {
|
|
770
|
+
const target = typeof body.id === "string" && body.id !== "" ? body.id : typeof body.term === "string" ? body.term : "";
|
|
771
|
+
if (target === "") return sendJson(res, 400, { error: "action remove requires id or term" });
|
|
772
|
+
const result = mutate((current) => core().removeEntry(current, target, { now: Date.now() }));
|
|
773
|
+
return sendJson(res, 200, { ok: true, removed: result.removed === true });
|
|
774
|
+
}
|
|
775
|
+
if (action === "update") {
|
|
776
|
+
if (typeof body.term !== "string" || body.term.trim() === "") return sendJson(res, 400, { error: "action update requires term" });
|
|
777
|
+
const result = mutate((current) =>
|
|
778
|
+
core().editEntry(
|
|
779
|
+
current,
|
|
780
|
+
body.term,
|
|
781
|
+
{
|
|
782
|
+
definition: body.definition !== null && typeof body.definition === "object" ? body.definition : undefined,
|
|
783
|
+
domain: typeof body.domain === "string" ? body.domain : undefined,
|
|
784
|
+
aliases: Array.isArray(body.aliases) ? body.aliases : undefined,
|
|
785
|
+
pinned: typeof body.pinned === "boolean" ? body.pinned : undefined
|
|
786
|
+
},
|
|
787
|
+
{ now: Date.now() }
|
|
788
|
+
)
|
|
789
|
+
);
|
|
790
|
+
return sendJson(res, 200, { ok: true, entry: result.entry ?? null });
|
|
791
|
+
}
|
|
792
|
+
return sendJson(res, 400, { error: `unknown action ${action}` });
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* `POST /explain` — ask the model for one term's explanation and store it.
|
|
797
|
+
* @param res - the response to own.
|
|
798
|
+
* @param body - the parsed request body.
|
|
799
|
+
*/
|
|
800
|
+
async function handleExplain(res, body) {
|
|
801
|
+
const term = typeof body.term === "string" ? body.term.trim() : "";
|
|
802
|
+
if (term === "") return sendJson(res, 400, { error: "term is required" });
|
|
803
|
+
if (model === null) return sendJson(res, 503, { error: "no model service is mounted in this profile" });
|
|
804
|
+
let definition;
|
|
805
|
+
try {
|
|
806
|
+
definition = await explainTerm(model, resolved, defaultModel, term, typeof body.context === "string" ? body.context : "", logger, {
|
|
807
|
+
lang: typeof body.lang === "string" ? body.lang : "auto",
|
|
808
|
+
depth: typeof body.depth === "string" ? body.depth : "normal",
|
|
809
|
+
// Strictly `true`, like every other field the page sends: a truthy string from some
|
|
810
|
+
// other caller must not be able to change the instruction.
|
|
811
|
+
retry: body.retry === true
|
|
812
|
+
});
|
|
813
|
+
} catch (error) {
|
|
814
|
+
return sendJson(res, 502, { error: error instanceof Error ? error.message : String(error) });
|
|
815
|
+
}
|
|
816
|
+
const result = mutate((current) =>
|
|
817
|
+
core().editEntry(current, term, { definition, domain: definition.domain }, { now: Date.now() })
|
|
818
|
+
);
|
|
819
|
+
return sendJson(res, 200, {
|
|
820
|
+
ok: true,
|
|
821
|
+
definition,
|
|
822
|
+
source: "llm",
|
|
823
|
+
entry: result.entry ?? null
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* `POST /feedback` — record one remark into the host's own session feedback log.
|
|
829
|
+
*
|
|
830
|
+
* What this channel is: the host keeps the remark in THIS machine's session log, where it never
|
|
831
|
+
* enters a model's context. What it is NOT: a way to reach the plugin's author — nothing leaves the
|
|
832
|
+
* machine — which is exactly why the plugin also exports a report the user can send themselves.
|
|
833
|
+
*
|
|
834
|
+
* `unavailable` comes back as a 200 with `ok: false`, not as an HTTP error: a composition without
|
|
835
|
+
* the feedback channel is a normal state, and the page distinguishes "this build cannot" from "that
|
|
836
|
+
* session is gone" and from "the host refused".
|
|
837
|
+
*
|
|
838
|
+
* @param res - the response to own.
|
|
839
|
+
* @param body - the parsed request body.
|
|
840
|
+
*/
|
|
841
|
+
async function handleFeedback(res, body) {
|
|
842
|
+
if (feedback === null) return sendJson(res, 200, { ok: false, error: "unavailable" });
|
|
843
|
+
const sessionId = typeof body.sessionId === "string" ? body.sessionId.trim() : "";
|
|
844
|
+
if (sessionId === "") return sendJson(res, 400, { error: "sessionId is required" });
|
|
845
|
+
const text = typeof body.text === "string" ? body.text.trim().slice(0, MAX_FEEDBACK_CHARS) : "";
|
|
846
|
+
const category = FEEDBACK_CATEGORIES.includes(body.category) ? body.category : "product-interaction";
|
|
847
|
+
let outcome;
|
|
848
|
+
try {
|
|
849
|
+
outcome = await feedback.record({ sessionId, text, category });
|
|
850
|
+
} catch (error) {
|
|
851
|
+
return sendJson(res, 502, { error: error instanceof Error ? error.message : String(error) });
|
|
852
|
+
}
|
|
853
|
+
if (outcome?.ok !== true) {
|
|
854
|
+
// The one failure worth naming: the page is holding a session id the host no longer has a
|
|
855
|
+
// live session for, which is a different thing from a channel that refused the remark.
|
|
856
|
+
return sendJson(res, 200, { ok: false, error: outcome?.error?.code === "session-not-found" ? "session-not-found" : "rejected" });
|
|
857
|
+
}
|
|
858
|
+
return sendJson(res, 200, { ok: true });
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* `POST /pack` — build a pack from this dictionary, or read one back from a share code.
|
|
863
|
+
*
|
|
864
|
+
* `action: "build"` publishes; `action: "decode"` reads. They share a route because they are the
|
|
865
|
+
* two directions of one thing, and because a caller that can do one usually wants the other.
|
|
866
|
+
*
|
|
867
|
+
* @param res - the response to own.
|
|
868
|
+
* @param body - the parsed request body.
|
|
869
|
+
*/
|
|
870
|
+
async function handlePack(res, body) {
|
|
871
|
+
if (body.action === "decode") {
|
|
872
|
+
const decoded = decodePackCode(body.code);
|
|
873
|
+
if (decoded.ok !== true) return sendJson(res, 400, { error: decoded.error });
|
|
874
|
+
return sendJson(res, 200, { ok: true, pack: decoded.pack, summary: packCore.summarizePack(decoded.pack) });
|
|
875
|
+
}
|
|
876
|
+
if (body.action !== "build") return sendJson(res, 400, { error: "action must be build or decode" });
|
|
877
|
+
const built = packCore.buildPack(dictionary.serializeState(dictionary.seal(state)).entries, {
|
|
878
|
+
id: body.id,
|
|
879
|
+
name: body.name,
|
|
880
|
+
description: body.description,
|
|
881
|
+
author: body.author,
|
|
882
|
+
license: body.license,
|
|
883
|
+
homepage: body.homepage,
|
|
884
|
+
build: pluginVersion(),
|
|
885
|
+
scope: body.scope,
|
|
886
|
+
domains: body.domains,
|
|
887
|
+
now: Date.now()
|
|
888
|
+
});
|
|
889
|
+
// A pack with nothing in it is refused rather than published empty, and the reason says which
|
|
890
|
+
// emptiness it was: the whole dictionary is empty, or the chosen categories are.
|
|
891
|
+
if (built.ok !== true) return sendJson(res, 400, { error: built.error });
|
|
892
|
+
const text = packCore.packBytes(built.pack);
|
|
893
|
+
return sendJson(res, 200, {
|
|
894
|
+
ok: true,
|
|
895
|
+
pack: built.pack,
|
|
896
|
+
summary: packCore.summarizePack(built.pack),
|
|
897
|
+
sha256: sha256Hex(text),
|
|
898
|
+
bytes: Buffer.byteLength(text, "utf8"),
|
|
899
|
+
// The code only when it was asked for: encoding is cheap, but a response carrying a code
|
|
900
|
+
// nobody wanted makes every log line of this route enormous.
|
|
901
|
+
...(body.code === true ? { code: encodePackCode(built.pack) } : {})
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* `GET /source/index` — the list of packs a source URL offers, through a cache.
|
|
907
|
+
*
|
|
908
|
+
* A source is somebody else's static file. The cache exists so that opening the packs page does not
|
|
909
|
+
* mean a request every time, and so an unreachable source still shows what it last said — marked
|
|
910
|
+
* stale rather than presented as current.
|
|
911
|
+
*
|
|
912
|
+
* @param res - the response to own.
|
|
913
|
+
* @param params - the query string.
|
|
914
|
+
*/
|
|
915
|
+
async function handleSourceIndex(res, params) {
|
|
916
|
+
const url = params.get("url") ?? "";
|
|
917
|
+
const refusal = packCore.refuseUrl(url);
|
|
918
|
+
if (refusal !== null) return sendJson(res, 400, { error: refusal });
|
|
919
|
+
const cache = readCache();
|
|
920
|
+
const entry = cache.indexes?.[url];
|
|
921
|
+
const fresh = entry !== undefined && Date.now() - entry.fetchedAt < INDEX_TTL_MS;
|
|
922
|
+
if (fresh && params.get("refresh") !== "1") {
|
|
923
|
+
return sendJson(res, 200, { ok: true, index: entry.index, fetchedAt: entry.fetchedAt, cached: true });
|
|
924
|
+
}
|
|
925
|
+
const fetched = await fetchText(url, MAX_INDEX_BYTES);
|
|
926
|
+
if (fetched.ok !== true) {
|
|
927
|
+
// An unreachable source with a usable cache is not a failure the page should shout about: it
|
|
928
|
+
// is yesterday's list, and saying so is more useful than an error.
|
|
929
|
+
if (entry !== undefined) {
|
|
930
|
+
return sendJson(res, 200, { ok: true, index: entry.index, fetchedAt: entry.fetchedAt, cached: true, stale: true, error: fetched.error });
|
|
931
|
+
}
|
|
932
|
+
return sendJson(res, 200, { ok: false, error: fetched.error });
|
|
933
|
+
}
|
|
934
|
+
let raw;
|
|
935
|
+
try {
|
|
936
|
+
raw = JSON.parse(fetched.text);
|
|
937
|
+
} catch (error) {
|
|
938
|
+
void error;
|
|
939
|
+
return sendJson(res, 200, { ok: false, error: "not-json" });
|
|
940
|
+
}
|
|
941
|
+
const parsed = packCore.parseIndex(raw);
|
|
942
|
+
if (parsed.ok !== true) return sendJson(res, 200, { ok: false, error: parsed.error });
|
|
943
|
+
const fetchedAt = Date.now();
|
|
944
|
+
writeCache({ ...cache, indexes: { ...(cache.indexes ?? {}), [url]: { fetchedAt, index: parsed.index } } });
|
|
945
|
+
return sendJson(res, 200, { ok: true, index: parsed.index, fetchedAt, cached: false, dropped: parsed.dropped });
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* `GET /source/pack` — one pack from a source, checksum-verified when the index promised one.
|
|
950
|
+
*
|
|
951
|
+
* The checksum is the whole point of the index carrying one: the file comes from a URL a third
|
|
952
|
+
* party wrote, and without a digest there is nothing to compare it against.
|
|
953
|
+
*
|
|
954
|
+
* @param res - the response to own.
|
|
955
|
+
* @param params - the query string.
|
|
956
|
+
*/
|
|
957
|
+
async function handleSourcePack(res, params) {
|
|
958
|
+
const url = params.get("url") ?? "";
|
|
959
|
+
const refusal = packCore.refuseUrl(url);
|
|
960
|
+
if (refusal !== null) return sendJson(res, 400, { error: refusal });
|
|
961
|
+
const promised = (params.get("sha256") ?? "").trim().toLowerCase();
|
|
962
|
+
const cache = readCache();
|
|
963
|
+
const entry = cache.packs?.[url];
|
|
964
|
+
const fresh = entry !== undefined && Date.now() - entry.fetchedAt < PACK_TTL_MS;
|
|
965
|
+
let text = null;
|
|
966
|
+
let fetchedAt = Date.now();
|
|
967
|
+
let cached = false;
|
|
968
|
+
if (fresh) {
|
|
969
|
+
// The cache is keyed by URL but the DIGEST is re-checked every time: a cached body is still a
|
|
970
|
+
// body from somewhere else, and the promise must hold for it too.
|
|
971
|
+
text = entry.text;
|
|
972
|
+
fetchedAt = entry.fetchedAt;
|
|
973
|
+
cached = true;
|
|
974
|
+
} else {
|
|
975
|
+
const fetched = await fetchText(url, MAX_PACK_BYTES);
|
|
976
|
+
if (fetched.ok !== true) return sendJson(res, 200, { ok: false, error: fetched.error });
|
|
977
|
+
text = fetched.text;
|
|
978
|
+
}
|
|
979
|
+
let digest = sha256Hex(text);
|
|
980
|
+
if (cached && promised !== "" && promised !== digest) {
|
|
981
|
+
// A cached copy that fails the promise is a STALE cached copy — somebody republished the file
|
|
982
|
+
// — and the digest is the authority, not the cache. Without this, republishing a pack made
|
|
983
|
+
// every reader see `checksum-mismatch` until the cache expired a day later, which looks
|
|
984
|
+
// exactly like a broken pack.
|
|
985
|
+
const refetched = await fetchText(url, MAX_PACK_BYTES);
|
|
986
|
+
if (refetched.ok !== true) return sendJson(res, 200, { ok: false, error: refetched.error });
|
|
987
|
+
text = refetched.text;
|
|
988
|
+
digest = sha256Hex(text);
|
|
989
|
+
fetchedAt = Date.now();
|
|
990
|
+
cached = false;
|
|
991
|
+
}
|
|
992
|
+
if (promised !== "" && promised !== digest) {
|
|
993
|
+
return sendJson(res, 200, { ok: false, error: "checksum-mismatch", sha256: digest });
|
|
994
|
+
}
|
|
995
|
+
let raw;
|
|
996
|
+
try {
|
|
997
|
+
raw = JSON.parse(text);
|
|
998
|
+
} catch (error) {
|
|
999
|
+
void error;
|
|
1000
|
+
return sendJson(res, 200, { ok: false, error: "not-json" });
|
|
1001
|
+
}
|
|
1002
|
+
const parsed = packCore.parsePack(raw);
|
|
1003
|
+
if (parsed.ok !== true) return sendJson(res, 200, { ok: false, error: parsed.error });
|
|
1004
|
+
if (!cached) writeCache({ ...cache, packs: { ...(cache.packs ?? {}), [url]: { fetchedAt, text } } });
|
|
1005
|
+
return sendJson(res, 200, { ok: true, pack: parsed.pack, summary: packCore.summarizePack(parsed.pack), sha256: digest, cached });
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
/** The cache file's contents, or an empty object when there is none to read. */
|
|
1009
|
+
function readCache() {
|
|
1010
|
+
const file = join(location.directory, "sources.json");
|
|
1011
|
+
try {
|
|
1012
|
+
const raw = fileSystem.readFileSync(file, "utf8");
|
|
1013
|
+
const parsed = JSON.parse(raw);
|
|
1014
|
+
return parsed !== null && typeof parsed === "object" ? parsed : {};
|
|
1015
|
+
} catch (error) {
|
|
1016
|
+
// Absent, unreadable or corrupt all mean the same thing here: ask the source again.
|
|
1017
|
+
void error;
|
|
1018
|
+
return {};
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/** Write the cache, or quietly do without one when the profile is read-only. */
|
|
1023
|
+
function writeCache(next) {
|
|
1024
|
+
if (!location.writable) return;
|
|
1025
|
+
const file = join(location.directory, "sources.json");
|
|
1026
|
+
try {
|
|
1027
|
+
fileSystem.mkdirSync(dirname(file), { recursive: true });
|
|
1028
|
+
// Written through a temporary file: a fetch that dies mid-write must not leave a cache the
|
|
1029
|
+
// next read cannot parse, which would silently turn every source into "unreachable".
|
|
1030
|
+
const temporary = `${file}.tmp`;
|
|
1031
|
+
fileSystem.writeFileSync(temporary, JSON.stringify(next), "utf8");
|
|
1032
|
+
fileSystem.renameSync(temporary, file);
|
|
1033
|
+
} catch (error) {
|
|
1034
|
+
logger?.warn?.(`term-dictionary: could not write the source cache (${error instanceof Error ? error.message : String(error)})`);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
/**
|
|
1039
|
+
* Fetch one text document from a URL that has already passed {@link packCore.refuseUrl}.
|
|
1040
|
+
*
|
|
1041
|
+
* @param url - an https URL.
|
|
1042
|
+
* @param maxBytes - the size cap for this document.
|
|
1043
|
+
* @returns `{ ok: true, text }`, or `{ ok: false, error }` with `unreachable`, `http-<status>` or
|
|
1044
|
+
* `too-large`.
|
|
1045
|
+
*/
|
|
1046
|
+
async function fetchText(url, maxBytes) {
|
|
1047
|
+
const doFetch = typeof runtimeOverrides?.fetch === "function" ? runtimeOverrides.fetch : globalThis.fetch;
|
|
1048
|
+
if (typeof doFetch !== "function") return { ok: false, error: "no-fetch" };
|
|
1049
|
+
try {
|
|
1050
|
+
const response = await doFetch(url, { redirect: "follow", signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
1051
|
+
if (response?.ok !== true) return { ok: false, error: `http-${response?.status ?? 0}` };
|
|
1052
|
+
const text = await response.text();
|
|
1053
|
+
if (typeof text !== "string" || text === "") return { ok: false, error: "empty" };
|
|
1054
|
+
// Measured on the bytes, not on the character count: a page of Chinese is three times the
|
|
1055
|
+
// size its length suggests, and a cap that can be tripled is not a cap.
|
|
1056
|
+
if (Buffer.byteLength(text, "utf8") > maxBytes) return { ok: false, error: "too-large" };
|
|
1057
|
+
return { ok: true, text };
|
|
1058
|
+
} catch (error) {
|
|
1059
|
+
// Timeout, DNS, TLS and connection reset all arrive here. The page cannot act differently on
|
|
1060
|
+
// any of them, and a source that is briefly down is a normal state rather than a bug.
|
|
1061
|
+
void error;
|
|
1062
|
+
return { ok: false, error: "unreachable" };
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
/** The services this bundle needs before it may activate. */
|
|
1068
|
+
export const inject = ["webServer"];
|
|
1069
|
+
/*
|
|
1070
|
+
* There is deliberately NO `Config` export here.
|
|
1071
|
+
*
|
|
1072
|
+
* Cordis validates a row's `config` through `runtime.Config["~standard"].validate`,
|
|
1073
|
+
* i.e. it requires a **Standard Schema**. A JSON Schema object exported as `Config`
|
|
1074
|
+
* is not one: activation died with
|
|
1075
|
+
*
|
|
1076
|
+
* TypeError: Cannot read properties of undefined (reading 'validate')
|
|
1077
|
+
* at resolveConfig (.../cordis/lib/index.js)
|
|
1078
|
+
*
|
|
1079
|
+
* which looks like a broken plugin rather than a schema mismatch. The DSH-native way
|
|
1080
|
+
* to declare one is `z.object({...})` from `@deepseek-ai/schemastery`, but that is a
|
|
1081
|
+
* HOST package: it is not resolvable from a plugin linked into a profile, and no
|
|
1082
|
+
* third-party plugin installed here uses it. Declaring no schema leaves the row's
|
|
1083
|
+
* `config` untouched (`resolveConfig` returns it unchanged), and every field below is
|
|
1084
|
+
* read defensively in this file, so a missing or malformed value falls back instead
|
|
1085
|
+
* of failing:
|
|
1086
|
+
*
|
|
1087
|
+
* provider — must be a non-empty string to be used, else the profile's own default
|
|
1088
|
+
* model route is chosen, and only failing that the first registered
|
|
1089
|
+
* provider.
|
|
1090
|
+
* model — must be a non-empty string to be used, else the chosen route's own
|
|
1091
|
+
* default model.
|
|
1092
|
+
* dataDir — must be a non-empty string to be used, else $DSH_HOME/dsh-plugin-term-dictionary.
|
|
1093
|
+
*/
|
|
1094
|
+
|
|
1095
|
+
/** Test seam: swap the shared core or inject fake services. */
|
|
1096
|
+
export function __testing(overrides) {
|
|
1097
|
+
runtimeOverrides = overrides ?? null;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
/**
|
|
1101
|
+
* Test seam: the provider/model precedence used by `POST /explain`.
|
|
1102
|
+
*
|
|
1103
|
+
* Exported because the rule it encodes was wrong once in a way no test could see: the
|
|
1104
|
+
* plugin asked the first registered provider, which in this profile is the API-key
|
|
1105
|
+
* route, and generation failed while the window was using a different, working route.
|
|
1106
|
+
*/
|
|
1107
|
+
export const __resolveTarget = resolveTarget;
|
|
1108
|
+
export const __resolveTargets = resolveTargets;
|
|
1109
|
+
export const __isCredentialFailure = isCredentialFailure;
|
|
1110
|
+
export const __buildSystemPrompt = buildSystemPrompt;
|