@240xu/dsh-websearch 2.3.0 → 2.7.2
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/README.md +52 -4
- package/lib/backends/anthropic-like.js +3 -13
- package/lib/backends/brave.js +4 -2
- package/lib/backends/ddg.js +1 -1
- package/lib/backends/exa.js +1 -1
- package/lib/backends/mojeek.js +4 -2
- package/lib/backends/openai.js +2 -2
- package/lib/backends/parallel.js +22 -8
- package/lib/backends/searxng.js +7 -2
- package/lib/backends/serper.js +4 -2
- package/lib/backends/tavily.js +4 -2
- package/lib/breaker.js +65 -0
- package/lib/cache.js +151 -0
- package/lib/client.js +75 -15
- package/lib/health.js +103 -0
- package/lib/history.js +163 -0
- package/lib/index.js +110 -11
- package/lib/prompting.js +105 -0
- package/lib/provider.js +206 -52
- package/lib/util/log.js +1 -1
- package/lib/util/mcp-client.js +15 -14
- package/lib/util/rank.js +1 -1
- package/package.json +2 -2
- package/lib/backends/index.js +0 -121
- package/lib/util/rpc.js +0 -262
package/lib/client.js
CHANGED
|
@@ -15,7 +15,7 @@ window.__ModuleLoader__.load({
|
|
|
15
15
|
{ id: "exa", keyEnv: null, label: "Exa (MCP, 免费)" },
|
|
16
16
|
{ id: "parallel", keyEnv: null, label: "Parallel (MCP, 免费)" },
|
|
17
17
|
{ id: "ddg", keyEnv: null, label: "DuckDuckGo (HTML)" },
|
|
18
|
-
{ id: "searxng", keyEnv: "SEARXNG_API_KEY", label: "SearXNG (元搜索)" },
|
|
18
|
+
{ id: "searxng", keyEnv: "SEARXNG_API_KEY", optionalKey: true, label: "SearXNG (元搜索)" },
|
|
19
19
|
{ id: "brave", keyEnv: "BRAVE_API_KEY", label: "Brave Search" },
|
|
20
20
|
{ id: "tavily", keyEnv: "TAVILY_API_KEY", label: "Tavily (AI)" },
|
|
21
21
|
{ id: "serper", keyEnv: "SERPER_API_KEY", label: "Serper.dev" },
|
|
@@ -32,8 +32,9 @@ window.__ModuleLoader__.load({
|
|
|
32
32
|
};
|
|
33
33
|
/** Boolean settings staged as "true"/"false" strings by the selects. */
|
|
34
34
|
const BOOLEAN_FIELDS = ["rerank", "safeSearch", "resultTelemetry"];
|
|
35
|
-
const KEYLESS = BACKENDS.filter((b) => !b.keyEnv);
|
|
36
|
-
const KEYED = BACKENDS.filter((b) => b.keyEnv);
|
|
35
|
+
const KEYLESS = BACKENDS.filter((b) => !b.keyEnv || b.optionalKey);
|
|
36
|
+
const KEYED = BACKENDS.filter((b) => b.keyEnv && !b.optionalKey);
|
|
37
|
+
const OPTIONAL_KEYS = BACKENDS.filter((b) => b.keyEnv && b.optionalKey);
|
|
37
38
|
|
|
38
39
|
/** Where the user goes to get/rotate each key. */
|
|
39
40
|
const KEY_URLS = {
|
|
@@ -90,6 +91,10 @@ window.__ModuleLoader__.load({
|
|
|
90
91
|
langPlaceholder: "如 en、zh-CN,留空为默认",
|
|
91
92
|
envProvided: "环境变量提供", readOnly: "只读,无需填写", conflict: "设置已在其他端被修改,请重试", keyFailed: "该 Key 保存失败",
|
|
92
93
|
telemetryLabel: "结果健康遥测",
|
|
94
|
+
fallbackHint: "全部后端已关闭:搜索将回退到默认免 Key 后端",
|
|
95
|
+
invalidFields: "以下字段未通过校验:",
|
|
96
|
+
readOnlyScope: "当前配置只读(可能由宿主锁定)",
|
|
97
|
+
optionalTitle: "私有实例可选 Key",
|
|
93
98
|
};
|
|
94
99
|
const en = {
|
|
95
100
|
nav: "Web Search",
|
|
@@ -138,6 +143,10 @@ window.__ModuleLoader__.load({
|
|
|
138
143
|
conflict: "Changed elsewhere - please retry",
|
|
139
144
|
keyFailed: "failed to save this key",
|
|
140
145
|
telemetryLabel: "Result health telemetry",
|
|
146
|
+
fallbackHint: "All backends off: search falls back to the default keyless four.",
|
|
147
|
+
invalidFields: "Fields failed validation:",
|
|
148
|
+
readOnlyScope: "Configuration is read-only (locked by host?)",
|
|
149
|
+
optionalTitle: "Private instance optional keys",
|
|
141
150
|
};
|
|
142
151
|
|
|
143
152
|
//#region styles
|
|
@@ -177,7 +186,8 @@ window.__ModuleLoader__.load({
|
|
|
177
186
|
this.saving = false;
|
|
178
187
|
this.failed = false;
|
|
179
188
|
this.conflict = false;
|
|
180
|
-
this.failedKeys = new
|
|
189
|
+
this.failedKeys = new Map(); // envRef -> failure reason
|
|
190
|
+
this.failedFields = new Set();
|
|
181
191
|
this.savedAt = 0;
|
|
182
192
|
this.store = createStore(this.projection());
|
|
183
193
|
this.scope.subscribe(() => this.republish());
|
|
@@ -209,7 +219,8 @@ window.__ModuleLoader__.load({
|
|
|
209
219
|
return s ? (s.clear ? "" : s.text) : undefined;
|
|
210
220
|
},
|
|
211
221
|
conflict: this.conflict,
|
|
212
|
-
failedKeys: [...this.failedKeys],
|
|
222
|
+
failedKeys: [...this.failedKeys].map(([ref, reason]) => ({ ref, reason })),
|
|
223
|
+
failedFields: [...this.failedFields],
|
|
213
224
|
saving: this.saving,
|
|
214
225
|
failed: this.failed,
|
|
215
226
|
version: (this.staged.size + ":" + [...this.staged.keys()].join(",")),
|
|
@@ -224,7 +235,12 @@ window.__ModuleLoader__.load({
|
|
|
224
235
|
|
|
225
236
|
/** Toggles write immediately (no staging); globals/keys stage + save. */
|
|
226
237
|
async setToggle(id, checked) {
|
|
227
|
-
|
|
238
|
+
try {
|
|
239
|
+
await this.scope.set("enable" + id.charAt(0).toUpperCase() + id.slice(1), checked);
|
|
240
|
+
} catch (err) {
|
|
241
|
+
this.failed = true;
|
|
242
|
+
this.conflict = String(err).includes("SETTINGS_CONFLICT");
|
|
243
|
+
}
|
|
228
244
|
|
|
229
245
|
this.republish();
|
|
230
246
|
}
|
|
@@ -245,7 +261,8 @@ window.__ModuleLoader__.load({
|
|
|
245
261
|
|
|
246
262
|
async save() {
|
|
247
263
|
if (this.saving) return;
|
|
248
|
-
this.saving = true; this.failed = false;
|
|
264
|
+
this.saving = true; this.failed = false;
|
|
265
|
+
this.conflict = false; this.failedFields.clear(); this.republish();
|
|
249
266
|
const doneFields = [];
|
|
250
267
|
const doneKeys = [];
|
|
251
268
|
try {
|
|
@@ -253,13 +270,13 @@ window.__ModuleLoader__.load({
|
|
|
253
270
|
// Empty draft clears the override so the schema default applies again.
|
|
254
271
|
if (staged.clear || staged.text === "") { await this.scope.unset(field); doneFields.push(field); continue; }
|
|
255
272
|
if (SELECT_FIELDS[field]) {
|
|
256
|
-
if (!SELECT_FIELDS[field].includes(staged.text)) { this.failed = true; continue; }
|
|
273
|
+
if (!SELECT_FIELDS[field].includes(staged.text)) { this.failed = true; this.failedFields.add(field); continue; }
|
|
257
274
|
await this.scope.set(field, staged.text);
|
|
258
275
|
doneFields.push(field);
|
|
259
276
|
continue;
|
|
260
277
|
}
|
|
261
278
|
if (BOOLEAN_FIELDS.includes(field)) {
|
|
262
|
-
if (staged.text !== "true" && staged.text !== "false") { this.failed = true; continue; }
|
|
279
|
+
if (staged.text !== "true" && staged.text !== "false") { this.failed = true; this.failedFields.add(field); continue; }
|
|
263
280
|
await this.scope.set(field, staged.text === "true");
|
|
264
281
|
doneFields.push(field);
|
|
265
282
|
continue;
|
|
@@ -274,7 +291,7 @@ window.__ModuleLoader__.load({
|
|
|
274
291
|
backendTimeoutMs: [1000, 120000],
|
|
275
292
|
};
|
|
276
293
|
const [lo, hi] = BOUNDS[field] ?? [-Infinity, Infinity];
|
|
277
|
-
|
|
294
|
+
{ this.failed = true; this.failedFields.add(field); continue; }
|
|
278
295
|
await this.scope.set(field, num);
|
|
279
296
|
doneFields.push(field);
|
|
280
297
|
} else {
|
|
@@ -293,7 +310,7 @@ window.__ModuleLoader__.load({
|
|
|
293
310
|
} catch (keyErr) {
|
|
294
311
|
// env-shadowed reference (fail-loud doctrine): isolate so other keys still land.
|
|
295
312
|
this.failed = true;
|
|
296
|
-
this.failedKeys.
|
|
313
|
+
this.failedKeys.set(ref, String(keyErr && keyErr.message ? keyErr.message : keyErr).slice(0, 140));
|
|
297
314
|
void keyErr;
|
|
298
315
|
}
|
|
299
316
|
}
|
|
@@ -331,7 +348,7 @@ window.__ModuleLoader__.load({
|
|
|
331
348
|
// no client-side default list to drift against the server.
|
|
332
349
|
const checked = props.value === true;
|
|
333
350
|
// Status dot: green keyless-or-keyed, amber enabled-but-key-missing.
|
|
334
|
-
const needsKey = !!b.keyEnv && !props.keyConfigured;
|
|
351
|
+
const needsKey = !!b.keyEnv && !b.optionalKey && !props.keyConfigured;
|
|
335
352
|
const dotColor = needsKey ? (checked ? "#e6a700" : "#888") : "#2ea043";
|
|
336
353
|
return react_jsx_runtime.jsxs("label", { className: "wsc-toggle", style: { display: "flex", alignItems: "center", gap: 8, cursor: "pointer" }, children: [
|
|
337
354
|
react_jsx_runtime.jsx("input", {
|
|
@@ -373,7 +390,7 @@ window.__ModuleLoader__.load({
|
|
|
373
390
|
|
|
374
391
|
// Status summary: enabled backends + configured keys at a glance.
|
|
375
392
|
const enabledCount = BACKENDS.filter((b) => state.value[toggleField(b.id)] === true).length;
|
|
376
|
-
const keyedBackends = BACKENDS.filter((b) => b.keyEnv);
|
|
393
|
+
const keyedBackends = BACKENDS.filter((b) => b.keyEnv && !b.optionalKey);
|
|
377
394
|
const keysReady = keyedBackends.filter((b) => state.keyState[b.keyEnv]?.configured).length;
|
|
378
395
|
|
|
379
396
|
const recencyOptions = [
|
|
@@ -399,6 +416,8 @@ window.__ModuleLoader__.load({
|
|
|
399
416
|
react_jsx_runtime.jsxs("span", { children: [props.t("statusKeys"), ": ", react_jsx_runtime.jsx("b", { children: keysReady + "/" + keyedBackends.length })] }),
|
|
400
417
|
] }),
|
|
401
418
|
react_jsx_runtime.jsx("p", { style: { margin: 0, fontSize: 12, opacity: .65 }, children: props.t("description") }),
|
|
419
|
+
(enabledCount === 0) && react_jsx_runtime.jsx("p", { style: { margin: 0, fontSize: 12, color: "#e6a700" }, children: props.t("fallbackHint") }),
|
|
420
|
+
(state.writable === false) && react_jsx_runtime.jsx("p", { style: { margin: 0, fontSize: 12, color: "#e6a700" }, children: props.t("readOnlyScope") }),
|
|
402
421
|
react_jsx_runtime.jsx("h5", { className: "wsc-sectionTitle", children: props.t("groupFree") }),
|
|
403
422
|
react_jsx_runtime.jsx("div", { className: "wsc-toggles", children: KEYLESS.map((b) =>
|
|
404
423
|
react_jsx_runtime.jsx(ToggleRow, {
|
|
@@ -475,7 +494,7 @@ window.__ModuleLoader__.load({
|
|
|
475
494
|
}),
|
|
476
495
|
] }),
|
|
477
496
|
react_jsx_runtime.jsx("h4", { className: "wsc-sectionTitle", children: props.t("keysTitle") }),
|
|
478
|
-
react_jsx_runtime.jsx("div", { className: "wsc-grid", children: BACKENDS.filter((b) => b.keyEnv).map((b) =>
|
|
497
|
+
react_jsx_runtime.jsx("div", { className: "wsc-grid", children: BACKENDS.filter((b) => b.keyEnv && !b.optionalKey).map((b) =>
|
|
479
498
|
react_jsx_runtime.jsxs("div", { children: [
|
|
480
499
|
react_jsx_runtime.jsxs("div", { className: "wsc-fieldHead", children: [
|
|
481
500
|
react_jsx_runtime.jsx("span", { className: "wsc-label", children: props.t("keyLabel", { id: b.id }) }),
|
|
@@ -500,11 +519,24 @@ window.__ModuleLoader__.load({
|
|
|
500
519
|
state.failedKeys.includes(b.keyEnv) && react_jsx_runtime.jsx("p", { className: "wsc-hint", style: { color: "#e5534b" }, children: props.t("keyFailed") }),
|
|
501
520
|
] }, b.id)
|
|
502
521
|
) }),
|
|
522
|
+
react_jsx_runtime.jsx("h5", { className: "wsc-sectionTitle", children: props.t("optionalTitle") }),
|
|
523
|
+
react_jsx_runtime.jsx("div", { className: "wsc-grid", children: OPTIONAL_KEYS.map((b) =>
|
|
524
|
+
react_jsx_runtime.jsxs("div", { children: [
|
|
525
|
+
react_jsx_runtime.jsx("div", { className: "wsc-fieldHead", children: react_jsx_runtime.jsx("span", { className: "wsc-label", children: props.t("keyLabel", { id: b.id }) }) }),
|
|
526
|
+
react_jsx_runtime.jsx("input", {
|
|
527
|
+
className: "wsc-keyInput", type: "password", autoComplete: "off",
|
|
528
|
+
placeholder: b.keyEnv,
|
|
529
|
+
value: state.stagedKeys[b.keyEnv] ?? "",
|
|
530
|
+
onChange: (e) => card.editKey(b.keyEnv, e.target.value),
|
|
531
|
+
}),
|
|
532
|
+
] }, b.id),
|
|
533
|
+
) }),
|
|
503
534
|
state.stagedCount > 0 && react_jsx_runtime.jsx("p", { style: { margin: 0, fontSize: 12, opacity: .6 }, children: props.t("unsavedHint") }),
|
|
504
535
|
react_jsx_runtime.jsxs("div", { className: "wsc-actions", children: [
|
|
505
|
-
react_jsx_runtime.jsx("button", { className: "wsc-btn", disabled: state.saving || state.stagedCount === 0, onClick: () => card.save(), children: state.saving ? props.t("saving") : props.t("save") }),
|
|
536
|
+
react_jsx_runtime.jsx("button", { className: "wsc-btn", disabled: state.saving || state.stagedCount === 0 || state.writable === false, onClick: () => card.save(), children: state.saving ? props.t("saving") : props.t("save") }),
|
|
506
537
|
(state.stagedCount > 0) && react_jsx_runtime.jsx("button", { className: "wsc-btn wsc-btnGhost", onClick: () => card.discard(), children: props.t("discard") }),
|
|
507
538
|
state.failed && react_jsx_runtime.jsx("span", { className: "wsc-status", style: { color: "#e5534b" }, children: props.t("failed") }),
|
|
539
|
+
(state.failedFields.length > 0) && react_jsx_runtime.jsx("span", { className: "wsc-status", style: { color: "#e5534b", fontSize: 11 }, children: props.t("invalidFields") + ": " + state.failedFields.join(", ") }),
|
|
508
540
|
state.conflict && react_jsx_runtime.jsx("span", { className: "wsc-status", style: { color: "#e6a700" }, children: props.t("conflict") }),
|
|
509
541
|
!state.failed && !state.saving && state.savedAt > 0 && react_jsx_runtime.jsx("span", { className: "wsc-status", style: { color: "#2ea043" }, children: props.t("savedOk") }),
|
|
510
542
|
] }),
|
|
@@ -530,6 +562,34 @@ window.__ModuleLoader__.load({
|
|
|
530
562
|
inject: () => controller.inject(),
|
|
531
563
|
}, UnifiedSearchSection));
|
|
532
564
|
controller.refreshAllCredentials();
|
|
565
|
+
|
|
566
|
+
// Interop: devkit (and any third-party plugin) dispatches the window
|
|
567
|
+
// event "dsh-websearch:open-settings" to surface this plugin's
|
|
568
|
+
// settings. The host exposes no section-jump API, so: if devkit's
|
|
569
|
+
// toast is available, point the user at the path; otherwise log.
|
|
570
|
+
// (fe-ui C3 keeps devkit toast as the ecosystem standard notifier.)
|
|
571
|
+
const onOpenSettings = () => {
|
|
572
|
+
const hint = "统一搜索设置位于:设置 → 左侧栏「搜索 / Web Search」";
|
|
573
|
+
try {
|
|
574
|
+
if (window.__dshDevkit && typeof window.__dshDevkit.toast === "function") {
|
|
575
|
+
window.__dshDevkit.toast(hint, { kind: "info" });
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
} catch (e) { /* fall through */ }
|
|
579
|
+
console.info("[dsh-websearch] " + hint);
|
|
580
|
+
};
|
|
581
|
+
// Probe contract (devkit 0.1.1): ready flag + ack event, so the
|
|
582
|
+
// palette can tell "listener present" from "gracefully absent".
|
|
583
|
+
try {
|
|
584
|
+
window.__dshWebsearchSettingsReady = true;
|
|
585
|
+
window.addEventListener("dsh-websearch:open-settings", (ev) => {
|
|
586
|
+
onOpenSettings(ev);
|
|
587
|
+
try {
|
|
588
|
+
window.dispatchEvent(new CustomEvent("dsh-websearch:open-settings:ack"));
|
|
589
|
+
} catch (e) { /* CustomEvent unavailable */ }
|
|
590
|
+
});
|
|
591
|
+
ctx.effect(() => () => window.removeEventListener("dsh-websearch:open-settings", onOpenSettings), "websearch: open-settings listener");
|
|
592
|
+
} catch (e) { /* non-window env */ }
|
|
533
593
|
}
|
|
534
594
|
|
|
535
595
|
exports.apply = apply;
|
package/lib/health.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// health.js — read-only health surface for the unified-search plugin.
|
|
2
|
+
//
|
|
3
|
+
// GET /api/unified-search/health reports which backends are enabled / keyless
|
|
4
|
+
// and the LAST per-backend telemetry captured during a real search. It never
|
|
5
|
+
// probes anything by itself (quota safety) and never echoes credential
|
|
6
|
+
// material: error strings are truncated to 40-120 chars (matching the
|
|
7
|
+
// provider's own telemetry truncation) and run through a defensive redaction
|
|
8
|
+
// pass for token-like fragments.
|
|
9
|
+
|
|
10
|
+
const MAX_ERROR_CHARS = 120;
|
|
11
|
+
|
|
12
|
+
/** Token-like fragments that must never appear in a health payload. */
|
|
13
|
+
const SECRET_PATTERNS = [
|
|
14
|
+
/sk-[A-Za-z0-9_-]{8,}/g,
|
|
15
|
+
/Bearer\s+[A-Za-z0-9._-]{8,}/gi,
|
|
16
|
+
/(api[_-]?key|token|secret|password)\s*[=:]\s*\S{6,}/gi,
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Truncate + redact an error string for safe display.
|
|
21
|
+
* Returns "" for falsy input; length bounded to 40-120 chars.
|
|
22
|
+
*/
|
|
23
|
+
export function sanitizeError(err) {
|
|
24
|
+
if (err === null || err === undefined) return "";
|
|
25
|
+
let s = String(err instanceof Error ? err.message : err);
|
|
26
|
+
for (const re of SECRET_PATTERNS) s = s.replace(re, "[redacted]");
|
|
27
|
+
s = s.replace(/\s+/g, " ").trim();
|
|
28
|
+
if (s.length > MAX_ERROR_CHARS) s = s.slice(0, MAX_ERROR_CHARS);
|
|
29
|
+
return s;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Build the health report body (pure — no ctx, no I/O; unit-testable).
|
|
34
|
+
*
|
|
35
|
+
* @param {object} deps
|
|
36
|
+
* @param {Function} deps.resolveOptions () => resolved options snapshot
|
|
37
|
+
* @param {object} deps.backends keyed backend registry (BACKENDS)
|
|
38
|
+
* @param {Map|Function} deps.getBackendHealth telemetry map or () => map
|
|
39
|
+
* entries: { ok: boolean, at: ms, ms: duration, error?: string,
|
|
40
|
+
* lastOkAt?: ms|null }
|
|
41
|
+
* @returns {object} JSON-serializable health payload
|
|
42
|
+
*/
|
|
43
|
+
export function buildHealthReport({ resolveOptions, backends, getBackendHealth }) {
|
|
44
|
+
const opts = resolveOptions();
|
|
45
|
+
const healthMap = typeof getBackendHealth === "function" ? getBackendHealth() : getBackendHealth;
|
|
46
|
+
const tel = healthMap instanceof Map ? healthMap : new Map();
|
|
47
|
+
|
|
48
|
+
const enabledSet = new Set(
|
|
49
|
+
(opts.enabledBackends ?? []).filter((id) => backends[id] !== undefined),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const list = Object.keys(backends).map((id) => {
|
|
53
|
+
const beOpts = opts.backends?.[id] ?? {};
|
|
54
|
+
const enabled = enabledSet.has(id);
|
|
55
|
+
const keyless = beOpts.requiresKey !== true;
|
|
56
|
+
const t = tel.get(id);
|
|
57
|
+
return {
|
|
58
|
+
id,
|
|
59
|
+
enabled,
|
|
60
|
+
keyless,
|
|
61
|
+
// reachable: true/false from the latest search telemetry; null = never probed.
|
|
62
|
+
reachable: t ? t.ok === true : null,
|
|
63
|
+
lastOk: t && typeof t.lastOkAt === "number" ? t.lastOkAt : null,
|
|
64
|
+
lastError: t && t.ok !== true ? sanitizeError(t.error) || "unknown error" : null,
|
|
65
|
+
};
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const enabledList = list.filter((b) => b.enabled);
|
|
69
|
+
return {
|
|
70
|
+
ok: true,
|
|
71
|
+
enabled: enabledList.length,
|
|
72
|
+
keyless: enabledList.filter((b) => b.keyless).length,
|
|
73
|
+
backends: list,
|
|
74
|
+
updatedAt: Date.now(),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Register the read-only health route on the (late-mounted) webServer.
|
|
80
|
+
* GET only; never probes backends; response is JSON with no credentials.
|
|
81
|
+
*/
|
|
82
|
+
export function registerHealthRoute(ws, deps) {
|
|
83
|
+
if (!ws || typeof ws.register !== "function") return;
|
|
84
|
+
ws.register({
|
|
85
|
+
kind: "exact",
|
|
86
|
+
path: "/api/unified-search/health",
|
|
87
|
+
handler: (req, res) => {
|
|
88
|
+
const send = (code, body) => {
|
|
89
|
+
res.writeHead(code, { "content-type": "application/json" });
|
|
90
|
+
res.end(JSON.stringify(body));
|
|
91
|
+
};
|
|
92
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
93
|
+
send(405, { ok: false, error: "method not allowed (GET only)" });
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
send(200, buildHealthReport(deps));
|
|
98
|
+
} catch (err) {
|
|
99
|
+
send(500, { ok: false, error: sanitizeError(err) || "health report failed" });
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
}
|
package/lib/history.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// history.js — v2.7.0 append-only search history (zero deps).
|
|
2
|
+
//
|
|
3
|
+
// Ring buffer of the most recent maxEntries searches, persisted as one JSON
|
|
4
|
+
// file under the plugin's cache dir. Writes are atomic (tmp + rename) and
|
|
5
|
+
// append-only: existing entries are never rewritten, only prepended; the
|
|
6
|
+
// oldest entry beyond the cap is dropped. Exposure is strictly read-only via
|
|
7
|
+
// GET /api/websearch/history; POST /api/websearch/history/clear empties it.
|
|
8
|
+
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
|
|
12
|
+
/** Ring-buffer cap (spec: 50). */
|
|
13
|
+
export const HISTORY_MAX_ENTRIES = 50;
|
|
14
|
+
|
|
15
|
+
/** Sanitize a history entry to the exact public shape (no credentials/URLs). */
|
|
16
|
+
function toPublicEntry(entry) {
|
|
17
|
+
return {
|
|
18
|
+
query: String(entry?.query ?? "").slice(0, 500),
|
|
19
|
+
time: Number.isFinite(entry?.time) ? entry.time : Date.now(),
|
|
20
|
+
resultCount: Math.max(0, Math.floor(Number(entry?.resultCount) || 0)),
|
|
21
|
+
backendsOk: Math.max(0, Math.floor(Number(entry?.backendsOk) || 0)),
|
|
22
|
+
backendsTotal: Math.max(0, Math.floor(Number(entry?.backendsTotal) || 0)),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Create an append-only history store.
|
|
28
|
+
*
|
|
29
|
+
* @param {object} opts
|
|
30
|
+
* @param {string} opts.dir directory holding history.json (created lazily)
|
|
31
|
+
* @param {number} [opts.maxEntries] ring cap (default 50)
|
|
32
|
+
* @returns {{ record, list, clear }}
|
|
33
|
+
*/
|
|
34
|
+
export function createHistoryStore({ dir, maxEntries = HISTORY_MAX_ENTRIES }) {
|
|
35
|
+
const cap = Math.max(1, Math.floor(Number(maxEntries) || HISTORY_MAX_ENTRIES));
|
|
36
|
+
const file = () => path.join(dir, "history.json");
|
|
37
|
+
// Serialize every write through a promise chain: record() is called from
|
|
38
|
+
// concurrent search completions; without the chain, two interleaved
|
|
39
|
+
// read-modify-write cycles lose updates (arch-review P2-2). The body stays
|
|
40
|
+
// synchronous (atomic tmp+rename), so the chain is the only lock needed
|
|
41
|
+
// within this process; cross-process writers are out of scope and each
|
|
42
|
+
// write is still atomic for readers.
|
|
43
|
+
let writeChain = Promise.resolve();
|
|
44
|
+
|
|
45
|
+
function readAll() {
|
|
46
|
+
try {
|
|
47
|
+
if (!existsSync(file())) return [];
|
|
48
|
+
const parsed = JSON.parse(readFileSync(file(), "utf8"));
|
|
49
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
50
|
+
} catch {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function atomicWriteAll(list) {
|
|
56
|
+
mkdirSync(dir, { recursive: true });
|
|
57
|
+
const f = file();
|
|
58
|
+
const tmp = `${f}.${process.pid}.tmp`;
|
|
59
|
+
writeFileSync(tmp, JSON.stringify(list, null, 2), "utf8");
|
|
60
|
+
renameSync(tmp, f);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
/** Append one search (best-effort; fs errors are swallowed). Newest first. */
|
|
65
|
+
record(entry) {
|
|
66
|
+
writeChain = writeChain.then(() => {
|
|
67
|
+
try {
|
|
68
|
+
const list = readAll();
|
|
69
|
+
list.unshift(toPublicEntry(entry));
|
|
70
|
+
if (list.length > cap) list.length = cap;
|
|
71
|
+
atomicWriteAll(list);
|
|
72
|
+
} catch {
|
|
73
|
+
// history write failures are non-fatal by design
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
return writeChain;
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
/** Read the current history, newest first (defensive copy). */
|
|
80
|
+
list() {
|
|
81
|
+
return readAll().slice(0, cap).map(toPublicEntry);
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
/** Empty the history (removes the file; best-effort). */
|
|
85
|
+
clear() {
|
|
86
|
+
try {
|
|
87
|
+
if (existsSync(file())) unlinkSync(file());
|
|
88
|
+
} catch {
|
|
89
|
+
// nothing to do
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Minimal trust fence for plugin-registered routes (the host /api RPC fence
|
|
97
|
+
* does not cover webServer.register paths): require a loopback Host (blocks
|
|
98
|
+
* DNS rebinding) and a same-site browser context (sec-fetch-site same-origin
|
|
99
|
+
* / none, or an Origin whose host matches the request Host — blocks
|
|
100
|
+
* cross-site CSRF including text/plain preflight-less POSTs).
|
|
101
|
+
* Returns null when trusted, otherwise a short rejection reason.
|
|
102
|
+
*/
|
|
103
|
+
export function isTrustedApiRequest(req) {
|
|
104
|
+
const host = String(req?.headers?.host ?? "");
|
|
105
|
+
const hostOk = /^(localhost|127\.\d{1,3}\.\d{1,3}\.\d{1,3}|\[::1\])(:\d+)?$/.test(host);
|
|
106
|
+
if (!hostOk) return "untrusted host";
|
|
107
|
+
const site = req?.headers?.["sec-fetch-site"];
|
|
108
|
+
if (site === "same-origin" || site === "none") return null;
|
|
109
|
+
const origin = req?.headers?.origin;
|
|
110
|
+
if (!origin) return site !== undefined ? "cross-site request" : null;
|
|
111
|
+
try {
|
|
112
|
+
return new URL(origin).host === host ? null : "cross-origin request";
|
|
113
|
+
} catch {
|
|
114
|
+
return "invalid origin";
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Register the read-only history routes on the (late-mounted) webServer.
|
|
120
|
+
* GET /api/websearch/history -> { ok, entries }
|
|
121
|
+
* POST /api/websearch/history/clear -> { ok, cleared: true }
|
|
122
|
+
* Both routes sit behind isTrustedApiRequest (loopback Host + same-site).
|
|
123
|
+
*/
|
|
124
|
+
export function registerHistoryRoutes(ws, { getStore }) {
|
|
125
|
+
if (!ws || typeof ws.register !== "function") return;
|
|
126
|
+
const send = (res, code, body) => {
|
|
127
|
+
res.writeHead(code, { "content-type": "application/json" });
|
|
128
|
+
res.end(JSON.stringify(body));
|
|
129
|
+
};
|
|
130
|
+
const guarded = (handler) => (req, res) => {
|
|
131
|
+
const fence = isTrustedApiRequest(req);
|
|
132
|
+
if (fence) {
|
|
133
|
+
send(res, 403, { ok: false, error: `untrusted request (${fence})` });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
handler(req, res);
|
|
137
|
+
};
|
|
138
|
+
ws.register({
|
|
139
|
+
kind: "exact",
|
|
140
|
+
path: "/api/websearch/history",
|
|
141
|
+
handler: guarded((req, res) => {
|
|
142
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
143
|
+
send(res, 405, { ok: false, error: "method not allowed (GET only)" });
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const store = getStore();
|
|
147
|
+
send(res, 200, { ok: true, entries: store ? store.list() : [] });
|
|
148
|
+
}),
|
|
149
|
+
});
|
|
150
|
+
ws.register({
|
|
151
|
+
kind: "exact",
|
|
152
|
+
path: "/api/websearch/history/clear",
|
|
153
|
+
handler: guarded((req, res) => {
|
|
154
|
+
if (req.method !== "POST") {
|
|
155
|
+
send(res, 405, { ok: false, error: "method not allowed (POST only)" });
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const store = getStore();
|
|
159
|
+
store?.clear();
|
|
160
|
+
send(res, 200, { ok: true, cleared: true });
|
|
161
|
+
}),
|
|
162
|
+
});
|
|
163
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -10,10 +10,12 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import z from "@deepseek-ai/schemastery";
|
|
12
12
|
import { credentialRef } from "@deepseek-ai/dsh-credentials";
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
// NOTE (2026-09-12, dsh 0.1.5 compat): installSettingsSection +
|
|
14
|
+
// settingsNamespace were removed from @deepseek-ai/dsh-settings in the 0.1.5
|
|
15
|
+
// line (only SettingsProvider/SettingsConflictError/redactSecrets remain).
|
|
16
|
+
// Settings sections are now installed through the `settings` service, as the
|
|
17
|
+
// stock plugins do (see dsh-permission-presets). NAMESPACE was always the
|
|
18
|
+
// plain string "unified-search". No import needed anymore.
|
|
17
19
|
import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
|
|
18
20
|
import { WebError } from "@deepseek-ai/dsh-web";
|
|
19
21
|
|
|
@@ -31,7 +33,12 @@ import { tavilyBackend } from "./backends/tavily.js";
|
|
|
31
33
|
import { serperBackend } from "./backends/serper.js";
|
|
32
34
|
import { searxngBackend } from "./backends/searxng.js";
|
|
33
35
|
import { mojeekBackend } from "./backends/mojeek.js";
|
|
34
|
-
import { createUnifiedSearchProvider } from "./provider.js";
|
|
36
|
+
import { createUnifiedSearchProvider, getLatestBackendHealth } from "./provider.js";
|
|
37
|
+
import { registerHealthRoute } from "./health.js";
|
|
38
|
+
import { createSearchCache, CACHE_DEFAULTS } from "./cache.js";
|
|
39
|
+
import { createHistoryStore, registerHistoryRoutes, HISTORY_MAX_ENTRIES } from "./history.js";
|
|
40
|
+
import { BREAKER_DEFAULTS } from "./breaker.js";
|
|
41
|
+
import path from "node:path";
|
|
35
42
|
|
|
36
43
|
/** Cordis plugin name used by loader diagnostics. */
|
|
37
44
|
export const name = "unified-search";
|
|
@@ -39,7 +46,7 @@ export const name = "unified-search";
|
|
|
39
46
|
export const inject = ["web"];
|
|
40
47
|
|
|
41
48
|
/** Settings namespace key. */
|
|
42
|
-
const NAMESPACE =
|
|
49
|
+
const NAMESPACE = "unified-search";
|
|
43
50
|
|
|
44
51
|
/** Full backend registry. Order = default fan-out priority. */
|
|
45
52
|
const BACKENDS = {
|
|
@@ -144,6 +151,23 @@ export const Config = z.object({
|
|
|
144
151
|
resultTelemetry: z.boolean().default(true)
|
|
145
152
|
.description("在搜索结果尾部附一行后端健康遥测(状态+耗时),模型可据此自诊断"),
|
|
146
153
|
|
|
154
|
+
// v2.7.0 disk cache / circuit breaker / search history
|
|
155
|
+
// W1 (fe-ui review): labels speak user language with a group prefix
|
|
156
|
+
// (【缓存】/【熔断】/【历史】) instead of camelCase keys; units are
|
|
157
|
+
// spelled out (秒 / 毫秒) and grouping follows the field order below.
|
|
158
|
+
cacheEnabled: z.boolean().default(true)
|
|
159
|
+
.description("启用搜索结果缓存【缓存】:相同查询+过滤+条数在有效期内直接命中,不再请求后端"),
|
|
160
|
+
cacheTtl: z.number().step(30).min(30).max(86400).default(900)
|
|
161
|
+
.description("缓存有效期【缓存】(单位:秒):默认 900 秒;不想用缓存请直接关闭上一项"),
|
|
162
|
+
breakerEnabled: z.boolean().default(true)
|
|
163
|
+
.description("后端熔断器【熔断】:连续失败达阈值后暂停该后端一段时间;全部暂停时自动放行,不会让搜索更差"),
|
|
164
|
+
breakerThreshold: z.number().step(1).min(1).max(20).default(BREAKER_DEFAULTS.threshold)
|
|
165
|
+
.description("熔断阈值【熔断】:连续失败多少次后触发(默认 3 次)"),
|
|
166
|
+
breakerCooldownMs: z.number().step(1000).min(0).max(3600000).default(BREAKER_DEFAULTS.cooldownMs)
|
|
167
|
+
.description("熔断冷却时长【熔断】(单位:毫秒):默认 60000;设为 0 表示关闭冷却(熔断仅计数)"),
|
|
168
|
+
historyEnabled: z.boolean().default(true)
|
|
169
|
+
.description("记录搜索历史【历史】:只追加,最多 50 条;可在设置 → 搜索 中查看与清空"),
|
|
170
|
+
|
|
147
171
|
// Enable toggles (one per backend)
|
|
148
172
|
enableExa: z.boolean().default(true).description(DEFAULT_LABELS.exa),
|
|
149
173
|
enableParallel: z.boolean().default(true).description(DEFAULT_LABELS.parallel),
|
|
@@ -190,6 +214,30 @@ export const Config = z.object({
|
|
|
190
214
|
.description("显式启用后端列表(优先于上方开关;留空则用开关)"),
|
|
191
215
|
});
|
|
192
216
|
|
|
217
|
+
/**
|
|
218
|
+
* Per-field user-language labels (fe-ui W1 remainder): the settings renderer
|
|
219
|
+
* falls back to camelCase keys when no label is present, so attach one to
|
|
220
|
+
* each v2.7 descriptor (meta.label, plus a plain .label for renderers that
|
|
221
|
+
* read the top level). Units live in the label tail (秒 / 毫秒). Whether the
|
|
222
|
+
* host actually renders them is visual-check V5; the schema layer now
|
|
223
|
+
* provides the field regardless.
|
|
224
|
+
*/
|
|
225
|
+
const FIELD_LABELS = {
|
|
226
|
+
cacheEnabled: "启用搜索结果缓存",
|
|
227
|
+
cacheTtl: "缓存有效期(秒)",
|
|
228
|
+
breakerEnabled: "后端熔断器",
|
|
229
|
+
breakerThreshold: "熔断阈值(连续失败次数)",
|
|
230
|
+
breakerCooldownMs: "熔断冷却时长(毫秒)",
|
|
231
|
+
historyEnabled: "记录搜索历史",
|
|
232
|
+
};
|
|
233
|
+
for (const [key, label] of Object.entries(FIELD_LABELS)) {
|
|
234
|
+
const sch = Config.dict?.[key];
|
|
235
|
+
if (sch) {
|
|
236
|
+
sch.meta = { ...sch.meta, label };
|
|
237
|
+
sch.label = label;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
193
241
|
/**
|
|
194
242
|
* Project one resolved settings section into the options snapshot the provider
|
|
195
243
|
* serves its NEXT search with. Key resolution: credentialRef -> credentials
|
|
@@ -268,6 +316,15 @@ export function resolveOptions(ctx, config) {
|
|
|
268
316
|
dedupStrategy: config?.dedupStrategy === "url+title" ? "url+title" : "url",
|
|
269
317
|
rerank: config?.rerank === true,
|
|
270
318
|
resultTelemetry: config?.resultTelemetry !== false,
|
|
319
|
+
// v2.7.0 cache / breaker / history
|
|
320
|
+
cacheEnabled: config?.cacheEnabled === true,
|
|
321
|
+
cacheTtlS: Math.max(30, Math.floor(Number(config?.cacheTtl) || CACHE_DEFAULTS.ttlMs / 1000)),
|
|
322
|
+
breakerEnabled: config?.breakerEnabled !== false,
|
|
323
|
+
breakerThreshold: Math.max(1, Math.floor(Number(config?.breakerThreshold) || BREAKER_DEFAULTS.threshold)),
|
|
324
|
+
breakerCooldownMs: config?.breakerCooldownMs != null
|
|
325
|
+
? Math.max(0, Math.floor(Number(config?.breakerCooldownMs) || 0)) // 0 = disabled
|
|
326
|
+
: BREAKER_DEFAULTS.cooldownMs,
|
|
327
|
+
historyEnabled: config?.historyEnabled !== false,
|
|
271
328
|
filters: {
|
|
272
329
|
recency: typeof config?.recency === "string" && config.recency !== "any" ? config.recency : undefined,
|
|
273
330
|
lang: language.length > 0 ? language : undefined,
|
|
@@ -285,18 +342,60 @@ export function resolveOptions(ctx, config) {
|
|
|
285
342
|
export function apply(ctx, config) {
|
|
286
343
|
let current = () => config;
|
|
287
344
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
345
|
+
ctx.inject(["settings"], (settingsCtx) => {
|
|
346
|
+
if (typeof settingsCtx.settings?.installSection !== "function") return;
|
|
347
|
+
settingsCtx.settings.installSection(ctx, NAMESPACE, Config, config, {
|
|
348
|
+
setSource: (source) => {
|
|
349
|
+
current = source;
|
|
350
|
+
},
|
|
351
|
+
onChange: () => {},
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
// v2.7.0 stores: disk cache + append-only search history, both under
|
|
356
|
+
// DSH_HOME/cache/websearch (created lazily; every fs error is swallowed so
|
|
357
|
+
// a broken store never breaks a search).
|
|
358
|
+
const storeDir = path.join(
|
|
359
|
+
process.env.DSH_HOME || path.join(process.env.HOME || "", ".dsh"),
|
|
360
|
+
"cache", "websearch",
|
|
361
|
+
);
|
|
362
|
+
const searchCache = createSearchCache({
|
|
363
|
+
dir: path.join(storeDir, "results"),
|
|
364
|
+
ttlMs: () => resolveOptions(ctx, current()).cacheTtlS * 1000,
|
|
365
|
+
maxEntries: CACHE_DEFAULTS.maxEntries,
|
|
293
366
|
});
|
|
367
|
+
const historyStore = createHistoryStore({ dir: storeDir, maxEntries: HISTORY_MAX_ENTRIES });
|
|
294
368
|
|
|
295
369
|
const provider = createUnifiedSearchProvider({
|
|
296
370
|
ctx,
|
|
297
371
|
resolveOptions: () => resolveOptions(ctx, current()),
|
|
298
372
|
backends: BACKENDS,
|
|
373
|
+
cache: {
|
|
374
|
+
get: (key) => searchCache.get(key),
|
|
375
|
+
set: (key, value) => searchCache.set(key, value),
|
|
376
|
+
},
|
|
377
|
+
history: {
|
|
378
|
+
record: (entry) => {
|
|
379
|
+
if (resolveOptions(ctx, current()).historyEnabled !== true) return;
|
|
380
|
+
historyStore.record(entry);
|
|
381
|
+
},
|
|
382
|
+
},
|
|
299
383
|
});
|
|
300
384
|
|
|
301
385
|
ctx.web.registerSearchProvider(provider);
|
|
386
|
+
|
|
387
|
+
// Read-only health route: webServer is an optional, late-mounted host
|
|
388
|
+
// service (same pattern as dsh-chat-import's panel routes). Headless / CI
|
|
389
|
+
// profiles never mount it and the callback simply never runs. The route
|
|
390
|
+
// only reads the settings snapshot + last per-search telemetry — it never
|
|
391
|
+
// probes backends and never touches credential values.
|
|
392
|
+
ctx.inject(["webServer"], (webCtx) => {
|
|
393
|
+
registerHealthRoute(webCtx.webServer, {
|
|
394
|
+
resolveOptions: () => resolveOptions(ctx, current()),
|
|
395
|
+
backends: BACKENDS,
|
|
396
|
+
getBackendHealth: getLatestBackendHealth,
|
|
397
|
+
});
|
|
398
|
+
// v2.7.0 search history: read-only GET + explicit clear POST.
|
|
399
|
+
registerHistoryRoutes(webCtx.webServer, { getStore: () => historyStore });
|
|
400
|
+
});
|
|
302
401
|
}
|