dsh-free-search 0.9.7 → 0.9.8

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
@@ -139,33 +139,45 @@ dsh web
139
139
 
140
140
  #### 接管行为与验证
141
141
 
142
- 插件加载后**自动接管搜索**(运行时接管,不改配置层):
142
+ 插件加载后**自动接管搜索**:
143
143
 
144
144
  - `web.searchProvider` 未设置,或仍是 DSH 出厂默认的官方搜索 `deepseek-official` 时,自动切换为本插件的 provider(id 固定为 `ddg`);
145
145
  - 如果(你或别的插件)已显式选择其他 provider,本插件不抢占,只在启动日志输出 WARN 与切换用的 YAML。
146
146
 
147
- **不再往配置里写 `searchProvider: ddg`**:静态覆盖曾导致"悬空引用"——patch 是整段覆盖 config,一旦插件那一行被**停用或跳过**(比如在插件管理器里关掉),配置仍指向 `ddg` 而没有任何东西注册它,于是每次搜索都失败:
147
+ **接管会写进 profile(DSH 0.2.x)**:从 `0.2.0-rc.2` 起 provider 选择存放在 config 的 volatile ref 里,只能经 `settings.mutate("web", …)` 写入,落盘结果就是 `profiles/<profile>/cordis.patch.yml` 多出:
148
+
149
+ ```yaml
150
+ - id: web
151
+ config:
152
+ searchProvider: ddg
153
+ fetchProvider: fs-quality-fetch
154
+ ```
155
+
156
+ 所以**停用或卸载本插件之前,请先删掉 `- id: web` 这两行**(或整行删掉让 DSH 回到出厂默认)。否则配置仍指向 `ddg` 而没有任何东西注册它,每次搜索都会失败:
148
157
 
149
158
  ```
150
159
  Error: configured web provider "ddg" is not registered
151
160
  ```
152
161
 
153
- 现在没有这层覆盖:插件被停用时 `web.searchProvider` 保持官方默认(`deepseek-official`),搜索至少还能走官方通道;插件启用时由上面的运行时接管切到 `ddg`。
162
+ 更早的 DSH(0.1.x)由 patch 静态覆盖 config,不需要也更不该手写这段,这里不再赘述。
154
163
 
155
- > 如果你曾按旧文档手动在 `profiles/<profile>/cordis.patch.yml` 里加过下面这段,而插件没有启用,就会看到上面那条错误——**删掉这段即可**(或重新启用插件):
164
+ #### DSH 的「DeepSeek 搜索 API」开关
156
165
 
157
- ```yaml
158
- # 可选,通常不需要:只有你想强制指定时才加。
159
- # DSH 0.1.2+ 的 patch 是"整段覆盖 config",必须保留 fetchProvider。
160
- - id: web
161
- config:
162
- searchProvider: ddg
163
- fetchProvider: http
164
- ```
166
+ DSH 0.2.1 设置 → 网页搜索 → **DeepSeek 搜索 API** 开关会和本插件互相影响,行为如下:
167
+
168
+ | 操作 | 结果 |
169
+ |---|---|
170
+ | 打开开关 | `web.searchProvider` 写成 `deepseek-search-api`(属于「显式选择其他 provider」),**本插件主动让路**,搜索走官方直连 API;抓取同时变成 `deepseek-fetch-api` |
171
+ | 关闭开关 | DSH 会把整行 `- id: web` 删掉,provider 回到出厂默认 `deepseek-official` —— 这是**模型搜索**(一次完整 aux 请求,实测 ~12s、按 token 计费),不是免费引擎 |
172
+ | 关闭后 | **0.9.8 起**:插件检测到 `deepseek-official` 会在运行时补一次接管(日志 `DSH's search switch put the web providers back to the official defaults — re-took …`),通常几秒内恢复免费链路;若没恢复,完整重启 DSH |
173
+
174
+ 想换引擎请用 `/free-search-engine` 或本插件设置页,**不要动那个开关**。打开开关再关掉,官方直连会退回模型搜索,直到插件重新接管或重启。
175
+
176
+ > 如果你在 0.1.x 时代按旧文档手写过 `- id: web`,现在可以删掉:0.2.x 由运行时接管写入。手写时注意 `fetchProvider` 应该是 `fs-quality-fetch`(本插件注册的抓取 provider),不是 `http`。
165
177
 
