dsh-free-search 0.4.5 → 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
- - **Auto-failover**: any engine failure (rate-limited free engine, or missing/invalid paid key, network error) automatically falls back to a working free engine (Bing/AnySearch etc.) with a note attached to the results — a search never fails outright because of engine issues.
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>
@@ -182,7 +182,7 @@ Windows users: The desktop shortcut already includes this configuration (`set NO
182
182
 
183
183
  ## How It Works
184
184
 
185
- - `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).
186
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`.
187
187
  - `cordis.patch.yml`: Plugin loader configuration.
188
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/无效/网络错误)都会自动回退到可用的免费引擎(Bing/AnySearch 等),并在结果中附带回退提示——搜索不会因引擎问题直接失败。
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>
@@ -183,7 +183,7 @@ Windows 用户:桌面快捷方式已内置此配置(`set NODE_USE_ENV_PROXY=
183
183
 
184
184
  ## 工作原理
185
185
 
186
- - `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` 工具;动态注入引擎清单到系统提示词(设置变更时自动刷新)。
187
187
  - `lib/client.js`:浏览器端。React 配置卡片(引擎选择 + key 输入),挂载到官方设置页的 `settings.plugin.item` 插槽(设置 → 插件 → 可配置),不依赖 dsh-web-ui。
188
188
  - `cordis.patch.yml`:插件 loader 配置。
189
189
 
