dsh-free-search 0.4.20 → 0.4.22
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/lib/client.js +81 -81
- package/lib/index.js +115 -115
- package/package.json +67 -67
package/lib/client.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
2
|
id: "dsh-free-search",
|
|
3
3
|
factory: (require) => {
|
|
4
4
|
var module = { exports: {} };
|
|
@@ -30,8 +30,8 @@
|
|
|
30
30
|
".dshfs-label{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:500}",
|
|
31
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%}",
|
|
32
32
|
".dshfs-select:hover:not(:disabled){border-color:var(--dsw-alias-label-dimmed)}",
|
|
33
|
-
//
|
|
34
|
-
// color-scheme
|
|
33
|
+
// 下拉选项列表配色锁死:不随皮肤变量变化(皮肤只影响 select 框体本身)
|
|
34
|
+
// color-scheme 让浏览器原生下拉按主题渲染;option 显式固定底色/文字色兜底
|
|
35
35
|
".dshfs-select{color-scheme:light dark}",
|
|
36
36
|
".dshfs-select option,.dshfs-select optgroup{background-color:#ffffff;color:#1f2328}",
|
|
37
37
|
"@media (prefers-color-scheme:dark){.dshfs-select{color-scheme:dark}.dshfs-select option,.dshfs-select optgroup{background-color:#1e1f24;color:#e8e8ea}}",
|
|
@@ -78,57 +78,57 @@
|
|
|
78
78
|
const NS = "free-search";
|
|
79
79
|
const I18N = {
|
|
80
80
|
zh: {
|
|
81
|
-
description: "
|
|
82
|
-
unsaved: "
|
|
83
|
-
searchEngine: "
|
|
84
|
-
visit: "
|
|
85
|
-
getKey: "
|
|
86
|
-
engineHint: "Bing
|
|
87
|
-
apiKeys: "API
|
|
88
|
-
exaPh: (c) => c ? "Exa API
|
|
89
|
-
tavilyPh: (c) => c ? "Tavily API
|
|
90
|
-
keenablePh: (c) => c ? "Keenable API
|
|
91
|
-
perplexityPh: (c) => c ? "Perplexity API
|
|
92
|
-
deepseekPh: (c) => c ? "DeepSeek API
|
|
93
|
-
keysHint: "
|
|
94
|
-
keyStorage: "Key
|
|
95
|
-
keyStorageCred: "
|
|
96
|
-
keyStorageSettings: "
|
|
97
|
-
keyStorageCredHint: (c) =>
|
|
98
|
-
keyStorageSettingsHint: "
|
|
99
|
-
platformSearch: "
|
|
100
|
-
platformHint: "
|
|
101
|
-
cacheTtl: "
|
|
102
|
-
cacheTtlHint: "0
|
|
103
|
-
unavailable: "
|
|
104
|
-
saveFailed: "
|
|
105
|
-
testing: "
|
|
106
|
-
testEngine: "
|
|
107
|
-
useBing: "
|
|
108
|
-
discard: "
|
|
109
|
-
saving: "
|
|
110
|
-
save: "
|
|
111
|
-
testOk: (r) =>
|
|
112
|
-
testFail: (e) =>
|
|
81
|
+
description: "免费搜索 —— 无需 API key(Bing / DuckDuckGo / AnySearch / Exa / Tavily / Keenable)",
|
|
82
|
+
unsaved: "未保存",
|
|
83
|
+
searchEngine: "搜索引擎",
|
|
84
|
+
visit: "访问官网 →",
|
|
85
|
+
getKey: "获取 API Key →",
|
|
86
|
+
engineHint: "Bing 是最稳定的免费引擎。DuckDuckGo 在共享 IP 上可能限流。API KEY 引擎需在下方填写凭据。",
|
|
87
|
+
apiKeys: "API 密钥(可选)",
|
|
88
|
+
exaPh: (c) => c ? "Exa API 密钥(已配置)" : "Exa API 密钥(可选,不填也可免费使用)",
|
|
89
|
+
tavilyPh: (c) => c ? "Tavily API 密钥(已配置)" : "Tavily API 密钥(可选,不填也可免费使用)",
|
|
90
|
+
keenablePh: (c) => c ? "Keenable API 密钥(已配置)" : "Keenable API 密钥(可选,不填也可免费使用)",
|
|
91
|
+
perplexityPh: (c) => c ? "Perplexity API 密钥(已配置)" : "Perplexity API 密钥(pplx-...)",
|
|
92
|
+
deepseekPh: (c) => c ? "DeepSeek API 密钥(已配置)" : "DeepSeek API 密钥(sk-...)",
|
|
93
|
+
keysHint: "密钥读取优先级:.credentials.yaml 凭据中心 > 这里 > 环境变量。推荐把 key 写进凭据中心(与官方 LLM 一致,一处管理)。",
|
|
94
|
+
keyStorage: "Key 存储位置",
|
|
95
|
+
keyStorageCred: "凭据中心(推荐)",
|
|
96
|
+
keyStorageSettings: "设置页(兼容)",
|
|
97
|
+
keyStorageCredHint: (c) => `保存后写入 ~/.dsh/.credentials.yaml(最高优先级)。当前已配置:${["exa", "tavily", "keenable", "perplexity", "deepseek"].filter((k) => c[k]).map((k) => k.toUpperCase()).join(", ") || "无"}`,
|
|
98
|
+
keyStorageSettingsHint: "保存后写入 settings.yaml(向后兼容路径,优先级低于凭据中心)。",
|
|
99
|
+
platformSearch: "平台搜索(platform_search 工具)",
|
|
100
|
+
platformHint: "为 agent 的 platform_search 工具启用平台。禁用的平台会被跳过。",
|
|
101
|
+
cacheTtl: "结果缓存时长(分钟)",
|
|
102
|
+
cacheTtlHint: "0 关闭缓存,最长 5 分钟。缩短可加快时效,延长可防限流、省额度。",
|
|
103
|
+
unavailable: "设置不可用 —— free-search 桥接未暴露。",
|
|
104
|
+
saveFailed: "保存失败",
|
|
105
|
+
testing: "测试中…",
|
|
106
|
+
testEngine: "测试引擎",
|
|
107
|
+
useBing: "恢复 Bing 默认",
|
|
108
|
+
discard: "撤销",
|
|
109
|
+
saving: "保存中…",
|
|
110
|
+
save: "保存",
|
|
111
|
+
testOk: (r) => `✓ ${r.count} 条结果(引擎: ${r.engine})${r.content ? ` — ${r.content}` : ""}${r.sample ? ` · 例如 "${r.sample.slice(0, 40)}"` : ""}`,
|
|
112
|
+
testFail: (e) => `✗ ${e}`,
|
|
113
113
|
toggleLang: "EN",
|
|
114
|
-
checkUpdate: "
|
|
115
|
-
checkingUpdate: "
|
|
116
|
-
updateAvailable: (c, l) =>
|
|
117
|
-
updateLatest: (c) =>
|
|
118
|
-
updateCheckFailed: "
|
|
119
|
-
updateView: "
|
|
120
|
-
upgrade: "
|
|
121
|
-
upgrading: "
|
|
122
|
-
upgradeLinkMode: "
|
|
123
|
-
upgradeDone: (l) =>
|
|
124
|
-
upgradeFailed: (m) =>
|
|
114
|
+
checkUpdate: "检查更新",
|
|
115
|
+
checkingUpdate: "检查中…",
|
|
116
|
+
updateAvailable: (c, l) => `发现新版本 v${l}(当前 v${c})`,
|
|
117
|
+
updateLatest: (c) => `已是最新版本 v${c}`,
|
|
118
|
+
updateCheckFailed: "检查更新失败(无法访问 npm registry)",
|
|
119
|
+
updateView: "查看 →",
|
|
120
|
+
upgrade: "升级",
|
|
121
|
+
upgrading: "升级中…",
|
|
122
|
+
upgradeLinkMode: "(本地开发模式,升级请用 git pull)",
|
|
123
|
+
upgradeDone: (l) => `升级到 v${l} 完成,重启 dsh 后生效`,
|
|
124
|
+
upgradeFailed: (m) => `升级失败:${m}`,
|
|
125
125
|
},
|
|
126
126
|
en: {
|
|
127
|
-
description: "Free web search
|
|
127
|
+
description: "Free web search — no API key needed (Bing / DuckDuckGo / AnySearch / Exa / Tavily / Keenable)",
|
|
128
128
|
unsaved: "unsaved",
|
|
129
129
|
searchEngine: "Search engine",
|
|
130
|
-
visit: "Visit website
|
|
131
|
-
getKey: "Get API Key
|
|
130
|
+
visit: "Visit website →",
|
|
131
|
+
getKey: "Get API Key →",
|
|
132
132
|
engineHint: "Bing is the most stable FREE engine. DuckDuckGo may rate-limit on shared IPs. API KEY engines need credentials below.",
|
|
133
133
|
apiKeys: "API keys (optional)",
|
|
134
134
|
exaPh: (c) => c ? "Exa API key (configured)" : "Exa API key (optional, free without)",
|
|
@@ -146,39 +146,39 @@
|
|
|
146
146
|
platformHint: "Enable platforms for the agent's platform_search tool. Disabled platforms are skipped.",
|
|
147
147
|
cacheTtl: "Result cache TTL (minutes)",
|
|
148
148
|
cacheTtlHint: "0 disables caching, max 5 minutes. Lower = fresher results, higher = less rate-limiting / fewer credits used.",
|
|
149
|
-
unavailable: "Settings unavailable
|
|
149
|
+
unavailable: "Settings unavailable — the free-search bridge is not exposed.",
|
|
150
150
|
saveFailed: "save failed",
|
|
151
|
-
testing: "Testing
|
|
151
|
+
testing: "Testing…",
|
|
152
152
|
testEngine: "Test engine",
|
|
153
153
|
useBing: "Use Bing default",
|
|
154
154
|
discard: "Discard",
|
|
155
|
-
saving: "Saving
|
|
155
|
+
saving: "Saving…",
|
|
156
156
|
save: "Save",
|
|
157
|
-
testOk: (r) =>
|
|
158
|
-
testFail: (e) =>
|
|
159
|
-
toggleLang: "
|
|
157
|
+
testOk: (r) => `✓ ${r.count} results (engine: ${r.engine})${r.content ? ` — ${r.content}` : ""}${r.sample ? ` · e.g. "${r.sample.slice(0, 40)}"` : ""}`,
|
|
158
|
+
testFail: (e) => `✗ ${e}`,
|
|
159
|
+
toggleLang: "中文",
|
|
160
160
|
checkUpdate: "Check update",
|
|
161
|
-
checkingUpdate: "Checking
|
|
161
|
+
checkingUpdate: "Checking…",
|
|
162
162
|
updateAvailable: (c, l) => `New version v${l} available (current v${c})`,
|
|
163
163
|
updateLatest: (c) => `You're on the latest version v${c}`,
|
|
164
164
|
updateCheckFailed: "Update check failed (cannot reach npm registry)",
|
|
165
|
-
updateView: "View
|
|
165
|
+
updateView: "View →",
|
|
166
166
|
upgrade: "Upgrade",
|
|
167
|
-
upgrading: "Upgrading
|
|
167
|
+
upgrading: "Upgrading…",
|
|
168
168
|
upgradeLinkMode: "(local dev install - use git pull to update)",
|
|
169
169
|
upgradeDone: (l) => `Upgraded to v${l} - restart dsh to apply`,
|
|
170
|
-
upgradeFailed: (m) => `Upgrade failed: ${m}`,
|
|
170
|
+
upgradeFailed: (m) => `Upgrade failed: ${m}`,
|
|
171
171
|
},
|
|
172
172
|
};
|
|
173
173
|
const tt = (lang) => I18N[lang === "en" ? "en" : "zh"];
|
|
174
|
-
//
|
|
175
|
-
const PLUGIN_VERSION = "0.4.
|
|
174
|
+
// 当前插件版本(与 lib/index.js 的 PLUGIN_VERSION 保持一致)
|
|
175
|
+
const PLUGIN_VERSION = "0.4.22";
|
|
176
176
|
const ENGINES = [
|
|
177
|
-
{ id: "ddg", label: "DuckDuckGo
|
|
178
|
-
{ id: "ddg-lite", label: "DuckDuckGo
|
|
177
|
+
{ id: "ddg", label: "DuckDuckGo · HTML", badge: "FREE", link: "https://duckduckgo.com" },
|
|
178
|
+
{ id: "ddg-lite", label: "DuckDuckGo · Lite", badge: "FREE", link: "https://duckduckgo.com" },
|
|
179
179
|
{ id: "bing", label: "Bing", badge: "FREE", link: "https://www.bing.com" },
|
|
180
|
-
{ id: "anysearch", label: "AnySearch
|
|
181
|
-
{ id: "searxng", label: "SearXNG
|
|
180
|
+
{ id: "anysearch", label: "AnySearch · AI", badge: "FREE", link: "https://anysearch.com" },
|
|
181
|
+
{ id: "searxng", label: "SearXNG · 元搜索", badge: "FREE", link: "https://github.com/searxng/searxng" },
|
|
182
182
|
{ id: "exa", label: "Exa", badge: "FREE", link: "https://dashboard.exa.ai/api-keys" },
|
|
183
183
|
{ id: "tavily", label: "Tavily", badge: "FREE", link: "https://app.tavily.com/home" },
|
|
184
184
|
{ id: "keenable", label: "Keenable", badge: "FREE", link: "https://keenable.ai/login" },
|
|
@@ -250,7 +250,7 @@
|
|
|
250
250
|
}
|
|
251
251
|
|
|
252
252
|
function FreeSearchCard(props) {
|
|
253
|
-
//
|
|
253
|
+
// 检测应用主题深浅(读 body 的 --dsw-alias-bg-base 变量亮度),用于锁定原生下拉配色
|
|
254
254
|
const isDarkScheme = react.useMemo(() => {
|
|
255
255
|
try {
|
|
256
256
|
const root = document.body || document.documentElement;
|
|
@@ -282,9 +282,9 @@
|
|
|
282
282
|
const [platforms, setPlatforms] = react.useState(["github", "v2ex", "bilibili", "reddit", "hn", "stackoverflow", "wikipedia", "npm"]);
|
|
283
283
|
const [cacheTtl, setCacheTtl] = react.useState(5);
|
|
284
284
|
const [keysConfigured, setKeysConfigured] = react.useState({});
|
|
285
|
-
// key
|
|
285
|
+
// key 存储位置:credentials(凭据中心,默认)| settings(设置页,兼容旧行为)
|
|
286
286
|
const [keyStorage, setKeyStorage] = react.useState("credentials");
|
|
287
|
-
//
|
|
287
|
+
// 凭据中心里已配置的 key(describe 不返回,需单独查)
|
|
288
288
|
const [credConfigured, setCredConfigured] = react.useState({});
|
|
289
289
|
const [lang, setLang] = react.useState("zh");
|
|
290
290
|
const [dirty, setDirty] = react.useState(false);
|
|
@@ -313,7 +313,7 @@
|
|
|
313
313
|
setDeepseekKey(v.deepseekApiKey ?? "");
|
|
314
314
|
setPlatforms(Array.isArray(v.platforms) && v.platforms.length > 0 ? v.platforms : ["github", "v2ex", "bilibili", "reddit", "hn", "stackoverflow", "wikipedia", "npm"]);
|
|
315
315
|
setCacheTtl(v.cacheTtl === undefined ? 5 : Math.min(Math.max(Number(v.cacheTtl) ?? 5, 0), 5));
|
|
316
|
-
// secrets
|
|
316
|
+
// secrets 字段标记哪些 key 已配置(值被脱敏,仅显示"已配置")
|
|
317
317
|
const configured = {};
|
|
318
318
|
for (const secret of view.secrets ?? []) {
|
|
319
319
|
if (secret.set) {
|
|
@@ -326,10 +326,10 @@
|
|
|
326
326
|
}
|
|
327
327
|
}
|
|
328
328
|
setKeysConfigured(configured);
|
|
329
|
-
// key
|
|
329
|
+
// key 存储位置(默认凭据中心)
|
|
330
330
|
setKeyStorage(v.keyStorage === "settings" ? "settings" : "credentials");
|
|
331
331
|
setState({ status: "ready", writable: result.value.writable });
|
|
332
|
-
//
|
|
332
|
+
// 查询凭据中心里各 key 的配置状态
|
|
333
333
|
try {
|
|
334
334
|
const cred = await bridgeCredentialsStatus();
|
|
335
335
|
if (cred.ok) {
|
|
@@ -366,7 +366,7 @@
|
|
|
366
366
|
setSaving(true);
|
|
367
367
|
setFailed(false);
|
|
368
368
|
try {
|
|
369
|
-
// key
|
|
369
|
+
// key 存储分流:凭据中心(默认)走 credentials-set;设置页走 settings mutate(兼容)
|
|
370
370
|
const keyFields = [
|
|
371
371
|
["exaApiKey", exaKey],
|
|
372
372
|
["tavilyApiKey", tavilyKey],
|
|
@@ -387,7 +387,7 @@
|
|
|
387
387
|
ops.push({ op: "set", path: ["keyStorage"], value: keyStorage });
|
|
388
388
|
ops.push({ op: "set", path: ["safeSearch"], value: safeSearch });
|
|
389
389
|
if (keyStorage !== "credentials") {
|
|
390
|
-
// settings
|
|
390
|
+
// settings 模式:key 仍写 settings.yaml(旧行为)
|
|
391
391
|
for (const [field, value] of keyFields) {
|
|
392
392
|
if (value.trim()) ops.push({ op: "set", path: [field], value: value.trim() });
|
|
393
393
|
}
|
|
@@ -527,7 +527,7 @@
|
|
|
527
527
|
}),
|
|
528
528
|
react_jsx_runtime.jsx("span", {
|
|
529
529
|
className: open ? "dshfs-chevron dshfs-chevronOpen" : "dshfs-chevron",
|
|
530
|
-
children: "
|
|
530
|
+
children: "▾",
|
|
531
531
|
}),
|
|
532
532
|
],
|
|
533
533
|
}),
|
|
@@ -890,8 +890,8 @@ react_jsx_runtime.jsx("div", {
|
|
|
890
890
|
const inject = ["slots", "commandUi"];
|
|
891
891
|
|
|
892
892
|
function apply(ctx) {
|
|
893
|
-
//
|
|
894
|
-
//
|
|
893
|
+
// 挂官方插槽 settings.plugin.item(设置 → 插件 → 可配置标签页)。
|
|
894
|
+
// 不依赖 dsh-web-ui:配置读写走自建 bridge(/api/dsh-free-search-settings)。
|
|
895
895
|
ctx.slots.inject("settings.plugin.item", () =>
|
|
896
896
|
ctx.slots.register(
|
|
897
897
|
{
|
|
@@ -904,14 +904,14 @@ react_jsx_runtime.jsx("div", {
|
|
|
904
904
|
FreeSearchCard
|
|
905
905
|
)
|
|
906
906
|
);
|
|
907
|
-
// /free-search-engine
|
|
908
|
-
//
|
|
907
|
+
// /free-search-engine 弹出式命令:输入 "/" 选中后弹出引擎列表,点选即切换。
|
|
908
|
+
// 等效于设置页切换引擎+保存;命令只改 provider 配置,搜索仍走回退链。
|
|
909
909
|
ctx.inject(["commandUi"], (sctx) => {
|
|
910
910
|
const command = sctx.get("commandUi");
|
|
911
911
|
sctx.effect(() => {
|
|
912
912
|
const dispose = command.register({
|
|
913
913
|
name: "free-search-engine",
|
|
914
|
-
description: "
|
|
914
|
+
description: "切换搜索引擎 / Switch web search engine",
|
|
915
915
|
available: () => true,
|
|
916
916
|
ui: {
|
|
917
917
|
kind: "popupSelect",
|
|
@@ -921,8 +921,8 @@ react_jsx_runtime.jsx("div", {
|
|
|
921
921
|
const current = view?.value?.provider ?? "bing";
|
|
922
922
|
return ENGINES.map((e) => ({
|
|
923
923
|
id: e.id,
|
|
924
|
-
label: `${e.label}${e.badge === "FREE" ? "
|
|
925
|
-
detail: e.id === current ? (view?.value?.lang === "en" ? "current" : "
|
|
924
|
+
label: `${e.label}${e.badge === "FREE" ? " · 免费" : " · API Key"}`,
|
|
925
|
+
detail: e.id === current ? (view?.value?.lang === "en" ? "current" : "当前") : undefined,
|
|
926
926
|
active: e.id === current,
|
|
927
927
|
}));
|
|
928
928
|
},
|
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
import { SettingsConflictError, SettingsProvider } from "@deepseek-ai/dsh-settings";
|
|
2
2
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
3
3
|
import z from "@deepseek-ai/schemastery";
|
|
4
4
|
import fs from "node:fs";
|
|
@@ -20,14 +20,14 @@ const BRIDGE_PREFIX = "/api/dsh-free-search-settings";
|
|
|
20
20
|
const FREE_ENGINES = ["ddg", "ddg-lite", "bing", "searxng", "anysearch"];
|
|
21
21
|
const ALL_ENGINES = ["ddg", "ddg-lite", "bing", "searxng", "anysearch", "exa", "tavily", "keenable", "perplexity", "deepseek-official"];
|
|
22
22
|
|
|
23
|
-
//
|
|
24
|
-
const PLUGIN_VERSION = "0.4.
|
|
25
|
-
//
|
|
23
|
+
// 当前插件版本(发布时与 package.json 同步)
|
|
24
|
+
const PLUGIN_VERSION = "0.4.22";
|
|
25
|
+
// 检查更新的 npm registry 元数据地址(dsh-free-search 是 npmjs 上的公开包)
|
|
26
26
|
const NPM_REGISTRY_URL = "https://registry.npmjs.org/dsh-free-search/latest";
|
|
27
27
|
const PLUGIN_NPM_URL = "https://www.npmjs.com/package/dsh-free-search";
|
|
28
28
|
const PLUGIN_REPO_URL = "https://github.com/DDDMUC/dsh-free-search";
|
|
29
29
|
|
|
30
|
-
//
|
|
30
|
+
// 查询 npm registry 的最新版本;失败时返回 null(网络/代理问题不阻塞设置页)
|
|
31
31
|
async function fetchLatestVersion(signal) {
|
|
32
32
|
const controller = new AbortController();
|
|
33
33
|
const timer = setTimeout(() => controller.abort(), 10000);
|
|
@@ -49,7 +49,7 @@ async function fetchLatestVersion(signal) {
|
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
//
|
|
52
|
+
// 简单的 semver 比较(仅处理 x.y.z 主/次/补丁,忽略预发布标签);a>b 返回 1, a<b 返回 -1, 相等返回 0
|
|
53
53
|
function compareVersions(a, b) {
|
|
54
54
|
const na = String(a).split(".").map((n) => parseInt(n, 10) || 0);
|
|
55
55
|
const nb = String(b).split(".").map((n) => parseInt(n, 10) || 0);
|
|
@@ -60,8 +60,8 @@ function compareVersions(a, b) {
|
|
|
60
60
|
return 0;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
//
|
|
64
|
-
// symlink
|
|
63
|
+
// 检测插件的安装模式:遍历 profiles/*/node_modules/dsh-free-search,
|
|
64
|
+
// symlink(link: 本地开发)→ isLink=true;npm 真安装 → isLink=false;找不到 → null
|
|
65
65
|
function detectInstallMode() {
|
|
66
66
|
const profilesDir = path.join(process.cwd(), "profiles");
|
|
67
67
|
let found = null;
|
|
@@ -80,7 +80,7 @@ function detectInstallMode() {
|
|
|
80
80
|
return found;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
// time_range
|
|
83
|
+
// time_range 支持:固定档 day/week/month/year,或自定义(相对 12h/3d/2mo/1y、绝对 YYYY-MM-DD)
|
|
84
84
|
const TIME_RANGES = ["day", "week", "month", "year"];
|
|
85
85
|
const DAYS_BY_RANGE = { day: 1, week: 7, month: 30, year: 365 };
|
|
86
86
|
const KEENABLE_REL = { day: "1d", week: "7d", month: "1mo", year: "1y" };
|
|
@@ -90,12 +90,12 @@ function isoDaysAgo(days) {
|
|
|
90
90
|
return new Date(Date.now() - days * 86_400_000).toISOString().replace(/\.\d{3}Z$/, ".000Z");
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
93
|
+
// 把用户/agent 给的 timeRange 解析成统一对象:{ days } 相对天数,或 { after } 绝对日期。
|
|
94
|
+
// 输入支持:day/week/month/year、12h/3d/2mo/1y、2026-07-01,或已解析的 {days}/{after} 对象。
|
|
95
|
+
// 无效返回 undefined。
|
|
96
96
|
function parseTimeRange(input) {
|
|
97
97
|
if (input === undefined || input === null) return undefined;
|
|
98
|
-
//
|
|
98
|
+
// 已解析对象:直接透传
|
|
99
99
|
if (typeof input === "object") {
|
|
100
100
|
if (typeof input.after === "string" && /^\d{4}-\d{2}-\d{2}$/.test(input.after)) return { after: input.after };
|
|
101
101
|
if (typeof input.days === "number" && Number.isFinite(input.days) && input.days > 0) return { days: input.days };
|
|
@@ -116,7 +116,7 @@ function parseTimeRange(input) {
|
|
|
116
116
|
return undefined;
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
-
//
|
|
119
|
+
// 把自定义天数映射到只支持固定档的引擎(Tavily / SearXNG / DDG)的最近似档位
|
|
120
120
|
function approximateTimeRange(days) {
|
|
121
121
|
if (days <= 2) return "day";
|
|
122
122
|
if (days <= 14) return "week";
|
|
@@ -136,18 +136,18 @@ function decodeEntities(text) {
|
|
|
136
136
|
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)));
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
-
//#region
|
|
139
|
+
//#region 结果缓存(防限流/省额度,LRU 50 条,TTL 可配置 0-5 分钟)
|
|
140
140
|
const CACHE_MAX_ENTRIES = 50;
|
|
141
|
-
// fallback
|
|
142
|
-
//
|
|
141
|
+
// fallback 条目(实际引擎 ≠ 首选引擎时)TTL = 配置 TTL 的 1/5(默认 5 分钟 → 60s):
|
|
142
|
+
// 首选引擎恢复后最多 1 分钟即可拿到新结果,避免回退结果被完整 TTL 钉死;首选成功条目仍用完整 TTL。
|
|
143
143
|
|
|
144
144
|
function buildCacheKey(query, maxResults, timeRangeLabel, preferred) {
|
|
145
145
|
return [query ?? "", maxResults ?? 5, timeRangeLabel ?? "", preferred].join("\u0000");
|
|
146
146
|
}
|
|
147
147
|
//#endregion
|
|
148
148
|
|
|
149
|
-
//
|
|
150
|
-
//
|
|
149
|
+
// 统一的 snippet 清洗:剔除登录/付费墙/订阅等噪音短语,折叠空白,限制长度。
|
|
150
|
+
// 只在回退链出口统一应用,各引擎内部不做,避免重复处理。
|
|
151
151
|
const SNIPPET_NOISE =
|
|
152
152
|
/\b(sign up|sign in|log in|login|subscribe( to| for)?|member[- ]?only|become a member|create (a )?free account|read more|continue reading|story continues|get started|install (the )?app|view on|medium membership|join \w+ for free|get updates from this writer|stories in your inbox|remember me for|unlock this|free to read|become a patron)\b/gi;
|
|
153
153
|
|
|
@@ -193,7 +193,7 @@ function uniqueSources(sources, limit) {
|
|
|
193
193
|
}
|
|
194
194
|
|
|
195
195
|
async function fetchHtml(url, signal) {
|
|
196
|
-
//
|
|
196
|
+
// 单次请求超时 12s,避免挂起被当成 Connection error
|
|
197
197
|
let response;
|
|
198
198
|
try {
|
|
199
199
|
const controller = new AbortController();
|
|
@@ -215,14 +215,14 @@ async function fetchHtml(url, signal) {
|
|
|
215
215
|
throw new Error(`HTTP ${response.status} from ${url.split("?")[0]}`);
|
|
216
216
|
}
|
|
217
217
|
const html = await response.text();
|
|
218
|
-
// DuckDuckGo
|
|
218
|
+
// DuckDuckGo 反爬验证页检测(HTTP 202 或验证关键字)
|
|
219
219
|
if (response.status === 202 || /anomaly|captcha|unusual traffic|robot check/i.test(html.slice(0, 4000))) {
|
|
220
220
|
throw new Error("DuckDuckGo is rate-limited right now (anti-bot challenge, usually temporary) - Bing works");
|
|
221
221
|
}
|
|
222
222
|
return html;
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
-
//
|
|
225
|
+
// 带重试的抓取:网络错误/空结果时重试,间隔 1.5s,最多 3 次
|
|
226
226
|
async function fetchHtmlWithRetry(url, signal) {
|
|
227
227
|
let lastError;
|
|
228
228
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
@@ -241,10 +241,10 @@ async function fetchHtmlWithRetry(url, signal) {
|
|
|
241
241
|
async function searchDdgHtml(query, maxResults, options, signal) {
|
|
242
242
|
const params = new URLSearchParams({ q: query });
|
|
243
243
|
if (options?.region) params.set("kl", options.region);
|
|
244
|
-
// DDG
|
|
244
|
+
// DDG 安全搜索:off(adlt=-1) / moderate(adlt=0) / strict(adlt=1)
|
|
245
245
|
const adlt = options?.safeSearch ?? "off";
|
|
246
246
|
params.set("adlt", adlt === "strict" ? "1" : adlt === "moderate" ? "0" : "-1");
|
|
247
|
-
// DDG
|
|
247
|
+
// DDG 时间过滤:df=d/w/m/y(只支持固定档,自定义取近似档)
|
|
248
248
|
if (options?.timeRange) {
|
|
249
249
|
const df = { day: "d", week: "w", month: "m", year: "y" }[approximateTimeRange(options.timeRange.days ?? 7)];
|
|
250
250
|
if (df) params.set("df", df);
|
|
@@ -273,7 +273,7 @@ async function searchDdgLite(query, maxResults, options, signal) {
|
|
|
273
273
|
const params = new URLSearchParams({ q: query });
|
|
274
274
|
const adlt = options?.safeSearch ?? "off";
|
|
275
275
|
params.set("adlt", adlt === "strict" ? "1" : adlt === "moderate" ? "0" : "-1");
|
|
276
|
-
// DDG Lite
|
|
276
|
+
// DDG Lite 同样支持 df 时间过滤
|
|
277
277
|
if (options?.timeRange) {
|
|
278
278
|
const df = { day: "d", week: "w", month: "m", year: "y" }[approximateTimeRange(options.timeRange.days ?? 7)];
|
|
279
279
|
if (df) params.set("df", df);
|
|
@@ -336,12 +336,12 @@ async function searchSearxng(query, maxResults, options, signal) {
|
|
|
336
336
|
const instances = options?.searxngInstances?.length
|
|
337
337
|
? options.searxngInstances
|
|
338
338
|
: SEARXNG_INSTANCES;
|
|
339
|
-
//
|
|
339
|
+
// 聚合所有实例的失败原因,避免只显示最后一个实例的错误
|
|
340
340
|
const errors = [];
|
|
341
341
|
for (const base of instances) {
|
|
342
342
|
try {
|
|
343
343
|
const params = new URLSearchParams({ q: query, format: "json" });
|
|
344
|
-
// SearXNG
|
|
344
|
+
// SearXNG 原生支持 time_range 过滤(只支持固定档,自定义取近似档)
|
|
345
345
|
if (options?.timeRange) {
|
|
346
346
|
const tr = SEARXNG_TIME[approximateTimeRange(options.timeRange.days ?? 7)];
|
|
347
347
|
if (tr) params.set("time_range", tr);
|
|
@@ -381,9 +381,9 @@ async function searchSearxng(query, maxResults, options, signal) {
|
|
|
381
381
|
errors.push(`${base}: ${message}`);
|
|
382
382
|
}
|
|
383
383
|
}
|
|
384
|
-
//
|
|
384
|
+
// 空实例列表兜底:避免 "all SearXNG instances failed: " 尾巴悬空
|
|
385
385
|
const detail = errors.length > 0 ? errors.join(", ") : "no instances configured";
|
|
386
|
-
// Note
|
|
386
|
+
// Note 会引用这个错误消息,截断避免 6 实例全挂时刷屏
|
|
387
387
|
throw new Error(`all SearXNG instances failed: ${detail.slice(0, 300)}`);
|
|
388
388
|
}
|
|
389
389
|
//#endregion
|
|
@@ -392,7 +392,7 @@ async function searchSearxng(query, maxResults, options, signal) {
|
|
|
392
392
|
const ANYSEARCH_URL = "https://api.anysearch.com/v1/search";
|
|
393
393
|
const EXA_MCP_URL = "https://mcp.exa.ai/mcp";
|
|
394
394
|
|
|
395
|
-
// AnySearch:
|
|
395
|
+
// AnySearch: 免费匿名额度(无 key),结构化 JSON 结果
|
|
396
396
|
async function searchAnysearch(query, maxResults, signal) {
|
|
397
397
|
const controller = new AbortController();
|
|
398
398
|
const timer = setTimeout(() => controller.abort(), 12000);
|
|
@@ -429,7 +429,7 @@ async function searchAnysearch(query, maxResults, signal) {
|
|
|
429
429
|
};
|
|
430
430
|
}
|
|
431
431
|
|
|
432
|
-
// Exa MCP:
|
|
432
|
+
// Exa MCP: 匿名公开 MCP(无 key),web_search_exa 工具
|
|
433
433
|
async function searchExaMCP(query, maxResults, signal) {
|
|
434
434
|
const controller = new AbortController();
|
|
435
435
|
const timer = setTimeout(() => controller.abort(), 20000);
|
|
@@ -457,7 +457,7 @@ async function searchExaMCP(query, maxResults, signal) {
|
|
|
457
457
|
}
|
|
458
458
|
if (!response.ok) throw new Error(`Exa MCP error (HTTP ${response.status})`);
|
|
459
459
|
const text = await response.text();
|
|
460
|
-
//
|
|
460
|
+
// 解析 SSE 格式:event: message\ndata: {...}
|
|
461
461
|
const lines = text.split("\n");
|
|
462
462
|
let json = null;
|
|
463
463
|
for (const line of lines) {
|
|
@@ -477,7 +477,7 @@ async function searchExaMCP(query, maxResults, signal) {
|
|
|
477
477
|
.filter((b) => b.type === "text")
|
|
478
478
|
.map((b) => b.text)
|
|
479
479
|
.join("\n");
|
|
480
|
-
//
|
|
480
|
+
// 解析 "Title: X\nURL: Y\nPublished: Z\nHighlights:\n..."
|
|
481
481
|
const blocks = textBlocks.split(/\n(?=Title:)/);
|
|
482
482
|
for (const block of blocks) {
|
|
483
483
|
const title = block.match(/^Title: (.+)$/m)?.[1];
|
|
@@ -489,7 +489,7 @@ async function searchExaMCP(query, maxResults, signal) {
|
|
|
489
489
|
url,
|
|
490
490
|
...(title ? { title } : {}),
|
|
491
491
|
...(highlights ? { snippet: highlights.slice(0, 300) } : {}),
|
|
492
|
-
//
|
|
492
|
+
// 只保留日期形态(ISO 或 YYYY-MM-DD),过滤 "N/A" 等占位符
|
|
493
493
|
...(published && /^\d{4}-\d{2}-\d{2}/.test(published) ? { publishedAt: published } : {}),
|
|
494
494
|
});
|
|
495
495
|
}
|
|
@@ -523,7 +523,7 @@ async function searchGithub(query, maxResults, signal) {
|
|
|
523
523
|
sources: (data.items ?? []).map((item) => ({
|
|
524
524
|
url: item.html_url,
|
|
525
525
|
title: item.full_name ?? item.name,
|
|
526
|
-
snippet: `${item.description ?? ""}${item.stargazers_count ? `
|
|
526
|
+
snippet: `${item.description ?? ""}${item.stargazers_count ? ` ⭐${item.stargazers_count}` : ""}`.trim(),
|
|
527
527
|
})),
|
|
528
528
|
truncated: false,
|
|
529
529
|
};
|
|
@@ -617,11 +617,11 @@ async function searchHackerNews(query, maxResults, signal) {
|
|
|
617
617
|
sources: (data.hits ?? [])
|
|
618
618
|
.filter((h) => h.title || h.story_title)
|
|
619
619
|
.map((h) => ({
|
|
620
|
-
//
|
|
620
|
+
// 有外链用外链,纯讨论帖用 HN 讨论页
|
|
621
621
|
url: h.url ?? `https://news.ycombinator.com/item?id=${h.objectID}`,
|
|
622
622
|
title: h.title ?? h.story_title,
|
|
623
623
|
...((h.points !== undefined && h.points !== null) || (h.num_comments !== undefined && h.num_comments !== null)
|
|
624
|
-
? { snippet: `HN discussion
|
|
624
|
+
? { snippet: `HN discussion · ${h.points ?? 0} points · ${h.num_comments ?? 0} comments` }
|
|
625
625
|
: {}),
|
|
626
626
|
})),
|
|
627
627
|
truncated: false,
|
|
@@ -644,7 +644,7 @@ async function searchStackOverflow(query, maxResults, signal) {
|
|
|
644
644
|
url: it.link,
|
|
645
645
|
title: it.title,
|
|
646
646
|
...(it.score !== undefined || it.answer_count !== undefined
|
|
647
|
-
? { snippet: `${it.is_answered ? "
|
|
647
|
+
? { snippet: `${it.is_answered ? "✓ answered" : "unanswered"} · score ${it.score ?? 0} · ${it.answer_count ?? 0} answers` }
|
|
648
648
|
: {}),
|
|
649
649
|
})),
|
|
650
650
|
truncated: false,
|
|
@@ -666,7 +666,7 @@ async function searchWikipedia(query, maxResults, signal, lang) {
|
|
|
666
666
|
sources: (data.query?.search ?? []).map((s) => ({
|
|
667
667
|
url: `https://${host}/wiki/${encodeURIComponent(String(s.title).replace(/ /g, "_"))}`,
|
|
668
668
|
title: s.title,
|
|
669
|
-
// snippet
|
|
669
|
+
// snippet 含 <span class="searchmatch"> 高亮标签,剥掉
|
|
670
670
|
...(s.snippet ? { snippet: stripTags(s.snippet).slice(0, 200) } : {}),
|
|
671
671
|
})),
|
|
672
672
|
truncated: false,
|
|
@@ -691,7 +691,7 @@ async function searchNpm(query, maxResults, signal) {
|
|
|
691
691
|
url: p.links?.npm ?? `https://www.npmjs.com/package/${p.name}`,
|
|
692
692
|
title: p.name,
|
|
693
693
|
...((p.description || p.version)
|
|
694
|
-
? { snippet: `v${p.version ?? "?"}${p.description ? `
|
|
694
|
+
? { snippet: `v${p.version ?? "?"}${p.description ? ` — ${String(p.description).slice(0, 160)}` : ""}` }
|
|
695
695
|
: {}),
|
|
696
696
|
})),
|
|
697
697
|
truncated: false,
|
|
@@ -731,7 +731,7 @@ async function searchExa(query, maxResults, apiKey, timeRange, signal) {
|
|
|
731
731
|
contents: { highlights: { highlightsPerUrl: 1 } },
|
|
732
732
|
...(maxResults !== undefined ? { numResults: maxResults } : {}),
|
|
733
733
|
};
|
|
734
|
-
// Exa
|
|
734
|
+
// Exa 时间过滤:startPublishedDate(ISO 日期;支持任意天数和绝对日期)
|
|
735
735
|
if (timeRange) {
|
|
736
736
|
if (timeRange.after) body.startPublishedDate = timeRange.after;
|
|
737
737
|
else if (timeRange.days !== undefined) body.startPublishedDate = isoDaysAgo(timeRange.days);
|
|
@@ -771,7 +771,7 @@ async function searchExa(query, maxResults, apiKey, timeRange, signal) {
|
|
|
771
771
|
return { sources: uniqueSources(sources, maxResults ?? 10), truncated: false };
|
|
772
772
|
}
|
|
773
773
|
|
|
774
|
-
// Tavily:
|
|
774
|
+
// Tavily: 无 key 走 keyless(免费匿名额度),有 key 走账号档(Bearer)
|
|
775
775
|
async function searchTavily(query, maxResults, apiKey, timeRange, signal) {
|
|
776
776
|
const controller = new AbortController();
|
|
777
777
|
const timer = setTimeout(() => controller.abort(), 15000);
|
|
@@ -784,7 +784,7 @@ async function searchTavily(query, maxResults, apiKey, timeRange, signal) {
|
|
|
784
784
|
max_results: Math.min(maxResults ?? 5, 20),
|
|
785
785
|
search_depth: "basic",
|
|
786
786
|
};
|
|
787
|
-
// Tavily
|
|
787
|
+
// Tavily 时间过滤:time_range 只支持固定档,自定义天数取最近似档位
|
|
788
788
|
if (timeRange) {
|
|
789
789
|
const tr = approximateTimeRange(timeRange.days ?? 7);
|
|
790
790
|
if (tr) body.time_range = tr;
|
|
@@ -825,7 +825,7 @@ async function searchTavily(query, maxResults, apiKey, timeRange, signal) {
|
|
|
825
825
|
return { sources: uniqueSources(sources, maxResults ?? 10), truncated: false };
|
|
826
826
|
}
|
|
827
827
|
|
|
828
|
-
//
|
|
828
|
+
// 把任意天数转成 Keenable 的相对时间格式(12h / Nd / Nmo / Ny)
|
|
829
829
|
function formatKeenableRelative(days) {
|
|
830
830
|
if (days <= 0.5) return "12h";
|
|
831
831
|
if (days < 1) return `${Math.round(days * 24)}h`;
|
|
@@ -834,7 +834,7 @@ function formatKeenableRelative(days) {
|
|
|
834
834
|
return `${Math.round(days / 365)}y`;
|
|
835
835
|
}
|
|
836
836
|
|
|
837
|
-
// Keenable:
|
|
837
|
+
// Keenable: 有 key 走 REST API(X-API-Key),无 key 走 keyless MCP(免费匿名额度)
|
|
838
838
|
function extractKeenableSources(text, maxResults) {
|
|
839
839
|
const sources = [];
|
|
840
840
|
const blocks = String(text).split(/\n(?=Title:)/);
|
|
@@ -848,7 +848,7 @@ function extractKeenableSources(text, maxResults) {
|
|
|
848
848
|
url,
|
|
849
849
|
...(title ? { title } : {}),
|
|
850
850
|
...(snippets ? { snippet: snippets.slice(0, 300) } : {}),
|
|
851
|
-
//
|
|
851
|
+
// 与 Exa MCP 一致:只保留日期形态,过滤 "N/A" 等占位符
|
|
852
852
|
...(published && /^\d{4}-\d{2}-\d{2}/.test(published) ? { publishedAt: published } : {}),
|
|
853
853
|
});
|
|
854
854
|
}
|
|
@@ -863,7 +863,7 @@ async function searchKeenableREST(query, maxResults, apiKey, timeRange, signal)
|
|
|
863
863
|
let response;
|
|
864
864
|
try {
|
|
865
865
|
const body = { query, mode: "realtime" };
|
|
866
|
-
// Keenable
|
|
866
|
+
// Keenable 时间过滤:published_after(相对 12h/7d/1mo/1y 或绝对 YYYY-MM-DD)
|
|
867
867
|
if (timeRange) {
|
|
868
868
|
if (timeRange.after) body.published_after = timeRange.after;
|
|
869
869
|
else if (timeRange.days !== undefined) body.published_after = formatKeenableRelative(timeRange.days);
|
|
@@ -908,7 +908,7 @@ async function searchKeenableMCP(query, maxResults, timeRange, signal) {
|
|
|
908
908
|
let response;
|
|
909
909
|
try {
|
|
910
910
|
const arguments_ = { query };
|
|
911
|
-
// Keenable MCP
|
|
911
|
+
// Keenable MCP 支持 published_after(相对或绝对日期)
|
|
912
912
|
if (timeRange) {
|
|
913
913
|
if (timeRange.after) arguments_.published_after = timeRange.after;
|
|
914
914
|
else if (timeRange.days !== undefined) arguments_.published_after = formatKeenableRelative(timeRange.days);
|
|
@@ -935,7 +935,7 @@ async function searchKeenableMCP(query, maxResults, timeRange, signal) {
|
|
|
935
935
|
const data = await response.json();
|
|
936
936
|
if (data.error) throw new Error(`Keenable MCP error: ${data.error?.message ?? "unknown"}`);
|
|
937
937
|
const content = data.result?.content ?? [];
|
|
938
|
-
// isError=true
|
|
938
|
+
// isError=true 时 content 里是错误文本
|
|
939
939
|
const text = content.filter((b) => b.type === "text").map((b) => b.text).join("\n");
|
|
940
940
|
if (data.result?.isError) throw new Error(`Keenable MCP error: ${text.slice(0, 200)}`);
|
|
941
941
|
return { sources: extractKeenableSources(text, maxResults ?? 10), truncated: false };
|
|
@@ -948,7 +948,7 @@ async function searchKeenable(query, maxResults, apiKey, timeRange, signal) {
|
|
|
948
948
|
|
|
949
949
|
async function searchPerplexity(query, maxResults, apiKey, signal) {
|
|
950
950
|
if (!apiKey) throw new Error("Perplexity search requires PERPLEXITY_API_KEY");
|
|
951
|
-
//
|
|
951
|
+
// 内置 20s 超时(与外部 signal 组合):调用方不传 signal 时也不会永久卡住
|
|
952
952
|
const response = await fetch("https://api.perplexity.ai/chat/completions", {
|
|
953
953
|
method: "POST",
|
|
954
954
|
redirect: "error",
|
|
@@ -984,7 +984,7 @@ async function searchPerplexity(query, maxResults, apiKey, signal) {
|
|
|
984
984
|
|
|
985
985
|
async function searchDeepSeekOfficial(query, maxResults, apiKey, signal) {
|
|
986
986
|
if (!apiKey) throw new Error("DeepSeek search requires DEEPSEEK_API_KEY");
|
|
987
|
-
//
|
|
987
|
+
// 内置 20s 超时(与外部 signal 组合):调用方不传 signal 时也不会永久卡住
|
|
988
988
|
const response = await fetch("https://api.deepseek.com/anthropic/v1/messages", {
|
|
989
989
|
method: "POST",
|
|
990
990
|
redirect: "error",
|
|
@@ -1131,13 +1131,13 @@ function makeBridgeRoutes(settings, search, testEngine, getCredentials) {
|
|
|
1131
1131
|
hasUpdate: cmp > 0,
|
|
1132
1132
|
updateUrl: PLUGIN_NPM_URL,
|
|
1133
1133
|
repoUrl: PLUGIN_REPO_URL,
|
|
1134
|
-
//
|
|
1134
|
+
// 可一键升级:npm 真安装时 true;本地 link 开发模式 false(升级请 git pull 源码)
|
|
1135
1135
|
installable: mode !== null && !mode.isLink,
|
|
1136
1136
|
installMode: mode?.isLink ? "link" : mode !== null ? "registry" : "unknown",
|
|
1137
1137
|
},
|
|
1138
1138
|
};
|
|
1139
1139
|
},
|
|
1140
|
-
//
|
|
1140
|
+
// 一键升级:仅在 npm 真安装模式下执行 pnpm 升级;link 模式拒绝(避免破坏本地开发链路)
|
|
1141
1141
|
async updatePlugin() {
|
|
1142
1142
|
const mode = detectInstallMode();
|
|
1143
1143
|
if (mode === null) {
|
|
@@ -1177,7 +1177,7 @@ function makeBridgeRoutes(settings, search, testEngine, getCredentials) {
|
|
|
1177
1177
|
}
|
|
1178
1178
|
const maxResults = Math.min(Math.max(Number(request.maxResults) || 5, 1), 10);
|
|
1179
1179
|
const timeRange = parseTimeRange(request.timeRange);
|
|
1180
|
-
//
|
|
1180
|
+
// 指定 engine:直测该引擎本身(不走回退链),报告它自己的可用性
|
|
1181
1181
|
if (typeof request.engine === "string" && request.engine.length > 0) {
|
|
1182
1182
|
if (typeof testEngine !== "function") {
|
|
1183
1183
|
return { ok: false, code: "search-unavailable", message: "engine test is not wired" };
|
|
@@ -1207,11 +1207,11 @@ function makeBridgeRoutes(settings, search, testEngine, getCredentials) {
|
|
|
1207
1207
|
return {
|
|
1208
1208
|
ok: true,
|
|
1209
1209
|
value: {
|
|
1210
|
-
//
|
|
1210
|
+
// 实际使用的引擎:provider.search 在成功时返回 provider 字段
|
|
1211
1211
|
provider: result.provider ?? request.engine ?? request.provider ?? "bing",
|
|
1212
1212
|
sources: result.sources ?? [],
|
|
1213
1213
|
content: result.content ?? "",
|
|
1214
|
-
//
|
|
1214
|
+
// 缓存命中标记:provider.search 成功路径标记 _cache(hit=命中缓存,miss=真实搜索)
|
|
1215
1215
|
cache: result._cache === "hit" ? "hit" : "miss",
|
|
1216
1216
|
},
|
|
1217
1217
|
};
|
|
@@ -1257,7 +1257,7 @@ function makeBridgeRoutes(settings, search, testEngine, getCredentials) {
|
|
|
1257
1257
|
}
|
|
1258
1258
|
return { ok: true, value: toView(descriptor) };
|
|
1259
1259
|
},
|
|
1260
|
-
//
|
|
1260
|
+
// 凭据中心:查询各引擎 key 的配置状态(value 不返回,只返回是否已配置)
|
|
1261
1261
|
async credentialsStatus() {
|
|
1262
1262
|
const credentials = getCredentials();
|
|
1263
1263
|
if (!credentials) return { ok: false, code: "credentials-unavailable", message: "credentials service is not available" };
|
|
@@ -1272,7 +1272,7 @@ function makeBridgeRoutes(settings, search, testEngine, getCredentials) {
|
|
|
1272
1272
|
}
|
|
1273
1273
|
return { ok: true, value: { configured, available: true } };
|
|
1274
1274
|
},
|
|
1275
|
-
//
|
|
1275
|
+
// 凭据中心:写入一个引擎 key(ref 白名单限定)
|
|
1276
1276
|
async credentialsSet(request) {
|
|
1277
1277
|
const credentials = getCredentials();
|
|
1278
1278
|
if (!credentials) return { ok: false, code: "credentials-unavailable", message: "credentials service is not available" };
|
|
@@ -1289,7 +1289,7 @@ function makeBridgeRoutes(settings, search, testEngine, getCredentials) {
|
|
|
1289
1289
|
return { ok: false, code: "credentials-write-failed", message: error instanceof Error ? error.message : String(error) };
|
|
1290
1290
|
}
|
|
1291
1291
|
},
|
|
1292
|
-
//
|
|
1292
|
+
// 凭据中心:删除一个引擎 key
|
|
1293
1293
|
async credentialsUnset(request) {
|
|
1294
1294
|
const credentials = getCredentials();
|
|
1295
1295
|
if (!credentials) return { ok: false, code: "credentials-unavailable", message: "credentials service is not available" };
|
|
@@ -1409,7 +1409,7 @@ function makeBridgeRoutes(settings, search, testEngine, getCredentials) {
|
|
|
1409
1409
|
const name = "web-search-free";
|
|
1410
1410
|
const inject = ["web"];
|
|
1411
1411
|
|
|
1412
|
-
//
|
|
1412
|
+
// 引擎 key 与凭据中心 ref 的映射(白名单:只允许这些 ref 被 UI 读写凭据中心)
|
|
1413
1413
|
const KEY_REF_MAP = {
|
|
1414
1414
|
exaApiKey: "EXA_API_KEY",
|
|
1415
1415
|
tavilyApiKey: "TAVILY_API_KEY",
|
|
@@ -1420,9 +1420,9 @@ const KEY_REF_MAP = {
|
|
|
1420
1420
|
|
|
1421
1421
|
const Config = z.object({
|
|
1422
1422
|
provider: z.string().default("bing"),
|
|
1423
|
-
cache: z.boolean().default(true), //
|
|
1424
|
-
cacheTtl: z.number().default(5), //
|
|
1425
|
-
keyStorage: z.string().default("credentials"), // key
|
|
1423
|
+
cache: z.boolean().default(true), // 单 query 结果缓存开关(防限流/省额度)
|
|
1424
|
+
cacheTtl: z.number().default(5), // 缓存时长(分钟),0-5 可配置(使用处再 clamp)
|
|
1425
|
+
keyStorage: z.string().default("credentials"), // key 存储位置:credentials(凭据中心)| settings(设置页)
|
|
1426
1426
|
lang: z.string().default("zh"),
|
|
1427
1427
|
region: z.string(),
|
|
1428
1428
|
bingMarket: z.string().default("zh-CN"),
|
|
@@ -1439,16 +1439,16 @@ const Config = z.object({
|
|
|
1439
1439
|
function apply(ctx, config) {
|
|
1440
1440
|
let current = () => config ?? {};
|
|
1441
1441
|
const logger = ctx.logger;
|
|
1442
|
-
// credentials
|
|
1442
|
+
// credentials 服务可能晚于本插件挂载(跨 bundle 顺序),运行期动态获取而不是 apply 时缓存
|
|
1443
1443
|
const getCredentials = () => ctx.get("credentials");
|
|
1444
1444
|
|
|
1445
|
-
//
|
|
1445
|
+
// 系统提示词动态刷新:设置变更时重新生成,避免显示旧引擎
|
|
1446
1446
|
let refreshPrompt = null;
|
|
1447
1447
|
|
|
1448
|
-
//
|
|
1448
|
+
// 单 query 结果缓存(provider.search 内闭包持有):LRU 50 条 / TTL 可配置
|
|
1449
1449
|
const searchCache = new Map(); // key -> { value, expiresAt }
|
|
1450
1450
|
|
|
1451
|
-
// key
|
|
1451
|
+
// key 优先级:credentials(.credentials.yaml 凭据中心,官方推荐)> settings 的 free-search.<x>ApiKey(遗留兼容)> 环境变量
|
|
1452
1452
|
const resolveApiKey = async (envName, settingsKey) => {
|
|
1453
1453
|
const credentials = getCredentials();
|
|
1454
1454
|
if (credentials) {
|
|
@@ -1462,32 +1462,32 @@ function apply(ctx, config) {
|
|
|
1462
1462
|
return process.env[envName] ?? "";
|
|
1463
1463
|
};
|
|
1464
1464
|
|
|
1465
|
-
//
|
|
1466
|
-
//
|
|
1467
|
-
//
|
|
1465
|
+
// 总控 provider:按 settings 的 provider 字段路由到任意引擎。
|
|
1466
|
+
// 任何引擎失败(缺 key / 401 / 限流 / 网络)都会自动轮流尝试下一个引擎,
|
|
1467
|
+
// 直到成功或全部失败。并在结果里附带回退提示,避免 agent 搜索直接失败。
|
|
1468
1468
|
const provider = {
|
|
1469
1469
|
id: "ddg",
|
|
1470
1470
|
available() {
|
|
1471
1471
|
return true;
|
|
1472
1472
|
},
|
|
1473
|
-
//
|
|
1473
|
+
// 单 query 结果缓存:key=query+maxResults+timeRangeLabel+preferred,Map 天然 LRU
|
|
1474
1474
|
async search(request, signal) {
|
|
1475
|
-
//
|
|
1475
|
+
// 公共咽喉校验:web_search / advanced_search / raw-search 三条路径都经过这里
|
|
1476
1476
|
if (request === null || typeof request !== "object" || typeof request.query !== "string" || request.query.trim().length === 0) {
|
|
1477
1477
|
throw new Error("query is required");
|
|
1478
1478
|
}
|
|
1479
1479
|
const cfg = current();
|
|
1480
|
-
//
|
|
1480
|
+
// 首选引擎:free_search 工具显式指定(request.engine)优先于设置(cfg.provider)
|
|
1481
1481
|
const preferred =
|
|
1482
1482
|
typeof request.engine === "string" && ALL_ENGINES.includes(request.engine)
|
|
1483
1483
|
? request.engine
|
|
1484
1484
|
: cfg.provider ?? "bing";
|
|
1485
|
-
// time_range
|
|
1486
|
-
//
|
|
1485
|
+
// time_range 过滤(仅 advanced_search 工具透传;标准 web_search 无此参数)
|
|
1486
|
+
// 保留原始字符串用于 Note 展示;raw-search 桥可能已把 timeRange 解析成对象
|
|
1487
1487
|
const timeRange = parseTimeRange(request.timeRange);
|
|
1488
1488
|
const timeRangeLabel = typeof request.timeRange === "string" ? request.timeRange : String(timeRange?.days ?? timeRange?.after ?? "");
|
|
1489
1489
|
|
|
1490
|
-
//
|
|
1490
|
+
// 缓存 TTL(分钟,0-5 可配置);cache=false 或 ttl<=0 时完全禁用
|
|
1491
1491
|
const cacheTtlMs = (Math.min(Math.max(Number(cfg.cacheTtl) ?? 5, 0), 5)) * 60 * 1000;
|
|
1492
1492
|
const cacheEnabled = cfg.cache !== false && cacheTtlMs > 0;
|
|
1493
1493
|
const cacheKey = cacheEnabled
|
|
@@ -1499,31 +1499,31 @@ function apply(ctx, config) {
|
|
|
1499
1499
|
if (signal?.aborted) throw new Error("search aborted");
|
|
1500
1500
|
searchCache.delete(cacheKey);
|
|
1501
1501
|
searchCache.set(cacheKey, hit);
|
|
1502
|
-
//
|
|
1502
|
+
// 浅拷贝 + 私有标记:sources 数组也复制一层,彻底隔离缓存对象(调用方 push/改元素不影响缓存)
|
|
1503
1503
|
return { ...hit.value, sources: hit.value.sources?.slice(), _cache: "hit" };
|
|
1504
1504
|
}
|
|
1505
1505
|
if (hit) searchCache.delete(cacheKey);
|
|
1506
1506
|
}
|
|
1507
1507
|
|
|
1508
|
-
//
|
|
1508
|
+
// 统一引擎链:首选优先,然后其他付费引擎(有 key 的优先尝试),最后免费引擎
|
|
1509
1509
|
const paidEngines = ["exa", "tavily", "keenable", "perplexity", "deepseek-official"];
|
|
1510
1510
|
const freeEngines = ["bing", "anysearch", "ddg", "ddg-lite", "searxng"];
|
|
1511
|
-
//
|
|
1511
|
+
// 支持 time_range 过滤的引擎:tavily / exa / keenable / searxng / ddg / ddg-lite
|
|
1512
1512
|
const timeEngines = ["tavily", "exa", "keenable", "searxng", "ddg", "ddg-lite"];
|
|
1513
1513
|
let chain;
|
|
1514
|
-
//
|
|
1515
|
-
// - "time-filter"
|
|
1516
|
-
// - "failed"
|
|
1517
|
-
// - null
|
|
1514
|
+
// 首选引擎被跳过的原因(用于生成准确的 Note,避免误导 agent/用户):
|
|
1515
|
+
// - "time-filter":带 timeRange 且首选引擎不支持时间过滤(根本没尝试)
|
|
1516
|
+
// - "failed":首选引擎确实被尝试但失败(缺 key / 401 / 限流 / 0 结果 / 网络)
|
|
1517
|
+
// - null:首选引擎成功或无回退
|
|
1518
1518
|
let preferredSkippedReason = null;
|
|
1519
1519
|
if (timeRange) {
|
|
1520
|
-
//
|
|
1520
|
+
// 有时间过滤需求时,把支持过滤的引擎排前面(首选引擎若支持仍优先)
|
|
1521
1521
|
const preferredFirst = [preferred].filter((e) => timeEngines.includes(e));
|
|
1522
1522
|
const otherTime = timeEngines.filter((e) => e !== preferred);
|
|
1523
1523
|
const noTime = [...paidEngines, ...freeEngines].filter((e) => !timeEngines.includes(e) && e !== preferred);
|
|
1524
1524
|
chain = [...preferredFirst, ...otherTime, ...noTime];
|
|
1525
1525
|
if (!timeEngines.includes(preferred)) {
|
|
1526
|
-
//
|
|
1526
|
+
// 首选引擎不支持时间过滤 → 它不在链里,不会被尝试(这不等于失败)
|
|
1527
1527
|
preferredSkippedReason = "time-filter";
|
|
1528
1528
|
}
|
|
1529
1529
|
} else {
|
|
@@ -1534,9 +1534,9 @@ function apply(ctx, config) {
|
|
|
1534
1534
|
|
|
1535
1535
|
let lastError = null;
|
|
1536
1536
|
let usedEngine = null;
|
|
1537
|
-
//
|
|
1537
|
+
// 首选引擎若被尝试后失败,记录失败详情(用于 Note)
|
|
1538
1538
|
let preferredFailure = null;
|
|
1539
|
-
//
|
|
1539
|
+
// 总超时预算:串行回退时限制整条引擎链的总时长,防止各引擎超时累加达分钟级
|
|
1540
1540
|
const BUDGET_MS = 30000;
|
|
1541
1541
|
const deadline = Date.now() + BUDGET_MS;
|
|
1542
1542
|
for (const engine of chain) {
|
|
@@ -1544,7 +1544,7 @@ function apply(ctx, config) {
|
|
|
1544
1544
|
if (remaining <= 0) {
|
|
1545
1545
|
throw new Error(`search timed out after ${BUDGET_MS / 1000}s`);
|
|
1546
1546
|
}
|
|
1547
|
-
//
|
|
1547
|
+
// 组合外部取消 signal + 剩余预算超时:官方 web_search 取消、引擎超时、总预算都能触发
|
|
1548
1548
|
const effSignal = AbortSignal.any([...(signal !== undefined ? [signal] : []), AbortSignal.timeout(remaining)]);
|
|
1549
1549
|
try {
|
|
1550
1550
|
let result;
|
|
@@ -1559,7 +1559,7 @@ function apply(ctx, config) {
|
|
|
1559
1559
|
} else if (engine === "anysearch") {
|
|
1560
1560
|
result = await searchAnysearch(request.query, request.maxResults, effSignal);
|
|
1561
1561
|
} else if (engine === "exa") {
|
|
1562
|
-
// exa
|
|
1562
|
+
// exa:有 key 走 REST,无 key 走 keyless MCP(免费)
|
|
1563
1563
|
const key = await resolveApiKey("EXA_API_KEY", "exaApiKey");
|
|
1564
1564
|
if (key) {
|
|
1565
1565
|
result = await searchExa(request.query, request.maxResults, key, timeRange, effSignal);
|
|
@@ -1567,11 +1567,11 @@ function apply(ctx, config) {
|
|
|
1567
1567
|
result = await searchExaMCP(request.query, request.maxResults, effSignal);
|
|
1568
1568
|
}
|
|
1569
1569
|
} else if (engine === "tavily") {
|
|
1570
|
-
// tavily
|
|
1570
|
+
// tavily:有 key 走账号档,无 key 走 keyless(免费匿名额度)
|
|
1571
1571
|
const key = await resolveApiKey("TAVILY_API_KEY", "tavilyApiKey");
|
|
1572
1572
|
result = await searchTavily(request.query, request.maxResults, key, timeRange, effSignal);
|
|
1573
1573
|
} else if (engine === "keenable") {
|
|
1574
|
-
// keenable
|
|
1574
|
+
// keenable:有 key 走 REST,无 key 走 keyless MCP(免费)
|
|
1575
1575
|
const key = await resolveApiKey("KEENABLE_API_KEY", "keenableApiKey");
|
|
1576
1576
|
result = await searchKeenable(request.query, request.maxResults, key, timeRange, effSignal);
|
|
1577
1577
|
} else if (engine === "perplexity") {
|
|
@@ -1580,7 +1580,7 @@ function apply(ctx, config) {
|
|
|
1580
1580
|
lastError = new Error("Perplexity requires PERPLEXITY_API_KEY");
|
|
1581
1581
|
if (engine === preferred) preferredFailure = "PERPLEXITY_API_KEY is not configured";
|
|
1582
1582
|
logger.warn(`free-search: engine "${engine}" skipped (no key), trying next engine`);
|
|
1583
|
-
continue; //
|
|
1583
|
+
continue; // 无 key 跳过
|
|
1584
1584
|
}
|
|
1585
1585
|
result = await searchPerplexity(request.query, request.maxResults, key, effSignal);
|
|
1586
1586
|
} else if (engine === "deepseek-official") {
|
|
@@ -1589,7 +1589,7 @@ function apply(ctx, config) {
|
|
|
1589
1589
|
lastError = new Error("DeepSeek requires DEEPSEEK_API_KEY");
|
|
1590
1590
|
if (engine === preferred) preferredFailure = "DEEPSEEK_API_KEY is not configured";
|
|
1591
1591
|
logger.warn(`free-search: engine "${engine}" skipped (no key), trying next engine`);
|
|
1592
|
-
continue; //
|
|
1592
|
+
continue; // 无 key 跳过
|
|
1593
1593
|
}
|
|
1594
1594
|
result = await searchDeepSeekOfficial(request.query, request.maxResults, key, effSignal);
|
|
1595
1595
|
} else {
|
|
@@ -1598,11 +1598,11 @@ function apply(ctx, config) {
|
|
|
1598
1598
|
|
|
1599
1599
|
if (result.sources.length > 0) {
|
|
1600
1600
|
usedEngine = engine;
|
|
1601
|
-
//
|
|
1601
|
+
// 统一清洗 snippet:去登录/付费墙/订阅噪音,折叠空白(有值的才处理,保持 lossless JSON)
|
|
1602
1602
|
result.sources = result.sources.map((s) =>
|
|
1603
1603
|
s.snippet ? { ...s, snippet: cleanSnippet(s.snippet) } : s
|
|
1604
1604
|
);
|
|
1605
|
-
//
|
|
1605
|
+
// 用了非首选引擎时,在结果里附上准确提示(区分"不支持时间过滤被跳过"与"真实失败")
|
|
1606
1606
|
if (engine !== preferred) {
|
|
1607
1607
|
if (preferredSkippedReason === "time-filter") {
|
|
1608
1608
|
result.content = `Note: ${preferred} does not support time filtering (timeRange=${timeRangeLabel}), using ${engine}.`;
|
|
@@ -1612,10 +1612,10 @@ function apply(ctx, config) {
|
|
|
1612
1612
|
result.content = `Note: ${preferred} unavailable or failed, using ${engine}.`;
|
|
1613
1613
|
}
|
|
1614
1614
|
}
|
|
1615
|
-
//
|
|
1615
|
+
// 写入缓存(只缓存成功结果,失败走 throw 天然不缓存)
|
|
1616
1616
|
const cached = { ...result, provider: engine, engine: engine };
|
|
1617
1617
|
if (cacheKey !== null) {
|
|
1618
|
-
// fallback
|
|
1618
|
+
// fallback 条目(实际引擎≠首选)用配置 TTL 的 1/5,首选成功保持完整 TTL
|
|
1619
1619
|
const entryTtlMs = engine !== preferred ? Math.max(cacheTtlMs / 5, 1000) : cacheTtlMs;
|
|
1620
1620
|
searchCache.set(cacheKey, {
|
|
1621
1621
|
value: cached,
|
|
@@ -1648,7 +1648,7 @@ function apply(ctx, config) {
|
|
|
1648
1648
|
current = source;
|
|
1649
1649
|
},
|
|
1650
1650
|
onChange: () => {
|
|
1651
|
-
// settings
|
|
1651
|
+
// settings 变更时刷新系统提示词(显示最新引擎)
|
|
1652
1652
|
if (typeof refreshPrompt === "function") refreshPrompt();
|
|
1653
1653
|
},
|
|
1654
1654
|
});
|
|
@@ -1670,16 +1670,16 @@ function apply(ctx, config) {
|
|
|
1670
1670
|
|
|
1671
1671
|
ctx.web.registerSearchProvider(provider);
|
|
1672
1672
|
|
|
1673
|
-
//
|
|
1674
|
-
//
|
|
1675
|
-
//
|
|
1676
|
-
//
|
|
1673
|
+
// 运行时兜底:DSH 0.1.1+ 中 profile patch 的 config 会整体覆盖 bundle patch 的 config,
|
|
1674
|
+
// 用户的 `- id: web` patch(如只设 fetchProvider)会静默抹掉 searchProvider,导致回退 DeepSeek 官方搜索。
|
|
1675
|
+
// 这里在 provider 注册后检查:未指向任何 provider(undefined)时自动接管为本插件;
|
|
1676
|
+
// 用户显式配置了其他 provider 则不动。
|
|
1677
1677
|
if (!ctx.web.searchProviderId) {
|
|
1678
1678
|
ctx.web.searchProviderId = provider.id;
|
|
1679
1679
|
logger.info(`free-search: web.searchProvider was unset (patch override or missing config), taking over as "${provider.id}"`);
|
|
1680
1680
|
}
|
|
1681
1681
|
|
|
1682
|
-
//
|
|
1682
|
+
// 测试工具:让 agent 逐个测试所有搜索引擎,报告可用性
|
|
1683
1683
|
const runEngineTest = async (engine, query, timeRange) => {
|
|
1684
1684
|
const cfg = current();
|
|
1685
1685
|
const q = query || "DeepSeek Harness";
|
|
@@ -1725,9 +1725,9 @@ function apply(ctx, config) {
|
|
|
1725
1725
|
};
|
|
1726
1726
|
try {
|
|
1727
1727
|
const result = await attempt();
|
|
1728
|
-
//
|
|
1728
|
+
// 付费引擎无 key:直接透传失败结果
|
|
1729
1729
|
if (result.ok === false) return result;
|
|
1730
|
-
//
|
|
1730
|
+
// 免费引擎偶发反爬/空结果时重试一次
|
|
1731
1731
|
if (result.sources && result.sources.length === 0) {
|
|
1732
1732
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
1733
1733
|
return await attempt();
|
|
@@ -1815,7 +1815,7 @@ function apply(ctx, config) {
|
|
|
1815
1815
|
return { results };
|
|
1816
1816
|
},
|
|
1817
1817
|
finalizeContent(exec, result) {
|
|
1818
|
-
//
|
|
1818
|
+
// 把 render 输出包装成合法的 text block(content 必须是 block 数组)
|
|
1819
1819
|
const text = result.content;
|
|
1820
1820
|
if (typeof text === "string" && text.length > 0) {
|
|
1821
1821
|
return [{ type: "text", text }];
|
|
@@ -1830,7 +1830,7 @@ function apply(ctx, config) {
|
|
|
1830
1830
|
}, "free-search: test engines tool");
|
|
1831
1831
|
});
|
|
1832
1832
|
|
|
1833
|
-
//
|
|
1833
|
+
// 平台搜索工具:GitHub / V2EX / Bilibili / Reddit(公开 API,零依赖)
|
|
1834
1834
|
ctx.inject(["tools"], (sctx) => {
|
|
1835
1835
|
sctx.effect(() => {
|
|
1836
1836
|
const dispose = sctx.tools.register(
|
|
@@ -1882,7 +1882,7 @@ function apply(ctx, config) {
|
|
|
1882
1882
|
if (!PLATFORMS[platform]) {
|
|
1883
1883
|
throw new Error(`unknown platform "${platform}" - use one of: ${Object.keys(PLATFORMS).join(", ")}`);
|
|
1884
1884
|
}
|
|
1885
|
-
//
|
|
1885
|
+
// 平台开关:settings 里禁用某平台时,工具明确告知
|
|
1886
1886
|
const enabled = current().platforms ?? ["github", "v2ex", "bilibili", "reddit", "hn", "stackoverflow", "wikipedia", "npm"];
|
|
1887
1887
|
if (!enabled.includes(platform)) {
|
|
1888
1888
|
throw new Error(
|
|
@@ -1891,7 +1891,7 @@ function apply(ctx, config) {
|
|
|
1891
1891
|
}
|
|
1892
1892
|
const limit = Math.min(args.maxResults ?? 5, 10);
|
|
1893
1893
|
const result = await searchPlatform(platform, args.query, limit, undefined, current().lang);
|
|
1894
|
-
// lossless JSON
|
|
1894
|
+
// lossless JSON 不允许 undefined 字段:剔除缺失字段
|
|
1895
1895
|
const sources = (result.sources ?? []).map((s) => {
|
|
1896
1896
|
const source = {};
|
|
1897
1897
|
if (s.url !== undefined && s.url !== null && s.url !== "") source.url = s.url;
|
|
@@ -1914,8 +1914,8 @@ function apply(ctx, config) {
|
|
|
1914
1914
|
}, "free-search: platform search tool");
|
|
1915
1915
|
});
|
|
1916
1916
|
|
|
1917
|
-
//
|
|
1918
|
-
//
|
|
1917
|
+
// 高级搜索工具:支持时间过滤(time_range)和指定引擎(engine)。
|
|
1918
|
+
// 走与 web_search 相同的统一回退链,但允许 agent 显式请求"最近 N 天"的结果。
|
|
1919
1919
|
ctx.inject(["tools"], (sctx) => {
|
|
1920
1920
|
sctx.effect(() => {
|
|
1921
1921
|
const dispose = sctx.tools.register(
|
|
@@ -1975,10 +1975,10 @@ function apply(ctx, config) {
|
|
|
1975
1975
|
maxResults: Math.min(args.maxResults ?? 5, 10),
|
|
1976
1976
|
};
|
|
1977
1977
|
if (parseTimeRange(args.timeRange) !== undefined) request.timeRange = args.timeRange;
|
|
1978
|
-
// engine
|
|
1978
|
+
// engine 指定时:仅当该引擎可用才优先(仍走回退链,失败自动换引擎)
|
|
1979
1979
|
if (args.engine && ALL_ENGINES.includes(args.engine)) request.engine = args.engine;
|
|
1980
1980
|
const result = await provider.search(request);
|
|
1981
|
-
// lossless JSON
|
|
1981
|
+
// lossless JSON 不允许 undefined 字段:按存在的值构造对象,缺字段直接省略
|
|
1982
1982
|
return {
|
|
1983
1983
|
provider: result.provider ?? result._provider ?? "bing",
|
|
1984
1984
|
content: typeof result.content === "string" ? result.content : "",
|
|
@@ -2007,7 +2007,7 @@ function apply(ctx, config) {
|
|
|
2007
2007
|
}, "free-search: advanced search tool");
|
|
2008
2008
|
});
|
|
2009
2009
|
|
|
2010
|
-
//
|
|
2010
|
+
// 让 agent 知道可用搜索引擎(动态生成,随 key/设置变化)
|
|
2011
2011
|
ctx.inject(["systemPrompt"], (sctx) => {
|
|
2012
2012
|
let disposeSection = null;
|
|
2013
2013
|
refreshPrompt = () => {
|
|
@@ -2045,7 +2045,7 @@ function apply(ctx, config) {
|
|
|
2045
2045
|
"",
|
|
2046
2046
|
"For platform-specific searches (GitHub repos, V2EX threads, Bilibili videos, Reddit posts, Hacker News discussions, Stack Overflow questions, Wikipedia articles, npm packages), use the platform_search tool with platform: github|v2ex|bilibili|reddit|hn|stackoverflow|wikipedia|npm.",
|
|
2047
2047
|
"",
|
|
2048
|
-
"The user can switch the search engine themselves by typing /free-search-engine in the chat
|
|
2048
|
+
"The user can switch the search engine themselves by typing /free-search-engine in the chat — it opens a picker to choose an engine, just like the settings page. This changes the preferred engine; search still falls back to other engines automatically if it fails. You should not switch engines on your own; let the user decide.",
|
|
2049
2049
|
].join("\n"),
|
|
2050
2050
|
});
|
|
2051
2051
|
};
|
package/package.json
CHANGED
|
@@ -1,67 +1,67 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-free-search",
|
|
3
|
+
"version": "0.4.22",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./lib/index.js",
|
|
9
|
+
"./client": "./lib/client.js",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"lib/index.js",
|
|
14
|
+
"lib/client.js",
|
|
15
|
+
"cordis.patch.yml"
|
|
16
|
+
],
|
|
17
|
+
"keywords": [
|
|
18
|
+
"deepseek-harness",
|
|
19
|
+
"dsh",
|
|
20
|
+
"dsh-plugin",
|
|
21
|
+
"web-search",
|
|
22
|
+
"duckduckgo",
|
|
23
|
+
"bing",
|
|
24
|
+
"tavily",
|
|
25
|
+
"exa",
|
|
26
|
+
"keenable",
|
|
27
|
+
"perplexity",
|
|
28
|
+
"search",
|
|
29
|
+
"free"
|
|
30
|
+
],
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
},
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "https://github.com/DDDMUC/dsh-free-search.git"
|
|
38
|
+
},
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=20"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@deepseek-ai/schemastery": "^3.18.1"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"@deepseek-ai/dsh-settings": ">=0.1.0-rc.6",
|
|
47
|
+
"@deepseek-ai/dsh-tools": ">=0.1.0-rc.6"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@deepseek-ai/dsh-settings": "0.1.0-rc.7",
|
|
51
|
+
"@deepseek-ai/dsh-tools": "0.1.0-rc.7"
|
|
52
|
+
},
|
|
53
|
+
"dsh": {
|
|
54
|
+
"bundle": {
|
|
55
|
+
"patch": "cordis.patch.yml"
|
|
56
|
+
},
|
|
57
|
+
"client": {
|
|
58
|
+
"inject": [
|
|
59
|
+
"@deepseek-ai/dsh-client-runtime"
|
|
60
|
+
],
|
|
61
|
+
"platform": "web"
|
|
62
|
+
},
|
|
63
|
+
"engines": {
|
|
64
|
+
"dsh": ">=0.1.1-rc.1"
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|