166
178
  - `searchProvider: ddg` —— 本插件的 **provider id(固定值)**,不是"使用 DuckDuckGo 引擎";用哪个引擎由设置页的 `provider` 字段决定(`bing`/`baidu`/`auto`/…)。
167
- - `fetchProvider: http` —— 官方网页抓取(web-fetch-http),手动声明时必须保留。
168
- - 想改用官方搜索:在插件管理器里停用本插件条目即可,不需要改配置。
179
+ - `fetchProvider: fs-quality-fetch` —— 本插件的抓取 provider(直接 → exa contents → firecrawl → crawl4ai)。
180
+ - 想改用官方搜索:**先删掉 `- id: web` 的两行,再在插件管理器里停用本插件**。
169
181
 
170
182
  #### 姊妹插件:dsh-preset-workbench(预设工作台)
171
183
 
@@ -460,7 +472,7 @@ Search engine test:
460
472
 
461
473
  #### 离线自检断言(tools/)
462
474
 
463
- 仓库自带 **18 个**零依赖断言脚本(合计 **520 项**),**不需要 API key、不联网、不需要测试框架**,Node ≥ 20 即可跑:
475
+ 仓库自带 **19 个**零依赖断言脚本(合计 **528 项**),**不需要 API key、不联网、不需要测试框架**,Node ≥ 20 即可跑:
464
476
 
465
477
  | 脚本 | 覆盖 | 运行(项数) |
466
478
  |---|---|---|
