dsh-free-search 0.4.4 → 0.4.6

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.en.md CHANGED
@@ -28,7 +28,7 @@ This plugin provides multiple free search engines with automatic fallback, compl
28
28
  - **Multi-Engine Support** — DuckDuckGo (HTML / Lite), Bing, AnySearch AI, SearXNG (meta-search with custom instances), Exa, Perplexity, and DeepSeek Official
29
29
  - **Web Settings UI** — Engine switching and API key configuration (API keys are masked in the UI and displayed as "configured")
30
30
  - **Engine Testing Tool** — `free_search_test`, allowing the agent to test the availability of all engines in a single call
31
- - **Automatic Fallback** — Automatically falls back to the next available free engine if one fails or gets rate-limited
31
+ - **Unified Engine Fallback** — Any engine failure (paid or free, missing key, 401, rate limit, network error) automatically tries the next engine: other paid engines with configured keys first, then free engines. Search never fails outright.
32
32
  - **System Prompt Injection** — The agent is aware of the currently active engine and which engines require API keys
33
33
  - **Visual Badges** — Free engines feature a green `FREE` badge, while paid engines show an orange `API KEY` badge in the settings UI
34
34
  - **Webpage Fetching (`web_fetch`)** — Allows the agent to read full webpage contents (official `dsh-web-fetch-http` provider, pure JS, zero extra dependencies)
@@ -49,7 +49,7 @@ This plugin provides multiple free search engines with automatic fallback, compl
49
49
  | `deepseek-official` | DeepSeek Official | Paid | Requires `DEEPSEEK_API_KEY` |
50
50
 
51
51
  - **Default engine is `bing`** (free and most stable), ready to use out of the box after installation.
52
- - Free engines automatically fall back to other free engines upon failure. Paid engines report clear error messages when keys are missing or invalid, avoiding silent fallbacks.
52
+ - **Auto-failover**: any engine failure (rate-limited free engine, or missing/invalid paid key, network error) automatically tries the next engine — other paid engines with configured keys first, then free engines (Bing/AnySearch etc.) with a note attached to the results. Search never fails outright because of engine issues.
53
53
  - **Official Links in Settings**: Free engines display "Visit Website →", while paid engines display "Get API Key →" (opens in a new tab):
54
54
  - Exa: <https://dashboard.exa.ai/api-keys>
55
55
  - Perplexity: <https://www.perplexity.ai/settings/api>
@@ -86,7 +86,8 @@ After installation, navigate to **Settings → Plugins → Configurable** tab
86
86
 
87
87
  - **Search engine**: Select an engine from the dropdown; changes take effect immediately upon saving.
88
88
  - **API keys**: Enter keys for Exa / Perplexity / DeepSeek (password fields; displayed as "configured" once saved).
89
- - **Use Bing default**: stage a switch back to the stable free Bing engine; `Discard` only cancels unsaved edits.
89
+ - **Use Bing default**: stage a switch back to the stable free Bing engine; `Discard` only cancels unsaved edits
90
+ - **Platform search**: check GitHub / V2EX / Bilibili to enable them for the `platform_search` tool (disabled platforms are skipped).
90
91
 
91
92
  <table align="center" style="border: none; border-collapse: collapse;">
92
93
  <tr style="border: none;">
