@240xu/dsh-websearch 2.8.2 → 2.8.4
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 +9 -0
- package/lib/backends/anthropic-like.js +2 -1
- package/lib/backends/brave.js +2 -1
- package/lib/backends/ddg.js +2 -1
- package/lib/backends/mojeek.js +2 -1
- package/lib/backends/openai.js +2 -1
- package/lib/backends/searxng.js +2 -1
- package/lib/backends/serper.js +2 -1
- package/lib/backends/tavily.js +2 -1
- package/lib/client.js +139 -20
- package/lib/health.js +7 -0
- package/lib/history.js +7 -5
- package/lib/index.js +25 -4
- package/lib/multiquery.js +8 -1
- package/lib/prompting.js +1 -1
- package/lib/provider.js +9 -0
- package/lib/settings-api.js +277 -0
- package/lib/trust.js +179 -0
- package/lib/util/egress-fetch.js +338 -0
- package/lib/util/mcp-client.js +5 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -266,3 +266,12 @@ MIT © 2026 240xu
|
|
|
266
266
|
exa 漏了——默认关 deepCoverage 也注入 category 收窄结果,与 README 承诺相反。
|
|
267
267
|
- 回归测试 3 条(零结果/limiter 不死锁带 HUNG 超时护栏/exa 双态门控);
|
|
268
268
|
`createLimiter` 导出供测试。89/89。
|
|
269
|
+
|
|
270
|
+
## 2.8.3 · Bug 猎场修复第二批(P2×3 + 测试)
|
|
271
|
+
|
|
272
|
+
- **[P2] 整形空查询早拒**:寒暄独词("搜索"/"search"/"查询")被剥空后不再扇出、
|
|
273
|
+
不入缓存(原来空串共享缓存键 + 落入 all-backends-failed(0) 误导错误);
|
|
274
|
+
ZH 寒暄表补裸「搜索」(原 `搜索一下?` 要求带“一”漏掉裸词)。
|
|
275
|
+
- **[P2] CJK 分隔符词内保护**:和/比较/对比 须**任一侧**有空白/标点边界才切——
|
|
276
|
+
词内裸和(柔和光线/和平精英)不再切断实体;单侧空格的真分隔("sftp 和面板")照切。
|
|
277
|
+
- 回归测试 ×4(零结果/limiter 死锁/exa 门控/CJK 保护/空查询拒),91/91。
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import { throwIfSearchAborted, searchAborted, isAbortError } from "../util/abort.js";
|
|
19
19
|
import { WebError } from "@deepseek-ai/dsh-web";
|
|
20
|
+
import { egressFetch } from "../util/egress-fetch.js";
|
|
20
21
|
|
|
21
22
|
/** Attribution header; bumped with the package version. */
|
|
22
23
|
const UA = "dsh-websearch/2.3";
|
|
@@ -111,7 +112,7 @@ export function createAnthropicLikeBackend(spec) {
|
|
|
111
112
|
|
|
112
113
|
let response;
|
|
113
114
|
try {
|
|
114
|
-
response = await
|
|
115
|
+
response = await egressFetch(endpoint, {
|
|
115
116
|
method: "POST",
|
|
116
117
|
redirect: "error",
|
|
117
118
|
headers: {
|
package/lib/backends/brave.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { WebError } from "@deepseek-ai/dsh-web";
|
|
9
9
|
import { throwIfSearchAborted, searchAborted, isAbortError } from "../util/abort.js";
|
|
10
10
|
import { applyBraveFilters, normalizeFilters, safeSearchParams } from "../util/filters.js";
|
|
11
|
+
import { egressFetch } from "../util/egress-fetch.js";
|
|
11
12
|
|
|
12
13
|
const BRAVE_ENDPOINT = "https://api.search.brave.com/res/v1/web/search";
|
|
13
14
|
|
|
@@ -46,7 +47,7 @@ export const braveBackend = {
|
|
|
46
47
|
|
|
47
48
|
let response;
|
|
48
49
|
try {
|
|
49
|
-
response = await
|
|
50
|
+
response = await egressFetch(url, {
|
|
50
51
|
method: "GET",
|
|
51
52
|
headers: {
|
|
52
53
|
"X-Subscription-Token": apiKey,
|
package/lib/backends/ddg.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { searchAborted, isAbortError, throwIfSearchAborted } from "../util/abort.js";
|
|
9
9
|
import { applyDdgFilters, normalizeFilters } from "../util/filters.js";
|
|
10
10
|
import { WebError } from "@deepseek-ai/dsh-web";
|
|
11
|
+
import { egressFetch } from "../util/egress-fetch.js";
|
|
11
12
|
|
|
12
13
|
export const DDG_ENDPOINT = "https://html.duckduckgo.com/html/";
|
|
13
14
|
|
|
@@ -34,7 +35,7 @@ export const ddgBackend = {
|
|
|
34
35
|
|
|
35
36
|
let text;
|
|
36
37
|
try {
|
|
37
|
-
const res = await
|
|
38
|
+
const res = await egressFetch(DDG_ENDPOINT, {
|
|
38
39
|
method: "POST",
|
|
39
40
|
redirect: "error",
|
|
40
41
|
headers: {
|
package/lib/backends/mojeek.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { WebError } from "@deepseek-ai/dsh-web";
|
|
9
9
|
import { throwIfSearchAborted, searchAborted, isAbortError } from "../util/abort.js";
|
|
10
|
+
import { egressFetch } from "../util/egress-fetch.js";
|
|
10
11
|
|
|
11
12
|
const MOJEEK_ENDPOINT = "https://api.mojeek.com/v1/search";
|
|
12
13
|
|
|
@@ -52,7 +53,7 @@ export const mojeekBackend = {
|
|
|
52
53
|
|
|
53
54
|
let response;
|
|
54
55
|
try {
|
|
55
|
-
response = await
|
|
56
|
+
response = await egressFetch(url, {
|
|
56
57
|
method: "GET",
|
|
57
58
|
headers,
|
|
58
59
|
redirect: "error",
|
package/lib/backends/openai.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { throwIfSearchAborted, searchAborted, isAbortError } from "../util/abort.js";
|
|
14
14
|
import { WebError } from "@deepseek-ai/dsh-web";
|
|
15
|
+
import { egressFetch } from "../util/egress-fetch.js";
|
|
15
16
|
|
|
16
17
|
const UA = "dsh-websearch/2.3";
|
|
17
18
|
|
|
@@ -67,7 +68,7 @@ export const openaiBackend = {
|
|
|
67
68
|
|
|
68
69
|
let response;
|
|
69
70
|
try {
|
|
70
|
-
response = await
|
|
71
|
+
response = await egressFetch(endpoint, {
|
|
71
72
|
method: "POST",
|
|
72
73
|
redirect: "error",
|
|
73
74
|
headers: {
|
package/lib/backends/searxng.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { WebError } from "@deepseek-ai/dsh-web";
|
|
10
10
|
import { throwIfSearchAborted, searchAborted, isAbortError } from "../util/abort.js";
|
|
11
11
|
import { applySearxngFilters, normalizeFilters, safeSearchParams } from "../util/filters.js";
|
|
12
|
+
import { egressFetch } from "../util/egress-fetch.js";
|
|
12
13
|
|
|
13
14
|
const DEFAULT_SEARXNG_URL = "https://searx.be";
|
|
14
15
|
|
|
@@ -61,7 +62,7 @@ export const searxngBackend = {
|
|
|
61
62
|
|
|
62
63
|
let response;
|
|
63
64
|
try {
|
|
64
|
-
response = await
|
|
65
|
+
response = await egressFetch(url, {
|
|
65
66
|
method: "GET",
|
|
66
67
|
headers,
|
|
67
68
|
redirect: "error",
|
package/lib/backends/serper.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { WebError } from "@deepseek-ai/dsh-web";
|
|
9
9
|
import { throwIfSearchAborted, searchAborted, isAbortError } from "../util/abort.js";
|
|
10
10
|
import { applySerperFilters, normalizeFilters } from "../util/filters.js";
|
|
11
|
+
import { egressFetch } from "../util/egress-fetch.js";
|
|
11
12
|
|
|
12
13
|
const SERPER_ENDPOINT = "https://google.serper.dev/search";
|
|
13
14
|
|
|
@@ -46,7 +47,7 @@ export const serperBackend = {
|
|
|
46
47
|
|
|
47
48
|
let response;
|
|
48
49
|
try {
|
|
49
|
-
response = await
|
|
50
|
+
response = await egressFetch(baseURL, {
|
|
50
51
|
method: "POST",
|
|
51
52
|
headers: {
|
|
52
53
|
"X-API-KEY": apiKey,
|
package/lib/backends/tavily.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { WebError } from "@deepseek-ai/dsh-web";
|
|
9
9
|
import { throwIfSearchAborted, searchAborted, isAbortError } from "../util/abort.js";
|
|
10
10
|
import { applyTavilyFilters, normalizeFilters } from "../util/filters.js";
|
|
11
|
+
import { egressFetch } from "../util/egress-fetch.js";
|
|
11
12
|
|
|
12
13
|
const TAVILY_ENDPOINT = "https://api.tavily.com/search";
|
|
13
14
|
|
|
@@ -55,7 +56,7 @@ export const tavilyBackend = {
|
|
|
55
56
|
|
|
56
57
|
let response;
|
|
57
58
|
try {
|
|
58
|
-
response = await
|
|
59
|
+
response = await egressFetch(baseURL, {
|
|
59
60
|
method: "POST",
|
|
60
61
|
headers: {
|
|
61
62
|
"Authorization": `Bearer ${apiKey}`,
|
package/lib/client.js
CHANGED
|
@@ -171,6 +171,78 @@ window.__ModuleLoader__.load({
|
|
|
171
171
|
};
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
/**
|
|
175
|
+
* Settings bridge for 0.2.x builds where the legacy `settingsScope`
|
|
176
|
+
* client service is gone: the same snapshot/subscribe/set/unset surface
|
|
177
|
+
* the card controller expects, backed by this plugin's fenced settings
|
|
178
|
+
* route. Writes land in the volatile profile entry the host provider
|
|
179
|
+
* resolves from on the next call -- no second settings store.
|
|
180
|
+
*/
|
|
181
|
+
class SettingsRoutesBridge {
|
|
182
|
+
constructor() {
|
|
183
|
+
this.store = createStore({
|
|
184
|
+
status: "loading",
|
|
185
|
+
writable: false,
|
|
186
|
+
value: undefined,
|
|
187
|
+
base: undefined,
|
|
188
|
+
user: undefined,
|
|
189
|
+
revision: undefined,
|
|
190
|
+
mode: "routes",
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
getSnapshot() { return this.store.getSnapshot(); }
|
|
195
|
+
subscribe(listener) { return this.store.subscribe(listener); }
|
|
196
|
+
|
|
197
|
+
accept(payload) {
|
|
198
|
+
if (payload === null || typeof payload !== "object" || payload.ok !== true || payload.writable === false) {
|
|
199
|
+
// Keep the card rendered (status ready) but read-only, so the section
|
|
200
|
+
// explains itself instead of showing an endless loading ellipsis.
|
|
201
|
+
this.store.set({ status: "ready", writable: false, value: {}, base: undefined, user: undefined, revision: undefined, mode: "routes" });
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
this.store.set({
|
|
205
|
+
status: "ready",
|
|
206
|
+
writable: true,
|
|
207
|
+
value: payload.value,
|
|
208
|
+
base: payload.base,
|
|
209
|
+
user: payload.user,
|
|
210
|
+
revision: payload.revision,
|
|
211
|
+
mode: "routes",
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async load() {
|
|
216
|
+
try {
|
|
217
|
+
const response = await fetch("/api/unified-search/settings", { credentials: "same-origin", headers: { accept: "application/json" } });
|
|
218
|
+
const payload = await response.json().catch(() => undefined);
|
|
219
|
+
this.accept(response.status === 404 ? undefined : payload);
|
|
220
|
+
} catch (_noRoutes) {
|
|
221
|
+
this.accept(undefined);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async write(ops) {
|
|
226
|
+
const expectedRevision = this.store.getSnapshot().revision;
|
|
227
|
+
const response = await fetch("/api/unified-search/settings", {
|
|
228
|
+
method: "POST",
|
|
229
|
+
credentials: "same-origin",
|
|
230
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
231
|
+
// eslint-disable-next-line object-shorthand
|
|
232
|
+
body: JSON.stringify(expectedRevision === undefined ? { ops } : { ops, expectedRevision }),
|
|
233
|
+
});
|
|
234
|
+
const payload = await response.json().catch(() => undefined);
|
|
235
|
+
if (response.status === 409 || (payload !== undefined && payload.code === "SETTINGS_CONFLICT")) throw new Error("SETTINGS_CONFLICT");
|
|
236
|
+
if (!response.ok || payload === undefined || payload.ok !== true) {
|
|
237
|
+
throw new Error(payload !== undefined && payload.error !== undefined ? String(payload.error) : "settings request failed (" + response.status + ")");
|
|
238
|
+
}
|
|
239
|
+
this.accept(payload);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
set(field, value) { return this.write([{ op: "set", path: [field], value }]); }
|
|
243
|
+
unset(field) { return this.write([{ op: "unset", path: [field] }]); }
|
|
244
|
+
}
|
|
245
|
+
|
|
174
246
|
/**
|
|
175
247
|
* Card state machine: reads the bound settings scope snapshot, stages edits,
|
|
176
248
|
* writes section fields through scope.set/unset and secrets via credentials.
|
|
@@ -247,14 +319,37 @@ window.__ModuleLoader__.load({
|
|
|
247
319
|
|
|
248
320
|
async readCredential(ref) {
|
|
249
321
|
try {
|
|
250
|
-
|
|
251
|
-
if (
|
|
252
|
-
|
|
253
|
-
|
|
322
|
+
let view;
|
|
323
|
+
if (this.remote !== undefined && this.remote.credentials !== undefined && typeof this.remote.credentials.describe === "function") {
|
|
324
|
+
// 0.2.x remote face: Record<ref, {configured, writable, source}> behind {ok, value}.
|
|
325
|
+
const res = await this.remote.credentials.describe([ref]);
|
|
326
|
+
const record = res !== null && typeof res === "object" && res.value !== undefined ? res.value : res;
|
|
327
|
+
const byRef = record !== null && typeof record === "object" && record.credentials !== undefined ? record.credentials : record;
|
|
328
|
+
view = byRef !== null && typeof byRef === "object" ? byRef[ref] : undefined;
|
|
329
|
+
}
|
|
330
|
+
if (view === undefined && this.connection !== undefined && this.connection.api !== undefined && this.connection.api.credentials !== undefined) {
|
|
331
|
+
// Older line: the connection controller's credentials API.
|
|
332
|
+
const res = await this.connection.api.credentials.describe({ refs: [ref] });
|
|
333
|
+
if (res?.result?.ok) view = res.result.value.credentials[ref];
|
|
334
|
+
}
|
|
335
|
+
if (view === undefined) return;
|
|
336
|
+
this.keyState.set(ref, { configured: view.configured === true, writable: view.writable !== false });
|
|
254
337
|
} catch { /* ignore */ }
|
|
255
338
|
this.republish();
|
|
256
339
|
}
|
|
257
340
|
|
|
341
|
+
async writeCredential(ref, value) {
|
|
342
|
+
if (this.remote !== undefined && this.remote.credentials !== undefined && typeof this.remote.credentials.set === "function") {
|
|
343
|
+
await this.remote.credentials.set(ref, value);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
if (this.connection !== undefined && this.connection.api !== undefined && this.connection.api.credentials !== undefined && typeof this.connection.api.credentials.set === "function") {
|
|
347
|
+
await this.connection.api.credentials.set({ ref, value });
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
throw new Error("credentials service unavailable");
|
|
351
|
+
}
|
|
352
|
+
|
|
258
353
|
refreshAllCredentials() {
|
|
259
354
|
for (const b of BACKENDS) if (b.keyEnv) this.readCredential(b.keyEnv);
|
|
260
355
|
}
|
|
@@ -291,7 +386,7 @@ window.__ModuleLoader__.load({
|
|
|
291
386
|
backendTimeoutMs: [1000, 120000],
|
|
292
387
|
};
|
|
293
388
|
const [lo, hi] = BOUNDS[field] ?? [-Infinity, Infinity];
|
|
294
|
-
{ this.failed = true; this.failedFields.add(field); continue; }
|
|
389
|
+
if (!Number.isFinite(num) || num < lo || num > hi) { this.failed = true; this.failedFields.add(field); continue; }
|
|
295
390
|
await this.scope.set(field, num);
|
|
296
391
|
doneFields.push(field);
|
|
297
392
|
} else {
|
|
@@ -303,7 +398,7 @@ window.__ModuleLoader__.load({
|
|
|
303
398
|
const value = staged.text.trim();
|
|
304
399
|
if (value !== "") {
|
|
305
400
|
try {
|
|
306
|
-
await this.
|
|
401
|
+
await this.writeCredential(ref, value);
|
|
307
402
|
this.keyState.set(ref, { configured: true, writable: true });
|
|
308
403
|
doneKeys.push(ref);
|
|
309
404
|
this.failedKeys.delete(ref);
|
|
@@ -424,6 +519,7 @@ window.__ModuleLoader__.load({
|
|
|
424
519
|
backend: b,
|
|
425
520
|
value: state.value[toggleField(b.id)],
|
|
426
521
|
keyConfigured: true,
|
|
522
|
+
t: props.t,
|
|
427
523
|
onChange: (checked) => card.setToggle(b.id, checked),
|
|
428
524
|
}, b.id)
|
|
429
525
|
) }),
|
|
@@ -433,6 +529,7 @@ window.__ModuleLoader__.load({
|
|
|
433
529
|
backend: b,
|
|
434
530
|
value: state.value[toggleField(b.id)],
|
|
435
531
|
keyConfigured: !!state.keyState[b.keyEnv]?.configured,
|
|
532
|
+
t: props.t,
|
|
436
533
|
onChange: (checked) => card.setToggle(b.id, checked),
|
|
437
534
|
}, b.id)
|
|
438
535
|
) }),
|
|
@@ -545,27 +642,49 @@ window.__ModuleLoader__.load({
|
|
|
545
642
|
|
|
546
643
|
/**
|
|
547
644
|
* Plugin entry: register the card into the settings plugins page.
|
|
548
|
-
* Compatibility
|
|
549
|
-
*
|
|
550
|
-
*
|
|
645
|
+
* Compatibility: the legacy `settingsScope` client service was removed on
|
|
646
|
+
* the 0.2.x line; the card binds whichever data source the build offers
|
|
647
|
+
* (see selectBridge) and registers the section unconditionally.
|
|
551
648
|
*/
|
|
552
|
-
const inject = ["slots", "locale", "connection", "remote"
|
|
649
|
+
const inject = ["slots", "locale", "connection", "remote"];
|
|
650
|
+
/**
|
|
651
|
+
* Resolve the settings data source for this build, in order:
|
|
652
|
+
* 1. the legacy `settingsScope` service (DSH <= 0.1.6 line, or a shim);
|
|
653
|
+
* 2. this plugin's own fenced settings routes (0.2.x line, where the
|
|
654
|
+
* client service was removed and the settings shell renders client
|
|
655
|
+
* slot sections only);
|
|
656
|
+
* 3. an unavailable bridge -- the section still registers and the card
|
|
657
|
+
* explains itself through its read-only banner.
|
|
658
|
+
*/
|
|
659
|
+
function selectBridge(ctx) {
|
|
660
|
+
try {
|
|
661
|
+
const scope = ctx.get("settingsScope");
|
|
662
|
+
if (scope !== undefined && typeof scope.bind === "function") return scope.bind({ namespace: PLUGIN_NS });
|
|
663
|
+
} catch (_noSettingsScope) { /* fall through to the routes bridge */ }
|
|
664
|
+
return new SettingsRoutesBridge();
|
|
665
|
+
}
|
|
553
666
|
function apply(ctx) {
|
|
554
667
|
ctx.effect(() => ctx.locale.register(NS, { zh, en }), "websearch: copy dictionaries");
|
|
555
|
-
const
|
|
556
|
-
if (
|
|
557
|
-
// DSH >= 0.1.7 without dsh-settings-scope-shim: the settings card
|
|
558
|
-
// cannot bind its scope. Stop wiring the card instead of hard-
|
|
559
|
-
// crashing the client fiber; the breadcrumb aids diagnosis.
|
|
560
|
-
console.warn("[dsh-websearch] settingsScope service unavailable (DSH >= 0.1.7); unified-search settings card disabled. Configure via server settings/cordis config or install dsh-settings-scope-shim.");
|
|
561
|
-
return;
|
|
562
|
-
}
|
|
668
|
+
const bridge = selectBridge(ctx);
|
|
669
|
+
if (typeof bridge.load === "function") void bridge.load();
|
|
563
670
|
const controller = new UnifiedSearchCardController(
|
|
564
|
-
|
|
671
|
+
bridge,
|
|
565
672
|
ctx.get("connection"),
|
|
566
673
|
ctx.get("remote"),
|
|
567
674
|
);
|
|
568
|
-
|
|
675
|
+
// Credential invalidations: 0.2.x forwards `credentials/reference-updated`;
|
|
676
|
+
// older lines forwarded `credentials/updated`. Subscribe to whichever
|
|
677
|
+
// event face this build exposes; a missing face must not abort apply().
|
|
678
|
+
for (const event of ["credentials/reference-updated", "credentials/updated"]) {
|
|
679
|
+
try {
|
|
680
|
+
ctx.effect(() => ctx.remote.$on(event, () => controller.refreshAllCredentials()), "websearch: credential invalidations");
|
|
681
|
+
} catch (_noEventFace) { /* this build does not forward that event */ }
|
|
682
|
+
}
|
|
683
|
+
// Re-read when another client saves this namespace (settings document
|
|
684
|
+
// revisions travel through the remote event face on 0.2.x).
|
|
685
|
+
try {
|
|
686
|
+
ctx.effect(() => ctx.remote.$on("settings/document-updated", () => { if (typeof bridge.load === "function") void bridge.load(); }), "websearch: settings document invalidations");
|
|
687
|
+
} catch (_noSettingsEvent) { /* settings events unavailable */ }
|
|
569
688
|
ctx.slots.inject("settings.section", () => ctx.slots.register({
|
|
570
689
|
name: "settings.section",
|
|
571
690
|
id: "websearch",
|
package/lib/health.js
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
// provider's own telemetry truncation) and run through a defensive redaction
|
|
8
8
|
// pass for token-like fragments.
|
|
9
9
|
|
|
10
|
+
import { rejectionFor } from "./trust.js";
|
|
11
|
+
|
|
10
12
|
const MAX_ERROR_CHARS = 120;
|
|
11
13
|
|
|
12
14
|
/** Token-like fragments that must never appear in a health payload. */
|
|
@@ -89,6 +91,11 @@ export function registerHealthRoute(ws, deps) {
|
|
|
89
91
|
res.writeHead(code, { "content-type": "application/json" });
|
|
90
92
|
res.end(JSON.stringify(body));
|
|
91
93
|
};
|
|
94
|
+
const rejection = rejectionFor(req, typeof deps.getConnection === "function" ? deps.getConnection() : undefined);
|
|
95
|
+
if (rejection !== undefined) {
|
|
96
|
+
send(rejection, { ok: false, error: rejection === 503 ? "admission unavailable" : "untrusted request" });
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
92
99
|
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
93
100
|
send(405, { ok: false, error: "method not allowed (GET only)" });
|
|
94
101
|
return;
|
package/lib/history.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
10
10
|
import path from "node:path";
|
|
11
|
+
import { rejectionFor } from "./trust.js";
|
|
11
12
|
|
|
12
13
|
/** Ring-buffer cap (spec: 50). */
|
|
13
14
|
export const HISTORY_MAX_ENTRIES = 50;
|
|
@@ -143,18 +144,19 @@ export function toBackendObservability(healthMap, nowMs = Date.now()) {
|
|
|
143
144
|
* backends: per-backend last real outcome/latency/breaker state (v2.8.0),
|
|
144
145
|
* so fallback redundancy is observable without a separate endpoint.
|
|
145
146
|
* POST /api/websearch/history/clear -> { ok, cleared: true }
|
|
146
|
-
* Both routes sit behind
|
|
147
|
+
* Both routes sit behind the host connection admission fence (the same
|
|
148
|
+
* decision the kernel applies to its own /api routes).
|
|
147
149
|
*/
|
|
148
|
-
export function registerHistoryRoutes(ws, { getStore, getBackendHealth }) {
|
|
150
|
+
export function registerHistoryRoutes(ws, { getStore, getBackendHealth, getConnection }) {
|
|
149
151
|
if (!ws || typeof ws.register !== "function") return;
|
|
150
152
|
const send = (res, code, body) => {
|
|
151
153
|
res.writeHead(code, { "content-type": "application/json" });
|
|
152
154
|
res.end(JSON.stringify(body));
|
|
153
155
|
};
|
|
154
156
|
const guarded = (handler) => (req, res) => {
|
|
155
|
-
const
|
|
156
|
-
if (
|
|
157
|
-
send(res,
|
|
157
|
+
const rejection = rejectionFor(req, typeof getConnection === "function" ? getConnection() : undefined);
|
|
158
|
+
if (rejection !== undefined) {
|
|
159
|
+
send(res, rejection, { ok: false, error: rejection === 503 ? "admission unavailable" : "untrusted request" });
|
|
158
160
|
return;
|
|
159
161
|
}
|
|
160
162
|
handler(req, res);
|
package/lib/index.js
CHANGED
|
@@ -37,6 +37,7 @@ import { createUnifiedSearchProvider, getLatestBackendHealth } from "./provider.
|
|
|
37
37
|
import { registerHealthRoute } from "./health.js";
|
|
38
38
|
import { createSearchCache, CACHE_DEFAULTS } from "./cache.js";
|
|
39
39
|
import { createHistoryStore, registerHistoryRoutes, HISTORY_MAX_ENTRIES } from "./history.js";
|
|
40
|
+
import { registerSettingsRoutes } from "./settings-api.js";
|
|
40
41
|
import { BREAKER_DEFAULTS } from "./breaker.js";
|
|
41
42
|
import os from "node:os";
|
|
42
43
|
import path from "node:path";
|
|
@@ -391,6 +392,8 @@ export function resolveStoreDir(env = process.env, fallbackHome = os.homedir())
|
|
|
391
392
|
*/
|
|
392
393
|
export function apply(ctx, config) {
|
|
393
394
|
let current = () => config;
|
|
395
|
+
/** 0.2.x SettingsForms instance, captured for the browser settings bridge. */
|
|
396
|
+
let settingsForms;
|
|
394
397
|
|
|
395
398
|
ctx.inject(["settings"], (settingsCtx) => {
|
|
396
399
|
const settings = settingsCtx.settings;
|
|
@@ -404,10 +407,11 @@ export function apply(ctx, config) {
|
|
|
404
407
|
});
|
|
405
408
|
return;
|
|
406
409
|
}
|
|
407
|
-
// 0.2.
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
//
|
|
410
|
+
// 0.2.x SettingsForms line: no installSection. Keep the service so the
|
|
411
|
+
// browser settings card can read/write this profile entry through the
|
|
412
|
+
// fenced bridge in ./settings-api.js; values keep resolving via cordis
|
|
413
|
+
// config (the same volatile entry), so there is no second settings store.
|
|
414
|
+
if (typeof settings?.describe === "function" && typeof settings?.mutate === "function") settingsForms = settings;
|
|
411
415
|
});
|
|
412
416
|
|
|
413
417
|
// v2.7.0 stores: disk cache + append-only search history, both under
|
|
@@ -449,16 +453,33 @@ export function apply(ctx, config) {
|
|
|
449
453
|
// only reads the settings snapshot + last per-search telemetry — it never
|
|
450
454
|
// probes backends and never touches credential values.
|
|
451
455
|
ctx.inject(["webServer"], (webCtx) => {
|
|
456
|
+
const getConnection = () => {
|
|
457
|
+
try {
|
|
458
|
+
return ctx.get("connection");
|
|
459
|
+
} catch {
|
|
460
|
+
return undefined;
|
|
461
|
+
}
|
|
462
|
+
};
|
|
452
463
|
registerHealthRoute(webCtx.webServer, {
|
|
453
464
|
resolveOptions: () => resolveOptions(ctx, current()),
|
|
454
465
|
backends: BACKENDS,
|
|
455
466
|
getBackendHealth: getLatestBackendHealth,
|
|
467
|
+
getConnection,
|
|
456
468
|
});
|
|
457
469
|
// v2.7.0 search history: read-only GET + explicit clear POST.
|
|
458
470
|
// v2.8.0: GET also carries per-backend last-outcome observability.
|
|
459
471
|
registerHistoryRoutes(webCtx.webServer, {
|
|
460
472
|
getStore: () => historyStore,
|
|
461
473
|
getBackendHealth: getLatestBackendHealth,
|
|
474
|
+
getConnection,
|
|
475
|
+
});
|
|
476
|
+
// Settings bridge for the browser card on 0.2.x builds (no legacy
|
|
477
|
+
// settingsScope service). Fenced by the host connection admission; writes
|
|
478
|
+
// land in the volatile config entry above.
|
|
479
|
+
registerSettingsRoutes(webCtx.webServer, {
|
|
480
|
+
getSettings: () => settingsForms,
|
|
481
|
+
getConfig: () => Config,
|
|
482
|
+
getConnection,
|
|
462
483
|
});
|
|
463
484
|
});
|
|
464
485
|
}
|
package/lib/multiquery.js
CHANGED
|
@@ -9,7 +9,14 @@
|
|
|
9
9
|
// Separators per the v2.8 spec: "vs", "and", 比较, 对比, 和 — plus sentence
|
|
10
10
|
// punctuation for the long-query case.
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
// 2.8.2(P2):CJK 分隔符必须两侧有空白才切——裸 "和"(无空格)常在词内
|
|
13
|
+
// (柔和/和平/和尚),原无边界写法会把实体拦腰切断
|
|
14
|
+
// ("柔和光线和自然光" → 变体丢 "柔和";"和平精英" → 切出 "平精英")。
|
|
15
|
+
// 显式空格分隔的 "A 和 B" 与顿号/标点分隔仍正常切。
|
|
16
|
+
// 2.8.2(P2 定稿):CJK 分隔符须**任一侧**有空白/标点边界才切——词内裸和
|
|
17
|
+
// (柔和/和平/和尚,两侧都无空白)不切,保住实体;"sftp 和面板"(单侧空格)
|
|
18
|
+
// 这类真分隔照切。
|
|
19
|
+
const SEPARATOR_SPLIT_RE = /\s+(?:vs\.?|and)\s+|\s+(?:比较|对比|和)|(?:比较|对比|和)\s+|\s*[、;;。!?!?|]+\s*/i;
|
|
13
20
|
const COMPLEX_MARK_RE = /(?:\bvs\.?\b|\band\b|比较|对比|和)/i;
|
|
14
21
|
|
|
15
22
|
/** Sub-query hard cap (original + 2 variants max). */
|
package/lib/prompting.js
CHANGED
|
@@ -30,7 +30,7 @@ export const QUERY_MAX_CHARS = 1500;
|
|
|
30
30
|
const FILLER_PREFIX_RE =
|
|
31
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
32
|
const FILLER_PREFIX_RE_ZH =
|
|
33
|
-
/^(
|
|
33
|
+
/^(?:请|麻烦|帮我?|帮忙|帮我查一?下|查一?下|查询|搜索一?下?|搜一下?|查一下?|帮我找|找一下?|我想知道|请问)/;
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
36
|
* 确定性查询整形:剥离口语前缀(循环多次以处理叠加寒暄)、压缩空白、
|
package/lib/provider.js
CHANGED
|
@@ -196,6 +196,15 @@ export function createUnifiedSearchProvider({ ctx, resolveOptions, backends, cac
|
|
|
196
196
|
// 确定性归一 —— 剥离寒暄前缀、压缩空白、截断到 1500 字符;
|
|
197
197
|
// 专名/版本号/日期原样保留,不改写语义。
|
|
198
198
|
effReq.query = shapeQuery(effReq.query);
|
|
199
|
+
// 2.8.2(P2):整形后为空("搜索"/"search" 等寒暄独词被剥光)→ 早拒。
|
|
200
|
+
// 不拒会:①空串扇出全部后端 ②不同原始查询共享 "" 缓存键(别名污染)
|
|
201
|
+
// ③落入 all-backends-failed(0) 的误导错误。
|
|
202
|
+
if (!effReq.query) {
|
|
203
|
+
throw new WebError(
|
|
204
|
+
"unified search: empty query after shaping (the request contained only fillers like \"search\")",
|
|
205
|
+
"WEB_PROVIDER_ERROR",
|
|
206
|
+
);
|
|
207
|
+
}
|
|
199
208
|
if (Object.keys(filters).length > 0) effReq = { ...effReq, filters };
|
|
200
209
|
const maxResults = effReq.maxResults;
|
|
201
210
|
const concurrency = opts.concurrency ?? 6;
|