dsh-free-search 0.4.9 → 0.4.11

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
@@ -36,7 +36,7 @@ dsh 默认的搜索 provider 依赖 DeepSeek 官方 API key(`DEEPSEEK_API_KEY`
36
36
  - **统一引擎回退** —— 任何引擎失败(付费/免费,缺 key/401/限流/网络)自动轮流尝试下一个引擎:首选引擎 → 其他引擎(exa/tavily/keenable 无 key 也会尝试,因为它们自带 keyless 免费额度)→ 剩余免费引擎,搜索永不直接失败;结果顶部注明实际生效的引擎(如 `Note: perplexity unavailable or failed, using exa.`)
37
37
  - **时间过滤** —— `advanced_search` 工具支持 `timeRange`:固定档、自定义相对值、绝对日期三种形式(详见下方逻辑说明)
38
38
  - **系统提示词注入** —— agent 知道当前用哪个引擎、哪些需要 key
39
- - **版本号 + 检查更新** —— 设置卡片显示当前版本(v0.4.9),"检查更新"按钮直连 npm registry 对比最新版,有新版本时提示并可一键跳转
39
+ - **版本号 + 检查更新** —— 设置卡片显示当前版本(v0.4.11),"检查更新"按钮直连 npm registry 对比最新版,有新版本时提示并可一键跳转
40
40
  - **结果缓存** —— 相同查询(含引擎/时间过滤参数)5 分钟内命中缓存(LRU 50 条),防免费引擎限流、省付费额度;时长可在设置页 0-5 分钟自由配置(0 关闭)
41
41
  - **免费标注** —— 设置页中免费引擎带绿色 `FREE` 徽章,付费引擎带橙色 `API KEY` 徽章
42
42
  - **网页抓取(web_fetch)** —— 让 agent 抓取网页内容(官方 `dsh-web-fetch-http` provider,纯 JS,零额外依赖)
@@ -271,7 +271,7 @@ This plugin provides multiple free search engines with automatic fallback, compl
271
271
  - **Unified Engine Fallback** — Any engine failure (paid or free, missing key, 401, rate limit, network error) automatically tries the next engine: the configured engine first, then other engines (exa/tavily/keenable are tried even without a key because they have built-in keyless quota), then the remaining free engines (Bing/AnySearch etc.) — with a note attached to the results naming the engine that actually served them (e.g. `Note: perplexity unavailable or failed, using exa.`). Search never fails outright.
272
272
  - **Time Filtering** — The `advanced_search` tool supports `timeRange`: fixed tiers, custom relative values, or an absolute date (details below)
273
273
  - **System Prompt Injection** — The agent is aware of the currently active engine and which engines require API keys
274
- - **Version + Update Check** — The settings card shows the current version (v0.4.9), and a "Check update" button queries the npm registry to compare against the latest release, prompting a one-click jump when a newer version exists
274
+ - **Version + Update Check** — The settings card shows the current version (v0.4.11), and a "Check update" button queries the npm registry to compare against the latest release, prompting a one-click jump when a newer version exists
275
275
  - **Result Caching** — Identical queries (same engine / time-filter args) hit an LRU cache (50 entries) for up to 5 minutes, protecting free engines from rate-limiting and saving paid quota; the TTL is configurable from 0-5 minutes in the settings UI (0 disables caching)
276
276
  - **Visual Badges** — Free engines feature a green `FREE` badge, while paid engines show an orange `API KEY` badge in the settings UI
277
277
  - **Webpage Fetching (`web_fetch`)** — Allows the agent to read full webpage contents (official `dsh-web-fetch-http` provider, pure JS, zero extra dependencies)
package/cordis.patch.yml CHANGED
@@ -15,3 +15,7 @@
15
15
  config:
16
16
  provider: bing
17
17
  bingMarket: zh-CN
18
+ # 自动接管默认搜索:让 harness 的 web_search 直接用本插件的 provider(内部含全部引擎)
19
+ - id: web
20
+ config:
21
+ searchProvider: ddg
package/lib/client.js CHANGED
@@ -45,6 +45,9 @@ window.__ModuleLoader__.load({
45
45
  ".dshfs-save{border:1px solid var(--dsw-alias-button-info-fill);background:var(--dsw-alias-button-info-fill);color:var(--dsw-alias-label-primary-foreground)}",
46
46
  ".dshfs-save:hover:not(:disabled){border-color:var(--dsw-alias-button-info-hover);background:var(--dsw-alias-button-info-hover)}",
47
47
  ".dshfs-save:disabled{opacity:.5;cursor:default}",
48
+ ".dshfs-upgrade{border:1px solid rgba(80,200,120,.4);background:rgba(80,200,120,.15);color:#7ddb9c}",
49
+ ".dshfs-upgrade:hover:not(:disabled){background:rgba(80,200,120,.28)}",
50
+ ".dshfs-upgrade:disabled{opacity:.5;cursor:default}",
48
51
  ".dshfs-badge{background:var(--dsw-alias-interactive-bg-hover-accent);color:var(--dsw-alias-state-business-primary);white-space:nowrap;border-radius:999px;flex:none;padding:1px 6px;font-size:11px}",
49
52
  ".dshfs-badgeFree{background:rgba(80,200,120,.15);color:#7ddb9c;border:1px solid rgba(80,200,120,.3);white-space:nowrap;border-radius:999px;flex:none;padding:1px 6px;font-size:11px}",
50
53
  ".dshfs-badgeKey{background:rgba(240,170,80,.15);color:#f0b060;border:1px solid rgba(240,170,80,.3);white-space:nowrap;border-radius:999px;flex:none;padding:1px 6px;font-size:11px}",
@@ -102,6 +105,11 @@ window.__ModuleLoader__.load({
102
105
  updateLatest: (c) => `已是最新版本 v${c}`,
103
106
  updateCheckFailed: "检查更新失败(无法访问 npm registry)",
104
107
  updateView: "查看 →",
108
+ upgrade: "升级",
109
+ upgrading: "升级中…",
110
+ upgradeLinkMode: "(本地开发模式,升级请用 git pull)",
111
+ upgradeDone: (l) => `升级到 v${l} 完成,重启 dsh 后生效`,
112
+ upgradeFailed: (m) => `升级失败:${m}`,
105
113
  },
106
114
  en: {
107
115
  description: "Free web search — no API key needed (Bing / DuckDuckGo / AnySearch / Exa / Tavily / Keenable)",
@@ -135,14 +143,19 @@ window.__ModuleLoader__.load({
135
143
  checkUpdate: "Check update",
136
144
  checkingUpdate: "Checking…",
137
145
  updateAvailable: (c, l) => `New version v${l} available (current v${c})`,
138
- updateLatest: (c) => `You are up to date (v${c})`,
146
+ updateLatest: (c) => `You're on the latest version v${c}`,
139
147
  updateCheckFailed: "Update check failed (cannot reach npm registry)",
140
148
  updateView: "View →",
149
+ upgrade: "Upgrade",
150
+ upgrading: "Upgrading…",
151
+ upgradeLinkMode: "(local dev install - use git pull to update)",
152
+ upgradeDone: (l) => `Upgraded to v${l} - restart dsh to apply`,
153
+ upgradeFailed: (m) => `Upgrade failed: ${m}`,
141
154
  },
142
155
  };
143
156
  const tt = (lang) => I18N[lang === "en" ? "en" : "zh"];
144
157
  // 当前插件版本(与 lib/index.js 的 PLUGIN_VERSION 保持一致)
145
- const PLUGIN_VERSION = "0.4.9";
158
+ const PLUGIN_VERSION = "0.4.11";
146
159
  const ENGINES = [
147
160
  { id: "ddg", label: "DuckDuckGo · HTML", badge: "FREE", link: "https://duckduckgo.com" },
148
161
  { id: "ddg-lite", label: "DuckDuckGo · Lite", badge: "FREE", link: "https://duckduckgo.com" },
@@ -201,7 +214,7 @@ window.__ModuleLoader__.load({
201
214
  const [keenableKey, setKeenableKey] = react.useState("");
202
215
  const [perplexityKey, setPerplexityKey] = react.useState("");
203
216
  const [deepseekKey, setDeepseekKey] = react.useState("");
204
- const [platforms, setPlatforms] = react.useState(["github", "v2ex", "bilibili"]);
217
+ const [platforms, setPlatforms] = react.useState(["github", "v2ex", "bilibili", "reddit"]);
205
218
  const [cacheTtl, setCacheTtl] = react.useState(5);
206
219
  const [keysConfigured, setKeysConfigured] = react.useState({});
207
220
  const [lang, setLang] = react.useState("zh");
@@ -211,6 +224,7 @@ window.__ModuleLoader__.load({
211
224
  const [testing, setTesting] = react.useState(false);
212
225
  const [testResult, setTestResult] = react.useState(null);
213
226
  const [checkingUpdate, setCheckingUpdate] = react.useState(false);
227
+ const [upgrading, setUpgrading] = react.useState(false);
214
228
  const [updateInfo, setUpdateInfo] = react.useState(null);
215
229
 
216
230
  const load = react.useCallback(async () => {
@@ -227,7 +241,7 @@ window.__ModuleLoader__.load({
227
241
  setKeenableKey(v.keenableApiKey ?? "");
228
242
  setPerplexityKey(v.perplexityApiKey ?? "");
229
243
  setDeepseekKey(v.deepseekApiKey ?? "");
230
- setPlatforms(Array.isArray(v.platforms) && v.platforms.length > 0 ? v.platforms : ["github", "v2ex", "bilibili"]);
244
+ setPlatforms(Array.isArray(v.platforms) && v.platforms.length > 0 ? v.platforms : ["github", "v2ex", "bilibili", "reddit"]);
231
245
  setCacheTtl(v.cacheTtl === undefined ? 5 : Math.min(Math.max(Number(v.cacheTtl) ?? 5, 0), 5));
232
246
  // secrets 字段标记哪些 key 已配置(值被脱敏,仅显示"已配置")
233
247
  const configured = {};
@@ -346,6 +360,29 @@ window.__ModuleLoader__.load({
346
360
  }
347
361
  };
348
362
 
363
+ const runUpdate = async () => {
364
+ setUpgrading(true);
365
+ setUpdateInfo(null);
366
+ setFailed(false);
367
+ try {
368
+ const response = await fetch(`${BRIDGE_PREFIX}/update`, {
369
+ method: "POST",
370
+ headers: { "content-type": "application/json" },
371
+ body: "{}",
372
+ });
373
+ const result = await response.json();
374
+ if (result.ok) {
375
+ setUpdateInfo({ ok: true, hasUpdate: false, upgraded: true, message: result.value.message, latest: result.value.latest });
376
+ } else {
377
+ setUpdateInfo({ ok: false, upgradeFailed: result.message ?? "upgrade failed" });
378
+ }
379
+ } catch {
380
+ setUpdateInfo({ ok: false, upgradeFailed: "request failed" });
381
+ } finally {
382
+ setUpgrading(false);
383
+ }
384
+ };
385
+
349
386
  if (state.status === "loading") return null;
350
387
  const ready = state.status === "ready";
351
388
  const t = tt(lang);
@@ -515,7 +552,7 @@ react_jsx_runtime.jsx("div", {
515
552
  react_jsx_runtime.jsx("div", {
516
553
  className: "dshfs-platforms",
517
554
  children: [
518
- ["github", "GitHub"], ["v2ex", "V2EX"], ["bilibili", "Bilibili"],
555
+ ["github", "GitHub"], ["v2ex", "V2EX"], ["bilibili", "Bilibili"], ["reddit", "Reddit"],
519
556
  ].map(([id, label]) =>
520
557
  react_jsx_runtime.jsx("label", {
521
558
  className: "dshfs-platform",
@@ -605,29 +642,43 @@ react_jsx_runtime.jsx("div", {
605
642
  children: checkingUpdate ? t.checkingUpdate : t.checkUpdate,
606
643
  }),
607
644
  updateInfo && updateInfo.ok
608
- ? updateInfo.hasUpdate
645
+ ? updateInfo.upgraded
609
646
  ? react_jsx_runtime.jsx("span", {
610
- className: "dshfs-update",
611
- children: [
612
- t.updateAvailable(updateInfo.current, updateInfo.latest),
613
- " ",
614
- react_jsx_runtime.jsx("a", {
615
- className: "dshfs-link",
616
- href: updateInfo.updateUrl,
617
- target: "_blank",
618
- rel: "noopener noreferrer",
619
- children: t.updateView,
620
- }),
621
- ],
622
- })
623
- : react_jsx_runtime.jsx("span", {
624
647
  className: "dshfs-updateOk",
625
- children: t.updateLatest(updateInfo.current),
648
+ children: t.upgradeDone(updateInfo.latest),
626
649
  })
650
+ : updateInfo.hasUpdate
651
+ ? react_jsx_runtime.jsx("span", {
652
+ className: "dshfs-update",
653
+ children: [
654
+ t.updateAvailable(updateInfo.current, updateInfo.latest),
655
+ " ",
656
+ updateInfo.installable
657
+ ? react_jsx_runtime.jsx("button", {
658
+ className: "dshfs-btn dshfs-upgrade",
659
+ type: "button",
660
+ onClick: runUpdate,
661
+ disabled: upgrading || saving || !ready,
662
+ children: upgrading ? t.upgrading : t.upgrade,
663
+ })
664
+ : react_jsx_runtime.jsx("a", {
665
+ className: "dshfs-link",
666
+ href: updateInfo.updateUrl,
667
+ target: "_blank",
668
+ rel: "noopener noreferrer",
669
+ children: t.updateView,
670
+ }),
671
+ updateInfo.installable ? null : " " + t.upgradeLinkMode,
672
+ ],
673
+ })
674
+ : react_jsx_runtime.jsx("span", {
675
+ className: "dshfs-updateOk",
676
+ children: t.updateLatest(updateInfo.current),
677
+ })
627
678
  : updateInfo && !updateInfo.ok
628
679
  ? react_jsx_runtime.jsx("span", {
629
680
  className: "dshfs-failed",
630
- children: t.updateCheckFailed,
681
+ children: updateInfo.upgradeFailed ? t.upgradeFailed(updateInfo.upgradeFailed) : t.updateCheckFailed,
631
682
  })
632
683
  : null,
633
684
  ],
package/lib/index.js CHANGED
@@ -1,6 +1,9 @@
1
1
  import { SettingsConflictError, installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
2
2
  import { defineTool } from "@deepseek-ai/dsh-tools";
3
3
  import z from "@deepseek-ai/schemastery";
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import { exec } from "node:child_process";
4
7
 
5
8
  const DDG_HTML_URL = "https://html.duckduckgo.com/html/";
6
9
  const DDG_LITE_URL = "https://lite.duckduckgo.com/lite/";
@@ -18,7 +21,7 @@ const FREE_ENGINES = ["ddg", "ddg-lite", "bing", "searxng", "anysearch"];
18
21
  const ALL_ENGINES = ["ddg", "ddg-lite", "bing", "searxng", "anysearch", "exa", "tavily", "keenable", "perplexity", "deepseek-official"];
19
22
 
20
23
  // 当前插件版本(发布时与 package.json 同步)
21
- const PLUGIN_VERSION = "0.4.9";
24
+ const PLUGIN_VERSION = "0.4.11";
22
25
  // 检查更新的 npm registry 元数据地址(dsh-free-search 是 npmjs 上的公开包)
23
26
  const NPM_REGISTRY_URL = "https://registry.npmjs.org/dsh-free-search/latest";
24
27
  const PLUGIN_NPM_URL = "https://www.npmjs.com/package/dsh-free-search";
@@ -57,6 +60,26 @@ function compareVersions(a, b) {
57
60
  return 0;
58
61
  }
59
62
 
63
+ // 检测插件的安装模式:遍历 profiles/*/node_modules/dsh-free-search,
64
+ // symlink(link: 本地开发)→ isLink=true;npm 真安装 → isLink=false;找不到 → null
65
+ function detectInstallMode() {
66
+ const profilesDir = path.join(process.cwd(), "profiles");
67
+ let found = null;
68
+ try {
69
+ for (const name of fs.readdirSync(profilesDir)) {
70
+ const pkgPath = path.join(profilesDir, name, "node_modules", "dsh-free-search");
71
+ if (!fs.existsSync(pkgPath)) continue;
72
+ let isLink = false;
73
+ try {
74
+ isLink = fs.lstatSync(pkgPath).isSymbolicLink();
75
+ } catch {}
76
+ found = { profileDir: path.join(profilesDir, name), isLink };
77
+ break;
78
+ }
79
+ } catch {}
80
+ return found;
81
+ }
82
+
60
83
  // time_range 支持:固定档 day/week/month/year,或自定义(相对 12h/3d/2mo/1y、绝对 YYYY-MM-DD)
61
84
  const TIME_RANGES = ["day", "week", "month", "year"];
62
85
  const DAYS_BY_RANGE = { day: 1, week: 7, month: 30, year: 365 };
@@ -115,6 +138,8 @@ function decodeEntities(text) {
115
138
 
116
139
  //#region 结果缓存(防限流/省额度,LRU 50 条,TTL 可配置 0-5 分钟)
117
140
  const CACHE_MAX_ENTRIES = 50;
141
+ // fallback 条目(实际引擎 ≠ 首选引擎时)TTL = 配置 TTL 的 1/5(默认 5 分钟 → 60s):
142
+ // 首选引擎恢复后最多 1 分钟即可拿到新结果,避免回退结果被完整 TTL 钉死;首选成功条目仍用完整 TTL。
118
143
 
119
144
  function buildCacheKey(query, maxResults, timeRangeLabel, preferred) {
120
145
  return [query ?? "", maxResults ?? 5, timeRangeLabel ?? "", preferred].join("\u0000");
@@ -302,7 +327,8 @@ async function searchSearxng(query, maxResults, options, signal) {
302
327
  const instances = options?.searxngInstances?.length
303
328
  ? options.searxngInstances
304
329
  : SEARXNG_INSTANCES;
305
- let lastError = null;
330
+ // 聚合所有实例的失败原因,避免只显示最后一个实例的错误
331
+ const errors = [];
306
332
  for (const base of instances) {
307
333
  try {
308
334
  const params = new URLSearchParams({ q: query, format: "json" });
@@ -322,12 +348,12 @@ async function searchSearxng(query, maxResults, options, signal) {
322
348
  clearTimeout(timer);
323
349
  signal?.removeEventListener("abort", onAbort);
324
350
  if (!response.ok) {
325
- lastError = new Error(`${base} HTTP ${response.status}`);
351
+ errors.push(`${base}: HTTP ${response.status}`);
326
352
  continue;
327
353
  }
328
354
  const data = await response.json().catch(() => null);
329
355
  if (!data || !Array.isArray(data.results)) {
330
- lastError = new Error(`${base} invalid JSON`);
356
+ errors.push(`${base}: invalid JSON`);
331
357
  continue;
332
358
  }
333
359
  const sources = data.results
@@ -340,12 +366,16 @@ async function searchSearxng(query, maxResults, options, signal) {
340
366
  if (sources.length > 0) {
341
367
  return { sources: uniqueSources(sources, maxResults ?? 10), truncated: false };
342
368
  }
343
- lastError = new Error(`${base} 0 results`);
369
+ errors.push(`${base}: 0 results`);
344
370
  } catch (error) {
345
- lastError = error;
371
+ const message = error instanceof Error ? error.message : String(error);
372
+ errors.push(`${base}: ${message}`);
346
373
  }
347
374
  }
348
- throw lastError ?? new Error("all SearXNG instances failed");
375
+ // 空实例列表兜底:避免 "all SearXNG instances failed: " 尾巴悬空
376
+ const detail = errors.length > 0 ? errors.join(", ") : "no instances configured";
377
+ // Note 会引用这个错误消息,截断避免 6 实例全挂时刷屏
378
+ throw new Error(`all SearXNG instances failed: ${detail.slice(0, 300)}`);
349
379
  }
350
380
  //#endregion
351
381
 
@@ -450,7 +480,8 @@ async function searchExaMCP(query, maxResults, signal) {
450
480
  url,
451
481
  ...(title ? { title } : {}),
452
482
  ...(highlights ? { snippet: highlights.slice(0, 300) } : {}),
453
- ...(published ? { publishedAt: published } : {}),
483
+ // 只保留日期形态(ISO 或 YYYY-MM-DD),过滤 "N/A" 等占位符
484
+ ...(published && /^\d{4}-\d{2}-\d{2}/.test(published) ? { publishedAt: published } : {}),
454
485
  });
455
486
  }
456
487
  return { sources, truncated: false };
@@ -462,6 +493,7 @@ const PLATFORMS = {
462
493
  github: { name: "GitHub" },
463
494
  v2ex: { name: "V2EX" },
464
495
  bilibili: { name: "Bilibili" },
496
+ reddit: { name: "Reddit" },
465
497
  };
466
498
 
467
499
  async function searchGithub(query, maxResults, signal) {
@@ -700,7 +732,8 @@ function extractKeenableSources(text, maxResults) {
700
732
  url,
701
733
  ...(title ? { title } : {}),
702
734
  ...(snippets ? { snippet: snippets.slice(0, 300) } : {}),
703
- ...(published ? { publishedAt: published } : {}),
735
+ // 与 Exa MCP 一致:只保留日期形态,过滤 "N/A" 等占位符
736
+ ...(published && /^\d{4}-\d{2}-\d{2}/.test(published) ? { publishedAt: published } : {}),
704
737
  });
705
738
  }
706
739
  return uniqueSources(sources, maxResults ?? 10);
@@ -799,6 +832,7 @@ async function searchKeenable(query, maxResults, apiKey, timeRange, signal) {
799
832
 
800
833
  async function searchPerplexity(query, maxResults, apiKey, signal) {
801
834
  if (!apiKey) throw new Error("Perplexity search requires PERPLEXITY_API_KEY");
835
+ // 内置 20s 超时(与外部 signal 组合):调用方不传 signal 时也不会永久卡住
802
836
  const response = await fetch("https://api.perplexity.ai/chat/completions", {
803
837
  method: "POST",
804
838
  redirect: "error",
@@ -812,7 +846,7 @@ async function searchPerplexity(query, maxResults, apiKey, signal) {
812
846
  max_tokens: 1024,
813
847
  messages: [{ role: "user", content: query }],
814
848
  }),
815
- ...(signal !== undefined ? { signal } : {}),
849
+ signal: AbortSignal.any([...(signal !== undefined ? [signal] : []), AbortSignal.timeout(20000)]),
816
850
  });
817
851
  if (!response.ok) {
818
852
  const detail = await response.text().catch(() => "");
@@ -834,6 +868,7 @@ async function searchPerplexity(query, maxResults, apiKey, signal) {
834
868
 
835
869
  async function searchDeepSeekOfficial(query, maxResults, apiKey, signal) {
836
870
  if (!apiKey) throw new Error("DeepSeek search requires DEEPSEEK_API_KEY");
871
+ // 内置 20s 超时(与外部 signal 组合):调用方不传 signal 时也不会永久卡住
837
872
  const response = await fetch("https://api.deepseek.com/anthropic/v1/messages", {
838
873
  method: "POST",
839
874
  redirect: "error",
@@ -856,7 +891,7 @@ async function searchDeepSeekOfficial(query, maxResults, apiKey, signal) {
856
891
  ],
857
892
  tools: [{ type: "web_search_20250305", name: "web_search", max_uses: 1 }],
858
893
  }),
859
- ...(signal !== undefined ? { signal } : {}),
894
+ signal: AbortSignal.any([...(signal !== undefined ? [signal] : []), AbortSignal.timeout(20000)]),
860
895
  });
861
896
  if (!response.ok) {
862
897
  const detail = await response.text().catch(() => "");
@@ -971,6 +1006,7 @@ function makeBridgeRoutes(settings, search, testEngine) {
971
1006
  };
972
1007
  }
973
1008
  const cmp = compareVersions(latest, PLUGIN_VERSION);
1009
+ const mode = detectInstallMode();
974
1010
  return {
975
1011
  ok: true,
976
1012
  value: {
@@ -979,9 +1015,46 @@ function makeBridgeRoutes(settings, search, testEngine) {
979
1015
  hasUpdate: cmp > 0,
980
1016
  updateUrl: PLUGIN_NPM_URL,
981
1017
  repoUrl: PLUGIN_REPO_URL,
1018
+ // 可一键升级:npm 真安装时 true;本地 link 开发模式 false(升级请 git pull 源码)
1019
+ installable: mode !== null && !mode.isLink,
1020
+ installMode: mode?.isLink ? "link" : mode !== null ? "registry" : "unknown",
982
1021
  },
983
1022
  };
984
1023
  },
1024
+ // 一键升级:仅在 npm 真安装模式下执行 pnpm 升级;link 模式拒绝(避免破坏本地开发链路)
1025
+ async updatePlugin() {
1026
+ const mode = detectInstallMode();
1027
+ if (mode === null) {
1028
+ return { ok: false, code: "install-not-found", message: "could not locate dsh-free-search in any profile" };
1029
+ }
1030
+ if (mode.isLink) {
1031
+ return {
1032
+ ok: false,
1033
+ code: "local-link-mode",
1034
+ message: "local development install (symlink) - update the source repo instead (git pull), then restart dsh",
1035
+ };
1036
+ }
1037
+ try {
1038
+ const result = await new Promise((resolve, reject) => {
1039
+ exec("pnpm add dsh-free-search@latest", { cwd: mode.profileDir, timeout: 120000 }, (error, stdout, stderr) => {
1040
+ if (error) reject(new Error(`upgrade failed: ${(stderr || stdout || error.message).trim().slice(0, 300)}`));
1041
+ else resolve(stdout);
1042
+ });
1043
+ });
1044
+ const latest = await fetchLatestVersion();
1045
+ return {
1046
+ ok: true,
1047
+ value: {
1048
+ updated: true,
1049
+ latest: latest ?? "unknown",
1050
+ message: `upgraded to latest - restart dsh to apply`,
1051
+ output: String(result).trim().slice(0, 200),
1052
+ },
1053
+ };
1054
+ } catch (error) {
1055
+ return { ok: false, code: "upgrade-failed", message: error instanceof Error ? error.message : String(error) };
1056
+ }
1057
+ },
985
1058
  async rawSearch(request) {
986
1059
  if (request === null || typeof request !== "object" || typeof request.query !== "string" || request.query.length === 0) {
987
1060
  return { ok: false, code: "search-rejected", message: "malformed bridge search request (query is required)" };
@@ -1112,6 +1185,14 @@ function makeBridgeRoutes(settings, search, testEngine) {
1112
1185
  writeJson(res, 200, await handlers.checkUpdate());
1113
1186
  },
1114
1187
  },
1188
+ {
1189
+ kind: "exact",
1190
+ path: `${BRIDGE_PREFIX}/update`,
1191
+ handler: async (req, res) => {
1192
+ if (!guard(req, res)) return;
1193
+ writeJson(res, 200, await handlers.updatePlugin());
1194
+ },
1195
+ },
1115
1196
  {
1116
1197
  kind: "exact",
1117
1198
  path: `${BRIDGE_PREFIX}/raw-search`,
@@ -1140,7 +1221,7 @@ const Config = z.object({
1140
1221
  region: z.string(),
1141
1222
  bingMarket: z.string().default("zh-CN"),
1142
1223
  searxngInstances: z.array(z.string()),
1143
- platforms: z.array(z.string()).default(["github", "v2ex", "bilibili"]),
1224
+ platforms: z.array(z.string()).default(["github", "v2ex", "bilibili", "reddit"]),
1144
1225
  exaApiKey: z.string().role("secret"),
1145
1226
  tavilyApiKey: z.string().role("secret"),
1146
1227
  keenableApiKey: z.string().role("secret"),
@@ -1182,6 +1263,10 @@ function apply(ctx, config) {
1182
1263
  },
1183
1264
  // 单 query 结果缓存:key=query+maxResults+timeRangeLabel+preferred,Map 天然 LRU
1184
1265
  async search(request, signal) {
1266
+ // 公共咽喉校验:web_search / advanced_search / raw-search 三条路径都经过这里
1267
+ if (request === null || typeof request !== "object" || typeof request.query !== "string" || request.query.trim().length === 0) {
1268
+ throw new Error("query is required");
1269
+ }
1185
1270
  const cfg = current();
1186
1271
  // 首选引擎:free_search 工具显式指定(request.engine)优先于设置(cfg.provider)
1187
1272
  const preferred =
@@ -1205,9 +1290,8 @@ function apply(ctx, config) {
1205
1290
  if (signal?.aborted) throw new Error("search aborted");
1206
1291
  searchCache.delete(cacheKey);
1207
1292
  searchCache.set(cacheKey, hit);
1208
- // 浅拷贝 + 私有标记:调用方改 sources 数组不影响缓存(数组元素仍共享引用,
1209
- // 但下游 advanced_search/raw-search 都会重新 map 构造,无污染风险)
1210
- return { ...hit.value, _cache: "hit" };
1293
+ // 浅拷贝 + 私有标记:sources 数组也复制一层,彻底隔离缓存对象(调用方 push/改元素不影响缓存)
1294
+ return { ...hit.value, sources: hit.value.sources?.slice(), _cache: "hit" };
1211
1295
  }
1212
1296
  if (hit) searchCache.delete(cacheKey);
1213
1297
  }
@@ -1243,35 +1327,44 @@ function apply(ctx, config) {
1243
1327
  let usedEngine = null;
1244
1328
  // 首选引擎若被尝试后失败,记录失败详情(用于 Note)
1245
1329
  let preferredFailure = null;
1330
+ // 总超时预算:串行回退时限制整条引擎链的总时长,防止各引擎超时累加达分钟级
1331
+ const BUDGET_MS = 30000;
1332
+ const deadline = Date.now() + BUDGET_MS;
1246
1333
  for (const engine of chain) {
1334
+ const remaining = deadline - Date.now();
1335
+ if (remaining <= 0) {
1336
+ throw new Error(`search timed out after ${BUDGET_MS / 1000}s`);
1337
+ }
1338
+ // 组合外部取消 signal + 剩余预算超时:官方 web_search 取消、引擎超时、总预算都能触发
1339
+ const effSignal = AbortSignal.any([...(signal !== undefined ? [signal] : []), AbortSignal.timeout(remaining)]);
1247
1340
  try {
1248
1341
  let result;
1249
1342
  if (engine === "ddg") {
1250
- result = await searchDdgHtml(request.query, request.maxResults, { ...cfg, timeRange }, signal);
1343
+ result = await searchDdgHtml(request.query, request.maxResults, { ...cfg, timeRange }, effSignal);
1251
1344
  } else if (engine === "ddg-lite") {
1252
- result = await searchDdgLite(request.query, request.maxResults, { ...cfg, timeRange }, signal);
1345
+ result = await searchDdgLite(request.query, request.maxResults, { ...cfg, timeRange }, effSignal);
1253
1346
  } else if (engine === "bing") {
1254
- result = await searchBing(request.query, request.maxResults, cfg, signal);
1347
+ result = await searchBing(request.query, request.maxResults, cfg, effSignal);
1255
1348
  } else if (engine === "searxng") {
1256
- result = await searchSearxng(request.query, request.maxResults, { ...cfg, timeRange }, signal);
1349
+ result = await searchSearxng(request.query, request.maxResults, { ...cfg, timeRange }, effSignal);
1257
1350
  } else if (engine === "anysearch") {
1258
- result = await searchAnysearch(request.query, request.maxResults, signal);
1351
+ result = await searchAnysearch(request.query, request.maxResults, effSignal);
1259
1352
  } else if (engine === "exa") {
1260
1353
  // exa:有 key 走 REST,无 key 走 keyless MCP(免费)
1261
1354
  const key = await resolveApiKey("EXA_API_KEY", "exaApiKey");
1262
1355
  if (key) {
1263
- result = await searchExa(request.query, request.maxResults, key, timeRange, signal);
1356
+ result = await searchExa(request.query, request.maxResults, key, timeRange, effSignal);
1264
1357
  } else {
1265
- result = await searchExaMCP(request.query, request.maxResults, signal);
1358
+ result = await searchExaMCP(request.query, request.maxResults, effSignal);
1266
1359
  }
1267
1360
  } else if (engine === "tavily") {
1268
1361
  // tavily:有 key 走账号档,无 key 走 keyless(免费匿名额度)
1269
1362
  const key = await resolveApiKey("TAVILY_API_KEY", "tavilyApiKey");
1270
- result = await searchTavily(request.query, request.maxResults, key, timeRange, signal);
1363
+ result = await searchTavily(request.query, request.maxResults, key, timeRange, effSignal);
1271
1364
  } else if (engine === "keenable") {
1272
1365
  // keenable:有 key 走 REST,无 key 走 keyless MCP(免费)
1273
1366
  const key = await resolveApiKey("KEENABLE_API_KEY", "keenableApiKey");
1274
- result = await searchKeenable(request.query, request.maxResults, key, timeRange, signal);
1367
+ result = await searchKeenable(request.query, request.maxResults, key, timeRange, effSignal);
1275
1368
  } else if (engine === "perplexity") {
1276
1369
  const key = await resolveApiKey("PERPLEXITY_API_KEY", "perplexityApiKey");
1277
1370
  if (!key) {
@@ -1280,7 +1373,7 @@ function apply(ctx, config) {
1280
1373
  logger.warn(`free-search: engine "${engine}" skipped (no key), trying next engine`);
1281
1374
  continue; // 无 key 跳过
1282
1375
  }
1283
- result = await searchPerplexity(request.query, request.maxResults, key, signal);
1376
+ result = await searchPerplexity(request.query, request.maxResults, key, effSignal);
1284
1377
  } else if (engine === "deepseek-official") {
1285
1378
  const key = await resolveApiKey("DEEPSEEK_API_KEY", "deepseekApiKey");
1286
1379
  if (!key) {
@@ -1289,7 +1382,7 @@ function apply(ctx, config) {
1289
1382
  logger.warn(`free-search: engine "${engine}" skipped (no key), trying next engine`);
1290
1383
  continue; // 无 key 跳过
1291
1384
  }
1292
- result = await searchDeepSeekOfficial(request.query, request.maxResults, key, signal);
1385
+ result = await searchDeepSeekOfficial(request.query, request.maxResults, key, effSignal);
1293
1386
  } else {
1294
1387
  continue;
1295
1388
  }
@@ -1313,9 +1406,11 @@ function apply(ctx, config) {
1313
1406
  // 写入缓存(只缓存成功结果,失败走 throw 天然不缓存)
1314
1407
  const cached = { ...result, provider: engine, engine: engine };
1315
1408
  if (cacheKey !== null) {
1409
+ // fallback 条目(实际引擎≠首选)用配置 TTL 的 1/5,首选成功保持完整 TTL
1410
+ const entryTtlMs = engine !== preferred ? Math.max(cacheTtlMs / 5, 1000) : cacheTtlMs;
1316
1411
  searchCache.set(cacheKey, {
1317
1412
  value: cached,
1318
- expiresAt: Date.now() + cacheTtlMs,
1413
+ expiresAt: Date.now() + entryTtlMs,
1319
1414
  });
1320
1415
  if (searchCache.size > CACHE_MAX_ENTRIES) {
1321
1416
  const oldest = searchCache.keys().next().value;
@@ -1514,18 +1609,18 @@ function apply(ctx, config) {
1514
1609
  }, "free-search: test engines tool");
1515
1610
  });
1516
1611
 
1517
- // 平台搜索工具:GitHub / V2EX / Bilibili(公开 API,零依赖)
1612
+ // 平台搜索工具:GitHub / V2EX / Bilibili / Reddit(公开 API,零依赖)
1518
1613
  ctx.inject(["tools"], (sctx) => {
1519
1614
  sctx.effect(() => {
1520
1615
  const dispose = sctx.tools.register(
1521
1616
  defineTool({
1522
1617
  name: "platform_search",
1523
1618
  description:
1524
- "Search a specific platform (GitHub / V2EX / Bilibili) for a query. Returns source URLs with titles and snippets. Use this when the user asks about repos, code, forum threads, or videos.",
1619
+ "Search a specific platform (GitHub / V2EX / Bilibili / Reddit) for a query. Returns source URLs with titles and snippets. Use this when the user asks about repos, code, forum threads, videos, or discussions.",
1525
1620
  parameters: {
1526
1621
  platform: {
1527
1622
  type: "string",
1528
- description: "Platform to search: github, v2ex, bilibili",
1623
+ description: "Platform to search: github, v2ex, bilibili, reddit",
1529
1624
  },
1530
1625
  query: {
1531
1626
  type: "string",
@@ -1567,7 +1662,7 @@ function apply(ctx, config) {
1567
1662
  throw new Error(`unknown platform "${platform}" - use one of: ${Object.keys(PLATFORMS).join(", ")}`);
1568
1663
  }
1569
1664
  // 平台开关:settings 里禁用某平台时,工具明确告知
1570
- const enabled = current().platforms ?? ["github", "v2ex", "bilibili"];
1665
+ const enabled = current().platforms ?? ["github", "v2ex", "bilibili", "reddit"];
1571
1666
  if (!enabled.includes(platform)) {
1572
1667
  throw new Error(
1573
1668
  `platform "${platform}" is disabled in Free Search settings - enable it in Settings > Plugins > Free Search to use it`
@@ -1653,6 +1748,7 @@ function apply(ctx, config) {
1653
1748
  },
1654
1749
  },
1655
1750
  async execute(args) {
1751
+ if (!args.query || !String(args.query).trim()) throw new Error("query is required");
1656
1752
  const request = {
1657
1753
  query: args.query,
1658
1754
  maxResults: Math.min(args.maxResults ?? 5, 10),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-free-search",
3
- "version": "0.4.9",
3
+ "version": "0.4.11",
4
4
  "description": "Free web search for DeepSeek Harness: 10 engines (Bing/DuckDuckGo/AnySearch/SearXNG/Exa/Tavily/Keenable keyless) + time filtering + platform search + web_fetch, with web settings UI.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",