@240xu/dsh-websearch 2.1.1 → 2.3.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/README.md CHANGED
@@ -31,6 +31,18 @@ DSH([DeepSeek Harness](https://github.com/deepseek-ai/dsh))原生插件:
31
31
 
32
32
  **Default enabled set**: `["exa", "parallel", "ddg", "searxng"]` — all keyless. The key-gated backends only join when their `available()` returns true (key present + baseURL reachable).
33
33
 
34
+ ### SearXNG 实例 | Public instances
35
+
36
+ SearXNG 默认指向 `https://searx.be`;公共实例可用性随网络环境波动,失败时错误信息会提示换源。
37
+ 在设置面板修改 `searxngBaseURL` 即可切换,常见候选:
38
+
39
+ | 实例 | 备注 |
40
+ |---|---|
41
+ | https://searx.be | 官方默认,部分地区不可达 |
42
+ | https://searx.tiekoetter.com | 稳定性较好 |
43
+ | https://priv.au | 隐私友好 |
44
+ | 自托管 | 最可靠,支持 API Key(`searxngApiKeyEnv`) |
45
+
34
46
  ## Install | 安装
35
47
 
36
48
  ### 方式一:作为 profile 依赖安装(推荐,规范做法)
@@ -81,6 +93,9 @@ pnpm install
81
93
 
82
94
  **v2.1 结果策略**:设置面板新增「结果策略」分区。时间/语言过滤按各后端能力自动映射——Brave `freshness/country/search_lang`、Tavily `time_range`、Serper `tbs/hl/gl`(Google 日期语法)、SearXNG `time_range/language`、DDG `df`;不支持的后端自动忽略对应维度。内部超时以真实原因失败(backend "<id>" timed out after Nms),不再被静默降级为取消;Mojeek 的 API Key 改走 Authorization 头,不再出现在 URL 中。
83
95
 
96
+ **结果健康遥测(默认开)**:每次搜索的 `content` 尾部附一行 `[websearch backends] exa ✓5ms/3 · ddg ✗30000ms`,
97
+ 模型可据此自诊断并建议用户调整设置;设置面板「结果策略」可关闭(`resultTelemetry`)。
98
+
84
99
  **11 个后端开关**:`enableExa` / `enableParallel` / `enableDdg` / `enableSearxng`(默认开);`enableBrave` / `enableTavily` / `enableSerper` / `enableMojeek` / `enableDeepseek` / `enableAnthropic` / `enableOpenai`(默认关)
85
100
 
86
101
  **API Key 授权**(credential-ref,填环境变量名,实际 key 存于 credentials 服务或环境变量):
@@ -66,7 +66,8 @@ export const searxngBackend = {
66
66
  });
67
67
  } catch (error) {
68
68
  if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
69
- throw new WebError("SearXNG request failed: " + error, "WEB_PROVIDER_ERROR", { cause: error });
69
+ throw new WebError("SearXNG request failed: " + error +
70
+ " (instance unreachable? point searxngBaseURL at another public instance)", "WEB_PROVIDER_ERROR", { cause: error });
70
71
  }
71
72
 