package/lib/client.js CHANGED
@@ -388,6 +388,7 @@ window.__ModuleLoader__.load({
388
388
  ctx.slots.register(
389
389
  {
390
390
  name: "settings.plugin.item",
391
+ key: "free-search",
391
392
  id: "dsh-free-search",
392
393
  order: 120,
393
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
@@ -753,6 +788,9 @@ function apply(ctx, config) {
753
788
  const logger = ctx.logger;
754
789
  const credentials = ctx.get("credentials");
755
790
 
791
+ // 系统提示词动态刷新:设置变更时重新生成,避免显示旧引擎
792
+ let refreshPrompt = null;
793
+
756
794
  // key 优先级:settings 的 free-search.<x>ApiKey > 环境变量/credentials
757
795
  const resolveApiKey = async (envName, settingsKey) => {
758
796
  const cfg = current();
@@ -767,8 +805,8 @@ function apply(ctx, config) {
767
805
  };
768
806
 
769
807
  // 总控 provider:按 settings 的 provider 字段路由到任意引擎。
770
- // 任何引擎失败(缺 key / 401 / 限流 / 网络)都会自动回退到可用的免费引擎,
771
- // 并在结果里附带回退提示,避免 agent 搜索直接失败。
808
+ // 任何引擎失败(缺 key / 401 / 限流 / 网络)都会自动轮流尝试下一个引擎,
809
+ // 直到成功或全部失败。并在结果里附带回退提示,避免 agent 搜索直接失败。
772
810
  const provider = {
773
811
  id: "ddg",
774
812
  available() {
@@ -778,83 +816,61 @@ function apply(ctx, config) {
778
816
  const cfg = current();
779
817
  const preferred = cfg.provider ?? "bing";
780
818
 
781
- // 付费引擎:先尝试;失败(缺 key / 401 / 网络)则回退免费引擎链
782
- let fallbackReason = null;
783
- if (preferred === "exa" || preferred === "perplexity" || preferred === "deepseek-official") {
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];
825
+
826
+ let lastError = null;
827
+ let usedEngine = null;
828
+ for (const engine of chain) {
784
829
  try {
785
- if (preferred === "exa") {
830
+ let result;
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(免费)
786
843
  const key = await resolveApiKey("EXA_API_KEY", "exaApiKey");
787
844
  if (key) {
788
- const result = await searchExa(request.query, request.maxResults, key, signal);
789
- if (result.sources.length > 0) return result;
790
- fallbackReason = "Exa returned no results";
845
+ result = await searchExa(request.query, request.maxResults, key, signal);
791
846
  } else {
792
- // exa 无 key 时走 keyless MCP(免费)
793
- try {
794
- const result = await searchExaMCP(request.query, request.maxResults, signal);
795
- if (result.sources.length > 0) return result;
796
- fallbackReason = "Exa keyless MCP returned no results";
797
- } catch (mcpError) {
798
- fallbackReason = mcpError instanceof Error ? mcpError.message : String(mcpError);
799
- }
847
+ result = await searchExaMCP(request.query, request.maxResults, signal);
800
848
  }
801
- } else if (preferred === "perplexity") {
849
+ } else if (engine === "perplexity") {
802
850
  const key = await resolveApiKey("PERPLEXITY_API_KEY", "perplexityApiKey");
803
- if (!key) throw new Error("Perplexity search requires PERPLEXITY_API_KEY");
804
- const result = await searchPerplexity(request.query, request.maxResults, key, signal);
805
- if (result.sources.length > 0) return result;
806
- fallbackReason = "Perplexity returned no results";
807
- } else {
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") {
808
858
  const key = await resolveApiKey("DEEPSEEK_API_KEY", "deepseekApiKey");
809
- if (!key) throw new Error("DeepSeek search requires DEEPSEEK_API_KEY");
810
- const result = await searchDeepSeekOfficial(request.query, request.maxResults, key, signal);
811
- if (result.sources.length > 0) return result;
812
- fallbackReason = "DeepSeek search returned no results";
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;
813
867
  }
814
- } catch (error) {
815
- fallbackReason = error instanceof Error ? error.message : String(error);
816
- logger.warn(`free-search: paid engine "${preferred}" failed (${fallbackReason}), falling back to free engines`);
817
- }
818
- }
819
868
 
820
- // 免费引擎链(付费引擎失败时也走这里)
821
- const chain =
822
- preferred === "ddg-lite"
823
- ? ["ddg-lite", "ddg", "anysearch", "searxng", "bing"]
824
- : preferred === "bing"
825
- ? ["bing", "anysearch", "searxng", "ddg", "ddg-lite"]
826
- : preferred === "searxng"
827
- ? ["searxng", "anysearch", "bing", "ddg", "ddg-lite"]
828
- : preferred === "anysearch"
829
- ? ["anysearch", "bing", "searxng", "ddg", "ddg-lite"]
830
- : ["bing", "anysearch", "searxng", "ddg", "ddg-lite"];
831
- let lastError = null;
832
- for (const engine of chain) {
833
- try {
834
- let result;
835
- switch (engine) {
836
- case "ddg":
837
- result = await searchDdgHtml(request.query, request.maxResults, cfg, signal);
838
- break;
839
- case "ddg-lite":
840
- result = await searchDdgLite(request.query, request.maxResults, signal);
841
- break;
842
- case "bing":
843
- result = await searchBing(request.query, request.maxResults, cfg, signal);
844
- break;
845
- case "searxng":
846
- result = await searchSearxng(request.query, request.maxResults, cfg, signal);
847
- break;
848
- case "anysearch":
849
- result = await searchAnysearch(request.query, request.maxResults, signal);
850
- break;
851
- default:
852
- result = await searchDdgHtml(request.query, request.maxResults, cfg, signal);
853
- }
854
869
  if (result.sources.length > 0) {
855
- // 付费引擎失败回退时,在结果里附上提示(agent 会看到)
856
- if (fallbackReason) {
857
- result.content = `Note: ${preferred} search unavailable (${fallbackReason}). Results from ${engine}.`;
870
+ usedEngine = engine;
871
+ // 用了非首选引擎时,在结果里附上提示(agent 会看到)
872
+ if (engine !== preferred) {
873
+ result.content = `Note: ${preferred} unavailable or failed, using ${engine}.`;
858
874
  }
859
875
  return result;
860
876
  }
@@ -874,12 +890,17 @@ function apply(ctx, config) {
874
890
  setSource: (source) => {
875
891
  current = source;
876
892
  },
877
- onChange: () => {},
893
+ onChange: () => {
894
+ // settings 变更时刷新系统提示词(显示最新引擎)
895
+ if (typeof refreshPrompt === "function") refreshPrompt();
896
+ },
878
897
  });
879
898
 
880
899
  ctx.inject(["webServer", "settings"], (sctx) => {
881
900
  sctx.effect(() => {
882
- 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
+ );
883
904
  return () => {
884
905
  for (const dispose of disposers) dispose();
885
906
  };
@@ -1100,10 +1121,15 @@ function apply(ctx, config) {
1100
1121
  }, "free-search: platform search tool");
1101
1122
  });
1102
1123
 
1103
- // 让 agent 知道可用搜索引擎(动态生成,随 key 配置变化)
1124
+ // 让 agent 知道可用搜索引擎(动态生成,随 key/设置变化)
1104
1125
  ctx.inject(["systemPrompt"], (sctx) => {
1105
- sctx.effect(() => {
1106
- const section = {
1126
+ let disposeSection = null;
1127
+ refreshPrompt = () => {
1128
+ if (disposeSection) {
1129
+ disposeSection();
1130
+ disposeSection = null;
1131
+ }
1132
+ disposeSection = sctx.systemPrompt.section({
1107
1133
  name: "free-search:engines",
1108
1134
  order: 500,
1109
1135
  text: [
@@ -1122,16 +1148,19 @@ function apply(ctx, config) {
1122
1148
  "- perplexity - requires PERPLEXITY_API_KEY",
1123
1149
  "- deepseek-official - requires DEEPSEEK_API_KEY",
1124
1150
  "",
1125
- "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.",
1126
1152
  "",
1127
1153
  "Use the free_search_test tool to test which engines actually work right now.",
1128
1154
  "",
1129
1155
  "For platform-specific searches (GitHub repos, V2EX threads, Bilibili videos), use the platform_search tool with platform: github|v2ex|bilibili.",
1130
1156
  ].join("\n"),
1131
- };
1132
- const dispose = sctx.systemPrompt.section(section);
1157
+ });
1158
+ };
1159
+ sctx.effect(() => {
1160
+ refreshPrompt();
1133
1161
  return () => {
1134
- dispose();
1162
+ if (disposeSection) disposeSection();
1163
+ disposeSection = null;
1135
1164
  };
1136
1165
  }, "free-search: engine list prompt section");
1137
1166
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-free-search",
3
- "version": "0.4.5",
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": {