@240xu/dsh-websearch 2.3.0 → 2.7.2
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 +52 -4
- package/lib/backends/anthropic-like.js +3 -13
- package/lib/backends/brave.js +4 -2
- package/lib/backends/ddg.js +1 -1
- package/lib/backends/exa.js +1 -1
- package/lib/backends/mojeek.js +4 -2
- package/lib/backends/openai.js +2 -2
- package/lib/backends/parallel.js +22 -8
- package/lib/backends/searxng.js +7 -2
- package/lib/backends/serper.js +4 -2
- package/lib/backends/tavily.js +4 -2
- package/lib/breaker.js +65 -0
- package/lib/cache.js +151 -0
- package/lib/client.js +75 -15
- package/lib/health.js +103 -0
- package/lib/history.js +163 -0
- package/lib/index.js +110 -11
- package/lib/prompting.js +105 -0
- package/lib/provider.js +206 -52
- package/lib/util/log.js +1 -1
- package/lib/util/mcp-client.js +15 -14
- package/lib/util/rank.js +1 -1
- package/package.json +2 -2
- package/lib/backends/index.js +0 -121
- package/lib/util/rpc.js +0 -262
package/lib/prompting.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-websearch — 系统性搜索提示词(纯函数,零依赖)。
|
|
3
|
+
*
|
|
4
|
+
* 依据权威资料(2024-2026 官方文档与一手论文)实现三项必做:
|
|
5
|
+
* 1. 查询整形(query shaping):后端扇出前对口语化长查询做确定性归一,
|
|
6
|
+
* 并导出给宿主/代理层使用的查询构造指引。
|
|
7
|
+
* 依据:Tavily Best Practices(agent 风格 ≤1500 字符查询、子查询分解)
|
|
8
|
+
* https://docs.tavily.com/documentation/best-practices/best-practices-search
|
|
9
|
+
* Exa Search Best Practices(语义丰富、描述性查询)
|
|
10
|
+
* https://exa.ai/docs/search-best-practices
|
|
11
|
+
* 2. 结果呈现头(result-presentation header):模型看到的结果块以「来源
|
|
12
|
+
* 甄别 + 引用要求」开头,要求每条事实注明来源链接、优先官方/一手来源、
|
|
13
|
+
* 时效敏感主题优先较新结果。
|
|
14
|
+
* 依据:Anthropic Web Search Tool(结果块置于问题之前、引用来源)
|
|
15
|
+
* https://platform.claude.com/docs/en/agents-and-tools/tool-use/web-search-tool
|
|
16
|
+
* OpenAI Prompt Engineering(分解 + 持久性指令)
|
|
17
|
+
* https://platform.openai.com/docs/guides/prompt-engineering
|
|
18
|
+
* 3. 多面查询分解指引(导出常量,供宿主/代理层按需使用;不对本插件内
|
|
19
|
+
* 的单次请求盲目多查询——盲目多查询可能劣化,见 arXiv:2404.01037)。
|
|
20
|
+
* 依据:RAG-Fusion / 多查询变体 https://github.com/Raudaschl/rag-fusion
|
|
21
|
+
* Step-Back Prompting https://arxiv.org/abs/2310.06117
|
|
22
|
+
* HyDE https://arxiv.org/abs/2212.10496
|
|
23
|
+
* @module dsh-websearch/prompting
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** 查询硬上限(Tavily:agent 查询应简短聚焦;>1500 字符视为跑题长文)。 */
|
|
27
|
+
export const QUERY_MAX_CHARS = 1500;
|
|
28
|
+
|
|
29
|
+
/** 去口语化前缀(中英常见寒暄/请求词,确定性剥离,不改写语义)。 */
|
|
30
|
+
const FILLER_PREFIX_RE =
|
|
31
|
+
/^(?:please|pls|kindly|could you(?: please)?|can you(?: please)?|help me(?: to)?|search for|search|look up|google|find me|find|i(?:'d| would)? like to know|tell me|what is|who is|when is|where is|"|「|『)\s*/i;
|
|
32
|
+
const FILLER_PREFIX_RE_ZH =
|
|
33
|
+
/^(?:请|麻烦|帮我?|帮忙|帮我查一?下|查一?下|查询|搜索一下?|搜一下?|查一下?|帮我找|找一下?|我想知道|请问)/;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 确定性查询整形:剥离口语前缀(循环多次以处理叠加寒暄)、压缩空白、
|
|
37
|
+
* 截断到 QUERY_MAX_CHARS。绝不改写实体/版本号/日期。
|
|
38
|
+
* @param {string} query
|
|
39
|
+
* @returns {string}
|
|
40
|
+
*/
|
|
41
|
+
export function shapeQuery(query) {
|
|
42
|
+
if (typeof query !== "string") return "";
|
|
43
|
+
let q = query.replace(/\s+/g, " ").trim();
|
|
44
|
+
// 中文寒暄与英文寒暄交替剥离,直到稳定。
|
|
45
|
+
for (let i = 0; i < 6; i++) {
|
|
46
|
+
const before = q;
|
|
47
|
+
q = q.replace(FILLER_PREFIX_RE, "").trim();
|
|
48
|
+
q = q.replace(FILLER_PREFIX_RE_ZH, "").trim();
|
|
49
|
+
if (q === before) break;
|
|
50
|
+
}
|
|
51
|
+
if (q.length > QUERY_MAX_CHARS) q = q.slice(0, QUERY_MAX_CHARS).trim();
|
|
52
|
+
return q;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 结果呈现头(英文)。置于模型可见结果块最前(Anthropic:结果先于问题
|
|
57
|
+
* 显著提升引用召回)。要求:逐条标注 [n]、优先官方/一手来源、时效敏感
|
|
58
|
+
* 主题优先较新结果、每条事实给出来源链接、与问题无关的条目忽略。
|
|
59
|
+
*/
|
|
60
|
+
export const RESULT_HEADER_EN =
|
|
61
|
+
"Web search results (merged from multiple engines, deduplicated by URL). " +
|
|
62
|
+
"Each entry: [n] title — URL. Prefer official / primary sources; prefer newer " +
|
|
63
|
+
"results for fast-moving topics; ignore entries whose snippet does not actually " +
|
|
64
|
+
"answer the question. Cite the URL for every fact you take from a result.";
|
|
65
|
+
|
|
66
|
+
/** 结果呈现头(中文)。 */
|
|
67
|
+
export const RESULT_HEADER_ZH =
|
|
68
|
+
"网页搜索结果(多引擎合并、已按 URL 去重)。每条格式:[n] 标题 — 链接。" +
|
|
69
|
+
"优先采用官方/一手来源;时效敏感主题优先较新结果;摘要与问题无关的条目直接忽略。" +
|
|
70
|
+
"引用结果中的任何事实时必须注明来源链接。";
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 查询构造指引(导出给宿主/代理层注入 agent 系统提示用;本插件不发送)。
|
|
74
|
+
* EN 面向 agent,ZH 为同义说明。要点:一条聚焦查询、保留专名/版本/日期、
|
|
75
|
+
* 多面请求按维度拆成多条子查询、首轮结果不佳时先写 2-3 句假想答案再以其
|
|
76
|
+
* 关键短语重搜(HyDE)、持续检索直到每个维度都有答案。
|
|
77
|
+
*/
|
|
78
|
+
export const QUERY_GUIDANCE_EN =
|
|
79
|
+
"When searching the web: issue ONE concise, agent-style query (keep named " +
|
|
80
|
+
"entities, version numbers and dates; drop conversational filler). If the " +
|
|
81
|
+
"request has 2+ independent facets, issue separate focused queries per facet " +
|
|
82
|
+
"and merge findings. If the first round returns poor results, write a 2-3 " +
|
|
83
|
+
"sentence hypothetical ideal answer and re-query with its key phrases. " +
|
|
84
|
+
"Keep searching until every facet is answered or demonstrably unavailable.";
|
|
85
|
+
|
|
86
|
+
/** 查询构造指引(中文版)。 */
|
|
87
|
+
export const QUERY_GUIDANCE_ZH =
|
|
88
|
+
"联网检索时:一次发出一条简洁的 agent 风格查询(保留专名/版本号/日期,去掉口语寒暄)。" +
|
|
89
|
+
"若请求含 2 个以上独立维度,按维度分别发起聚焦查询并汇总结论。" +
|
|
90
|
+
"首轮结果不佳时,先写 2-3 句假想的理想答案,再用其中的关键短语重新检索。" +
|
|
91
|
+
"持续检索直到每个维度都有答案或确认不可得。";
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 按语言取结果呈现头。
|
|
95
|
+
* @param {"zh"|"en"|string} [lang]
|
|
96
|
+
*/
|
|
97
|
+
export function resultHeader(lang) {
|
|
98
|
+
return String(lang || "").toLowerCase().startsWith("zh") ? RESULT_HEADER_ZH : RESULT_HEADER_EN;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 遥测行自标注(零成本修复):模型可能把后端失败遥测误读为结果来源;
|
|
103
|
+
* 显式声明「仅诊断,不是来源」。随结果块一并呈现。
|
|
104
|
+
*/
|
|
105
|
+
export const TELEMETRY_TAG = " (diagnostic only, not a source)";
|
package/lib/provider.js
CHANGED
|
@@ -22,9 +22,44 @@ import {
|
|
|
22
22
|
} from "./util/log.js";
|
|
23
23
|
import { dedupeSources, rerankSources } from "./util/rank.js";
|
|
24
24
|
import { normalizeFilters } from "./util/filters.js";
|
|
25
|
+
import { shapeQuery, resultHeader, TELEMETRY_TAG } from "./prompting.js";
|
|
26
|
+
import { cacheKeyFor } from "./cache.js";
|
|
27
|
+
import { foldBreaker, isCooled, cooledAnnotation } from "./breaker.js";
|
|
25
28
|
|
|
26
29
|
const PROVIDER_ID = "unified";
|
|
27
30
|
|
|
31
|
+
// Module-level per-backend health telemetry (id -> entry), updated on every
|
|
32
|
+
// backend completion during a real search. Purely passive: nothing writes here
|
|
33
|
+
// outside search(), so the read-only /api/unified-search/health route can
|
|
34
|
+
// report the LAST known reachability without firing probes or burning quota.
|
|
35
|
+
// Entry shape: { ok: boolean, at: ms, ms: duration, count?: number,
|
|
36
|
+
// error?: string (already truncated to 80), lastOkAt: ms|null }
|
|
37
|
+
const latestBackendHealth = new Map();
|
|
38
|
+
|
|
39
|
+
/** Read-only access for the health route (never mutate the returned map). */
|
|
40
|
+
export function getLatestBackendHealth() {
|
|
41
|
+
return latestBackendHealth;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function recordBackendHealth(id, entry, breakerCfg) {
|
|
45
|
+
const prev = latestBackendHealth.get(id);
|
|
46
|
+
const breaker = foldBreaker(prev, entry.ok === true, entry.at, breakerCfg ?? undefined);
|
|
47
|
+
latestBackendHealth.set(id, {
|
|
48
|
+
...entry,
|
|
49
|
+
lastOkAt: entry.ok === true ? entry.at : (prev && prev.lastOkAt) || null,
|
|
50
|
+
failCount: breaker.failCount,
|
|
51
|
+
cooledUntil: breaker.cooledUntil,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Read-only access to one backend's breaker state (failCount / cooledUntil).
|
|
57
|
+
*/
|
|
58
|
+
export function getBackendBreaker(id) {
|
|
59
|
+
const e = latestBackendHealth.get(id);
|
|
60
|
+
return e ? { failCount: e.failCount ?? 0, cooledUntil: e.cooledUntil ?? 0 } : { failCount: 0, cooledUntil: 0 };
|
|
61
|
+
}
|
|
62
|
+
|
|
28
63
|
/**
|
|
29
64
|
* Simple concurrency limiter using a semaphore pattern.
|
|
30
65
|
* No external dependency needed.
|
|
@@ -52,6 +87,47 @@ function createLimiter(concurrency) {
|
|
|
52
87
|
};
|
|
53
88
|
}
|
|
54
89
|
|
|
90
|
+
/**
|
|
91
|
+
* PHASE 1 (collect): fold settled fan-out results into the raw merge inputs.
|
|
92
|
+
* Sources keep fan-out priority order; dedupe/rank over the FULL set happens
|
|
93
|
+
* later (a mid-merge maxResults cap would silently drop later backends'
|
|
94
|
+
* entries before cross-backend fill-in and ranking could see them).
|
|
95
|
+
* URL scheme whitelist is enforced here — javascript:/data: payloads from
|
|
96
|
+
* compromised or poisoned backends never reach model/GUI consumers.
|
|
97
|
+
*/
|
|
98
|
+
function collectFanoutResults(results, eligible, signal) {
|
|
99
|
+
const collected = [];
|
|
100
|
+
let mergedContent;
|
|
101
|
+
let hardAborted = false;
|
|
102
|
+
let hardError = null;
|
|
103
|
+
const failures = [];
|
|
104
|
+
|
|
105
|
+
let backendIndex = 0;
|
|
106
|
+
for (const settled of results) {
|
|
107
|
+
const failedId = eligible[backendIndex] ? eligible[backendIndex].id : "unknown";
|
|
108
|
+
backendIndex++;
|
|
109
|
+
if (settled.status === "fulfilled") {
|
|
110
|
+
if (mergedContent === undefined && typeof settled.value?.content === "string" && settled.value.content.length > 0) {
|
|
111
|
+
mergedContent = settled.value.content;
|
|
112
|
+
}
|
|
113
|
+
for (const src of settled.value?.sources ?? []) {
|
|
114
|
+
if (src && typeof src.url === "string" && /^https?:\/\//i.test(src.url)) collected.push(src);
|
|
115
|
+
}
|
|
116
|
+
} else {
|
|
117
|
+
const err = settled.reason;
|
|
118
|
+
if (err instanceof WebError && err.code === "WEB_ABORTED") {
|
|
119
|
+
// Per-backend abort -> soft unless the caller signal fired.
|
|
120
|
+
if (signal?.aborted === true) hardAborted = true;
|
|
121
|
+
else failures.push({ id: failedId, err });
|
|
122
|
+
} else {
|
|
123
|
+
failures.push({ id: failedId, err });
|
|
124
|
+
if (hardError === null) hardError = err;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return { collected, mergedContent, hardAborted, hardError, failures };
|
|
129
|
+
}
|
|
130
|
+
|
|
55
131
|
/**
|
|
56
132
|
* Build the UnifiedSearchProvider.
|
|
57
133
|
*
|
|
@@ -60,7 +136,7 @@ function createLimiter(concurrency) {
|
|
|
60
136
|
* @param {Function} deps.resolveOptions () => resolved options snapshot
|
|
61
137
|
* @param {object} deps.backends keyed backend registry
|
|
62
138
|
*/
|
|
63
|
-
export function createUnifiedSearchProvider({ ctx, resolveOptions, backends }) {
|
|
139
|
+
export function createUnifiedSearchProvider({ ctx, resolveOptions, backends, cache, history }) {
|
|
64
140
|
return {
|
|
65
141
|
id: PROVIDER_ID,
|
|
66
142
|
|
|
@@ -87,19 +163,89 @@ export function createUnifiedSearchProvider({ ctx, resolveOptions, backends }) {
|
|
|
87
163
|
async search(request, signal) {
|
|
88
164
|
throwIfSearchAborted(signal);
|
|
89
165
|
const opts = resolveOptions();
|
|
90
|
-
const
|
|
166
|
+
const filters = normalizeFilters({
|
|
167
|
+
...(opts.filters ?? {}),
|
|
168
|
+
...(request?.filters ?? {}),
|
|
169
|
+
});
|
|
170
|
+
let effReq = { ...request, maxResults: Math.max(1, Number(request?.maxResults) || opts.numResults || 8) };
|
|
171
|
+
// 系统性查询整形(Tavily/Exa best practices):扇出前对口语化查询做
|
|
172
|
+
// 确定性归一 —— 剥离寒暄前缀、压缩空白、截断到 1500 字符;
|
|
173
|
+
// 专名/版本号/日期原样保留,不改写语义。
|
|
174
|
+
effReq.query = shapeQuery(effReq.query);
|
|
175
|
+
if (Object.keys(filters).length > 0) effReq = { ...effReq, filters };
|
|
176
|
+
const maxResults = effReq.maxResults;
|
|
91
177
|
const concurrency = opts.concurrency ?? 6;
|
|
92
178
|
const backendTimeoutMs = opts.backendTimeoutMs ?? 30000;
|
|
93
179
|
|
|
94
|
-
//
|
|
95
|
-
|
|
180
|
+
// v2.7.0 disk cache: keyed on (shaped query + filters + maxResults).
|
|
181
|
+
// A hit short-circuits the fan-out entirely; sources are byte-identical
|
|
182
|
+
// to the first search and content carries a "cache hit, age Ns" note.
|
|
183
|
+
// Cache failures never break the search (cache API never throws).
|
|
184
|
+
const cacheEnabled = opts.cacheEnabled === true && cache;
|
|
185
|
+
let cacheKey = null;
|
|
186
|
+
if (cacheEnabled) {
|
|
187
|
+
cacheKey = cacheKeyFor({ query: effReq.query, filters, maxResults, backends: opts.enabledBackends });
|
|
188
|
+
const hit = cache.get(cacheKey);
|
|
189
|
+
if (hit && Array.isArray(hit.value?.sources) && hit.value.sources.length > 0) {
|
|
190
|
+
// Defense in depth: the fresh path drops non-http(s) schemes before
|
|
191
|
+
// merge; a tampered cache file must not bypass that whitelist.
|
|
192
|
+
const safeSources = hit.value.sources.filter(
|
|
193
|
+
(src) => src && typeof src.url === "string" && /^https?:\/\//i.test(src.url),
|
|
194
|
+
);
|
|
195
|
+
if (safeSources.length === 0) {
|
|
196
|
+
// whole entry poisoned or filtered empty -> treat as a miss
|
|
197
|
+
cacheKey = null;
|
|
198
|
+
} else {
|
|
199
|
+
let hitContent = typeof hit.value.content === "string" ? hit.value.content : undefined;
|
|
200
|
+
const cacheNote = `[websearch cache] cache hit, age ${hit.age}s`;
|
|
201
|
+
hitContent = hitContent === undefined ? cacheNote : hitContent + "\n\n" + cacheNote;
|
|
202
|
+
history?.record({
|
|
203
|
+
query: request?.query,
|
|
204
|
+
time: Date.now(),
|
|
205
|
+
resultCount: hit.value.sources.length,
|
|
206
|
+
backendsOk: 0,
|
|
207
|
+
backendsTotal: 0,
|
|
208
|
+
});
|
|
209
|
+
return {
|
|
210
|
+
sources: safeSources,
|
|
211
|
+
...(hitContent !== undefined ? { content: hitContent } : {}),
|
|
212
|
+
truncated: hit.value.truncated === true,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Pick enabled & available backends. With the breaker enabled, a
|
|
219
|
+
// backend inside its 60s cooldown window is skipped (annotated in
|
|
220
|
+
// telemetry as "id ⏸cooled"). Fail-open: if EVERY candidate is cooled,
|
|
221
|
+
// run them all anyway — the breaker must never make search worse than
|
|
222
|
+
// no breaker.
|
|
223
|
+
const breakerCfg = opts.breakerEnabled === false ? null : {
|
|
224
|
+
threshold: opts.breakerThreshold,
|
|
225
|
+
cooldownMs: opts.breakerCooldownMs,
|
|
226
|
+
};
|
|
227
|
+
const nowMs = Date.now();
|
|
228
|
+
const candidates = [];
|
|
96
229
|
for (const id of opts.enabledBackends) {
|
|
97
230
|
const be = backends[id];
|
|
98
231
|
if (!be) continue;
|
|
99
232
|
const beOpts = opts.backends?.[id] ?? {};
|
|
100
233
|
const avail = be.available(beOpts);
|
|
101
234
|
if (!avail) continue;
|
|
102
|
-
|
|
235
|
+
candidates.push({ id, backend: be, beOpts });
|
|
236
|
+
}
|
|
237
|
+
const cooled = new Map();
|
|
238
|
+
let eligible = candidates;
|
|
239
|
+
if (breakerCfg) {
|
|
240
|
+
const active = candidates.filter((c) => {
|
|
241
|
+
if (isCooled(latestBackendHealth.get(c.id), nowMs)) {
|
|
242
|
+
cooled.set(c.id, latestBackendHealth.get(c.id));
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
return true;
|
|
246
|
+
});
|
|
247
|
+
if (active.length > 0) eligible = active;
|
|
248
|
+
else cooled.clear(); // all cooled -> fail-open
|
|
103
249
|
}
|
|
104
250
|
|
|
105
251
|
if (eligible.length === 0) {
|
|
@@ -112,16 +258,10 @@ export function createUnifiedSearchProvider({ ctx, resolveOptions, backends }) {
|
|
|
112
258
|
// Settings-level default filters, overridden by per-request filters
|
|
113
259
|
// (normalizeFilters drops unknown/invalid values so typos degrade to
|
|
114
260
|
// "no filter" instead of breaking the search).
|
|
115
|
-
const filters = normalizeFilters({
|
|
116
|
-
...(opts.filters ?? {}),
|
|
117
|
-
...(request?.filters ?? {}),
|
|
118
|
-
});
|
|
119
|
-
const effectiveRequest =
|
|
120
|
-
Object.keys(filters).length > 0 ? { ...request, filters } : request;
|
|
121
261
|
|
|
122
262
|
const telemetry = [];
|
|
123
263
|
// Concurrency limiter
|
|
124
|
-
const limit = createLimiter(
|
|
264
|
+
const limit = createLimiter(concurrency);
|
|
125
265
|
|
|
126
266
|
// Run one backend under the per-backend timeout. The internal timer's
|
|
127
267
|
// controller is merged into the caller's signal; when the INTERNAL timer
|
|
@@ -130,20 +270,35 @@ export function createUnifiedSearchProvider({ ctx, resolveOptions, backends }) {
|
|
|
130
270
|
// failures instead of being silently demoted like user cancellations.
|
|
131
271
|
const runBackend = ({ id, backend, beOpts, hooks }) => {
|
|
132
272
|
const startedAtMs = Date.now();
|
|
273
|
+
// Trust boundary: baseURL is operator-configurable and API keys are sent to it.
|
|
274
|
+
// Enforce http(s); plain http only for private hosts (self-hosted SearXNG).
|
|
275
|
+
// Reject embedded credentials. Failure is a per-backend soft error.
|
|
276
|
+
if (beOpts.baseURL !== undefined) {
|
|
277
|
+
let bu = null;
|
|
278
|
+
try { bu = new URL(beOpts.baseURL); } catch { bu = null; }
|
|
279
|
+
const privHost = bu ? /^(localhost|127(\.\d{1,3}){3}|10(\.\d{1,3}){3}|192\.168(\.\d{1,3}){3}|172\.(1[6-9]|2\d|3[01])(\.\d{1,3}){3})$/.test(bu.hostname) : false;
|
|
280
|
+
const schemeOk = bu && (bu.protocol === "https:" || (bu.protocol === "http:" && privHost));
|
|
281
|
+
if (!schemeOk || bu.username || bu.password) {
|
|
282
|
+
throw new WebError(`backend "${id}" has an invalid baseURL: https required (plain http only for private hosts), credentials-in-URL rejected`, "WEB_PROVIDER_ERROR");
|
|
283
|
+
}
|
|
284
|
+
}
|
|
133
285
|
const controller = new AbortController();
|
|
134
286
|
const mergedSignal = signal
|
|
135
287
|
? AbortSignal.any([signal, controller.signal])
|
|
136
288
|
: controller.signal;
|
|
137
289
|
const timeoutId = setTimeout(() => controller.abort(), backendTimeoutMs);
|
|
138
290
|
return backend
|
|
139
|
-
.search(
|
|
291
|
+
.search(effReq, mergedSignal, beOpts, hooks)
|
|
140
292
|
.then(
|
|
141
293
|
(value) => {
|
|
142
294
|
telemetry.push({ id, ok: true, ms: Date.now() - startedAtMs, count: value?.sources?.length ?? 0 });
|
|
295
|
+
recordBackendHealth(id, { ok: true, at: Date.now(), ms: Date.now() - startedAtMs, count: value?.sources?.length ?? 0 }, breakerCfg);
|
|
143
296
|
return value;
|
|
144
297
|
},
|
|
145
298
|
(err) => {
|
|
146
|
-
|
|
299
|
+
const msg = String(err instanceof Error ? err.message : err).slice(0, 80);
|
|
300
|
+
telemetry.push({ id, ok: false, ms: Date.now() - startedAtMs, error: msg });
|
|
301
|
+
recordBackendHealth(id, { ok: false, at: Date.now(), ms: Date.now() - startedAtMs, error: msg }, breakerCfg);
|
|
147
302
|
throw err;
|
|
148
303
|
},
|
|
149
304
|
)
|
|
@@ -172,45 +327,18 @@ export function createUnifiedSearchProvider({ ctx, resolveOptions, backends }) {
|
|
|
172
327
|
|
|
173
328
|
throwIfSearchAborted(signal);
|
|
174
329
|
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
const collected = [];
|
|
180
|
-
let mergedContent;
|
|
181
|
-
let hardAborted = false;
|
|
182
|
-
let hardError = null;
|
|
183
|
-
const failures = [];
|
|
184
|
-
|
|
185
|
-
let backendIndex = 0;
|
|
186
|
-
for (const settled of results) {
|
|
187
|
-
const failedId = eligible[backendIndex] ? eligible[backendIndex].id : "unknown";
|
|
188
|
-
backendIndex++;
|
|
189
|
-
if (settled.status === "fulfilled") {
|
|
190
|
-
if (mergedContent === undefined && typeof settled.value?.content === "string" && settled.value.content.length > 0) {
|
|
191
|
-
mergedContent = settled.value.content;
|
|
192
|
-
}
|
|
193
|
-
for (const s of settled.value?.sources ?? []) collected.push(s);
|
|
194
|
-
} else {
|
|
195
|
-
// settled.status === "rejected"
|
|
196
|
-
const err = settled.reason;
|
|
197
|
-
if (err instanceof WebError && err.code === "WEB_ABORTED") {
|
|
198
|
-
// Per-backend abort → soft unless caller signal fired.
|
|
199
|
-
if (signal?.aborted === true) hardAborted = true;
|
|
200
|
-
else failures.push({ id: failedId, err });
|
|
201
|
-
} else {
|
|
202
|
-
failures.push({ id: failedId, err });
|
|
203
|
-
if (hardError === null) hardError = err;
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
}
|
|
330
|
+
// PHASE 1 collect: fold the settled fan-out into raw sources (fan-out
|
|
331
|
+
// priority order), first backend content, and per-backend failures.
|
|
332
|
+
const { collected, mergedContent, hardAborted, hardError, failures } =
|
|
333
|
+
collectFanoutResults(results, eligible, signal);
|
|
207
334
|
|
|
208
335
|
// Caller-cancellation check (hard abort fires before we yield results).
|
|
209
336
|
if (signal?.aborted === true || hardAborted) {
|
|
210
337
|
throw searchAborted(signal);
|
|
211
338
|
}
|
|
212
339
|
|
|
213
|
-
//
|
|
340
|
+
// PHASE 2 dedupe / PHASE 3 rerank: "url" preserves the historical
|
|
341
|
+
// URL-key behavior; "url+title"
|
|
214
342
|
// additionally collapses same-story syndicated copies across backends.
|
|
215
343
|
const strategy = opts.dedupStrategy === "url+title" ? "url+title" : "url";
|
|
216
344
|
const deduped = dedupeSources(collected, strategy);
|
|
@@ -223,11 +351,37 @@ export function createUnifiedSearchProvider({ ctx, resolveOptions, backends }) {
|
|
|
223
351
|
// If we got ANY sources, return them even if some backends failed.
|
|
224
352
|
if (ordered.length > 0) {
|
|
225
353
|
const sources = ordered.slice(0, maxResults);
|
|
354
|
+
// 结果呈现头(Anthropic web-search / OpenAI grounding 官方指引):
|
|
355
|
+
// 置于结果块最前,要求逐条引用来源 URL、优先官方/一手与较新来源;
|
|
356
|
+
// 语言按查询文本自动选择(含 CJK 用中文头)。可用
|
|
357
|
+
// opts.resultHeader === false 关闭。
|
|
226
358
|
let outContent = mergedContent;
|
|
227
|
-
if (opts.
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
359
|
+
if (opts.resultHeader !== false && (sources.length > 0 || outContent !== undefined)) {
|
|
360
|
+
const header = resultHeader(typeof request?.query === "string" && /[\u4e00-\u9fff]/.test(request.query) ? "zh" : "en");
|
|
361
|
+
outContent = outContent === undefined ? header : header + "\n\n" + outContent;
|
|
362
|
+
}
|
|
363
|
+
// Store the pre-telemetry core (sources + headered content) so a cache
|
|
364
|
+
// hit reproduces the exact sources and the model-facing header.
|
|
365
|
+
if (cacheKey && cache) {
|
|
366
|
+
cache.set(cacheKey, { sources, content: outContent, truncated: ordered.length > maxResults });
|
|
367
|
+
}
|
|
368
|
+
history?.record({
|
|
369
|
+
query: request?.query,
|
|
370
|
+
time: Date.now(),
|
|
371
|
+
resultCount: sources.length,
|
|
372
|
+
backendsOk: telemetry.filter((t) => t.ok).length,
|
|
373
|
+
backendsTotal: telemetry.length,
|
|
374
|
+
});
|
|
375
|
+
if (opts.resultTelemetry !== false && (telemetry.length > 0 || cooled.size > 0)) {
|
|
376
|
+
const cooledParts = [];
|
|
377
|
+
for (const [cid, centry] of cooled) {
|
|
378
|
+
const ann = cooledAnnotation(centry, Date.now()) || "⏸cooled";
|
|
379
|
+
cooledParts.push(cid + " " + ann);
|
|
380
|
+
}
|
|
381
|
+
const parts = telemetry
|
|
382
|
+
.map((t) => t.id + (t.ok ? " ✓" : " ✗") + t.ms + "ms" + (t.ok ? "/" + t.count : (t.error ? " (" + String(t.error).slice(0, 40) + ")" : "")))
|
|
383
|
+
.concat(cooledParts);
|
|
384
|
+
const line = "[websearch backends]" + TELEMETRY_TAG + " " + parts.join(" · ");
|
|
231
385
|
outContent = outContent === undefined ? line : outContent + "\n\n" + line;
|
|
232
386
|
}
|
|
233
387
|
return {
|
|
@@ -244,7 +398,7 @@ export function createUnifiedSearchProvider({ ctx, resolveOptions, backends }) {
|
|
|
244
398
|
.join("; ");
|
|
245
399
|
const allKeyMissing =
|
|
246
400
|
failures.length > 0 &&
|
|
247
|
-
failures.every((f) => f.err instanceof WebError && f.err.code === "WEB_PROVIDER_UNAVAILABLE");
|
|
401
|
+
failures.every((f) => f.err instanceof WebError && (f.err.code === "WEB_PROVIDER_UNAVAILABLE" || f.err.code === "WEB_PROVIDER_CREDENTIAL_MISSING"));
|
|
248
402
|
const hint = allKeyMissing
|
|
249
403
|
? " (every enabled backend is missing its API key - add keys in Settings > Web Search, or enable a keyless backend)"
|
|
250
404
|
: "";
|
package/lib/util/log.js
CHANGED
|
@@ -56,7 +56,7 @@ export function recordBackendOutcome(ctx, backendId, outcome) {
|
|
|
56
56
|
const logger = ctx && ctx.logger;
|
|
57
57
|
if (!logger) return;
|
|
58
58
|
const status = outcome && typeof outcome.status === "string" ? outcome.status : "";
|
|
59
|
-
const line = "[websearch:" + backendId + "] outcome " + JSON.stringify(outcome ?? {});
|
|
59
|
+
const line = "[websearch:" + backendId + "] outcome " + JSON.stringify(outcome ?? {}).slice(0, 500);
|
|
60
60
|
if (status === "error" && typeof logger.warn === "function") logger.warn(line);
|
|
61
61
|
else if (typeof logger.debug === "function") logger.debug(line);
|
|
62
62
|
} catch {
|
package/lib/util/mcp-client.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { WebError } from "@deepseek-ai/dsh-web";
|
|
9
9
|
|
|
10
|
-
const UA = "dsh-websearch/2.
|
|
10
|
+
const UA = "dsh-websearch/2.3";
|
|
11
11
|
const PROTOCOL_VERSION = "2025-06-18";
|
|
12
12
|
const SESSION_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
|
13
13
|
|
|
@@ -58,7 +58,7 @@ async function ensureSession(url, signal) {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
if (!initResponse.ok) {
|
|
61
|
-
const text = await initResponse.text().catch(() => "");
|
|
61
|
+
const text = await initResponse.text().catch(() => "").then((tx) => tx.slice(0, 300));
|
|
62
62
|
throw new WebError(`MCP initialize HTTP ${initResponse.status}: ${text}`, "WEB_PROVIDER_ERROR");
|
|
63
63
|
}
|
|
64
64
|
|
|
@@ -75,7 +75,7 @@ async function ensureSession(url, signal) {
|
|
|
75
75
|
};
|
|
76
76
|
|
|
77
77
|
try {
|
|
78
|
-
await fetch(url, {
|
|
78
|
+
const notifyResponse = await fetch(url, {
|
|
79
79
|
method: "POST",
|
|
80
80
|
headers: {
|
|
81
81
|
"Content-Type": "application/json",
|
|
@@ -86,6 +86,8 @@ async function ensureSession(url, signal) {
|
|
|
86
86
|
signal,
|
|
87
87
|
redirect: "error",
|
|
88
88
|
});
|
|
89
|
+
// Drain the discarded body so undici returns the socket to the pool.
|
|
90
|
+
notifyResponse.body?.cancel?.().catch(() => {});
|
|
89
91
|
} catch (error) {
|
|
90
92
|
// Best-effort: Exa requires this, Parallel tolerates absence
|
|
91
93
|
if (signal?.aborted) throw error;
|
|
@@ -104,17 +106,16 @@ async function ensureSession(url, signal) {
|
|
|
104
106
|
function parseEnvelope(text, expectedId) {
|
|
105
107
|
// Try SSE format first: "event: message\ndata: {...}\n\n"
|
|
106
108
|
if (text.includes("data:")) {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
if (dataLine) {
|
|
109
|
+
// SSE spec: one JSON payload may span multiple data: lines - join them.
|
|
110
|
+
const joined = text
|
|
111
|
+
.trim()
|
|
112
|
+
.split("\n")
|
|
113
|
+
.filter((l) => l.startsWith("data:"))
|
|
114
|
+
.map((l) => l.slice(5).trim())
|
|
115
|
+
.join("");
|
|
116
|
+
if (joined) {
|
|
116
117
|
try {
|
|
117
|
-
return JSON.parse(
|
|
118
|
+
return JSON.parse(joined);
|
|
118
119
|
} catch {}
|
|
119
120
|
}
|
|
120
121
|
}
|
|
@@ -205,7 +206,7 @@ export async function mcpCall(url, toolName, args, extraHeaders = {}, signal) {
|
|
|
205
206
|
}
|
|
206
207
|
|
|
207
208
|
if (!response.ok) {
|
|
208
|
-
const text = await response.text().catch(() => "");
|
|
209
|
+
const text = await response.text().catch(() => "").then((tx) => tx.slice(0, 300));
|
|
209
210
|
throw new WebError(`MCP tools/call HTTP ${response.status}: ${text}`, "WEB_PROVIDER_ERROR");
|
|
210
211
|
}
|
|
211
212
|
|
package/lib/util/rank.js
CHANGED
|
@@ -97,7 +97,7 @@ function scoreSource(source, queryTokens) {
|
|
|
97
97
|
let score = 0;
|
|
98
98
|
const titleTokens = tokenize(source.title ?? "");
|
|
99
99
|
const snippetTokens = tokenize(source.snippet ?? "");
|
|
100
|
-
const urlTokens = tokenize(String(source.url ?? "")
|
|
100
|
+
const urlTokens = tokenize(String(source.url ?? ""));
|
|
101
101
|
for (const q of queryTokens) {
|
|
102
102
|
if (titleTokens.has(q)) score += 3;
|
|
103
103
|
if (snippetTokens.has(q)) score += 2;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@240xu/dsh-websearch",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "Unified web search provider for DeepSeek Harness (ctx.web): 11 backends
|
|
3
|
+
"version": "2.7.2",
|
|
4
|
+
"description": "Unified web search provider for DeepSeek Harness (ctx.web): 11 backends — Exa + Parallel + DDG + SearXNG (keyless), DeepSeek + Anthropic + OpenAI + Brave + Tavily + Serper + Mojeek (key-gated via Settings credentials). Registers provider id \"unified\" for zero-config web_search tool. v2.7.0: disk result cache, per-backend circuit breaker, append-only search history API. v2.7.1 review fixes: cache key includes enabledBackends, scheme whitelist on cache-hit path, serialized history writes, trust fence on /api/websearch/* routes, cooldownMs=0 disables breaker window, user-language settings labels.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|