72
73
  if (!response.ok) {
package/lib/client.js CHANGED
@@ -31,7 +31,7 @@ window.__ModuleLoader__.load({
31
31
  dedupStrategy: ["url", "url+title"],
32
32
  };
33
33
  /** Boolean settings staged as "true"/"false" strings by the selects. */
34
- const BOOLEAN_FIELDS = ["rerank", "safeSearch"];
34
+ const BOOLEAN_FIELDS = ["rerank", "safeSearch", "resultTelemetry"];
35
35
  const KEYLESS = BACKENDS.filter((b) => !b.keyEnv);
36
36
  const KEYED = BACKENDS.filter((b) => b.keyEnv);
37
37
 
@@ -88,6 +88,8 @@ window.__ModuleLoader__.load({
88
88
  dedupUrl: "按链接",
89
89
  dedupTitle: "链接 + 标题(合并转载)",
90
90
  langPlaceholder: "如 en、zh-CN,留空为默认",
91
+ envProvided: "环境变量提供", readOnly: "只读,无需填写", conflict: "设置已在其他端被修改,请重试", keyFailed: "该 Key 保存失败",
92
+ telemetryLabel: "结果健康遥测",
91
93
  };
92
94
  const en = {
93
95
  nav: "Web Search",
@@ -131,6 +133,11 @@ window.__ModuleLoader__.load({
131
133
  dedupUrl: "By URL",
132
134
  dedupTitle: "URL + title (merge syndication)",
133
135
  langPlaceholder: "e.g. en, zh-CN; blank = backend default",
136
+ envProvided: "provided by env",
137
+ readOnly: "read-only, nothing to fill",
138
+ conflict: "Changed elsewhere - please retry",
139
+ keyFailed: "failed to save this key",
140
+ telemetryLabel: "Result health telemetry",
134
141
  };
135
142
 
136
143
  //#region styles
@@ -169,6 +176,8 @@ window.__ModuleLoader__.load({
169
176
  this.keyState = new Map(); // envRef -> {configured}
170
177
  this.saving = false;
171
178
  this.failed = false;
179
+ this.conflict = false;
180
+ this.failedKeys = new Set();
172
181
  this.savedAt = 0;
173
182
  this.store = createStore(this.projection());
174
183
  this.scope.subscribe(() => this.republish());
@@ -199,6 +208,8 @@ window.__ModuleLoader__.load({
199
208
  const s = this.staged.get(field);
200
209
  return s ? (s.clear ? "" : s.text) : undefined;
201
210
  },
211
+ conflict: this.conflict,
212
+ failedKeys: [...this.failedKeys],
202
213
  saving: this.saving,
203
214
  failed: this.failed,
204
215
  version: (this.staged.size + ":" + [...this.staged.keys()].join(",")),
@@ -223,7 +234,7 @@ window.__ModuleLoader__.load({
223
234
  const res = await this.connection.api.credentials.describe({ refs: [ref] });
224
235
  if (!res.result.ok) return;
225
236
  const view = res.result.value.credentials[ref];
226
- this.keyState.set(ref, { configured: view?.configured ?? false });
237
+ this.keyState.set(ref, { configured: view?.configured ?? false, writable: view?.writable !== false });
227
238
  } catch { /* ignore */ }
228
239
  this.republish();
229
240
  }
@@ -274,15 +285,26 @@ window.__ModuleLoader__.load({
274
285
  for (const [ref, staged] of this.stagedKeys) {
275
286
  const value = staged.text.trim();
276
287
  if (value !== "") {
277
- await this.connection.api.credentials.set({ ref, value });
278
- this.keyState.set(ref, { configured: true });
279
- doneKeys.push(ref);
288
+ try {
289
+ await this.connection.api.credentials.set({ ref, value });
290
+ this.keyState.set(ref, { configured: true, writable: true });
291
+ doneKeys.push(ref);
292
+ this.failedKeys.delete(ref);
293
+ } catch (keyErr) {
294
+ // env-shadowed reference (fail-loud doctrine): isolate so other keys still land.
295
+ this.failed = true;
296
+ this.failedKeys.add(ref);
297
+ void keyErr;
298
+ }
280
299
  }
281
300
  }
282
301
  for (const f of doneFields) this.staged.delete(f);
283
302
  for (const k of doneKeys) this.stagedKeys.delete(k);
284
303
  if (!this.failed && (doneFields.length > 0 || doneKeys.length > 0)) this.savedAt = Date.now();
285
- } catch { this.failed = true; }
304
+ } catch (err) {
305
+ this.failed = true;
306
+ this.conflict = String(err).includes("SETTINGS_CONFLICT");
307
+ }
286
308
  this.saving = false;
287
309
  this.republish();
288
310
  }
@@ -297,7 +319,7 @@ window.__ModuleLoader__.load({
297
319
  reset: (f) => this.reset(f),
298
320
  editKey: (r, t) => this.editKey(r, t),
299
321
  save: () => this.save(),
300
- discard: () => { this.staged.clear(); this.stagedKeys.clear(); this.failed = false; this.republish(); },
322
+ discard: () => { this.staged.clear(); this.stagedKeys.clear(); this.failed = false; this.conflict = false; this.failedKeys.clear(); this.republish(); },
301
323
  };
302
324
  }
303
325
  }
@@ -445,6 +467,12 @@ window.__ModuleLoader__.load({
445
467
  options: boolOptions,
446
468
  onChange: (v) => card.edit("safeSearch", v),
447
469
  }),
470
+ react_jsx_runtime.jsx(SelectField, {
471
+ label: props.t("telemetryLabel"),
472
+ value: selVal("resultTelemetry", "true"),
473
+ options: boolOptions,
474
+ onChange: (v) => card.edit("resultTelemetry", v),
475
+ }),
448
476
  ] }),
449
477
  react_jsx_runtime.jsx("h4", { className: "wsc-sectionTitle", children: props.t("keysTitle") }),
450
478
  react_jsx_runtime.jsx("div", { className: "wsc-grid", children: BACKENDS.filter((b) => b.keyEnv).map((b) =>
@@ -458,15 +486,18 @@ window.__ModuleLoader__.load({
458
486
  children: props.t("getKey"),
459
487
  }),
460
488
  react_jsx_runtime.jsx("span", { className: "wsc-badge" + (state.keyState[b.keyEnv]?.configured ? " wsc-badgeOn" : ""), children: state.keyState[b.keyEnv]?.configured ? props.t("keySet") : props.t("keyUnset") }),
489
+ state.keyState[b.keyEnv]?.configured && state.keyState[b.keyEnv]?.writable === false && react_jsx_runtime.jsx("span", { className: "wsc-badge", style: { opacity: .75 }, children: props.t("envProvided") }),
461
490
  ] }),
462
491
  ] }),