@@ -480,8 +492,9 @@ Search engine test:
480
492
  | `assert-killtree-win32.mjs` | Windows 子进程树终止(#74):不再依赖 `taskkill` | `node tools/assert-killtree-win32.mjs`(8) |
481
493
  | `assert-killtree-portable.mjs` | 跨平台 `killTree` 便携版契约 | `node tools/assert-killtree-portable.mjs`(5) |
482
494
  | `assert-parallel-mcp.mjs` | Parallel MCP 引擎:25 s 超时/调用方取消覆盖**响应体读取**、共享 SSE 解析器(通知/多行帧)、6 类错误路径、REST 精确时间过滤 | `node tools/assert-parallel-mcp.mjs`(15) |
495
+ | `assert-fetch-abort.mjs` | 抓取中断(#87):压缩响应中途断连必须 reject(不再永久 pending)、明文断连同样 reject、abort 能打断停滞的 gzip 流、complete 响应与重定向仍正常 | `node tools/assert-fetch-abort.mjs`(6) |
483
496
  | `assert-bridge-access.mjs` | 桥接 Host 围栏(#84):loopback 放行、remote peer 打 loopback Host 拒绝、`--trusted-host`(无端口匹配任意端口 / 带端口精确匹配)、`--public-url`、绑定地址、跨站与异源拒绝 | `node tools/assert-bridge-access.mjs`(11) |
484
- | `assert-provider-takeover.mjs` | provider 接管:旧属性 API 与 rc.2 的 `config.*` ref 两套兼容、`settings.mutate(web)` 单批写入、显式指定的 provider 不被抢占、`fetchProviderTakeover: false` 时保留显式 `http`、describe 噪声不误报、嵌套 config 变化与 web 重建分别成行、apply 重入/重复注册不再中断、重复日志去重 | `node tools/assert-provider-takeover.mjs`(14) |
497
+ | `assert-provider-takeover.mjs` | provider 接管:旧属性 API 与 rc.2 的 `config.*` ref 两套兼容、`settings.mutate(web)` 单批写入、显式指定的 provider 不被抢占、`fetchProviderTakeover: false` 时保留显式 `http`、describe 噪声不误报、嵌套 config 变化与 web 重建分别成行、关闭 DSH 搜索开关后重新接管且不抢 `deepseek-search-api`、apply 重入/重复注册不再中断、重复日志去重 | `node tools/assert-provider-takeover.mjs`(16) |
485
498
 
486
499
  任何一个失败会以非 0 退出码结束并列出失败项;把某个旧版本文件路径作为参数传进去,可以看到修复前必红(用来证明断言不是空转)。改动发布相关逻辑后建议先跑一遍再发版。
487
500
 
@@ -641,33 +654,45 @@ dsh web
641
654
 
642
655
  #### Takeover behavior and verification
643
656
 
644
- The plugin **takes over search at runtime** once loaded (it does not touch the config layer):
657
+ The plugin **takes over search** once loaded:
645
658
 
646
659
  - When `web.searchProvider` is unset, or still the shipped default `deepseek-official`, it switches to this plugin's provider (id: `ddg`);
647
660
  - If you (or another plugin) explicitly selected a different provider, it does not steal it — it only logs a WARN with a copy-pasteable YAML snippet.
648
661
 
649
- **It no longer writes `searchProvider: ddg` into the config layer.** A static override used to create a dangling reference: a patch replaces the whole row config, so when the plugin's row was **disabled or skipped** (e.g. switched off in the plugin manager) the config still said `ddg` while nothing registered it, and every search failed with:
662
+ **The takeover is persisted into the profile (DSH 0.2.x).** Since `0.2.0-rc.2` the provider selection lives in volatile config refs and can only be written through `settings.mutate("web", …)`, which lands in `profiles/<profile>/cordis.patch.yml` as:
663
+
664
+ ```yaml
665
+ - id: web
666
+ config:
667
+ searchProvider: ddg
668
+ fetchProvider: fs-quality-fetch
669
+ ```
670
+
671
+ So **remove those `- id: web` lines before disabling or uninstalling this plugin** (or delete the whole row and let DSH fall back to its defaults). Otherwise the config still points at `ddg` while nothing registers it, and every search fails with:
650
672
 
651
673
  ```
652
674
  Error: configured web provider "ddg" is not registered
653
675
  ```
654
676
 
655
- Without that override, a disabled plugin leaves `web.searchProvider` at the shipped default (`deepseek-official`) so search still works through the official provider; when the plugin is enabled the runtime takeover above points it at `ddg`.
677
+ Older DSH (0.1.x) used a static patch layer for this; writing that row by hand there is neither needed nor recommended.
656
678
 
657
- > If you previously added the snippet below by hand to `profiles/<profile>/cordis.patch.yml` *and* the plugin is not enabled, you will see that error — **just remove the snippet** (or re-enable the plugin):
679
+ #### DSH's "DeepSeek search API" switch
658
680
 
659
- ```yaml
660
- # Optional; normally unnecessary — only to force a specific provider.
661
- # Since DSH 0.1.2 a patch REPLACES the whole entry config: keep fetchProvider.
662
- - id: web
663
- config:
664
- searchProvider: ddg
665
- fetchProvider: http
666
- ```
681
+ DSH 0.2.1 has Settings → Web search → **DeepSeek search API**, which interacts with this plugin:
682
+
683
+ | Action | Result |
684
+ |---|---|
685
+ | Turn the switch on | `web.searchProvider` becomes `deepseek-search-api` (an explicit choice), so **this plugin steps aside** and search goes through the official direct API; fetch becomes `deepseek-fetch-api` as well |
686
+ | Turn the switch off | DSH deletes the whole `- id: web` row, so the provider falls back to the shipped default `deepseek-official` — that is **model search** (a full auxiliary request, ~12 s in practice, billed per token), not the free engines |
687
+ | After turning it off | **Since 0.9.8**: the plugin notices `deepseek-official` and re-takes at runtime (log line `DSH's search switch put the web providers back to the official defaults — re-took …`), normally within seconds; if it does not, restart DSH |
688
+
689
+ Use `/free-search-engine` or this plugin's settings page to change engines, **not that switch**. Turning it on and then off drops you from the official direct API back to model search until the plugin re-takes over or DSH restarts.
690
+
691
+ > If you hand-wrote `- id: web` back in the 0.1.x era, you can drop it now: 0.2.x writes it at runtime. If you do declare it by hand, `fetchProvider` should be `fs-quality-fetch` (the fetch provider this plugin registers), not `http`.
667
692
 
668
693
  - `searchProvider: ddg` — this plugin's **fixed provider id**, not "use the DuckDuckGo engine"; the engine is chosen by the `provider` field in settings (`bing` / `baidu` / `auto` / …).
669
- - `fetchProvider: http` — the official web-fetch provider; keep it when declaring the row by hand.
670
- - To go back to official search: disable this plugin's entry in the plugin manager — no config edit needed.
694
+ - `fetchProvider: fs-quality-fetch` — this plugin's fetch provider (direct → exa contents → firecrawl → crawl4ai).
695
+ - To go back to official search: **remove those `- id: web` lines first, then disable this plugin** in the plugin manager.
671
696
 
672
697
  #### Sister Plugin: dsh-preset-workbench
673
698
 
@@ -962,7 +987,7 @@ Restart `dsh web` after switching to apply changes.
962
987
 
963
988
  #### Offline self-check assertions (`tools/`)
964
989
 
965
- **Eighteen** zero-dependency assertion scripts ship with the repo (**520 assertions** total): **no API keys, no network, no test framework**, Node ≥ 20:
990
+ **Nineteen** zero-dependency assertion scripts ship with the repo (**528 assertions** total): **no API keys, no network, no test framework**, Node ≥ 20:
966
991
 
967
992
  | Script | Covers | Run (count) |
968
993
  |---|---|---|
@@ -982,8 +1007,9 @@ Restart `dsh web` after switching to apply changes.
982
1007
  | `assert-killtree-win32.mjs` | Windows process-tree termination (#74): no longer relies on `taskkill` | `node tools/assert-killtree-win32.mjs` (8) |
983
1008
  | `assert-killtree-portable.mjs` | portable `killTree` contract | `node tools/assert-killtree-portable.mjs` (5) |
984
1009
  | `assert-parallel-mcp.mjs` | Parallel MCP engine: the 25 s timeout / caller abort covers the **body read**, shared SSE parser (notifications, multiline frames), six error paths, REST precise time filtering | `node tools/assert-parallel-mcp.mjs` (15) |
1010
+ | `assert-fetch-abort.mjs` | fetch interruption (#87): a compressed response cut mid-body rejects instead of hanging forever, identity truncation rejects too, abort breaks a stalled gzip stream, and complete responses plus redirects still work | `node tools/assert-fetch-abort.mjs` (6) |
985
1011
  | `assert-bridge-access.mjs` | bridge Host fence (#84): loopback allowed, a remote peer behind a loopback Host refused, `--trusted-host` (port-less matches any port, explicit port matches exactly), `--public-url`, the bind address, cross-site and foreign-Origin refused | `node tools/assert-bridge-access.mjs` (11) |
986
- | `assert-provider-takeover.mjs` | provider takeover: both the legacy property API and the rc.2 `config.*` refs, a single batched `settings.mutate(web)`, explicit foreign providers left untouched, an explicit `http` kept when `fetchProviderTakeover` is off, describe()-triggered events ignored when nothing changed, nested config changes and web rebuilds reported separately, apply re-entry / duplicate registrations tolerated, and repeated logs deduplicated | `node tools/assert-provider-takeover.mjs` (14) |
1012
+ | `assert-provider-takeover.mjs` | provider takeover: both the legacy property API and the rc.2 `config.*` refs, a single batched `settings.mutate(web)`, explicit foreign providers left untouched, an explicit `http` kept when `fetchProviderTakeover` is off, describe()-triggered events ignored when nothing changed, nested config changes and web rebuilds reported separately, re-takeover after DSH's search switch is turned off without fighting an explicit `deepseek-search-api`, apply re-entry / duplicate registrations tolerated, and repeated logs deduplicated | `node tools/assert-provider-takeover.mjs` (16) |
987
1013
 
988
1014
  Any failure exits non-zero with the failing items listed. Passing the path of an older file shows the pre-fix failures (proving the assertions are not vacuous). Worth running before a release.
989
1015
 
package/lib/client.js CHANGED
@@ -426,7 +426,7 @@ window.__ModuleLoader__.load({
426
426
  };
427
427
  const tt = (lang) => I18N[lang === "en" ? "en" : "zh"];
428
428
  // 当前插件版本(与 lib/index.js 的 PLUGIN_VERSION 保持一致;跟随上游 v0.7.5)
429
- const PLUGIN_VERSION = "0.9.7";
429
+ const PLUGIN_VERSION = "0.9.8";
430
430
  const PLUGIN_REPO_URL = "https://github.com/DDDMUC/dsh-free-search";
431
431
  // 右侧浮动目录(#85):条目顺序即页面顺序,anchor 是页面里对应 field 的 id。
432
432
  const NAV_ITEMS = [
@@ -1064,7 +1064,7 @@ window.__ModuleLoader__.load({
1064
1064
  };
1065
1065
 
1066
1066
  // 浮动目录(#85):放在卡片右侧的页面留白里,绝对定位不参与卡片布局,
1067
- // 所以正文宽度和 0.9.7 完全一致;留白不足(窄窗口/卡片贴边)时直接隐藏。
1067
+ // 所以正文宽度和 0.9.8 完全一致;留白不足(窄窗口/卡片贴边)时直接隐藏。
1068
1068
  react.useEffect(() => {
1069
1069
  if (!open) return undefined;
1070
1070
  const place = () => {
@@ -123,18 +123,70 @@ function browserDumpDom(browserPath, url, { timeoutMs = 25000, signal } = {}) {
123
123
  }
124
124
 
125
125
  /** GET 跟随重定向(≤5 跳),返回最终 URL / 状态 / 正文 / 内容类型;支持 gzip/br/deflate 解压。 */
126
- function httpsGetFollow(url, { headers = {}, timeoutMs = 20000, signal } = {}) {
126
+ export function httpsGetFollow(url, { headers = {}, timeoutMs = 20000, signal } = {}, transport = https) {
127
127
  return new Promise((resolve, reject) => {
128
+ let settled = false;
129
+ let activeReq = null;
130
+ let activeRes = null;
131
+ let activeStream = null;
132
+
133
+ const cleanup = () => {
134
+ if (signal) signal.removeEventListener("abort", onAbort);
135
+ };
136
+ const settle = (ok, value) => {
137
+ if (settled) return;
138
+ settled = true;
139
+ cleanup();
140
+ if (ok) resolve(value);
141
+ else reject(value);
142
+ };
143
+ const destroyActive = () => {
144
+ for (const target of [activeStream, activeRes, activeReq]) {
145
+ try {
146
+ target?.destroy?.(new Error("aborted"));
147
+ } catch {
148
+ // 忽略清理失败
149
+ }
150
+ }
151
+ };
152
+ const onAbort = () => {
153
+ destroyActive();
154
+ settle(false, signal?.reason instanceof Error ? signal.reason : new Error("aborted"));
155
+ };
156
+
157
+ if (signal) {
158
+ if (signal.aborted) {
159
+ settle(false, new Error("aborted"));
160
+ return;
161
+ }
162
+ signal.addEventListener("abort", onAbort, { once: true });
163
+ }
164
+
128
165
  const hop = (current, left) => {
129
- const u = new URL(current);
130
- const req = https.request(
131
- { hostname: u.hostname, port: u.port || 443, path: u.pathname + u.search, method: "GET", headers, timeout: timeoutMs, signal },
166
+ if (settled) return;
167
+ let target;
168
+ try {
169
+ target = new URL(current);
170
+ } catch (error) {
171
+ settle(false, error);
172
+ return;
173
+ }
174
+ const req = transport.request(
175
+ {
176
+ hostname: target.hostname,
177
+ port: target.port || (target.protocol === "http:" ? 80 : 443),
178
+ path: target.pathname + target.search,
179
+ method: "GET",
180
+ headers,
181
+ timeout: timeoutMs,
182
+ },
132
183
  (res) => {
184
+ activeRes = res;
133
185
  if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location && left > 0) {
134
186
  res.resume();
135
- const next = new URL(res.headers.location, current).toString();
136
- return hop(next, left - 1);
187
+ return hop(new URL(res.headers.location, current).toString(), left - 1);
137
188
  }
189
+
138
190
  const enc = String(res.headers["content-encoding"] || "").toLowerCase();
139
191
  let stream = res;
140
192
  try {
@@ -144,17 +196,53 @@ function httpsGetFollow(url, { headers = {}, timeoutMs = 20000, signal } = {}) {
144
196
  } catch {
145
197
  stream = res;
146
198
  }
199
+ activeStream = stream;
200
+
147
201
  const chunks = [];
148
- stream.on("data", (c) => chunks.push(c));
149
- stream.on("end", () => resolve({ url: current, status: res.statusCode, text: Buffer.concat(chunks).toString("utf8"), contentType: res.headers["content-type"] || "" }));
150
- stream.on("error", reject);
202
+ // 压缩响应的解压流感知不到上游 res 的销毁:pipe 不转发 error/close,
203
+ // 对端中途断连时解压流既不 end 也不 error,Promise 会永久挂起(#87)。
204
+ // 因此 body 的失败必须从 res 侧判定,并主动销毁解压流。
205
+ const bodyGone = (reason) => {
206
+ if (settled) return;
207
+ try {
208
+ stream.destroy?.();
209
+ } catch {
210
+ // 忽略
211
+ }
212
+ settle(false, reason instanceof Error ? reason : new Error(String(reason)));
213
+ };
214
+ res.on("aborted", () => bodyGone(new Error("connection aborted while reading the response body")));
215
+ res.on("error", (error) => bodyGone(error));
216
+ res.on("close", () => {
217
+ if (!res.complete) bodyGone(new Error("connection closed before the response body completed"));
218
+ });
219
+
220
+ stream.on("data", (chunk) => chunks.push(chunk));
221
+ stream.on("end", () =>
222
+ settle(true, {
223
+ url: current,
224
+ status: res.statusCode,
225
+ text: Buffer.concat(chunks).toString("utf8"),
226
+ contentType: res.headers["content-type"] || "",
227
+ })
228
+ );
229
+ stream.on("error", (error) => settle(false, error));
151
230
  }
152
231
  );
153
- req.on("timeout", () => req.destroy(new Error(`timeout after ${timeoutMs}ms`)));
154
- req.on("error", reject);
232
+ activeReq = req;
233
+ req.on("timeout", () => {
234
+ req.destroy(new Error(`timeout after ${timeoutMs}ms`));
235
+ settle(false, new Error(`timeout after ${timeoutMs}ms`));
236
+ });
237
+ req.on("error", (error) => settle(false, error));
155
238
  req.end();
156
239
  };
157
- hop(url, 5);
240
+
241
+ try {
242
+ hop(url, 5);
243
+ } catch (error) {
244
+ settle(false, error);
245
+ }
158
246
  });
159
247
  }
160
248
 
@@ -222,7 +310,7 @@ export function createFetchProvider({ resolveApiKey, logger }) {
222
310
  const firecrawlKey = await resolveApiKey("FIRECRAWL_API_KEY", "firecrawlApiKey").catch(() => undefined);
223
311
  if (firecrawlKey) {
224
312
  try {
225
- const md = await callFirecrawl(url, firecrawlKey);
313
+ const md = await callFirecrawl(url, firecrawlKey, signal);
226
314
  if (md.trim().length >= T1_MIN_CHARS) return { url, statusCode: 200, body: { kind: "text", content: md }, truncated: false };
227
315
  errors.push("firecrawl: short content");
228
316
  } catch (e) { errors.push(`firecrawl: ${e.message}`); }
@@ -231,7 +319,7 @@ export function createFetchProvider({ resolveApiKey, logger }) {
231
319
  const crawl4aiKey = await resolveApiKey("CRAWL4AI_API_KEY", undefined).catch(() => undefined);
232
320
  if (crawl4aiKey) {
233
321
  try {
234
- const text = await callCrawl4ai(url, crawl4aiKey);
322
+ const text = await callCrawl4ai(url, crawl4aiKey, signal);
235
323
  if (text.trim().length >= T1_MIN_CHARS) return { url, statusCode: 200, body: { kind: "text", content: text }, truncated: false };
236
324
  errors.push("crawl4ai: short content");
237
325
  } catch (e) { errors.push(`crawl4ai: ${e.message}`); }
package/lib/index.js CHANGED
@@ -256,7 +256,7 @@ function routeEngines(query, { timeRange, disabledEngines = [], fallbackOrder =
256
256
  }
257
257
 
258
258
  // 当前插件版本(发布时与 package.json 同步;跟随上游 v0.7.5,不在 PR 里 bump)
259
- const PLUGIN_VERSION = "0.9.7";
259
+ const PLUGIN_VERSION = "0.9.8";
260
260
  // 检查更新的 npm registry 元数据地址(dsh-free-search 是 npmjs 上的公开包)
261
261
  const NPM_REGISTRY_URL = "https://registry.npmjs.org/dsh-free-search/latest";
262
262
  const PLUGIN_NPM_URL = "https://www.npmjs.com/package/dsh-free-search";
@@ -4049,7 +4049,7 @@ function apply(ctx, config) {
4049
4049
  // Re-render the dynamic system-prompt engine list whenever this entry's config changes.
4050
4050
  // #82:dsh-settings 在 describe() 里也会 emit `settings/document-updated`(raw 快照变了就发),
4051
4051
  // 所以「收到事件」≠「有人写入」。事件只当触发信号,并且必须**推迟到 emit 栈之外**再读配置:
4052
- // 在 emit 的同步栈里再调 describe() 会和设置文档的写入路径打架(0.9.7 首版实测把 web 服务卡死)。
4052
+ // 在 emit 的同步栈里再调 describe() 会和设置文档的写入路径打架(0.9.8 首版实测把 web 服务卡死)。
4053
4053
  // 读到值后再对 web 条目的 uid + 整份 config 做深度比对:没变(describe 噪声)不记,变了才记一条。
4054
4054
  const snapshotWebEntry = () => {
4055
4055
  try {
@@ -4063,10 +4063,42 @@ function apply(ctx, config) {
4063
4063
  let lastWebEntry = null; // 首次事件只用来建立基线,不当成「变化」
4064
4064
  let webDiagLogged = false;
4065
4065
  let webDiagQueued = false;
4066
+ // #86:DSH 设置里的「DeepSeek 搜索 API」开关关掉时会把整行 `- id: web` 删掉,
4067
+ // provider 回到出厂默认 `deepseek-official`(模型搜索,更慢更贵),而插件只在加载时接管过一次。
4068
+ // 这里在 web 配置变化后补一次运行时接管;只认「出厂默认」,绝不抢显式选择的 `deepseek-search-api`。
4069
+ let reclaimAttempted = false;
4070
+ const reclaimOfficialDefaults = (next) => {
4071
+ if (reclaimAttempted) return;
4072
+ const config = next.config ?? {};
4073
+ const ops = [];
4074
+ if (config.searchProvider === "deepseek-official") takeOverOps(ctx.web, "searchProvider", provider.id, ops);
4075
+ if (
4076
+ (config.fetchProvider === "http" || config.fetchProvider === undefined) &&
4077
+ current().fetchProviderTakeover !== false
4078
+ ) {
4079
+ takeOverOps(ctx.web, "fetchProvider", fetchProvider.id, ops);
4080
+ }
4081
+ if (ops.length === 0) return;
4082
+ reclaimAttempted = true;
4083
+ const fields = [...new Set(ops.map((op) => String(op.path?.[0] ?? "?")))].join(" + ");
4084
+ Promise.resolve(sctx.settings.mutate("web", ops)).then(
4085
+ () =>
4086
+ logger.info(
4087
+ `free-search: DSH's search switch put the web providers back to the official defaults — re-took ${fields}.`
4088
+ ),
4089
+ (error) =>
4090
+ logger.warn(
4091
+ `free-search: could not re-take the web providers after the switch was turned off (${error?.message ?? error}); restart DSH to re-apply`
4092
+ )
4093
+ );
4094
+ };
4066
4095
  const runWebDiag = () => {
4067
4096
  const next = snapshotWebEntry();
4068
4097
  const previous = lastWebEntry;
4069
- if (next !== null) lastWebEntry = next;
4098
+ if (next !== null) {
4099
+ lastWebEntry = next;
4100
+ reclaimOfficialDefaults(next);
4101
+ }
4070
4102
  if (previous === null || next === null || webDiagLogged) return;
4071
4103
  const changed = changedConfigPaths(previous.config, next.config);
4072
4104
  const rebuilt = previous.uid !== next.uid;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-free-search",
3
- "version": "0.9.7",
3
+ "version": "0.9.8",
4
4
  "description": "Free web search for DeepSeek Harness: 27 engines (Bing/DuckDuckGo/AnySearch/SearXNG/Exa/Tavily/Keenable/Firecrawl/Parallel keyless; Zhihu global/site via MCP and Perplexity/SerpBase/DeepSeek (official + native tools/search)/You.com/Baidu/Kimi/Aliyun/Doubao with key; OpenAI/Gemini/Claude model built-in search, explicit-only) + auto smart routing + global engine enable/disable & fallback priority + editable zh/en language pools + multi mode (parallel full language-pool merge) + multi_search cross-source tool + failure-aware fallback (quota/auth cooldown, transient retry, fallbackOn) + time filtering + platform search + domain blocklist (blockedDomains) + web_fetch, with web settings UI.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",