@@ -181,7 +182,7 @@ Windows users: The desktop shortcut already includes this configuration (`set NO
181
182
 
182
183
  ## How It Works
183
184
 
184
- - `lib/index.js`: Host side. Implements `WebSearchProvider` (`id` / `available()` / `search()`), multi-engine routing + auto-fallback; registers the `free-search` settings namespace; provides the `/api/dsh-free-search-settings` read/write bridge; registers the `free_search_test` tool; injects the engine list into system prompts.
185
+ - `lib/index.js`: Host side. Implements `WebSearchProvider` (`id` / `available()` / `search()`), unified engine routing + auto-fallback (paid engines first, free as fallback); registers the `free-search` settings namespace; provides the `/api/dsh-free-search-settings` read/write bridge + `raw-search` debug endpoint; registers the `free_search_test` and `platform_search` tools; dynamically injects the engine list into system prompts (auto-refreshes on settings change).
185
186
  - `lib/client.js`: Browser side. React configuration card (engine select + key inputs), mounted into the official `settings.plugin.item` slot (Settings → Plugins → Configurable), without requiring `dsh-web-ui`.
186
187
  - `cordis.patch.yml`: Plugin loader configuration.
187
188
 
package/README.md CHANGED
@@ -28,7 +28,7 @@ dsh 默认的搜索 provider 依赖 DeepSeek 官方 API key(`DEEPSEEK_API_KEY`
28
28
  - **多引擎可选**:DuckDuckGo(html/lite)、Bing、SearXNG(元搜索,支持自定义实例)、Exa、Perplexity、DeepSeek 官方
29
29
  - **网页设置页** —— 引擎切换 + API key 配置(UI 中 key 脱敏显示"已配置")
30
30
  - **引擎测试工具** —— `free_search_test`,让 agent 一键测试所有引擎可用性
31
- - **自动回退** —— 免费引擎失败/限流时自动切换到下一个可用引擎
31
+ - **统一引擎回退** —— 任何引擎失败(付费/免费,缺 key/401/限流/网络)自动轮流尝试下一个引擎:先试其他已配 key 的付费引擎,再试免费引擎,搜索永不直接失败
32
32
  - **系统提示词注入** —— agent 知道当前用哪个引擎、哪些需要 key
33
33
  - **免费标注** —— 设置页中免费引擎带绿色 `FREE` 徽章,付费引擎带橙色 `API KEY` 徽章
34
34
  - **网页抓取(web_fetch)** —— 让 agent 抓取网页内容(官方 `dsh-web-fetch-http` provider,纯 JS,零额外依赖)
@@ -49,7 +49,7 @@ dsh 默认的搜索 provider 依赖 DeepSeek 官方 API key(`DEEPSEEK_API_KEY`
49
49
  | `deepseek-official` | DeepSeek 官方 | 付费 | 需 `DEEPSEEK_API_KEY` |
50
50
 
51
51
  - **默认引擎为 `bing`**(免费且最稳定),安装后开箱即用。
52
- - 免费引擎失败会自动回退到其他免费引擎;付费引擎缺 key key 无效时报清晰错误,不会静默切换。
52
+ - **自动回退**:任何引擎失败(免费限流/反爬,付费缺 key/无效/网络错误)都会自动轮流尝试下一个引擎——先试其他已配 key 的付费引擎,再试免费引擎(Bing/AnySearch 等),并在结果中附带回退提示——搜索不会因引擎问题直接失败。
53
53
  - **设置页有官网链接**:免费引擎显示"访问官网 →",付费引擎显示"获取 API Key →"(新标签页打开):
54
54
  - Exa:<https://dashboard.exa.ai/api-keys>
55
55
  - Perplexity:<https://www.perplexity.ai/settings/api>
@@ -85,8 +85,9 @@ dsh web
85
85
  安装后,打开 **设置 → 插件 → 可配置** 标签页 → **Free Search** 卡片(官方设置页):
86
86
 
87
87
  - **Search engine**:下拉框切换引擎,保存即生效
88
- - **API keys**:为 Exa / Perplexity / DeepSeek 填写 key(密码框,保存后只显示"已配置")
89
- - **Use Bing default**:把当前搜索引擎切回稳定的免费 Bing;`Discard` 只撤销尚未保存的编辑
88
+ - **API keys**:为 Exa / Perplexity / DeepSeek 填写 key(密码框,保存后只显示"已配置")
89
+ - **Use Bing default**:把当前搜索引擎切回稳定的免费 Bing;`Discard` 只撤销尚未保存的编辑
90
+ - **Platform search**:勾选启用 GitHub / V2EX / Bilibili 平台搜索(`platform_search` 工具按此过滤)
90
91
 
91
92
  <table align="center" style="border: none; border-collapse: collapse;">
92
93
  <tr style="border: none;">
@@ -182,7 +183,7 @@ Windows 用户:桌面快捷方式已内置此配置(`set NODE_USE_ENV_PROXY=
182
183
 
183
184
  ## 工作原理
184
185
 
185
- - `lib/index.js`:host 端。实现 `WebSearchProvider`(`id` / `available()` / `search()`),多引擎路由 + 自动回退;注册 `free-search` settings namespace;提供 `/api/dsh-free-search-settings` 读写桥;注册 `free_search_test` 工具;注入引擎清单到系统提示词。
186
+ - `lib/index.js`:host 端。实现 `WebSearchProvider`(`id` / `available()` / `search()`),统一引擎路由 + 自动回退(付费引擎优先,免费兜底);注册 `free-search` settings namespace;提供 `/api/dsh-free-search-settings` 读写桥 + `raw-search` 调试接口;注册 `free_search_test` 和 `platform_search` 工具;动态注入引擎清单到系统提示词(设置变更时自动刷新)。
186
187
  - `lib/client.js`:浏览器端。React 配置卡片(引擎选择 + key 输入),挂载到官方设置页的 `settings.plugin.item` 插槽(设置 → 插件 → 可配置),不依赖 dsh-web-ui。
187
188
  - `cordis.patch.yml`:插件 loader 配置。
188
189
 
package/lib/client.js CHANGED
@@ -31,6 +31,9 @@ window.__ModuleLoader__.load({
31
31
  ".dshfs-input:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:1px}",
32
32
  ".dshfs-input:disabled{opacity:.6;cursor:default}",
33
33
  ".dshfs-hint{color:var(--dsw-alias-label-secondary);margin:0;font-size:12px}",
34
+ ".dshfs-platforms{display:flex;gap:10px;flex-wrap:wrap}",
35
+ ".dshfs-platform{display:flex;align-items:center;gap:5px;color:var(--dsw-alias-label-primary);font-size:13px;cursor:pointer}",
36
+ ".dshfs-platform input{accent-color:var(--dsw-alias-state-business-primary)}",
34
37
  ".dshfs-link{color:var(--dsw-alias-state-business-primary);font-size:12px;text-decoration:none;align-self:flex-start;padding:2px 0}",
35
38
  ".dshfs-link:hover{text-decoration:underline}",
36
39
  ".dshfs-btn{font:inherit;cursor:pointer;border-radius:6px;padding:5px 12px;font-size:13px;transition:background-color .13s,border-color .13s,color .13s}",
@@ -89,6 +92,7 @@ window.__ModuleLoader__.load({
89
92
  const [exaKey, setExaKey] = react.useState("");
90
93
  const [perplexityKey, setPerplexityKey] = react.useState("");
91
94
  const [deepseekKey, setDeepseekKey] = react.useState("");
95
+ const [platforms, setPlatforms] = react.useState(["github", "v2ex", "bilibili"]);
92
96
  const [keysConfigured, setKeysConfigured] = react.useState({});
93
97
  const [dirty, setDirty] = react.useState(false);
94
98
  const [saving, setSaving] = react.useState(false);
@@ -105,6 +109,7 @@ window.__ModuleLoader__.load({
105
109
  setExaKey(v.exaApiKey ?? "");
106
110
  setPerplexityKey(v.perplexityApiKey ?? "");
107
111
  setDeepseekKey(v.deepseekApiKey ?? "");
112
+ setPlatforms(Array.isArray(v.platforms) && v.platforms.length > 0 ? v.platforms : ["github", "v2ex", "bilibili"]);
108
113
  // secrets 字段标记哪些 key 已配置(值被脱敏,仅显示"已配置")
109
114
  const configured = {};
110
115
  for (const secret of view.secrets ?? []) {
@@ -146,6 +151,7 @@ window.__ModuleLoader__.load({
146
151
  if (exaKey.trim()) ops.push({ op: "set", path: ["exaApiKey"], value: exaKey.trim() });
147
152
  if (perplexityKey.trim()) ops.push({ op: "set", path: ["perplexityApiKey"], value: perplexityKey.trim() });
148
153
  if (deepseekKey.trim()) ops.push({ op: "set", path: ["deepseekApiKey"], value: deepseekKey.trim() });
154
+ ops.push({ op: "set", path: ["platforms"], value: platforms });
149
155
  const result = await bridgeMutate({ ns: NS, ops });
150
156
  if (result.ok) {
151
157
  setDirty(false);
@@ -291,6 +297,44 @@ window.__ModuleLoader__.load({
291
297
  }),
292
298
  ],
293
299
  }),
300
+ react_jsx_runtime.jsx("div", {
301
+ className: "dshfs-field",
302
+ children: [
303
+ react_jsx_runtime.jsx("div", {
304
+ className: "dshfs-label",
305
+ children: "Platform search (platform_search tool)",
306
+ }),
307
+ react_jsx_runtime.jsx("div", {
308
+ className: "dshfs-platforms",
309
+ children: [
310
+ ["github", "GitHub"], ["v2ex", "V2EX"], ["bilibili", "Bilibili"],
311
+ ].map(([id, label]) =>
312
+ react_jsx_runtime.jsx("label", {
313
+ className: "dshfs-platform",
314
+ children: [
315
+ react_jsx_runtime.jsx("input", {
316
+ type: "checkbox",
317
+ checked: platforms.includes(id),
318
+ disabled: !ready || saving,
319
+ onChange: (e) => {
320
+ setPlatforms((prev) =>
321
+ e.target.checked ? [...prev, id] : prev.filter((p) => p !== id)
322
+ );
323
+ setDirty(true);
324
+ setFailed(false);
325
+ },
326
+ }),
327
+ label,
328
+ ],
329
+ }, id)
330
+ ),
331
+ }),
332
+ react_jsx_runtime.jsx("p", {
333
+ className: "dshfs-hint",
334
+ children: "Enable platforms for the agent's platform_search tool. Disabled platforms are skipped.",
335
+ }),
336
+ ],
337
+ }),
294
338
  !ready
295
339
  ? react_jsx_runtime.jsx("p", {
296
340
  className: "dshfs-hint",
@@ -344,6 +388,7 @@ window.__ModuleLoader__.load({
344
388
  ctx.slots.register(
345
389
  {
346
390
  name: "settings.plugin.item",
391
+ key: "free-search",
347
392
  id: "dsh-free-search",
348
393
  order: 120,
349
394
  inject: () => ({}),
package/lib/index.js CHANGED
@@ -648,7 +648,7 @@ function toView(descriptor) {
648
648
  };
649
649
  }
650
650
 
651
- function makeBridgeRoutes(settings) {
651
+ function makeBridgeRoutes(settings, search) {
652
652
  const allowlisted = () =>
653
653
  settings
654
654
  .describe({ redactSecrets: true })
@@ -656,6 +656,28 @@ function makeBridgeRoutes(settings) {
656
656
  .map((descriptor) => String(descriptor.ns));
657
657
 
658
658
  const handlers = {
659
+ async rawSearch(request) {
660
+ if (request === null || typeof request !== "object" || typeof request.query !== "string" || request.query.length === 0) {
661
+ return { ok: false, code: "search-rejected", message: "malformed bridge search request (query is required)" };
662
+ }
663
+ if (typeof search !== "function") {
664
+ return { ok: false, code: "search-unavailable", message: "search provider is not wired" };
665
+ }
666
+ const maxResults = Math.min(Math.max(Number(request.maxResults) || 5, 1), 10);
667
+ try {
668
+ const result = await search(request.query, maxResults);
669
+ return {
670
+ ok: true,
671
+ value: {
672
+ provider: (settings.describe({ redactSecrets: true }).find((c) => String(c.ns) === FREE_SEARCH_NS)?.schema ?? {}).provider ?? undefined,
673
+ sources: result.sources ?? [],
674
+ content: result.content ?? "",
675
+ },
676
+ };
677
+ } catch (error) {
678
+ return { ok: false, code: "search-failed", message: error instanceof Error ? error.message : String(error) };
679
+ }
680
+ },
659
681
  async describe() {
660
682
  const descriptors = settings.describe({ redactSecrets: true });
661
683
  return {
@@ -730,6 +752,19 @@ function makeBridgeRoutes(settings) {
730
752
  writeJson(res, 200, await handlers.mutate(body));
731
753
  },
732
754
  },
755
+ {
756
+ kind: "exact",
757
+ path: `${BRIDGE_PREFIX}/raw-search`,
758
+ handler: async (req, res) => {
759
+ if (!guard(req, res)) return;
760
+ const body = await readJsonBody(req);
761
+ if (body === undefined) {
762
+ writeJson(res, 400, { ok: false, code: "search-rejected", message: "malformed JSON body" });
763
+ return;
764
+ }
765
+ writeJson(res, 200, await handlers.rawSearch(body));
766
+ },
767
+ },
733
768
  ];
734
769
  }
735
770
  //#endregion
@@ -742,6 +777,7 @@ const Config = z.object({
742
777
  region: z.string(),
743
778
  bingMarket: z.string().default("zh-CN"),
744
779
  searxngInstances: z.array(z.string()),
780
+ platforms: z.array(z.string()).default(["github", "v2ex", "bilibili"]),
745
781
  exaApiKey: z.string().role("secret"),
746
782
  perplexityApiKey: z.string().role("secret"),
747
783
  deepseekApiKey: z.string().role("secret"),
@@ -752,6 +788,9 @@ function apply(ctx, config) {
752
788
  const logger = ctx.logger;
753
789
  const credentials = ctx.get("credentials");
754
790
 
791
+ // 系统提示词动态刷新:设置变更时重新生成,避免显示旧引擎
792
+ let refreshPrompt = null;
793
+
755
794
  // key 优先级:settings 的 free-search.<x>ApiKey > 环境变量/credentials
756
795
  const resolveApiKey = async (envName, settingsKey) => {
757
796
  const cfg = current();
@@ -766,7 +805,8 @@ function apply(ctx, config) {
766
805
  };
767
806
 
768
807
  // 总控 provider:按 settings 的 provider 字段路由到任意引擎。
769
- // 免费引擎失败自动回退其他免费引擎;付费引擎缺 key 时报清晰错误(不静默切换)。
808
+ // 任何引擎失败(缺 key / 401 / 限流 / 网络)都会自动轮流尝试下一个引擎,
809
+ // 直到成功或全部失败。并在结果里附带回退提示,避免 agent 搜索直接失败。
770
810
  const provider = {
771
811
  id: "ddg",
772
812
  available() {
@@ -776,56 +816,64 @@ function apply(ctx, config) {
776
816
  const cfg = current();
777
817
  const preferred = cfg.provider ?? "bing";
778
818
 
779
- // 付费引擎:直接调用,缺 key 报错(exa 无 key 时走 Exa MCP keyless)
780
- if (preferred === "exa") {
781
- const key = await resolveApiKey("EXA_API_KEY", "exaApiKey");
782
- if (key) return searchExa(request.query, request.maxResults, key, signal);
783
- return searchExaMCP(request.query, request.maxResults, signal);
784
- }
785
- if (preferred === "perplexity") {
786
- const key = await resolveApiKey("PERPLEXITY_API_KEY", "perplexityApiKey");
787
- return searchPerplexity(request.query, request.maxResults, key, signal);
788
- }
789
- if (preferred === "deepseek-official") {
790
- const key = await resolveApiKey("DEEPSEEK_API_KEY", "deepseekApiKey");
791
- return searchDeepSeekOfficial(request.query, request.maxResults, key, signal);
792
- }
819
+ // 统一引擎链:首选优先,然后其他付费引擎(有 key 的优先尝试),最后免费引擎
820
+ const paidEngines = ["exa", "perplexity", "deepseek-official"];
821
+ const freeEngines = ["bing", "anysearch", "ddg", "ddg-lite", "searxng"];
822
+ const othersPaid = paidEngines.filter((e) => e !== preferred);
823
+ const othersFree = freeEngines.filter((e) => e !== preferred);
824
+ const chain = [preferred, ...othersPaid, ...othersFree];
793
825
 
794
- // 免费引擎:首选 + 自动回退(anysearch / searxng 无 key)
795
- const chain =
796
- preferred === "ddg-lite"
797
- ? ["ddg-lite", "ddg", "anysearch", "searxng", "bing"]
798
- : preferred === "bing"
799
- ? ["bing", "anysearch", "searxng", "ddg", "ddg-lite"]
800
- : preferred === "searxng"
801
- ? ["searxng", "anysearch", "bing", "ddg", "ddg-lite"]
802
- : preferred === "anysearch"
803
- ? ["anysearch", "bing", "searxng", "ddg", "ddg-lite"]
804
- : ["ddg", "anysearch", "searxng", "ddg-lite", "bing"];
805
826
  let lastError = null;
827
+ let usedEngine = null;
806
828
  for (const engine of chain) {
807
829
  try {
808
830
  let result;
809
- switch (engine) {
810
- case "ddg":
811
- result = await searchDdgHtml(request.query, request.maxResults, cfg, signal);
812
- break;
813
- case "ddg-lite":
814
- result = await searchDdgLite(request.query, request.maxResults, signal);
815
- break;
816
- case "bing":
817
- result = await searchBing(request.query, request.maxResults, cfg, signal);
818
- break;
819
- case "searxng":
820
- result = await searchSearxng(request.query, request.maxResults, cfg, signal);
821
- break;
822
- case "anysearch":
823
- result = await searchAnysearch(request.query, request.maxResults, signal);
824
- break;
825
- default:
826
- result = await searchDdgHtml(request.query, request.maxResults, cfg, signal);
831
+ if (engine === "ddg") {
832
+ result = await searchDdgHtml(request.query, request.maxResults, cfg, signal);
833
+ } else if (engine === "ddg-lite") {
834
+ result = await searchDdgLite(request.query, request.maxResults, signal);
835
+ } else if (engine === "bing") {
836
+ result = await searchBing(request.query, request.maxResults, cfg, signal);
837
+ } else if (engine === "searxng") {
838
+ result = await searchSearxng(request.query, request.maxResults, cfg, signal);
839
+ } else if (engine === "anysearch") {
840
+ result = await searchAnysearch(request.query, request.maxResults, signal);
841
+ } else if (engine === "exa") {
842
+ // exa:有 key REST,无 key 走 keyless MCP(免费)
843
+ const key = await resolveApiKey("EXA_API_KEY", "exaApiKey");
844
+ if (key) {
845
+ result = await searchExa(request.query, request.maxResults, key, signal);
846
+ } else {
847
+ result = await searchExaMCP(request.query, request.maxResults, signal);
848
+ }
849
+ } else if (engine === "perplexity") {
850
+ const key = await resolveApiKey("PERPLEXITY_API_KEY", "perplexityApiKey");
851
+ if (!key) {
852
+ lastError = new Error("Perplexity requires PERPLEXITY_API_KEY");
853
+ logger.warn(`free-search: engine "${engine}" skipped (no key), trying next engine`);
854
+ continue; // 无 key 跳过
855
+ }
856
+ result = await searchPerplexity(request.query, request.maxResults, key, signal);
857
+ } else if (engine === "deepseek-official") {
858
+ const key = await resolveApiKey("DEEPSEEK_API_KEY", "deepseekApiKey");
859
+ if (!key) {
860
+ lastError = new Error("DeepSeek requires DEEPSEEK_API_KEY");
861
+ logger.warn(`free-search: engine "${engine}" skipped (no key), trying next engine`);
862
+ continue; // 无 key 跳过
863
+ }
864
+ result = await searchDeepSeekOfficial(request.query, request.maxResults, key, signal);
865
+ } else {
866
+ continue;
867
+ }
868
+
869
+ if (result.sources.length > 0) {
870
+ usedEngine = engine;
871
+ // 用了非首选引擎时,在结果里附上提示(agent 会看到)
872
+ if (engine !== preferred) {
873
+ result.content = `Note: ${preferred} unavailable or failed, using ${engine}.`;
874
+ }
875
+ return result;
827
876
  }
828
- if (result.sources.length > 0) return result;
829
877
  lastError = new Error(`engine "${engine}" returned 0 results`);
830
878
  logger.warn(`free-search: ${engine} returned 0 results, trying next engine`);
831
879
  } catch (error) {
@@ -842,12 +890,17 @@ function apply(ctx, config) {
842
890
  setSource: (source) => {
843
891
  current = source;
844
892
  },
845
- onChange: () => {},
893
+ onChange: () => {
894
+ // settings 变更时刷新系统提示词(显示最新引擎)
895
+ if (typeof refreshPrompt === "function") refreshPrompt();
896
+ },
846
897
  });
847
898
 
848
899
  ctx.inject(["webServer", "settings"], (sctx) => {
849
900
  sctx.effect(() => {
850
- const disposers = makeBridgeRoutes(sctx.settings).map((route) => sctx.webServer.register(route));
901
+ const disposers = makeBridgeRoutes(sctx.settings, (query, maxResults) => provider.search({ query, maxResults }, undefined)).map((route) =>
902
+ sctx.webServer.register(route)
903
+ );
851
904
  return () => {
852
905
  for (const dispose of disposers) dispose();
853
906
  };
@@ -1044,6 +1097,13 @@ function apply(ctx, config) {
1044
1097
  if (!PLATFORMS[platform]) {
1045
1098
  throw new Error(`unknown platform "${platform}" - use one of: ${Object.keys(PLATFORMS).join(", ")}`);
1046
1099
  }
1100
+ // 平台开关:settings 里禁用某平台时,工具明确告知
1101
+ const enabled = current().platforms ?? ["github", "v2ex", "bilibili"];
1102
+ if (!enabled.includes(platform)) {
1103
+ throw new Error(
1104
+ `platform "${platform}" is disabled in Free Search settings - enable it in Settings > Plugins > Free Search to use it`
1105
+ );
1106
+ }
1047
1107
  const limit = Math.min(args.maxResults ?? 5, 10);
1048
1108
  const result = await searchPlatform(platform, args.query, limit);
1049
1109
  return { platform, sources: result.sources };
@@ -1061,10 +1121,15 @@ function apply(ctx, config) {
1061
1121
  }, "free-search: platform search tool");
1062
1122
  });
1063
1123
 
1064
- // 让 agent 知道可用搜索引擎(动态生成,随 key 配置变化)
1124
+ // 让 agent 知道可用搜索引擎(动态生成,随 key/设置变化)
1065
1125
  ctx.inject(["systemPrompt"], (sctx) => {
1066
- sctx.effect(() => {
1067
- const section = {
1126
+ let disposeSection = null;
1127
+ refreshPrompt = () => {
1128
+ if (disposeSection) {
1129
+ disposeSection();
1130
+ disposeSection = null;
1131
+ }
1132
+ disposeSection = sctx.systemPrompt.section({
1068
1133
  name: "free-search:engines",
1069
1134
  order: 500,
1070
1135
  text: [
@@ -1083,16 +1148,19 @@ function apply(ctx, config) {
1083
1148
  "- perplexity - requires PERPLEXITY_API_KEY",
1084
1149
  "- deepseek-official - requires DEEPSEEK_API_KEY",
1085
1150
  "",
1086
- "FREE engines auto-fallback to another FREE engine on failure. Paid engines fail with a clear error when their key is missing - tell the user which key to configure.",
1151
+ "IMPORTANT: If the configured engine fails (missing key, invalid key, 401, rate limit, or network error), web_search automatically tries other engines in this order: (1) other PAID engines whose API key is configured, (2) then FREE engines (Bing, AnySearch, DuckDuckGo, SearXNG). This applies to ALL engines - paid or free. The results include a note showing which engine was actually used. Never tell the user search is unavailable - it always falls back.",
1087
1152
  "",
1088
1153
  "Use the free_search_test tool to test which engines actually work right now.",
1089
1154
  "",
1090
1155
  "For platform-specific searches (GitHub repos, V2EX threads, Bilibili videos), use the platform_search tool with platform: github|v2ex|bilibili.",
1091
1156
  ].join("\n"),
1092
- };
1093
- const dispose = sctx.systemPrompt.section(section);
1157
+ });
1158
+ };
1159
+ sctx.effect(() => {
1160
+ refreshPrompt();
1094
1161
  return () => {
1095
- dispose();
1162
+ if (disposeSection) disposeSection();
1163
+ disposeSection = null;
1096
1164
  };
1097
1165
  }, "free-search: engine list prompt section");
1098
1166
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-free-search",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
4
4
  "description": "Free web search for DeepSeek Harness: 8 engines (Bing/DuckDuckGo/AnySearch/SearXNG/Exa keyless) + platform search + web_fetch, with web settings UI.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -41,12 +41,12 @@
41
41
  "@deepseek-ai/schemastery": "^3.18.1"
42
42
  },
43
43
  "peerDependencies": {
44
- "@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
45
- "@deepseek-ai/dsh-tools": "^0.1.0-rc.6"
44
+ "@deepseek-ai/dsh-settings": ">=0.1.0-rc.6",
45
+ "@deepseek-ai/dsh-tools": ">=0.1.0-rc.6"
46
46
  },
47
47
  "devDependencies": {
48
- "@deepseek-ai/dsh-settings": "0.1.0-rc.6",
49
- "@deepseek-ai/dsh-tools": "0.1.0-rc.6"
48
+ "@deepseek-ai/dsh-settings": "0.1.0-rc.7",
49
+ "@deepseek-ai/dsh-tools": "0.1.0-rc.7"
50
50
  },
51
51
  "dsh": {
52
52
  "bundle": {