463
492
  react_jsx_runtime.jsx("input", {
464
493
  className: "wsc-keyInput", type: "password", autoComplete: "off",
465
- placeholder: b.keyEnv,
494
+ disabled: state.keyState[b.keyEnv]?.configured && state.keyState[b.keyEnv]?.writable === false,
495
+ placeholder: (state.keyState[b.keyEnv]?.configured && state.keyState[b.keyEnv]?.writable === false) ? props.t("readOnly") : b.keyEnv,
466
496
  value: state.stagedKeys[b.keyEnv] ?? "",
467
497
  onChange: (e) => card.editKey(b.keyEnv, e.target.value),
468
498
  }),
469
499
  react_jsx_runtime.jsx("p", { className: "wsc-hint", children: props.t("keyHint") }),
500
+ state.failedKeys.includes(b.keyEnv) && react_jsx_runtime.jsx("p", { className: "wsc-hint", style: { color: "#e5534b" }, children: props.t("keyFailed") }),
470
501
  ] }, b.id)
471
502
  ) }),
472
503
  state.stagedCount > 0 && react_jsx_runtime.jsx("p", { style: { margin: 0, fontSize: 12, opacity: .6 }, children: props.t("unsavedHint") }),
@@ -474,6 +505,7 @@ window.__ModuleLoader__.load({
474
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") }),
475
506
  (state.stagedCount > 0) && react_jsx_runtime.jsx("button", { className: "wsc-btn wsc-btnGhost", onClick: () => card.discard(), children: props.t("discard") }),
476
507
  state.failed && react_jsx_runtime.jsx("span", { className: "wsc-status", style: { color: "#e5534b" }, children: props.t("failed") }),
508
+ state.conflict && react_jsx_runtime.jsx("span", { className: "wsc-status", style: { color: "#e6a700" }, children: props.t("conflict") }),
477
509
  !state.failed && !state.saving && state.savedAt > 0 && react_jsx_runtime.jsx("span", { className: "wsc-status", style: { color: "#2ea043" }, children: props.t("savedOk") }),
478
510
  ] }),
479
511
  ] });
