dsh-free-search 0.4.7 → 0.4.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 +5 -3
- package/lib/client.js +117 -21
- package/lib/index.js +117 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -33,9 +33,10 @@ dsh 默认的搜索 provider 依赖 DeepSeek 官方 API key(`DEEPSEEK_API_KEY`
|
|
|
33
33
|
- **网页设置页** —— 引擎切换 + API key 配置(UI 中 key 脱敏显示"已配置")+ 中英文切换
|
|
34
34
|
- **弹出式切换命令** —— 聊天框输入 `/free-search-engine`,弹出引擎选择窗口,点选即切换(等效设置页 + 保存)
|
|
35
35
|
- **引擎测试** —— `free_search_test` 工具让 agent 一键测试所有引擎;设置页也有"测试引擎"按钮(直测当前引擎,不走回退链,付费引擎无 key 会明确报错)
|
|
36
|
-
- **统一引擎回退** —— 任何引擎失败(付费/免费,缺 key/401
|
|
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.8),"检查更新"按钮直连 npm registry 对比最新版,有新版本时提示并可一键跳转
|
|
39
40
|
- **免费标注** —— 设置页中免费引擎带绿色 `FREE` 徽章,付费引擎带橙色 `API KEY` 徽章
|
|
40
41
|
- **网页抓取(web_fetch)** —— 让 agent 抓取网页内容(官方 `dsh-web-fetch-http` provider,纯 JS,零额外依赖)
|
|
41
42
|
- **平台搜索(platform_search)** —— 搜 GitHub / V2EX / B站(公开 API,零依赖)
|
|
@@ -266,9 +267,10 @@ This plugin provides multiple free search engines with automatic fallback, compl
|
|
|
266
267
|
- **Web Settings UI** — Engine switching, API key configuration (keys masked as "configured" in the UI), and a Chinese/English toggle
|
|
267
268
|
- **Popup Switch Command** — Type `/free-search-engine` in the chat: a picker opens with all engines; click one to switch (equivalent to the settings page + save)
|
|
268
269
|
- **Engine Testing** — `free_search_test` for the agent to check all engines in one call; the settings UI also has a "Test engine" button that tests the selected engine directly (no fallback chain; paid engines without a key report an explicit error)
|
|
269
|
-
- **Unified Engine Fallback** — Any engine failure (paid or free, missing key, 401, rate limit, network error) automatically tries the next engine: other
|
|
270
|
+
- **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.
|
|
270
271
|
- **Time Filtering** — The `advanced_search` tool supports `timeRange`: fixed tiers, custom relative values, or an absolute date (details below)
|
|
271
272
|
- **System Prompt Injection** — The agent is aware of the currently active engine and which engines require API keys
|
|
273
|
+
- **Version + Update Check** — The settings card shows the current version (v0.4.8), 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
|
|
272
274
|
- **Visual Badges** — Free engines feature a green `FREE` badge, while paid engines show an orange `API KEY` badge in the settings UI
|
|
273
275
|
- **Webpage Fetching (`web_fetch`)** — Allows the agent to read full webpage contents (official `dsh-web-fetch-http` provider, pure JS, zero extra dependencies)
|
|
274
276
|
- **Platform Search (`platform_search`)** — Search GitHub / V2EX / Bilibili (public APIs, zero extra dependencies)
|
|
@@ -290,7 +292,7 @@ This plugin provides multiple free search engines with automatic fallback, compl
|
|
|
290
292
|
| `deepseek-official` | DeepSeek Official | Paid | Requires `DEEPSEEK_API_KEY` |
|
|
291
293
|
|
|
292
294
|
- **Default engine is `bing`** (free and most stable), ready to use out of the box after installation.
|
|
293
|
-
- **Auto-failover**: any engine failure (rate-limited free engine, or missing/invalid paid key, network error) automatically tries the next engine — other
|
|
295
|
+
- **Auto-failover**: any engine failure (rate-limited free engine, or missing/invalid paid key, 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 because of engine issues.
|
|
294
296
|
- **Official Links in Settings**: Free engines display "Visit Website →", while paid engines display "Get API Key →" (opens in a new tab):
|
|
295
297
|
- Exa: <https://dashboard.exa.ai/api-keys>
|
|
296
298
|
- Tavily: <https://app.tavily.com/home>
|
package/lib/client.js
CHANGED
|
@@ -20,9 +20,12 @@ window.__ModuleLoader__.load({
|
|
|
20
20
|
".dshfs-chevron{color:var(--dsw-alias-label-tertiary);flex:none;font-size:13px;transition:transform .12s}",
|
|
21
21
|
".dshfs-chevronOpen{transform:rotate(180deg)}",
|
|
22
22
|
".dshfs-body{flex-direction:column;gap:14px;padding:0 14px 14px;display:flex}",
|
|
23
|
-
".dshfs-footer{justify-content:
|
|
24
|
-
".dshfs-
|
|
25
|
-
".dshfs-
|
|
23
|
+
".dshfs-footer{justify-content:space-between;align-items:center;gap:8px;display:flex;flex-wrap:wrap}",
|
|
24
|
+
".dshfs-footerLeft{display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0}",
|
|
25
|
+
".dshfs-footerRight{display:flex;align-items:center;gap:8px;flex-wrap:wrap}",
|
|
26
|
+
".dshfs-failed{color:var(--dsw-alias-state-error-primary);font-size:12px}",
|
|
27
|
+
".dshfs-testOk{color:#7ddb9c;font-size:12px;line-height:1.5}",
|
|
28
|
+
".dshfs-resultRow{display:flex;flex-direction:column;align-items:flex-start;gap:4px;min-width:0;margin-top:2px}",
|
|
26
29
|
".dshfs-field{flex-direction:column;gap:4px;min-width:0;display:flex}",
|
|
27
30
|
".dshfs-label{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:500}",
|
|
28
31
|
".dshfs-select{border:1px solid var(--dsw-alias-border-l2);font:inherit;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-primary);background:var(--dsw-specific-input-major);border-radius:6px;padding:6px 8px;font-size:13px;transition:border-color .13s,box-shadow .13s;width:100%}",
|
|
@@ -45,6 +48,10 @@ window.__ModuleLoader__.load({
|
|
|
45
48
|
".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}",
|
|
46
49
|
".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}",
|
|
47
50
|
".dshfs-langToggle{border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);background:transparent;flex:none;padding:2px 8px;font-size:11px;border-radius:6px}",
|
|
51
|
+
".dshfs-update{color:var(--dsw-alias-state-warn-primary);flex:none;font-size:11px;white-space:nowrap;border:1px solid rgba(240,170,80,.3);background:rgba(240,170,80,.12);border-radius:999px;padding:1px 6px}",
|
|
52
|
+
".dshfs-updateOk{color:#7ddb9c;flex:none;font-size:11px;white-space:nowrap;border:1px solid rgba(80,200,120,.3);background:rgba(80,200,120,.12);border-radius:999px;padding:1px 6px}",
|
|
53
|
+
".dshfs-updateLine{display:flex;align-items:center;gap:8px;flex-wrap:wrap}",
|
|
54
|
+
".dshfs-version{color:var(--dsw-alias-label-tertiary);font-size:11px;font-variant-numeric:tabular-nums;white-space:nowrap}",
|
|
48
55
|
].join("");
|
|
49
56
|
const tagId = "dsh-free-search/card.css";
|
|
50
57
|
if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
|
|
@@ -86,6 +93,12 @@ window.__ModuleLoader__.load({
|
|
|
86
93
|
testOk: (r) => `✓ ${r.count} 条结果(引擎: ${r.engine})${r.content ? ` — ${r.content}` : ""}${r.sample ? ` · 例如 "${r.sample.slice(0, 40)}"` : ""}`,
|
|
87
94
|
testFail: (e) => `✗ ${e}`,
|
|
88
95
|
toggleLang: "EN",
|
|
96
|
+
checkUpdate: "检查更新",
|
|
97
|
+
checkingUpdate: "检查中…",
|
|
98
|
+
updateAvailable: (c, l) => `发现新版本 v${l}(当前 v${c})`,
|
|
99
|
+
updateLatest: (c) => `已是最新版本 v${c}`,
|
|
100
|
+
updateCheckFailed: "检查更新失败(无法访问 npm registry)",
|
|
101
|
+
updateView: "查看 →",
|
|
89
102
|
},
|
|
90
103
|
en: {
|
|
91
104
|
description: "Free web search — no API key needed (Bing / DuckDuckGo / AnySearch / Exa / Tavily / Keenable)",
|
|
@@ -114,9 +127,17 @@ window.__ModuleLoader__.load({
|
|
|
114
127
|
testOk: (r) => `✓ ${r.count} results (engine: ${r.engine})${r.content ? ` — ${r.content}` : ""}${r.sample ? ` · e.g. "${r.sample.slice(0, 40)}"` : ""}`,
|
|
115
128
|
testFail: (e) => `✗ ${e}`,
|
|
116
129
|
toggleLang: "中文",
|
|
130
|
+
checkUpdate: "Check update",
|
|
131
|
+
checkingUpdate: "Checking…",
|
|
132
|
+
updateAvailable: (c, l) => `New version v${l} available (current v${c})`,
|
|
133
|
+
updateLatest: (c) => `You are up to date (v${c})`,
|
|
134
|
+
updateCheckFailed: "Update check failed (cannot reach npm registry)",
|
|
135
|
+
updateView: "View →",
|
|
117
136
|
},
|
|
118
137
|
};
|
|
119
138
|
const tt = (lang) => I18N[lang === "en" ? "en" : "zh"];
|
|
139
|
+
// 当前插件版本(与 lib/index.js 的 PLUGIN_VERSION 保持一致)
|
|
140
|
+
const PLUGIN_VERSION = "0.4.8";
|
|
120
141
|
const ENGINES = [
|
|
121
142
|
{ id: "ddg", label: "DuckDuckGo · HTML", badge: "FREE", link: "https://duckduckgo.com" },
|
|
122
143
|
{ id: "ddg-lite", label: "DuckDuckGo · Lite", badge: "FREE", link: "https://duckduckgo.com" },
|
|
@@ -157,6 +178,15 @@ window.__ModuleLoader__.load({
|
|
|
157
178
|
return response.json();
|
|
158
179
|
}
|
|
159
180
|
|
|
181
|
+
async function bridgeCheckUpdate() {
|
|
182
|
+
const response = await fetch(`${BRIDGE_PREFIX}/check-update`, {
|
|
183
|
+
method: "POST",
|
|
184
|
+
headers: { "content-type": "application/json" },
|
|
185
|
+
body: "{}",
|
|
186
|
+
});
|
|
187
|
+
return response.json();
|
|
188
|
+
}
|
|
189
|
+
|
|
160
190
|
function FreeSearchCard(props) {
|
|
161
191
|
const [open, setOpen] = react.useState(false);
|
|
162
192
|
const [state, setState] = react.useState({ status: "loading" });
|
|
@@ -174,6 +204,8 @@ window.__ModuleLoader__.load({
|
|
|
174
204
|
const [failed, setFailed] = react.useState(false);
|
|
175
205
|
const [testing, setTesting] = react.useState(false);
|
|
176
206
|
const [testResult, setTestResult] = react.useState(null);
|
|
207
|
+
const [checkingUpdate, setCheckingUpdate] = react.useState(false);
|
|
208
|
+
const [updateInfo, setUpdateInfo] = react.useState(null);
|
|
177
209
|
|
|
178
210
|
const load = react.useCallback(async () => {
|
|
179
211
|
try {
|
|
@@ -288,6 +320,24 @@ window.__ModuleLoader__.load({
|
|
|
288
320
|
}
|
|
289
321
|
};
|
|
290
322
|
|
|
323
|
+
const runCheckUpdate = async () => {
|
|
324
|
+
setCheckingUpdate(true);
|
|
325
|
+
setUpdateInfo(null);
|
|
326
|
+
setFailed(false);
|
|
327
|
+
try {
|
|
328
|
+
const result = await bridgeCheckUpdate();
|
|
329
|
+
if (result.ok) {
|
|
330
|
+
setUpdateInfo({ ok: true, ...result.value });
|
|
331
|
+
} else {
|
|
332
|
+
setUpdateInfo({ ok: false });
|
|
333
|
+
}
|
|
334
|
+
} catch {
|
|
335
|
+
setUpdateInfo({ ok: false });
|
|
336
|
+
} finally {
|
|
337
|
+
setCheckingUpdate(false);
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
|
|
291
341
|
if (state.status === "loading") return null;
|
|
292
342
|
const ready = state.status === "ready";
|
|
293
343
|
const t = tt(lang);
|
|
@@ -312,14 +362,11 @@ window.__ModuleLoader__.load({
|
|
|
312
362
|
"aria-expanded": open,
|
|
313
363
|
onClick: () => setOpen(!open),
|
|
314
364
|
children: [
|
|
315
|
-
|
|
316
|
-
className: "dshfs-headText",
|
|
317
|
-
children: [
|
|
365
|
+
react_jsx_runtime.jsx("span", { className: "dshfs-headText", children: [
|
|
318
366
|
react_jsx_runtime.jsx("span", { className: "dshfs-name", children: title }),
|
|
319
367
|
react_jsx_runtime.jsx("span", { className: "dshfs-description", children: description }),
|
|
320
|
-
],
|
|
321
|
-
|
|
322
|
-
react_jsx_runtime.jsx("span", { className: badgeClass, children: currentEngine.badge }),
|
|
368
|
+
] }),
|
|
369
|
+
react_jsx_runtime.jsx("span", { className: badgeClass, children: currentEngine.badge }),
|
|
323
370
|
dirty ? react_jsx_runtime.jsx("span", { className: "dshfs-pending", children: t.unsaved }) : null,
|
|
324
371
|
react_jsx_runtime.jsx("button", {
|
|
325
372
|
type: "button",
|
|
@@ -488,24 +535,71 @@ react_jsx_runtime.jsx("div", {
|
|
|
488
535
|
}),
|
|
489
536
|
],
|
|
490
537
|
}),
|
|
491
|
-
!ready
|
|
492
|
-
? react_jsx_runtime.jsx("p", {
|
|
493
|
-
className: "dshfs-hint",
|
|
494
|
-
children: t.unavailable,
|
|
495
|
-
})
|
|
496
|
-
: null,
|
|
497
538
|
react_jsx_runtime.jsx("div", {
|
|
498
|
-
className: "dshfs-
|
|
539
|
+
className: "dshfs-resultRow",
|
|
499
540
|
children: [
|
|
500
|
-
failed ? react_jsx_runtime.jsx("
|
|
541
|
+
failed ? react_jsx_runtime.jsx("span", { className: "dshfs-failed", children: t.saveFailed }) : null,
|
|
501
542
|
testResult
|
|
502
|
-
? react_jsx_runtime.jsx("
|
|
543
|
+
? react_jsx_runtime.jsx("span", {
|
|
503
544
|
className: testResult.ok ? "dshfs-testOk" : "dshfs-failed",
|
|
504
545
|
children: testResult.ok
|
|
505
546
|
? t.testOk(testResult)
|
|
506
547
|
: t.testFail(testResult.error),
|
|
507
548
|
})
|
|
508
549
|
: null,
|
|
550
|
+
],
|
|
551
|
+
}),
|
|
552
|
+
!ready
|
|
553
|
+
? react_jsx_runtime.jsx("p", {
|
|
554
|
+
className: "dshfs-hint",
|
|
555
|
+
children: t.unavailable,
|
|
556
|
+
})
|
|
557
|
+
: null,
|
|
558
|
+
react_jsx_runtime.jsx("div", {
|
|
559
|
+
className: "dshfs-footer",
|
|
560
|
+
children: [
|
|
561
|
+
react_jsx_runtime.jsx("div", {
|
|
562
|
+
className: "dshfs-footerLeft",
|
|
563
|
+
children: [
|
|
564
|
+
react_jsx_runtime.jsx("span", { className: "dshfs-version", children: "v" + PLUGIN_VERSION }),
|
|
565
|
+
react_jsx_runtime.jsx("button", {
|
|
566
|
+
className: "dshfs-btn",
|
|
567
|
+
type: "button",
|
|
568
|
+
onClick: runCheckUpdate,
|
|
569
|
+
disabled: checkingUpdate || saving || !ready,
|
|
570
|
+
children: checkingUpdate ? t.checkingUpdate : t.checkUpdate,
|
|
571
|
+
}),
|
|
572
|
+
updateInfo && updateInfo.ok
|
|
573
|
+
? updateInfo.hasUpdate
|
|
574
|
+
? react_jsx_runtime.jsx("span", {
|
|
575
|
+
className: "dshfs-update",
|
|
576
|
+
children: [
|
|
577
|
+
t.updateAvailable(updateInfo.current, updateInfo.latest),
|
|
578
|
+
" ",
|
|
579
|
+
react_jsx_runtime.jsx("a", {
|
|
580
|
+
className: "dshfs-link",
|
|
581
|
+
href: updateInfo.updateUrl,
|
|
582
|
+
target: "_blank",
|
|
583
|
+
rel: "noopener noreferrer",
|
|
584
|
+
children: t.updateView,
|
|
585
|
+
}),
|
|
586
|
+
],
|
|
587
|
+
})
|
|
588
|
+
: react_jsx_runtime.jsx("span", {
|
|
589
|
+
className: "dshfs-updateOk",
|
|
590
|
+
children: t.updateLatest(updateInfo.current),
|
|
591
|
+
})
|
|
592
|
+
: updateInfo && !updateInfo.ok
|
|
593
|
+
? react_jsx_runtime.jsx("span", {
|
|
594
|
+
className: "dshfs-failed",
|
|
595
|
+
children: t.updateCheckFailed,
|
|
596
|
+
})
|
|
597
|
+
: null,
|
|
598
|
+
],
|
|
599
|
+
}),
|
|
600
|
+
react_jsx_runtime.jsx("div", {
|
|
601
|
+
className: "dshfs-footerRight",
|
|
602
|
+
children: [
|
|
509
603
|
react_jsx_runtime.jsx("button", {
|
|
510
604
|
className: "dshfs-btn",
|
|
511
605
|
type: "button",
|
|
@@ -540,9 +634,11 @@ react_jsx_runtime.jsx("div", {
|
|
|
540
634
|
}),
|
|
541
635
|
],
|
|
542
636
|
}),
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
637
|
+
],
|
|
638
|
+
})
|
|
639
|
+
],
|
|
640
|
+
})
|
|
641
|
+
: null,
|
|
546
642
|
],
|
|
547
643
|
});
|
|
548
644
|
}
|
package/lib/index.js
CHANGED
|
@@ -17,6 +17,46 @@ const BRIDGE_PREFIX = "/api/dsh-free-search-settings";
|
|
|
17
17
|
const FREE_ENGINES = ["ddg", "ddg-lite", "bing", "searxng", "anysearch"];
|
|
18
18
|
const ALL_ENGINES = ["ddg", "ddg-lite", "bing", "searxng", "anysearch", "exa", "tavily", "keenable", "perplexity", "deepseek-official"];
|
|
19
19
|
|
|
20
|
+
// 当前插件版本(发布时与 package.json 同步)
|
|
21
|
+
const PLUGIN_VERSION = "0.4.8";
|
|
22
|
+
// 检查更新的 npm registry 元数据地址(dsh-free-search 是 npmjs 上的公开包)
|
|
23
|
+
const NPM_REGISTRY_URL = "https://registry.npmjs.org/dsh-free-search/latest";
|
|
24
|
+
const PLUGIN_NPM_URL = "https://www.npmjs.com/package/dsh-free-search";
|
|
25
|
+
const PLUGIN_REPO_URL = "https://github.com/DDDMUC/dsh-free-search";
|
|
26
|
+
|
|
27
|
+
// 查询 npm registry 的最新版本;失败时返回 null(网络/代理问题不阻塞设置页)
|
|
28
|
+
async function fetchLatestVersion(signal) {
|
|
29
|
+
const controller = new AbortController();
|
|
30
|
+
const timer = setTimeout(() => controller.abort(), 10000);
|
|
31
|
+
const onAbort = () => controller.abort();
|
|
32
|
+
signal?.addEventListener("abort", onAbort);
|
|
33
|
+
try {
|
|
34
|
+
const response = await fetch(NPM_REGISTRY_URL, {
|
|
35
|
+
headers: { accept: "application/json", "user-agent": "deepseek-harness/free-search" },
|
|
36
|
+
signal: controller.signal,
|
|
37
|
+
});
|
|
38
|
+
if (!response.ok) return null;
|
|
39
|
+
const data = await response.json();
|
|
40
|
+
return typeof data.version === "string" ? data.version : null;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
} finally {
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
signal?.removeEventListener("abort", onAbort);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 简单的 semver 比较(仅处理 x.y.z 主/次/补丁,忽略预发布标签);a>b 返回 1, a<b 返回 -1, 相等返回 0
|
|
50
|
+
function compareVersions(a, b) {
|
|
51
|
+
const na = String(a).split(".").map((n) => parseInt(n, 10) || 0);
|
|
52
|
+
const nb = String(b).split(".").map((n) => parseInt(n, 10) || 0);
|
|
53
|
+
for (let i = 0; i < 3; i++) {
|
|
54
|
+
if ((na[i] ?? 0) > (nb[i] ?? 0)) return 1;
|
|
55
|
+
if ((na[i] ?? 0) < (nb[i] ?? 0)) return -1;
|
|
56
|
+
}
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
20
60
|
// time_range 支持:固定档 day/week/month/year,或自定义(相对 12h/3d/2mo/1y、绝对 YYYY-MM-DD)
|
|
21
61
|
const TIME_RANGES = ["day", "week", "month", "year"];
|
|
22
62
|
const DAYS_BY_RANGE = { day: 1, week: 7, month: 30, year: 365 };
|
|
@@ -898,6 +938,27 @@ function makeBridgeRoutes(settings, search, testEngine) {
|
|
|
898
938
|
.map((descriptor) => String(descriptor.ns));
|
|
899
939
|
|
|
900
940
|
const handlers = {
|
|
941
|
+
async checkUpdate() {
|
|
942
|
+
const latest = await fetchLatestVersion();
|
|
943
|
+
if (latest === null) {
|
|
944
|
+
return {
|
|
945
|
+
ok: false,
|
|
946
|
+
code: "update-check-failed",
|
|
947
|
+
message: "could not reach the npm registry (network/proxy) - check your connection",
|
|
948
|
+
};
|
|
949
|
+
}
|
|
950
|
+
const cmp = compareVersions(latest, PLUGIN_VERSION);
|
|
951
|
+
return {
|
|
952
|
+
ok: true,
|
|
953
|
+
value: {
|
|
954
|
+
current: PLUGIN_VERSION,
|
|
955
|
+
latest,
|
|
956
|
+
hasUpdate: cmp > 0,
|
|
957
|
+
updateUrl: PLUGIN_NPM_URL,
|
|
958
|
+
repoUrl: PLUGIN_REPO_URL,
|
|
959
|
+
},
|
|
960
|
+
};
|
|
961
|
+
},
|
|
901
962
|
async rawSearch(request) {
|
|
902
963
|
if (request === null || typeof request !== "object" || typeof request.query !== "string" || request.query.length === 0) {
|
|
903
964
|
return { ok: false, code: "search-rejected", message: "malformed bridge search request (query is required)" };
|
|
@@ -1018,6 +1079,14 @@ function makeBridgeRoutes(settings, search, testEngine) {
|
|
|
1018
1079
|
writeJson(res, 200, await handlers.mutate(body));
|
|
1019
1080
|
},
|
|
1020
1081
|
},
|
|
1082
|
+
{
|
|
1083
|
+
kind: "exact",
|
|
1084
|
+
path: `${BRIDGE_PREFIX}/check-update`,
|
|
1085
|
+
handler: async (req, res) => {
|
|
1086
|
+
if (!guard(req, res)) return;
|
|
1087
|
+
writeJson(res, 200, await handlers.checkUpdate());
|
|
1088
|
+
},
|
|
1089
|
+
},
|
|
1021
1090
|
{
|
|
1022
1091
|
kind: "exact",
|
|
1023
1092
|
path: `${BRIDGE_PREFIX}/raw-search`,
|
|
@@ -1085,7 +1154,9 @@ function apply(ctx, config) {
|
|
|
1085
1154
|
const cfg = current();
|
|
1086
1155
|
const preferred = cfg.provider ?? "bing";
|
|
1087
1156
|
// time_range 过滤(仅 advanced_search 工具透传;标准 web_search 无此参数)
|
|
1157
|
+
// 保留原始字符串用于 Note 展示;raw-search 桥可能已把 timeRange 解析成对象
|
|
1088
1158
|
const timeRange = parseTimeRange(request.timeRange);
|
|
1159
|
+
const timeRangeLabel = typeof request.timeRange === "string" ? request.timeRange : String(timeRange?.days ?? timeRange?.after ?? "");
|
|
1089
1160
|
|
|
1090
1161
|
// 统一引擎链:首选优先,然后其他付费引擎(有 key 的优先尝试),最后免费引擎
|
|
1091
1162
|
const paidEngines = ["exa", "tavily", "keenable", "perplexity", "deepseek-official"];
|
|
@@ -1093,12 +1164,21 @@ function apply(ctx, config) {
|
|
|
1093
1164
|
// 支持 time_range 过滤的引擎:tavily / exa / keenable / searxng / ddg / ddg-lite
|
|
1094
1165
|
const timeEngines = ["tavily", "exa", "keenable", "searxng", "ddg", "ddg-lite"];
|
|
1095
1166
|
let chain;
|
|
1167
|
+
// 首选引擎被跳过的原因(用于生成准确的 Note,避免误导 agent/用户):
|
|
1168
|
+
// - "time-filter":带 timeRange 且首选引擎不支持时间过滤(根本没尝试)
|
|
1169
|
+
// - "failed":首选引擎确实被尝试但失败(缺 key / 401 / 限流 / 0 结果 / 网络)
|
|
1170
|
+
// - null:首选引擎成功或无回退
|
|
1171
|
+
let preferredSkippedReason = null;
|
|
1096
1172
|
if (timeRange) {
|
|
1097
1173
|
// 有时间过滤需求时,把支持过滤的引擎排前面(首选引擎若支持仍优先)
|
|
1098
1174
|
const preferredFirst = [preferred].filter((e) => timeEngines.includes(e));
|
|
1099
1175
|
const otherTime = timeEngines.filter((e) => e !== preferred);
|
|
1100
1176
|
const noTime = [...paidEngines, ...freeEngines].filter((e) => !timeEngines.includes(e) && e !== preferred);
|
|
1101
1177
|
chain = [...preferredFirst, ...otherTime, ...noTime];
|
|
1178
|
+
if (!timeEngines.includes(preferred)) {
|
|
1179
|
+
// 首选引擎不支持时间过滤 → 它不在链里,不会被尝试(这不等于失败)
|
|
1180
|
+
preferredSkippedReason = "time-filter";
|
|
1181
|
+
}
|
|
1102
1182
|
} else {
|
|
1103
1183
|
const othersPaid = paidEngines.filter((e) => e !== preferred);
|
|
1104
1184
|
const othersFree = freeEngines.filter((e) => e !== preferred);
|
|
@@ -1107,6 +1187,8 @@ function apply(ctx, config) {
|
|
|
1107
1187
|
|
|
1108
1188
|
let lastError = null;
|
|
1109
1189
|
let usedEngine = null;
|
|
1190
|
+
// 首选引擎若被尝试后失败,记录失败详情(用于 Note)
|
|
1191
|
+
let preferredFailure = null;
|
|
1110
1192
|
for (const engine of chain) {
|
|
1111
1193
|
try {
|
|
1112
1194
|
let result;
|
|
@@ -1140,6 +1222,7 @@ function apply(ctx, config) {
|
|
|
1140
1222
|
const key = await resolveApiKey("PERPLEXITY_API_KEY", "perplexityApiKey");
|
|
1141
1223
|
if (!key) {
|
|
1142
1224
|
lastError = new Error("Perplexity requires PERPLEXITY_API_KEY");
|
|
1225
|
+
if (engine === preferred) preferredFailure = "PERPLEXITY_API_KEY is not configured";
|
|
1143
1226
|
logger.warn(`free-search: engine "${engine}" skipped (no key), trying next engine`);
|
|
1144
1227
|
continue; // 无 key 跳过
|
|
1145
1228
|
}
|
|
@@ -1148,6 +1231,7 @@ function apply(ctx, config) {
|
|
|
1148
1231
|
const key = await resolveApiKey("DEEPSEEK_API_KEY", "deepseekApiKey");
|
|
1149
1232
|
if (!key) {
|
|
1150
1233
|
lastError = new Error("DeepSeek requires DEEPSEEK_API_KEY");
|
|
1234
|
+
if (engine === preferred) preferredFailure = "DEEPSEEK_API_KEY is not configured";
|
|
1151
1235
|
logger.warn(`free-search: engine "${engine}" skipped (no key), trying next engine`);
|
|
1152
1236
|
continue; // 无 key 跳过
|
|
1153
1237
|
}
|
|
@@ -1158,17 +1242,25 @@ function apply(ctx, config) {
|
|
|
1158
1242
|
|
|
1159
1243
|
if (result.sources.length > 0) {
|
|
1160
1244
|
usedEngine = engine;
|
|
1161
|
-
//
|
|
1245
|
+
// 用了非首选引擎时,在结果里附上准确提示(区分"不支持时间过滤被跳过"与"真实失败")
|
|
1162
1246
|
if (engine !== preferred) {
|
|
1163
|
-
|
|
1247
|
+
if (preferredSkippedReason === "time-filter") {
|
|
1248
|
+
result.content = `Note: ${preferred} does not support time filtering (timeRange=${timeRangeLabel}), using ${engine}.`;
|
|
1249
|
+
} else if (preferredFailure) {
|
|
1250
|
+
result.content = `Note: ${preferred} unavailable or failed (${preferredFailure}), using ${engine}.`;
|
|
1251
|
+
} else {
|
|
1252
|
+
result.content = `Note: ${preferred} unavailable or failed, using ${engine}.`;
|
|
1253
|
+
}
|
|
1164
1254
|
}
|
|
1165
1255
|
return { ...result, provider: engine, engine: engine };
|
|
1166
1256
|
}
|
|
1167
1257
|
lastError = new Error(`engine "${engine}" returned 0 results`);
|
|
1258
|
+
if (engine === preferred) preferredFailure = "returned 0 results";
|
|
1168
1259
|
logger.warn(`free-search: ${engine} returned 0 results, trying next engine`);
|
|
1169
1260
|
} catch (error) {
|
|
1170
1261
|
lastError = error;
|
|
1171
1262
|
const message = error instanceof Error ? error.message : String(error);
|
|
1263
|
+
if (engine === preferred) preferredFailure = message;
|
|
1172
1264
|
logger.warn(`free-search: engine "${engine}" failed (${message}), trying next engine`);
|
|
1173
1265
|
}
|
|
1174
1266
|
}
|
|
@@ -1407,7 +1499,15 @@ function apply(ctx, config) {
|
|
|
1407
1499
|
}
|
|
1408
1500
|
const limit = Math.min(args.maxResults ?? 5, 10);
|
|
1409
1501
|
const result = await searchPlatform(platform, args.query, limit);
|
|
1410
|
-
|
|
1502
|
+
// lossless JSON 不允许 undefined 字段:剔除缺失字段
|
|
1503
|
+
const sources = (result.sources ?? []).map((s) => {
|
|
1504
|
+
const source = {};
|
|
1505
|
+
if (s.url !== undefined && s.url !== null && s.url !== "") source.url = s.url;
|
|
1506
|
+
if (s.title !== undefined && s.title !== null && s.title !== "") source.title = String(s.title);
|
|
1507
|
+
if (s.snippet !== undefined && s.snippet !== null && s.snippet !== "") source.snippet = String(s.snippet);
|
|
1508
|
+
return source;
|
|
1509
|
+
});
|
|
1510
|
+
return { platform, sources };
|
|
1411
1511
|
},
|
|
1412
1512
|
finalizeContent(exec, result) {
|
|
1413
1513
|
// Tool-result content must be an array of content blocks, not a raw string.
|
|
@@ -1485,15 +1585,20 @@ function apply(ctx, config) {
|
|
|
1485
1585
|
// engine 指定时:仅当该引擎可用才优先(仍走回退链,失败自动换引擎)
|
|
1486
1586
|
if (args.engine && ALL_ENGINES.includes(args.engine)) request.engine = args.engine;
|
|
1487
1587
|
const result = await provider.search(request);
|
|
1588
|
+
// lossless JSON 不允许 undefined 字段:按存在的值构造对象,缺字段直接省略
|
|
1488
1589
|
return {
|
|
1489
|
-
provider: result.provider ?? result._provider,
|
|
1490
|
-
content: result.content
|
|
1491
|
-
sources: (result.sources ?? []).map((s) =>
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1590
|
+
provider: result.provider ?? result._provider ?? "bing",
|
|
1591
|
+
content: typeof result.content === "string" ? result.content : "",
|
|
1592
|
+
sources: (result.sources ?? []).map((s) => {
|
|
1593
|
+
const source = {};
|
|
1594
|
+
if (s.url !== undefined && s.url !== null && s.url !== "") source.url = s.url;
|
|
1595
|
+
if (s.title !== undefined && s.title !== null && s.title !== "") source.title = String(s.title);
|
|
1596
|
+
if (s.snippet !== undefined && s.snippet !== null && s.snippet !== "") source.snippet = String(s.snippet);
|
|
1597
|
+
if (s.publishedAt !== undefined && s.publishedAt !== null && s.publishedAt !== "") {
|
|
1598
|
+
source.publishedAt = String(s.publishedAt);
|
|
1599
|
+
}
|
|
1600
|
+
return source;
|
|
1601
|
+
}),
|
|
1497
1602
|
};
|
|
1498
1603
|
},
|
|
1499
1604
|
finalizeContent(exec, result) {
|
|
@@ -1538,7 +1643,7 @@ function apply(ctx, config) {
|
|
|
1538
1643
|
"- perplexity - requires PERPLEXITY_API_KEY",
|
|
1539
1644
|
"- deepseek-official - requires DEEPSEEK_API_KEY",
|
|
1540
1645
|
"",
|
|
1541
|
-
"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
|
|
1646
|
+
"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) the configured engine first, (2) then other engines with API keys configured (exa/tavily/keenable work keyless too, so they are tried even without a key), (3) then the remaining 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 and why the preferred one was skipped. Understand the two note forms: (a) 'Note: X does not support time filtering (timeRange=...), using Y.' means X cannot filter by time so it was skipped BEFORE any attempt (X did NOT fail); (b) 'Note: X unavailable or failed (reason), using Y.' means X was actually tried but failed (missing key / invalid key / 401 / rate limit / network / 0 results). Never tell the user search is unavailable - it always falls back.",
|
|
1542
1647
|
"",
|
|
1543
1648
|
"Use the free_search_test tool to test which engines actually work right now.",
|
|
1544
1649
|
"",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-free-search",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.8",
|
|
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",
|