@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/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 Set();
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
- await this.scope.set("enable" + id.charAt(0).toUpperCase() + id.slice(1), checked);
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; this.republish();
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
- if (Number.isNaN(num) || num < lo || num > hi) { this.failed = true; continue; }
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.add(ref);
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
- import {
14
- installSettingsSection,
15
- settingsNamespace,
16
- } from "@deepseek-ai/dsh-settings";
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 = settingsNamespace("unified-search");
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
- installSettingsSection(ctx, NAMESPACE, Config, config, {
289
- setSource: (source) => {
290
- current = source;
291
- },
292
- onChange: () => {},
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
  }