package/lib/index.js CHANGED
@@ -141,6 +141,8 @@ export const Config = z.object({
141
141
  .description("去重策略:url 仅按链接;url+title 额外合并同标题的转载镜像"),
142
142
  rerank: z.boolean().default(false)
143
143
  .description("按查询词相关性重排序(确定性、并列时保持后端优先级)"),
144
+ resultTelemetry: z.boolean().default(true)
145
+ .description("在搜索结果尾部附一行后端健康遥测(状态+耗时),模型可据此自诊断"),
144
146
 
145
147
  // Enable toggles (one per backend)
146
148
  enableExa: z.boolean().default(true).description(DEFAULT_LABELS.exa),
@@ -265,6 +267,7 @@ export function resolveOptions(ctx, config) {
265
267
  // v2.1 shaping options consumed by the provider merge pipeline.
266
268
  dedupStrategy: config?.dedupStrategy === "url+title" ? "url+title" : "url",
267
269
  rerank: config?.rerank === true,
270
+ resultTelemetry: config?.resultTelemetry !== false,
268
271
  filters: {
269
272
  recency: typeof config?.recency === "string" && config.recency !== "any" ? config.recency : undefined,
270
273
  lang: language.length > 0 ? language : undefined,
package/lib/provider.js CHANGED
@@ -119,6 +119,7 @@ export function createUnifiedSearchProvider({ ctx, resolveOptions, backends }) {
119
119
  const effectiveRequest =
120
120
  Object.keys(filters).length > 0 ? { ...request, filters } : request;
121
121
 
122
+ const telemetry = [];
122
123
  // Concurrency limiter
123
124
  const limit = createLimiter(opts.concurrency ?? 6);
124
125
 
@@ -128,6 +129,7 @@ export function createUnifiedSearchProvider({ ctx, resolveOptions, backends }) {
128
129
  // reclassified as WEB_PROVIDER_ERROR so timeouts surface as real
129
130
  // failures instead of being silently demoted like user cancellations.
130
131
  const runBackend = ({ id, backend, beOpts, hooks }) => {
132
+ const startedAtMs = Date.now();
131
133
  const controller = new AbortController();
132
134
  const mergedSignal = signal
133
135
  ? AbortSignal.any([signal, controller.signal])
@@ -135,6 +137,16 @@ export function createUnifiedSearchProvider({ ctx, resolveOptions, backends }) {
135
137
  const timeoutId = setTimeout(() => controller.abort(), backendTimeoutMs);
136
138
  return backend
137
139
  .search(effectiveRequest, mergedSignal, beOpts, hooks)
140
+ .then(
141
+ (value) => {
142
+ telemetry.push({ id, ok: true, ms: Date.now() - startedAtMs, count: value?.sources?.length ?? 0 });
143
+ return value;
144
+ },
145
+ (err) => {
146
+ telemetry.push({ id, ok: false, ms: Date.now() - startedAtMs, error: String(err instanceof Error ? err.message : err).slice(0, 80) });
147
+ throw err;
148
+ },
149
+ )
138
150
  .catch((err) => {
139
151
  if (controller.signal.aborted && !(signal && signal.aborted)) {
140
152
  throw new WebError(
@@ -170,7 +182,10 @@ export function createUnifiedSearchProvider({ ctx, resolveOptions, backends }) {
170
182
  let hardError = null;
171
183
  const failures = [];
172
184
 
185
+ let backendIndex = 0;
173
186
  for (const settled of results) {
187
+ const failedId = eligible[backendIndex] ? eligible[backendIndex].id : "unknown";
188
+ backendIndex++;
174
189
  if (settled.status === "fulfilled") {
175
190
  if (mergedContent === undefined && typeof settled.value?.content === "string" && settled.value.content.length > 0) {
176
191
  mergedContent = settled.value.content;
@@ -182,9 +197,9 @@ export function createUnifiedSearchProvider({ ctx, resolveOptions, backends }) {
182
197
  if (err instanceof WebError && err.code === "WEB_ABORTED") {
183
198
  // Per-backend abort → soft unless caller signal fired.
184
199
  if (signal?.aborted === true) hardAborted = true;
185
- else failures.push({ id: "abort", err });
200
+ else failures.push({ id: failedId, err });
186
201
  } else {
187
- failures.push({ id: "error", err });
202
+ failures.push({ id: failedId, err });
188
203
  if (hardError === null) hardError = err;
189
204
  }
190
205
  }
@@ -208,20 +223,33 @@ export function createUnifiedSearchProvider({ ctx, resolveOptions, backends }) {
208
223
  // If we got ANY sources, return them even if some backends failed.
209
224
  if (ordered.length > 0) {
210
225
  const sources = ordered.slice(0, maxResults);
226
+ let outContent = mergedContent;
227
+ if (opts.resultTelemetry !== false && telemetry.length > 0) {
228
+ const line = "[websearch backends] " + telemetry
229
+ .map((t) => t.id + (t.ok ? " ✓" : " ✗") + t.ms + "ms" + (t.ok ? "/" + t.count : ""))
230
+ .join(" · ");
231
+ outContent = outContent === undefined ? line : outContent + "\n\n" + line;
232
+ }
211
233
  return {
212
234
  sources,
213
- ...(mergedContent !== undefined ? { content: mergedContent } : {}),
235
+ ...(outContent !== undefined ? { content: outContent } : {}),
214
236
  truncated: ordered.length > maxResults,
215
237
  };
216
238
  }
217
239
 
218
- // All enabled backends failed — synthesize a meaningful WEB_PROVIDER_ERROR.
219
- const firstDetail =
220
- hardError instanceof Error
221
- ? hardError.message
222
- : String(hardError ?? "unknown");
240
+ // All enabled backends failed - synthesize one meaningful error that
241
+ // names each backend's own reason so the caller (model or user) can act.
242
+ const detail = failures.slice(0, 4)
243
+ .map((f) => f.id + ": " + String(f.err instanceof Error ? f.err.message : f.err).slice(0, 120))
244
+ .join("; ");
245
+ const allKeyMissing =
246
+ failures.length > 0 &&
247
+ failures.every((f) => f.err instanceof WebError && f.err.code === "WEB_PROVIDER_UNAVAILABLE");
248
+ const hint = allKeyMissing
249
+ ? " (every enabled backend is missing its API key - add keys in Settings > Web Search, or enable a keyless backend)"
250
+ : "";
223
251
  throw new WebError(
224
- `unified search: all enabled backends failed (${failures.length}). First: ${firstDetail}`,
252
+ `unified search: all enabled backends failed (${failures.length}) - ${detail}` + hint,
225
253
  "WEB_PROVIDER_ERROR",
226
254
  { cause: hardError ?? undefined },
227
255
  );
package/lib/util/log.js CHANGED
@@ -1,51 +1,65 @@
1
1
  /**
2
2
  * @module dsh-websearch/util/log
3
3
  *
4
- * Emit a structured "search/backend-request" event onto the current session,
5
- * so debugging / token-cost attribution is possible from the dsh session log.
6
- * Mirrors the official dsh-web-search-deepseek `recordRequest` pattern but
7
- * is per-backend (each backend records its own endpoint + body so it is
8
- * auditable which backend fired which request).
4
+ * Per-backend request/outcome diagnostics.
9
5
  *
10
- * Resilient to missing agents plane — if the session ledger is unavailable,
11
- * the call silently no-ops (we MUST NOT break search just because we can't
12
- * record an audit row).
6
+ * IMPORTANT: these intentionally go to the HOST logger (ctx.logger), NOT to
7
+ * the session ledger. The session event vocabulary is a closed, generated set
8
+ * (@deepseek-ai/dsh-session known-event-types): the persistence read path
9
+ * refuses to interpret ANY session containing an envelope type outside that
10
+ * set unless the ENVELOPE carries ignorable: true -- and the runtime
11
+ * session.append() API provides no way to set that envelope marker (its opts
12
+ * carry only sourceEventSeqs/surfaceOp). A third-party plugin writing custom
13
+ * types therefore poisons the whole session log: writes succeed, the next
14
+ * continue/load refuses. Mirroring the in-repo web/deepseek-search-llm-request
15
+ * pattern is not available to out-of-repo packages by construction.
16
+ *
17
+ * Both emitters are best-effort and never throw: diagnostics must not break
18
+ * search.
13
19
  */
14
20
 
21
+ /** One-line summary safe for the host log. */
22
+ function summarize(request) {
23
+ try {
24
+ const body = request && request.body ? JSON.stringify(request.body) : "";
25
+ const trimmed = body.length > 200 ? body.slice(0, 200) + "..." : body;
26
+ return String((request && request.endpoint) || "") +
27
+ (request && request.toolName ? " tool=" + request.toolName : "") +
28
+ (trimmed ? " body=" + trimmed : "");
29
+ } catch {
30
+ return String(request && request.endpoint);
31
+ }
32
+ }
33
+
15
34
  /**
16
- * Record one backend request to the current session's event ledger.
35
+ * Record one backend request to the host log.
17
36
  *
18
- * @param {object} ctx - the Cordis plugin context (`ctx.get("agents")`).
19
- * @param {string} backendId - e.g. "exa" / "parallel" / "ddg" / "deepseek" / "anthropic" / "openai".
20
- * @param {object} request - { endpoint, toolName, body, headers? } — headers are
21
- * filtered by the caller (do not pass Authorization/x-api-key here).
37
+ * @param {object} ctx - the Cordis plugin context (ctx.logger).
38
+ * @param {string} backendId - e.g. exa / parallel / brave.
39
+ * @param {object} request - { endpoint, toolName, body } - never includes secrets.
22
40
  */
23
41
  export function recordBackendRequest(ctx, backendId, request) {
24
42
  try {
25
- const agents = ctx.get?.("agents");
26
- const initiator = agents?.currentInitiator?.();
27
- const session = initiator?.session;
28
- if (session?.append) {
29
- session.append("web/unified-search-backend-request", { backendId, ...request, ignorable: true });
30
- }
43
+ const logger = ctx && ctx.logger;
44
+ if (!logger || typeof logger.debug !== "function") return;
45
+ logger.debug("[websearch:" + backendId + "] request " + summarize(request));
31
46
  } catch {
32
- // audit logging is best-effort; never fatal
47
+ // best-effort; never fatal
33
48
  }
34
49
  }
35
50
 
36
51
  /**
37
- * Record a backend's final outcome (number of sources returned / error message).
38
- * Pairs with recordBackendRequest; useful for token-cost attribution.
52
+ * Record a backend's final outcome (sources count / error message) to the host log.
39
53
  */
40
54
  export function recordBackendOutcome(ctx, backendId, outcome) {
41
55
  try {
42
- const agents = ctx.get?.("agents");
43
- const initiator = agents?.currentInitiator?.();
44
- const session = initiator?.session;
45
- if (session?.append) {
46
- session.append("web/unified-search-backend-outcome", { backendId, ...outcome, ignorable: true });
47
- }
56
+ const logger = ctx && ctx.logger;
57
+ if (!logger) return;
58
+ const status = outcome && typeof outcome.status === "string" ? outcome.status : "";
59
+ const line = "[websearch:" + backendId + "] outcome " + JSON.stringify(outcome ?? {});
60
+ if (status === "error" && typeof logger.warn === "function") logger.warn(line);
61
+ else if (typeof logger.debug === "function") logger.debug(line);
48
62
  } catch {
49
63
  // best-effort
50
64
  }
51
- }
65
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@240xu/dsh-websearch",
3
- "version": "2.1.1",
4
- "description": "Unified web search provider for DeepSeek Harness (ctx.web): 11 backends — Exa + Parallel + DDG + SearXNG (keyless), DeepSeek + Anthropic + OpenAI + Brave + Tavily + Serper + Mojeek (key-gated via Settings credentials). Registers provider id \"unified\" for zero-config web_search tool.",
3
+ "version": "2.3.0",
4
+ "description": "Unified web search provider for DeepSeek Harness (ctx.web): 11 backends \u2014 Exa + Parallel + DDG + SearXNG (keyless), DeepSeek + Anthropic + OpenAI + Brave + Tavily + Serper + Mojeek (key-gated via Settings credentials). Registers provider id \"unified\" for zero-config web_search tool.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {