@240xu/dsh-websearch 2.2.0 → 2.4.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
@@ -29,7 +29,19 @@ DSH([DeepSeek Harness](https://github.com/deepseek-ai/dsh))原生插件:
29
29
  | `anthropic` | `ANTHROPIC_API_KEY` | `https://api.anthropic.com/v1/messages` | Anthropic Messages + `web_search_20250305`, model `claude-sonnet-4-6` | Claude Code 同机制 |
30
30
  | `openai` | `OPENAI_API_KEY` | `https://api.openai.com/v1/responses` | Responses API native `web_search` tool, parse `url_citation` annotations | Codex 同机制 |
31
31
 
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).
32
+ > 可用性说明:available() 只做本地配置检查(不联网探测可达性);密钥在每次搜索时按操作解析,缺失时该后端以明确错误码软失败(不阻塞其他后端)。全部缺 Key 时错误会指向设置页。
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`) |
33
45
 
34
46
  ## Install | 安装
35
47
 
@@ -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 服务或环境变量):
@@ -110,7 +125,7 @@ pnpm install
110
125
  - **Honest timeouts**: each backend runs under an internal abort controller merged into the caller signal; when only the internal timer fires, the failure is reclassified as WEB_PROVIDER_ERROR ("backend <id> timed out") instead of being masked as a user cancellation.
111
126
  - **Concurrency & Timeout Control**: `concurrency` (default 6) limits simultaneous calls; `backendTimeoutMs` (default 30s) caps each backend.
112
127
  - **Streamable-http MCP, custom handshake**: `lib/util/mcp-client.js` implements `initialize → notifications/initialized → tools/call` with `Mcp-Session-Id` header caching against Exa and Parallel — no dependency on the full MCP SDK.
113
- - **hooks.recordRequest/recordOutcome bridge**: each backend routes its request/outcome to `lib/util/log.js` which records `web/unified-search-backend-request` / `…backend-outcome` events onto the active DSH session via `ctx.get("agents")?.currentInitiator()?.session.append(...)` (mirrors `dsh-web-search-deepseek`).
128
+ - **Host-logger diagnostics**: each backend routes request/outcome through lib/util/log.js to the host logger (ctx.logger), best-effort and never thrown - deliberately NOT session events: the session event vocabulary is a closed set third-party plugins must not extend (an unknown envelope type makes the whole session log unreadable).
114
129
 
115
130
  ## Architecture | 架构
116
131
 
@@ -142,7 +157,7 @@ tests/
142
157
  ## Test | 测试
143
158
 
144
159
  ```bash
145
- node --test tests/parse.test.js tests/provider.test.js
160
+ node --test tests/
146
161
  ```
147
162
 
148
163
  33/33 pass.
@@ -19,7 +19,7 @@ import { throwIfSearchAborted, searchAborted, isAbortError } from "../util/abort
19
19
  import { WebError } from "@deepseek-ai/dsh-web";
20
20
 
21
21
  /** Attribution header; bumped with the package version. */
22
- const UA = "dsh-websearch/2.1";
22
+ const UA = "dsh-websearch/2.3";
23
23
 
24
24
  /**
25
25
  * Factory: create an Anthropic-like Messages backend.
@@ -30,7 +30,6 @@ const UA = "dsh-websearch/2.1";
30
30
  * @param {string} spec.defaultModel
31
31
  * @param {string} spec.defaultApiVersion e.g. "2023-06-01"
32
32
  * @param {string} spec.toolName "web_search_20250305"
33
- * @param {string} [spec.futureToolName] optional newer tool for dual fallback
34
33
  * @returns {object} backend object { id, requiresCredential, available, search }
35
34
  */
36
35
  export function createAnthropicLikeBackend(spec) {
@@ -40,7 +39,6 @@ export function createAnthropicLikeBackend(spec) {
40
39
  defaultModel,
41
40
  defaultApiVersion,
42
41
  toolName,
43
- futureToolName,
44
42
  } = spec;
45
43
 
46
44
  return {
@@ -154,7 +152,7 @@ export function createAnthropicLikeBackend(spec) {
154
152
  }
155
153
 
156
154
  try {
157
- const result = mapAnthropicResponse(await response.json(), id, futureToolName);
155
+ const result = mapAnthropicResponse(await response.json(), id);
158
156
  hooks.recordOutcome?.({ status: "ok", sources: result.sources.length });
159
157
  return { sources: result.sources, content: undefined, truncated: false };
160
158
  } catch (err) {
@@ -174,15 +172,7 @@ export function createAnthropicLikeBackend(spec) {
174
172
  };
175
173
  }
176
174
 
177
- /**
178
- * Optional dual-tool fallback: try the newer tool first; if that tool name is
179
- * rejected by the API (HTTP error mapping is upstream of us), the provider
180
- * retries with the established fallback. Here we just expose both — the
181
- * provider loops through candidates and uses whichever result block appears.
182
- */
183
- export function tryDualToolResponse(response, id, primaryTool, fallbackTool) {
184
- return mapAnthropicResponse(response, id, primaryTool);
185
- }
175
+
186
176
 
187
177
  /**
188
178
  * Walk web_search_tool_result blocks for citeable web_search_result items,
@@ -51,8 +51,10 @@ export const braveBackend = {
51
51
  headers: {
52
52
  "X-Subscription-Token": apiKey,
53
53
  "Accept": "application/json",
54
- "User-Agent": "dsh-websearch/2.1",
54
+ "User-Agent": "dsh-websearch/2.3",
55
55
  },
56
+ redirect: "error",
57
+
56
58
  signal,
57
59
  });
58
60
  } catch (error) {
@@ -61,7 +63,7 @@ export const braveBackend = {
61
63
  }
62
64
 
63
65
  if (!response.ok) {
64
- const text = await response.text().catch(() => "");
66
+ const text = await response.text().catch(() => "").then((tx) => tx.slice(0, 300));
65
67
  hooks?.recordOutcome?.({ status: "error", message: `HTTP ${response.status}: ${text}` });
66
68
  throw new WebError(`Brave Search API error (HTTP ${response.status}): ${text}`, "WEB_PROVIDER_ERROR");
67
69
  }
@@ -40,7 +40,7 @@ export const ddgBackend = {
40
40
  headers: {
41
41
  "Content-Type": "application/x-www-form-urlencoded",
42
42
  "Accept": "text/html",
43
- "User-Agent": "dsh-websearch/2.1",
43
+ "User-Agent": "dsh-websearch/2.3",
44
44
  },
45
45
  body,
46
46
  ...(signal !== undefined ? { signal } : {}),
@@ -7,7 +7,7 @@
7
7
  // .../Highlights: .../ ..." separated by "\n\n---\n\n"; we split into source objects.
8
8
  // Exa is keyless — its public MCP endpoint requires no API key.
9
9
 
10
- import { mcpCall, invalidateSession } from "../util/mcp-client.js";
10
+ import { mcpCall } from "../util/mcp-client.js";
11
11
  import { WebError } from "@deepseek-ai/dsh-web";
12
12
 
13
13
  const EXA_ENDPOINT = "https://mcp.exa.ai/mcp";
@@ -41,7 +41,7 @@ export const mojeekBackend = {
41
41
  const headers = {
42
42
  "Accept": "application/json",
43
43
  "Authorization": "Bearer " + apiKey,
44
- "User-Agent": "dsh-websearch/2.1",
44
+ "User-Agent": "dsh-websearch/2.3",
45
45
  };
46
46
 
47
47
  hooks?.recordRequest?.({
@@ -55,6 +55,8 @@ export const mojeekBackend = {
55
55
  response = await fetch(url, {
56
56
  method: "GET",
57
57
  headers,
58
+ redirect: "error",
59
+
58
60
  signal,
59
61
  });
60
62
  } catch (error) {
@@ -63,7 +65,7 @@ export const mojeekBackend = {
63
65
  }
64
66
 
65
67
  if (!response.ok) {
66
- const text = await response.text().catch(() => "");
68
+ const text = await response.text().catch(() => "").then((tx) => tx.slice(0, 300));
67
69
  hooks?.recordOutcome?.({ status: "error", message: "HTTP " + response.status + ": " + text });
68
70
  throw new WebError("Mojeek API error (HTTP " + response.status + "): " + text, "WEB_PROVIDER_ERROR");
69
71
  }
@@ -13,7 +13,7 @@
13
13
  import { throwIfSearchAborted, searchAborted, isAbortError } from "../util/abort.js";
14
14
  import { WebError } from "@deepseek-ai/dsh-web";
15
15
 
16
- const UA = "dsh-websearch/2.1";
16
+ const UA = "dsh-websearch/2.3";
17
17
 
18
18
  export const openaiBackend = {
19
19
  id: "openai",
@@ -47,7 +47,7 @@ export const openaiBackend = {
47
47
  );
48
48
  }
49
49
  const baseURL = (options.baseURL || "https://api.openai.com/v1").replace(/\/$/, "");
50
- const model = options.model || "gpt-5";
50
+ const model = options.model || "gpt-4o";
51
51
  const endpoint = `${baseURL}/responses`;
52
52
 
53
53
  const body = {
@@ -13,18 +13,15 @@
13
13
  // JSON-string with { search_id, results: [{ url, title, publish_date, excerpts:[] }] }.
14
14
  // Parallel is keyless — its public MCP endpoint requires no API key.
15
15
 
16
- import { mcpCall, invalidateSession } from "../util/mcp-client.js";
16
+ import { randomUUID } from "node:crypto";
17
+ import { mcpCall } from "../util/mcp-client.js";
17
18
  import { WebError } from "@deepseek-ai/dsh-web";
18
19
 
19
20
  const PARALLEL_ENDPOINT = "https://search.parallel.ai/mcp";
20
21
  const PARALLEL_TOOL = "web_search";
21
22
 
22
- /** Per-process session id — stable across searches so Parallel's free-tier
23
- * rate limiter correlates calls consistently. */
24
- const SESSION_ID =
25
- "dsh-websearch-" +
26
- process.pid?.toString(36) + "-" +
27
- Math.random().toString(36).slice(2, 10);
23
+ /** Per-process session id - stable so Parallel free-tier rate limiting correlates calls. */
24
+ const SESSION_ID = "dsh-websearch-" + randomUUID().slice(0, 8);
28
25
 
29
26
  export const parallelBackend = {
30
27
  id: "parallel",
@@ -60,9 +57,26 @@ export const parallelBackend = {
60
57
  );
61
58
  }
62
59
 
63
- hooks.recordOutcome?.({ status: "ok" });
60
+ if (result?.isError === true) {
61
+
62
+
63
+ const message = String(result.content?.[0]?.text ?? "unknown");
64
+
65
+
66
+ hooks.recordOutcome?.({ status: "error", message });
67
+
68
+
69
+ throw new WebError(`parallel returned an MCP error: ${message}`, "WEB_PROVIDER_ERROR");
70
+
71
+
72
+ }
73
+
74
+
64
75
  const sources = parseParallelResult(result, request.maxResults ?? 8);
65
76
 
77
+
78
+ hooks.recordOutcome?.({ status: "ok", count: sources.length });
79
+
66
80
  if (result?.isError === true) {
67
81
  throw new WebError(
68
82
  `parallel returned an MCP error: ${String(result.content?.[0]?.text ?? "unknown")}`,
@@ -44,7 +44,7 @@ export const searxngBackend = {
44
44
 
45
45
  const headers = {
46
46
  "Accept": "application/json",
47
- "User-Agent": "dsh-websearch/2.1",
47
+ "User-Agent": "dsh-websearch/2.3",
48
48
  };
49
49
 
50
50
  if (apiKey) {
@@ -62,16 +62,22 @@ export const searxngBackend = {
62
62
  response = await fetch(url, {
63
63
  method: "GET",
64
64
  headers,
65
+ redirect: "error",
66
+
65
67
  signal,
66
68
  });
67
69
  } catch (error) {
68
70
  if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
69
- throw new WebError("SearXNG request failed: " + error, "WEB_PROVIDER_ERROR", { cause: error });
71
+ throw new WebError("SearXNG request failed: " + error +
72
+ " (instance unreachable? point searxngBaseURL at another public instance)", "WEB_PROVIDER_ERROR", { cause: error });
70
73
  }
71
74
 
72
75
  if (!response.ok) {
73
- const text = await response.text().catch(() => "");
76
+ const text = await response.text().catch(() => "").then((tx) => tx.slice(0, 300));
74
77
  hooks?.recordOutcome?.({ status: "error", message: "HTTP " + response.status + ": " + text });
78
+ if (response.status === 429 || response.status >= 500) {
79
+ throw new WebError("SearXNG API error (HTTP " + response.status + "): " + text + " (instance struggling? point searxngBaseURL at another public instance)", "WEB_PROVIDER_ERROR");
80
+ }
75
81
  throw new WebError("SearXNG API error (HTTP " + response.status + "): " + text, "WEB_PROVIDER_ERROR");
76
82
  }
77
83
 
@@ -52,9 +52,11 @@ export const serperBackend = {
52
52
  "X-API-KEY": apiKey,
53
53
  "Content-Type": "application/json",
54
54
  "Accept": "application/json",
55
- "User-Agent": "dsh-websearch/2.1",
55
+ "User-Agent": "dsh-websearch/2.3",
56
56
  },
57
57
  body: JSON.stringify(body),
58
+ redirect: "error",
59
+
58
60
  signal,
59
61
  });
60
62
  } catch (error) {
@@ -63,7 +65,7 @@ export const serperBackend = {
63
65
  }
64
66
 
65
67
  if (!response.ok) {
66
- const text = await response.text().catch(() => "");
68
+ const text = await response.text().catch(() => "").then((tx) => tx.slice(0, 300));
67
69
  hooks?.recordOutcome?.({ status: "error", message: "HTTP " + response.status + ": " + text });
68
70
  throw new WebError("Serper API error (HTTP " + response.status + "): " + text, "WEB_PROVIDER_ERROR");
69
71
  }
@@ -56,9 +56,11 @@ export const tavilyBackend = {
56
56
  "Authorization": `Bearer ${apiKey}`,
57
57
  "Content-Type": "application/json",
58
58
  "Accept": "application/json",
59
- "User-Agent": "dsh-websearch/2.1",
59
+ "User-Agent": "dsh-websearch/2.3",
60
60
  },
61
61
  body: JSON.stringify(body),
62
+ redirect: "error",
63
+
62
64
  signal,
63
65
  });
64
66
  } catch (error) {
@@ -67,7 +69,7 @@ export const tavilyBackend = {
67
69
  }
68
70
 
69
71
  if (!response.ok) {
70
- const text = await response.text().catch(() => "");
72
+ const text = await response.text().catch(() => "").then((tx) => tx.slice(0, 300));
71
73
  hooks?.recordOutcome?.({ status: "error", message: `HTTP ${response.status}: ${text}` });
72
74
  throw new WebError(`Tavily API error (HTTP ${response.status}): ${text}`, "WEB_PROVIDER_ERROR");
73
75
  }
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" },
@@ -31,9 +31,10 @@ 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"];
35
- const KEYLESS = BACKENDS.filter((b) => !b.keyEnv);
36
- const KEYED = BACKENDS.filter((b) => b.keyEnv);
34
+ const BOOLEAN_FIELDS = ["rerank", "safeSearch", "resultTelemetry"];
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 = {
@@ -89,6 +90,11 @@ window.__ModuleLoader__.load({
89
90
  dedupTitle: "链接 + 标题(合并转载)",
90
91
  langPlaceholder: "如 en、zh-CN,留空为默认",
91
92
  envProvided: "环境变量提供", readOnly: "只读,无需填写", conflict: "设置已在其他端被修改,请重试", keyFailed: "该 Key 保存失败",
93
+ telemetryLabel: "结果健康遥测",
94
+ fallbackHint: "全部后端已关闭:搜索将回退到默认免 Key 后端",
95
+ invalidFields: "以下字段未通过校验:",
96
+ readOnlyScope: "当前配置只读(可能由宿主锁定)",
97
+ optionalTitle: "私有实例可选 Key",
92
98
  };
93
99
  const en = {
94
100
  nav: "Web Search",
@@ -136,6 +142,11 @@ window.__ModuleLoader__.load({
136
142
  readOnly: "read-only, nothing to fill",
137
143
  conflict: "Changed elsewhere - please retry",
138
144
  keyFailed: "failed to save this key",
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",
139
150
  };
140
151
 
141
152
  //#region styles
@@ -175,7 +186,8 @@ window.__ModuleLoader__.load({
175
186
  this.saving = false;
176
187
  this.failed = false;
177
188
  this.conflict = false;
178
- this.failedKeys = new Set();
189
+ this.failedKeys = new Map(); // envRef -> failure reason
190
+ this.failedFields = new Set();
179
191
  this.savedAt = 0;
180
192
  this.store = createStore(this.projection());
181
193
  this.scope.subscribe(() => this.republish());
@@ -207,7 +219,8 @@ window.__ModuleLoader__.load({
207
219
  return s ? (s.clear ? "" : s.text) : undefined;
208
220
  },
209
221
  conflict: this.conflict,
210
- failedKeys: [...this.failedKeys],
222
+ failedKeys: [...this.failedKeys].map(([ref, reason]) => ({ ref, reason })),
223
+ failedFields: [...this.failedFields],
211
224
  saving: this.saving,
212
225
  failed: this.failed,
213
226
  version: (this.staged.size + ":" + [...this.staged.keys()].join(",")),
@@ -222,7 +235,12 @@ window.__ModuleLoader__.load({
222
235
 
223
236
  /** Toggles write immediately (no staging); globals/keys stage + save. */
224
237
  async setToggle(id, checked) {
225
- 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
+ }
226
244
 
227
245
  this.republish();
228
246
  }
@@ -243,7 +261,8 @@ window.__ModuleLoader__.load({
243
261
 
244
262
  async save() {
245
263
  if (this.saving) return;
246
- this.saving = true; this.failed = false; this.republish();
264
+ this.saving = true; this.failed = false;
265
+ this.conflict = false; this.failedFields.clear(); this.republish();
247
266
  const doneFields = [];
248
267
  const doneKeys = [];
249
268
  try {
@@ -251,13 +270,13 @@ window.__ModuleLoader__.load({
251
270
  // Empty draft clears the override so the schema default applies again.
252
271
  if (staged.clear || staged.text === "") { await this.scope.unset(field); doneFields.push(field); continue; }
253
272
  if (SELECT_FIELDS[field]) {
254
- 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; }
255
274
  await this.scope.set(field, staged.text);
256
275
  doneFields.push(field);
257
276
  continue;
258
277
  }
259
278
  if (BOOLEAN_FIELDS.includes(field)) {
260
- 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; }
261
280
  await this.scope.set(field, staged.text === "true");
262
281
  doneFields.push(field);
263
282
  continue;
@@ -272,7 +291,7 @@ window.__ModuleLoader__.load({
272
291
  backendTimeoutMs: [1000, 120000],
273
292
  };
274
293
  const [lo, hi] = BOUNDS[field] ?? [-Infinity, Infinity];
275
- if (Number.isNaN(num) || num < lo || num > hi) { this.failed = true; continue; }
294
+ { this.failed = true; this.failedFields.add(field); continue; }
276
295
  await this.scope.set(field, num);
277
296
  doneFields.push(field);
278
297
  } else {
@@ -291,7 +310,7 @@ window.__ModuleLoader__.load({
291
310
  } catch (keyErr) {
292
311
  // env-shadowed reference (fail-loud doctrine): isolate so other keys still land.
293
312
  this.failed = true;
294
- this.failedKeys.add(ref);
313
+ this.failedKeys.set(ref, String(keyErr && keyErr.message ? keyErr.message : keyErr).slice(0, 140));
295
314
  void keyErr;
296
315
  }
297
316
  }
@@ -329,7 +348,7 @@ window.__ModuleLoader__.load({
329
348
  // no client-side default list to drift against the server.
330
349
  const checked = props.value === true;
331
350
  // Status dot: green keyless-or-keyed, amber enabled-but-key-missing.
332
- const needsKey = !!b.keyEnv && !props.keyConfigured;
351
+ const needsKey = !!b.keyEnv && !b.optionalKey && !props.keyConfigured;
333
352
  const dotColor = needsKey ? (checked ? "#e6a700" : "#888") : "#2ea043";
334
353
  return react_jsx_runtime.jsxs("label", { className: "wsc-toggle", style: { display: "flex", alignItems: "center", gap: 8, cursor: "pointer" }, children: [
335
354
  react_jsx_runtime.jsx("input", {
@@ -371,7 +390,7 @@ window.__ModuleLoader__.load({
371
390
 
372
391
  // Status summary: enabled backends + configured keys at a glance.
373
392
  const enabledCount = BACKENDS.filter((b) => state.value[toggleField(b.id)] === true).length;
374
- const keyedBackends = BACKENDS.filter((b) => b.keyEnv);
393
+ const keyedBackends = BACKENDS.filter((b) => b.keyEnv && !b.optionalKey);
375
394
  const keysReady = keyedBackends.filter((b) => state.keyState[b.keyEnv]?.configured).length;
376
395
 
377
396
  const recencyOptions = [
@@ -397,6 +416,8 @@ window.__ModuleLoader__.load({
397
416
  react_jsx_runtime.jsxs("span", { children: [props.t("statusKeys"), ": ", react_jsx_runtime.jsx("b", { children: keysReady + "/" + keyedBackends.length })] }),
398
417
  ] }),
399
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") }),
400
421
  react_jsx_runtime.jsx("h5", { className: "wsc-sectionTitle", children: props.t("groupFree") }),
401
422
  react_jsx_runtime.jsx("div", { className: "wsc-toggles", children: KEYLESS.map((b) =>
402
423
  react_jsx_runtime.jsx(ToggleRow, {
@@ -465,9 +486,15 @@ window.__ModuleLoader__.load({
465
486
  options: boolOptions,
466
487
  onChange: (v) => card.edit("safeSearch", v),
467
488
  }),
489
+ react_jsx_runtime.jsx(SelectField, {
490
+ label: props.t("telemetryLabel"),
491
+ value: selVal("resultTelemetry", "true"),
492
+ options: boolOptions,
493
+ onChange: (v) => card.edit("resultTelemetry", v),
494
+ }),
468
495
  ] }),
469
496
  react_jsx_runtime.jsx("h4", { className: "wsc-sectionTitle", children: props.t("keysTitle") }),
470
- 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) =>
471
498
  react_jsx_runtime.jsxs("div", { children: [
472
499
  react_jsx_runtime.jsxs("div", { className: "wsc-fieldHead", children: [
473
500
  react_jsx_runtime.jsx("span", { className: "wsc-label", children: props.t("keyLabel", { id: b.id }) }),
@@ -492,11 +519,24 @@ window.__ModuleLoader__.load({
492
519
  state.failedKeys.includes(b.keyEnv) && react_jsx_runtime.jsx("p", { className: "wsc-hint", style: { color: "#e5534b" }, children: props.t("keyFailed") }),
493
520
  ] }, b.id)
494
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
+ ) }),
495
534
  state.stagedCount > 0 && react_jsx_runtime.jsx("p", { style: { margin: 0, fontSize: 12, opacity: .6 }, children: props.t("unsavedHint") }),
496
535
  react_jsx_runtime.jsxs("div", { className: "wsc-actions", children: [
497
- 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") }),
498
537
  (state.stagedCount > 0) && react_jsx_runtime.jsx("button", { className: "wsc-btn wsc-btnGhost", onClick: () => card.discard(), children: props.t("discard") }),
499
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(", ") }),
500
540
  state.conflict && react_jsx_runtime.jsx("span", { className: "wsc-status", style: { color: "#e6a700" }, children: props.t("conflict") }),
501
541
  !state.failed && !state.saving && state.savedAt > 0 && react_jsx_runtime.jsx("span", { className: "wsc-status", style: { color: "#2ea043" }, children: props.t("savedOk") }),
502
542
  ] }),
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
@@ -87,7 +87,13 @@ export function createUnifiedSearchProvider({ ctx, resolveOptions, backends }) {
87
87
  async search(request, signal) {
88
88
  throwIfSearchAborted(signal);
89
89
  const opts = resolveOptions();
90
- const maxResults = request.maxResults ?? opts.numResults ?? 8;
90
+ const filters = normalizeFilters({
91
+ ...(opts.filters ?? {}),
92
+ ...(request?.filters ?? {}),
93
+ });
94
+ let effReq = { ...request, maxResults: Math.max(1, Number(request?.maxResults) || opts.numResults || 8) };
95
+ if (Object.keys(filters).length > 0) effReq = { ...effReq, filters };
96
+ const maxResults = effReq.maxResults;
91
97
  const concurrency = opts.concurrency ?? 6;
92
98
  const backendTimeoutMs = opts.backendTimeoutMs ?? 30000;
93
99
 
@@ -112,13 +118,8 @@ export function createUnifiedSearchProvider({ ctx, resolveOptions, backends }) {
112
118
  // Settings-level default filters, overridden by per-request filters
113
119
  // (normalizeFilters drops unknown/invalid values so typos degrade to
114
120
  // "no filter" instead of breaking the search).
115
- const filters = normalizeFilters({
116
- ...(opts.filters ?? {}),
117
- ...(request?.filters ?? {}),
118
- });
119
- const effectiveRequest =
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,13 +129,36 @@ 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();
133
+ // Trust boundary: baseURL is operator-configurable and API keys are sent to it.
134
+ // Enforce http(s); plain http only for private hosts (self-hosted SearXNG).
135
+ // Reject embedded credentials. Failure is a per-backend soft error.
136
+ if (beOpts.baseURL !== undefined) {
137
+ let bu = null;
138
+ try { bu = new URL(beOpts.baseURL); } catch { bu = null; }
139
+ const privHost = bu ? /^(localhost|127(\.\d{1,3}){3}|10(\.\d{1,3}){3}|192\.168(\.\d{1,3}){3}|172\.(1[6-9]|2\d|3[01])(\.\d{1,3}){3})$/.test(bu.hostname) : false;
140
+ const schemeOk = bu && (bu.protocol === "https:" || (bu.protocol === "http:" && privHost));
141
+ if (!schemeOk || bu.username || bu.password) {
142
+ throw new WebError(`backend "${id}" has an invalid baseURL: https required (plain http only for private hosts), credentials-in-URL rejected`, "WEB_PROVIDER_ERROR");
143
+ }
144
+ }
131
145
  const controller = new AbortController();
132
146
  const mergedSignal = signal
133
147
  ? AbortSignal.any([signal, controller.signal])
134
148
  : controller.signal;
135
149
  const timeoutId = setTimeout(() => controller.abort(), backendTimeoutMs);
136
150
  return backend
137
- .search(effectiveRequest, mergedSignal, beOpts, hooks)
151
+ .search(effReq, mergedSignal, beOpts, hooks)
152
+ .then(
153
+ (value) => {
154
+ telemetry.push({ id, ok: true, ms: Date.now() - startedAtMs, count: value?.sources?.length ?? 0 });
155
+ return value;
156
+ },
157
+ (err) => {
158
+ telemetry.push({ id, ok: false, ms: Date.now() - startedAtMs, error: String(err instanceof Error ? err.message : err).slice(0, 80) });
159
+ throw err;
160
+ },
161
+ )
138
162
  .catch((err) => {
139
163
  if (controller.signal.aborted && !(signal && signal.aborted)) {
140
164
  throw new WebError(
@@ -178,7 +202,12 @@ export function createUnifiedSearchProvider({ ctx, resolveOptions, backends }) {
178
202
  if (mergedContent === undefined && typeof settled.value?.content === "string" && settled.value.content.length > 0) {
179
203
  mergedContent = settled.value.content;
180
204
  }
181
- for (const s of settled.value?.sources ?? []) collected.push(s);
205
+ for (const src of settled.value?.sources ?? []) {
206
+ // Scheme whitelist: parsed URLs reach model/GUI consumers; drop
207
+ // javascript:/data: payloads from compromised or poisoned backends.
208
+ if (src && typeof src.url === "string" && !/^https?:\/\//i.test(src.url)) continue;
209
+ collected.push(src);
210
+ }
182
211
  } else {
183
212
  // settled.status === "rejected"
184
213
  const err = settled.reason;
@@ -211,9 +240,16 @@ export function createUnifiedSearchProvider({ ctx, resolveOptions, backends }) {
211
240
  // If we got ANY sources, return them even if some backends failed.
212
241
  if (ordered.length > 0) {
213
242
  const sources = ordered.slice(0, maxResults);
243
+ let outContent = mergedContent;
244
+ if (opts.resultTelemetry !== false && telemetry.length > 0) {
245
+ const line = "[websearch backends] " + telemetry
246
+ .map((t) => t.id + (t.ok ? " ✓" : " ✗") + t.ms + "ms" + (t.ok ? "/" + t.count : (t.error ? " (" + String(t.error).slice(0, 40) + ")" : "")))
247
+ .join(" · ");
248
+ outContent = outContent === undefined ? line : outContent + "\n\n" + line;
249
+ }
214
250
  return {
215
251
  sources,
216
- ...(mergedContent !== undefined ? { content: mergedContent } : {}),
252
+ ...(outContent !== undefined ? { content: outContent } : {}),
217
253
  truncated: ordered.length > maxResults,
218
254
  };
219
255
  }
@@ -225,7 +261,7 @@ export function createUnifiedSearchProvider({ ctx, resolveOptions, backends }) {
225
261
  .join("; ");
226
262
  const allKeyMissing =
227
263
  failures.length > 0 &&
228
- failures.every((f) => f.err instanceof WebError && f.err.code === "WEB_PROVIDER_UNAVAILABLE");
264
+ failures.every((f) => f.err instanceof WebError && (f.err.code === "WEB_PROVIDER_UNAVAILABLE" || f.err.code === "WEB_PROVIDER_CREDENTIAL_MISSING"));
229
265
  const hint = allKeyMissing
230
266
  ? " (every enabled backend is missing its API key - add keys in Settings > Web Search, or enable a keyless backend)"
231
267
  : "";
package/lib/util/log.js CHANGED
@@ -56,7 +56,7 @@ export function recordBackendOutcome(ctx, backendId, outcome) {
56
56
  const logger = ctx && ctx.logger;
57
57
  if (!logger) return;
58
58
  const status = outcome && typeof outcome.status === "string" ? outcome.status : "";
59
- const line = "[websearch:" + backendId + "] outcome " + JSON.stringify(outcome ?? {});
59
+ const line = "[websearch:" + backendId + "] outcome " + JSON.stringify(outcome ?? {}).slice(0, 500);
60
60
  if (status === "error" && typeof logger.warn === "function") logger.warn(line);
61
61
  else if (typeof logger.debug === "function") logger.debug(line);
62
62
  } catch {
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { WebError } from "@deepseek-ai/dsh-web";
9
9
 
10
- const UA = "dsh-websearch/2.1";
10
+ const UA = "dsh-websearch/2.3";
11
11
  const PROTOCOL_VERSION = "2025-06-18";
12
12
  const SESSION_TTL_MS = 10 * 60 * 1000; // 10 minutes
13
13
 
@@ -58,7 +58,7 @@ async function ensureSession(url, signal) {
58
58
  }
59
59
 
60
60
  if (!initResponse.ok) {
61
- const text = await initResponse.text().catch(() => "");
61
+ const text = await initResponse.text().catch(() => "").then((tx) => tx.slice(0, 300));
62
62
  throw new WebError(`MCP initialize HTTP ${initResponse.status}: ${text}`, "WEB_PROVIDER_ERROR");
63
63
  }
64
64
 
@@ -75,7 +75,7 @@ async function ensureSession(url, signal) {
75
75
  };
76
76
 
77
77
  try {
78
- await fetch(url, {
78
+ const notifyResponse = await fetch(url, {
79
79
  method: "POST",
80
80
  headers: {
81
81
  "Content-Type": "application/json",
@@ -86,6 +86,8 @@ async function ensureSession(url, signal) {
86
86
  signal,
87
87
  redirect: "error",
88
88
  });
89
+ // Drain the discarded body so undici returns the socket to the pool.
90
+ notifyResponse.body?.cancel?.().catch(() => {});
89
91
  } catch (error) {
90
92
  // Best-effort: Exa requires this, Parallel tolerates absence
91
93
  if (signal?.aborted) throw error;
@@ -104,17 +106,16 @@ async function ensureSession(url, signal) {
104
106
  function parseEnvelope(text, expectedId) {
105
107
  // Try SSE format first: "event: message\ndata: {...}\n\n"
106
108
  if (text.includes("data:")) {
107
- const lines = text.trim().split("\n");
108
- let dataLine = null;
109
- for (const line of lines) {
110
- if (line.startsWith("data:")) {
111
- dataLine = line.slice(5).trim();
112
- break;
113
- }
114
- }
115
- if (dataLine) {
109
+ // SSE spec: one JSON payload may span multiple data: lines - join them.
110
+ const joined = text
111
+ .trim()
112
+ .split("\n")
113
+ .filter((l) => l.startsWith("data:"))
114
+ .map((l) => l.slice(5).trim())
115
+ .join("");
116
+ if (joined) {
116
117
  try {
117
- return JSON.parse(dataLine);
118
+ return JSON.parse(joined);
118
119
  } catch {}
119
120
  }
120
121
  }
@@ -205,7 +206,7 @@ export async function mcpCall(url, toolName, args, extraHeaders = {}, signal) {
205
206
  }
206
207
 
207
208
  if (!response.ok) {
208
- const text = await response.text().catch(() => "");
209
+ const text = await response.text().catch(() => "").then((tx) => tx.slice(0, 300));
209
210
  throw new WebError(`MCP tools/call HTTP ${response.status}: ${text}`, "WEB_PROVIDER_ERROR");
210
211
  }
211
212
 
package/lib/util/rank.js CHANGED
@@ -97,7 +97,7 @@ function scoreSource(source, queryTokens) {
97
97
  let score = 0;
98
98
  const titleTokens = tokenize(source.title ?? "");
99
99
  const snippetTokens = tokenize(source.snippet ?? "");
100
- const urlTokens = tokenize(String(source.url ?? "").replace(/[^a-z0-9]+/g, " "));
100
+ const urlTokens = tokenize(String(source.url ?? ""));
101
101
  for (const q of queryTokens) {
102
102
  if (titleTokens.has(q)) score += 3;
103
103
  if (snippetTokens.has(q)) score += 2;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@240xu/dsh-websearch",
3
- "version": "2.2.0",
3
+ "version": "2.4.0",
4
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",
@@ -1,121 +0,0 @@
1
- /**
2
- * Unified backend registry for dsh-websearch.
3
- * Auto-discovers and exports all backend implementations.
4
- */
5
-
6
- import { exaBackend } from "./exa.js";
7
- import { parallelBackend } from "./parallel.js";
8
- import { ddgBackend } from "./ddg.js";
9
- import { deepseekBackend, anthropicBackend } from "./anthropic-like.js";
10
- import { openaiBackend } from "./openai.js";
11
- import { braveBackend } from "./brave.js";
12
- import { tavilyBackend } from "./tavily.js";
13
- import { serperBackend } from "./serper.js";
14
- import { searxngBackend } from "./searxng.js";
15
- import { mojeekBackend } from "./mojeek.js";
16
-
17
- // Individual backend exports for direct import
18
- export { exaBackend } from "./exa.js";
19
- export { parallelBackend } from "./parallel.js";
20
- export { ddgBackend } from "./ddg.js";
21
- export { deepseekBackend, anthropicBackend } from "./anthropic-like.js";
22
- export { openaiBackend } from "./openai.js";
23
- export { braveBackend } from "./brave.js";
24
- export { tavilyBackend } from "./tavily.js";
25
- export { serperBackend } from "./serper.js";
26
- export { searxngBackend } from "./searxng.js";
27
- export { mojeekBackend } from "./mojeek.js";
28
-
29
-
30
- /** All registered backends. Order = default fan-out priority. */
31
- export const BACKENDS = {
32
- exa: exaBackend,
33
- parallel: parallelBackend,
34
- ddg: ddgBackend,
35
- searxng: searxngBackend, // keyless, new
36
- brave: braveBackend, // key-gated, new
37
- tavily: tavilyBackend, // key-gated, new
38
- serper: serperBackend, // key-gated, new
39
- mojeek: mojeekBackend, // key-gated, new
40
- deepseek: deepseekBackend,
41
- anthropic: anthropicBackend,
42
- openai: openaiBackend,
43
- };
44
-
45
- /** All backend IDs in registry order. */
46
- export const ALL_BACKEND_IDS = Object.keys(BACKENDS);
47
-
48
- /** Default enabled backends (keyless only). */
49
- export const DEFAULT_ENABLED = ["exa", "parallel", "ddg", "searxng"];
50
-
51
- /** Environment variable names for key-gated backends. */
52
- export const DEFAULT_KEY_ENV = {
53
- deepseek: "DEEPSEEK_API_KEY",
54
- anthropic: "ANTHROPIC_API_KEY",
55
- openai: "OPENAI_API_KEY",
56
- brave: "BRAVE_API_KEY",
57
- tavily: "TAVILY_API_KEY",
58
- serper: "SERPER_API_KEY",
59
- mojeek: "MOJEEK_API_KEY",
60
- };
61
-
62
- /** Default base URLs for each backend. */
63
- export const DEFAULT_BASE_URLS = {
64
- exa: "https://mcp.exa.ai/mcp",
65
- parallel: "https://search.parallel.ai/mcp",
66
- ddg: "https://html.duckduckgo.com/html/",
67
- searxng: "https://searx.be",
68
- brave: "https://api.search.brave.com/res/v1/web/search",
69
- tavily: "https://api.tavily.com/search",
70
- serper: "https://google.serper.dev/search",
71
- mojeek: "https://api.mojeek.com/v1/search",
72
- deepseek: "https://api.deepseek.com/anthropic/v1",
73
- anthropic: "https://api.anthropic.com/v1",
74
- openai: "https://api.openai.com/v1",
75
- };
76
-
77
- /** Backend type classification for GUI. */
78
- export const BACKEND_TYPES = {
79
- exa: "mcp",
80
- parallel: "mcp",
81
- ddg: "keyless",
82
- searxng: "keyless",
83
- brave: "credential",
84
- tavily: "credential",
85
- serper: "credential",
86
- mojeek: "credential",
87
- deepseek: "credential",
88
- anthropic: "credential",
89
- openai: "credential",
90
- };
91
-
92
- /** Human-readable labels for GUI. */
93
- export const BACKEND_LABELS = {
94
- exa: "Exa (MCP, 免费无 Key)",
95
- parallel: "Parallel (MCP, 免费无 Key)",
96
- ddg: "DuckDuckGo (HTML 抓取, 兜底)",
97
- searxng: "SearXNG (元搜索, 免费无 Key)",
98
- brave: "Brave Search (独立索引, 2000/月免费)",
99
- tavily: "Tavily (AI 专用, 含答案摘要)",
100
- serper: "Serper.dev (Google 抓取, 2500/月免费)",
101
- mojeek: "Mojeek (独立索引, 1000/天免费)",
102
- deepseek: "DeepSeek (原生 web_search, 需 Key)",
103
- anthropic: "Anthropic (Claude 原生 web_search, 需 Key)",
104
- openai: "OpenAI (Responses API web_search, 需 Key)",
105
- };
106
-
107
- /** Backend descriptions for GUI tooltips. */
108
- export const BACKEND_DESCRIPTIONS = {
109
- exa: "流式 HTTP MCP 协议,原生 web_search_exa 工具,结果含标题/URL/高亮摘要",
110
- parallel: "流式 HTTP MCP 协议,原生 web_search 工具,返回结构化 JSON 含摘要数组",
111
- ddg: "HTML 页面抓取解析,通过 uddg= 参数还原真实 URL,零依赖兜底",
112
- searxng: "开源元搜索引擎聚合,可指向公共实例或自托管,隐私友好",
113
- brave: "Brave 独立搜索索引,REST API,免费 2000 次/月,结果质量高",
114
- tavily: "AI 专用搜索,可返回 AI 生成答案 + 结构化结果,支持深度搜索模式",
115
- serper: "抓取 Google SERP,结构化 organic[] 结果,免费 2500 次/月,速度极快",
116
- mojeek: "英国独立搜索引擎,无追踪,免费 1000 次/天,英文为主",
117
- deepseek: "DeepSeek Anthropic 兼容 API + 原生 web_search_20250305 工具",
118
- anthropic: "Claude 官方 Messages API + web_search_20250305 原生工具",
119
- openai: "OpenAI Responses API + 原生 web_search 工具,解析 url_citation 注释",
120
- };
121
-
package/lib/util/rpc.js DELETED
@@ -1,262 +0,0 @@
1
- /**
2
- * @module dsh-websearch/util/rpc
3
- *
4
- * Minimal MCP JSON-RPC 2.0 client for the streamable-http transport used by
5
- * Exa (https://mcp.exa.ai/mcp) and Parallel (https://search.parallel.ai/mcp).
6
- *
7
- * Streamable-HTTP MCP servers require a 2-step handshake per session:
8
- * 1. POST initialize → server replies with its capabilities and (for Exa)
9
- * a Mcp-Session-Id header. Parallel also returns this header.
10
- * 2. POST notifications/initialized → notify the server the client is ready.
11
- * (Exa rejects tools/call without this; Parallel tolerates its absence
12
- * but sending it is harmless.)
13
- * 3. POST tools/call with the Mcp-Session-Id header → returns either a plain
14
- * JSON body OR a text/event-stream of "data: {…}\n\n" frames.
15
- *
16
- * We cache the Mcp-Session-Id per server URL inside the process so subsequent
17
- * searches skip the handshake (and one cache entry survives across calls).
18
- * Sessions are NOT shared across processes (each dsh-web worker owns its own).
19
- *
20
- * Failures are zh-folded into WebError so the provider fan-out can demote them
21
- * to soft nulls when only one backend fails.
22
- */
23
- import { WebError } from "@deepseek-ai/dsh-web";
24
- import { throwIfSearchAborted, searchAborted, isAbortError } from "./abort.js";
25
-
26
- /** User-Agent header; bumped with the package version. */
27
- export const UA = "dsh-websearch/2.1";
28
-
29
- /** MCP protocol version we negotiate. */
30
- const PROTOCOL_VERSION = "2025-06-18";
31
-
32
- /** Per-URL session cache: url → { sessionId, initializedAt }. */
33
- const sessionCache = new Map();
34
- const SESSION_TTL_MS = 10 * 60 * 1000; // Exa sessions live indefinitely; this is a courtesy cap.
35
-
36
- /** Monotonic request id so concurrent inits don't collide. */
37
- let nextReqId = 1;
38
-
39
- /**
40
- * Initialize (or reuse) a streamable-http MCP session.
41
- * Sends initialize + notifications/initialized. Returns the Mcp-Session-Id,
42
- * caching it so callers don't pay the handshake more than once per TTL.
43
- *
44
- * @param {string} url
45
- * @param {AbortSignal} [signal]
46
- * @returns {Promise<string|null>} Mcp-Session-Id (or null if the server omits it).
47
- * @throws {WebError} on transport error or abort.
48
- */
49
- async function ensureSession(url, signal) {
50
- throwIfSearchAborted(signal);
51
- const cached = sessionCache.get(url);
52
- if (cached && Date.now() - cached.initializedAt < SESSION_TTL_MS && cached.sessionId) {
53
- return cached.sessionId;
54
- }
55
-
56
- const initId = nextReqId++;
57
- const initBody = JSON.stringify({
58
- jsonrpc: "2.0",
59
- id: initId,
60
- method: "initialize",
61
- params: {
62
- protocolVersion: PROTOCOL_VERSION,
63
- capabilities: {},
64
- clientInfo: { name: "dsh-websearch", version: "1.0" },
65
- },
66
- });
67
-
68
- let res;
69
- try {
70
- res = await fetch(url, {
71
- method: "POST",
72
- redirect: "error",
73
- headers: {
74
- "Content-Type": "application/json",
75
- "Accept": "application/json, text/event-stream",
76
- "User-Agent": UA,
77
- },
78
- body: initBody,
79
- ...(signal !== undefined ? { signal } : {}),
80
- });
81
- } catch (error) {
82
- if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
83
- throw new WebError(
84
- `mcp initialize fetch failed for ${url}: ${String(error?.message ?? error)}`,
85
- "WEB_PROVIDER_ERROR",
86
- { cause: error },
87
- );
88
- }
89
- throwIfSearchAborted(signal);
90
- if (!res.ok) {
91
- let detail = "";
92
- try { detail = (await res.text()).slice(0, 300); } catch {}
93
- throw new WebError(
94
- `mcp initialize HTTP ${res.status} for ${url}${detail ? ": " + detail : ""}`,
95
- "WEB_PROVIDER_ERROR",
96
- );
97
- }
98
-
99
- // Drain the initialize response body (we don't need its content).
100
- await res.text();
101
-
102
- const sessionId = res.headers.get("Mcp-Session-Id") || null;
103
-
104
- // Send notifications/initialized (some servers reject tools/call without it).
105
- if (sessionId) {
106
- try {
107
- await fetch(url, {
108
- method: "POST",
109
- redirect: "error",
110
- headers: {
111
- "Content-Type": "application/json",
112
- "Accept": "application/json, text/event-stream",
113
- "User-Agent": UA,
114
- "Mcp-Session-Id": sessionId,
115
- },
116
- body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }),
117
- ...(signal !== undefined ? { signal } : {}),
118
- });
119
- } catch (error) {
120
- // notifications/initialized is best-effort; ignore transport errors here.
121
- // (If the session is gone we'll discover that via tools/call.) Re-raise aborts.
122
- if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
123
- }
124
- }
125
-
126
- sessionCache.set(url, { sessionId, initializedAt: Date.now() });
127
- return sessionId;
128
- }
129
-
130
- /**
131
- * Parse a streamable-HTTP MCP response body into the first JSON-RPC envelope.
132
- *
133
- * Servers respond with EITHER:
134
- * - a single JSON object (Parallel: {"jsonrpc":"2.0","id":N,"result":{…}} or {...,"error":{…}}), OR
135
- * - a text/event-stream of "event: <type>\n data: {…}\n\n" frames (Exa).
136
- *
137
- * We scan line-by-line; SSE data: frames take precedence, otherwise we fall back
138
- * to scanning whole-line JSON.
139
- *
140
- * @param {string} text
141
- * @param {number} reqId the request id we expect to be echoed back
142
- * @returns {{result?:object, error?:object}}
143
- * @throws if no parseable envelope is found.
144
- */
145
- function parseEnvelope(text, reqId) {
146
- const lines = String(text).split("\n");
147
- let lastJson = null;
148
- for (const raw of lines) {
149
- const line = raw.trim();
150
- if (!line) continue;
151
- let json;
152
- if (line.startsWith("data: ")) {
153
- json = line.slice(6).trim();
154
- } else if (line.startsWith("{")) {
155
- json = line;
156
- } else {
157
- continue;
158
- }
159
- if (!json.startsWith("{")) continue;
160
- let obj;
161
- try { obj = JSON.parse(json); } catch { continue; }
162
- // Prefer the envelope matching our request id; otherwise keep the last seen.
163
- if (typeof obj.id === "number" && obj.id === reqId) return obj;
164
- lastJson = obj;
165
- }
166
- return lastJson;
167
- }
168
-
169
- /**
170
- * JSON-RPC 2.0 tools/call against a streamable-http MCP server (after the
171
- * initialize + notifications/initialized handshake is established or reused).
172
- *
173
- * @param {string} url the MCP server endpoint
174
- * @param {string} toolName the tool to invoke (e.g. "web_search_exa", "web_search")
175
- * @param {object} args the tool's arguments (per its input schema)
176
- * @param {object} [extraHeaders]
177
- * @param {AbortSignal} [signal]
178
- * @returns {Promise<object>} the RPC `result` payload (the `content` array etc.)
179
- * @throws {WebError} on HTTP failure, RPC error, abort, or unparseable response.
180
- */
181
- export async function rpcCall(url, toolName, args, extraHeaders, signal) {
182
- const sessionId = await ensureSession(url, signal);
183
- const reqId = nextReqId++;
184
-
185
- const headers = {
186
- "Content-Type": "application/json",
187
- "Accept": "application/json, text/event-stream",
188
- "User-Agent": UA,
189
- ...(sessionId ? { "Mcp-Session-Id": sessionId } : {}),
190
- ...(extraHeaders || {}),
191
- };
192
- const body = JSON.stringify({
193
- jsonrpc: "2.0",
194
- id: reqId,
195
- method: "tools/call",
196
- params: { name: toolName, arguments: args },
197
- });
198
-
199
- let res;
200
- try {
201
- res = await fetch(url, {
202
- method: "POST",
203
- redirect: "error",
204
- headers,
205
- body,
206
- ...(signal !== undefined ? { signal } : {}),
207
- });
208
- } catch (error) {
209
- if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
210
- throw new WebError(
211
- `mcp rpc ${toolName} fetch failed: ${String(error?.message ?? error)}`,
212
- "WEB_PROVIDER_ERROR",
213
- { cause: error },
214
- );
215
- }
216
- throwIfSearchAborted(signal);
217
-
218
- if (!res.ok) {
219
- let detail = "";
220
- try { detail = (await res.text()).slice(0, 300); } catch {}
221
- // If the server rejected the request id/session, nuke the cached session
222
- // so the next call re-initializes. 404/401/403 typically mean session death.
223
- if (res.status === 401 || res.status === 403 || res.status === 404) {
224
- sessionCache.delete(url);
225
- }
226
- throw new WebError(
227
- `mcp rpc ${toolName} HTTP ${res.status}${detail ? ": " + detail : ""}`,
228
- "WEB_PROVIDER_ERROR",
229
- );
230
- }
231
-
232
- const text = await res.text();
233
- throwIfSearchAborted(signal);
234
-
235
- const env = parseEnvelope(text, reqId);
236
- if (!env) {
237
- throw new WebError(
238
- `mcp rpc ${toolName} returned an unparseable response`,
239
- "WEB_PROVIDER_ERROR",
240
- );
241
- }
242
- if (env.error) {
243
- throw new WebError(
244
- `mcp rpc ${toolName} error: ${env.error.message || JSON.stringify(env.error)}`,
245
- "WEB_PROVIDER_ERROR",
246
- );
247
- }
248
- if (!env.result) {
249
- throw new WebError(
250
- `mcp rpc ${toolName} returned a response with no result`,
251
- "WEB_PROVIDER_ERROR",
252
- );
253
- }
254
- return env.result;
255
- }
256
-
257
- /** Drop the cached session for a url (used by backends after a fatal error). */
258
- export function invalidateSession(url) {
259
- sessionCache.delete(url);
260
- }
261
-
262
- export { throwIfSearchAborted, searchAborted, isAbortError };