@klarkxy/dsh-web-search-manager 0.1.0
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/LICENSE +41 -0
- package/README.md +73 -0
- package/cordis.patch.yml +13 -0
- package/lib/client.inner.cjs +676 -0
- package/lib/client.inner.cjs.map +1 -0
- package/lib/client.js +846 -0
- package/lib/contracts.d.ts +79 -0
- package/lib/contracts.js +41 -0
- package/lib/contracts.js.map +1 -0
- package/lib/index.d.ts +60 -0
- package/lib/index.js +1070 -0
- package/lib/index.js.map +1 -0
- package/lib/tools.d.ts +9 -0
- package/lib/tools.js +56 -0
- package/lib/tools.js.map +1 -0
- package/package.json +118 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,1070 @@
|
|
|
1
|
+
import { WEB_SEARCH_RPC_CHANNEL, defaultSettings, migrateSearchOrder, pickActiveSearch, providerKey, resolveSearchOrder, validateBaseURL } from "./contracts.js";
|
|
2
|
+
import { credentialRef } from "@deepseek-ai/dsh-credentials";
|
|
3
|
+
import { WebError } from "@deepseek-ai/dsh-web";
|
|
4
|
+
import { DEEPSEEK_DEFAULT_API_VERSION, DEEPSEEK_DEFAULT_BASE_URL, DEEPSEEK_DEFAULT_MAX_TOKENS, DEEPSEEK_DEFAULT_MAX_USES, DEEPSEEK_DEFAULT_MODEL, DeepSeekSearchProvider } from "@deepseek-ai/dsh-web-search-deepseek";
|
|
5
|
+
import { ExaSearchProvider } from "@deepseek-ai/dsh-web-search-exa";
|
|
6
|
+
import { HttpFetchProvider } from "@deepseek-ai/dsh-web-fetch-http";
|
|
7
|
+
import { defineDomain, domainTable } from "@deepseek-ai/dsh-storage-domain";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
const SEARCH_ENGINE_PAGE = "https://lite.duckduckgo.com/lite/";
|
|
10
|
+
const MAX_RESPONSE_BYTES$2 = 2e6;
|
|
11
|
+
const USER_AGENT = "Mozilla/5.0 (compatible; dsh-editor-search/1.0)";
|
|
12
|
+
function searchEngineUrl(query, page = SEARCH_ENGINE_PAGE) {
|
|
13
|
+
const url = new URL(page);
|
|
14
|
+
if (url.protocol !== "https:" || url.username || url.password) throw new Error("Invalid search page");
|
|
15
|
+
url.searchParams.set("q", query);
|
|
16
|
+
return url.href;
|
|
17
|
+
}
|
|
18
|
+
function decodeEntities(value) {
|
|
19
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, "\"").replace(/'/g, "'").replace(/ /g, " ");
|
|
20
|
+
}
|
|
21
|
+
function stripTags(value) {
|
|
22
|
+
return value.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ");
|
|
23
|
+
}
|
|
24
|
+
function unwrapSearchHref(href, pageUrl) {
|
|
25
|
+
let raw = decodeEntities(href.trim());
|
|
26
|
+
let url;
|
|
27
|
+
try {
|
|
28
|
+
url = new URL(raw, pageUrl);
|
|
29
|
+
} catch {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) return;
|
|
33
|
+
const host = url.hostname;
|
|
34
|
+
if ((host === "duckduckgo.com" || host.endsWith(".duckduckgo.com")) && (url.pathname === "/l/" || url.pathname === "/l")) {
|
|
35
|
+
const nested = url.searchParams.get("uddg") ?? url.searchParams.get("u");
|
|
36
|
+
if (nested) {
|
|
37
|
+
try {
|
|
38
|
+
url = new URL(nested);
|
|
39
|
+
} catch {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) return;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const resultHost = url.hostname.replace(/^www\./, "");
|
|
46
|
+
if (resultHost === "duckduckgo.com" || resultHost.endsWith(".duckduckgo.com")) return;
|
|
47
|
+
return url.href;
|
|
48
|
+
}
|
|
49
|
+
function parseSearchEngineHtml(html, pageUrl = SEARCH_ENGINE_PAGE) {
|
|
50
|
+
const sources = [];
|
|
51
|
+
const seen = /* @__PURE__ */ new Set();
|
|
52
|
+
const re = /<a\b[^>]*href\s*=\s*(?:"([^"]*)"|'([^']*)')[^>]*>([\s\S]*?)<\/a>/gi;
|
|
53
|
+
let match;
|
|
54
|
+
while (match = re.exec(html)) {
|
|
55
|
+
const url = unwrapSearchHref(match[1] ?? match[2] ?? "", pageUrl);
|
|
56
|
+
if (!url || seen.has(url)) continue;
|
|
57
|
+
seen.add(url);
|
|
58
|
+
const title = decodeEntities(stripTags(match[3] ?? "")).trim().slice(0, 1e3);
|
|
59
|
+
sources.push(title ? {
|
|
60
|
+
url,
|
|
61
|
+
title
|
|
62
|
+
} : { url });
|
|
63
|
+
}
|
|
64
|
+
return sources;
|
|
65
|
+
}
|
|
66
|
+
async function readText(response, signal) {
|
|
67
|
+
const reader = response.body?.getReader();
|
|
68
|
+
if (!reader) throw new Error("Missing response");
|
|
69
|
+
let size = 0;
|
|
70
|
+
const chunks = [];
|
|
71
|
+
try {
|
|
72
|
+
while (true) {
|
|
73
|
+
signal?.throwIfAborted();
|
|
74
|
+
const { value, done } = await reader.read();
|
|
75
|
+
if (done) break;
|
|
76
|
+
size += value.length;
|
|
77
|
+
if (size > MAX_RESPONSE_BYTES$2) throw new Error("Response too large");
|
|
78
|
+
chunks.push(value);
|
|
79
|
+
}
|
|
80
|
+
} finally {
|
|
81
|
+
await reader.cancel().catch(() => {});
|
|
82
|
+
reader.releaseLock();
|
|
83
|
+
}
|
|
84
|
+
const bytes = new Uint8Array(size);
|
|
85
|
+
let offset = 0;
|
|
86
|
+
for (const chunk of chunks) {
|
|
87
|
+
bytes.set(chunk, offset);
|
|
88
|
+
offset += chunk.length;
|
|
89
|
+
}
|
|
90
|
+
return new TextDecoder().decode(bytes);
|
|
91
|
+
}
|
|
92
|
+
/** Adapter for `ctx.web.search`; `dsh-tool-web` still owns the model-facing `web_search` tool. */
|
|
93
|
+
var SearchEngineProvider = class {
|
|
94
|
+
options;
|
|
95
|
+
id = "ddg";
|
|
96
|
+
constructor(options = {}) {
|
|
97
|
+
this.options = options;
|
|
98
|
+
}
|
|
99
|
+
available() {
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
async search(request, signal) {
|
|
103
|
+
if (!request.query?.trim() || request.query.length > 4e3) throw new WebError("查询无效。", "WEB_INVALID_REQUEST");
|
|
104
|
+
const count = request.maxResults ?? 5;
|
|
105
|
+
if (!Number.isInteger(count) || count < 1 || count > 20) throw new WebError("结果数须为 1 至 20。", "WEB_INVALID_REQUEST");
|
|
106
|
+
try {
|
|
107
|
+
signal?.throwIfAborted();
|
|
108
|
+
const url = searchEngineUrl(request.query.trim());
|
|
109
|
+
const response = await (this.options.fetch ?? fetch)(url, {
|
|
110
|
+
method: "GET",
|
|
111
|
+
redirect: "follow",
|
|
112
|
+
signal,
|
|
113
|
+
headers: {
|
|
114
|
+
accept: "text/html",
|
|
115
|
+
"user-agent": USER_AGENT
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
if (!response.ok) {
|
|
119
|
+
await response.body?.cancel().catch(() => {});
|
|
120
|
+
throw new WebError(`搜索页请求失败(HTTP ${response.status})。`, "WEB_PROVIDER_ERROR");
|
|
121
|
+
}
|
|
122
|
+
const sources = parseSearchEngineHtml(await readText(response, signal), response.url || "https://lite.duckduckgo.com/lite/");
|
|
123
|
+
return {
|
|
124
|
+
sources: sources.slice(0, count),
|
|
125
|
+
truncated: sources.length > count
|
|
126
|
+
};
|
|
127
|
+
} catch (error) {
|
|
128
|
+
if (signal?.aborted) throw new WebError("搜索已取消。", "WEB_ABORTED");
|
|
129
|
+
if (error instanceof WebError) throw error;
|
|
130
|
+
throw new WebError("无法读取公开搜索页。", "WEB_PROVIDER_ERROR");
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
function record$1(value) {
|
|
135
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : void 0;
|
|
136
|
+
}
|
|
137
|
+
async function readJSON$1(response, signal) {
|
|
138
|
+
const reader = response.body?.getReader();
|
|
139
|
+
if (!reader) throw new Error("Missing response");
|
|
140
|
+
let size = 0;
|
|
141
|
+
const chunks = [];
|
|
142
|
+
try {
|
|
143
|
+
while (true) {
|
|
144
|
+
signal?.throwIfAborted();
|
|
145
|
+
const { value, done } = await reader.read();
|
|
146
|
+
if (done) break;
|
|
147
|
+
size += value.length;
|
|
148
|
+
if (size > 2e6) throw new Error("Response too large");
|
|
149
|
+
chunks.push(value);
|
|
150
|
+
}
|
|
151
|
+
} finally {
|
|
152
|
+
await reader.cancel().catch(() => {});
|
|
153
|
+
reader.releaseLock();
|
|
154
|
+
}
|
|
155
|
+
const bytes = new Uint8Array(size);
|
|
156
|
+
let offset = 0;
|
|
157
|
+
for (const chunk of chunks) {
|
|
158
|
+
bytes.set(chunk, offset);
|
|
159
|
+
offset += chunk.length;
|
|
160
|
+
}
|
|
161
|
+
return JSON.parse(new TextDecoder().decode(bytes));
|
|
162
|
+
}
|
|
163
|
+
function httpsEndpoint(base, path = "") {
|
|
164
|
+
const url = new URL(base);
|
|
165
|
+
if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash) throw new Error("Invalid endpoint");
|
|
166
|
+
return `${url.href.replace(/\/+$/, "")}${path}`;
|
|
167
|
+
}
|
|
168
|
+
function sourceFrom(raw, urlKey, titleKey, snippetKey) {
|
|
169
|
+
const row = record$1(raw);
|
|
170
|
+
if (typeof row?.[urlKey] !== "string") return;
|
|
171
|
+
let url;
|
|
172
|
+
try {
|
|
173
|
+
url = new URL(row[urlKey]);
|
|
174
|
+
} catch {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (!["https:", "http:"].includes(url.protocol) || url.username || url.password) return;
|
|
178
|
+
const title = titleKey && typeof row[titleKey] === "string" ? row[titleKey].slice(0, 1e3) : void 0;
|
|
179
|
+
const snippet = snippetKey && typeof row[snippetKey] === "string" ? row[snippetKey].slice(0, 8e3) : void 0;
|
|
180
|
+
return {
|
|
181
|
+
url: url.href,
|
|
182
|
+
...title ? { title } : {},
|
|
183
|
+
...snippet ? { snippet } : {}
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
//#endregion
|
|
187
|
+
//#region src/rest-search.ts
|
|
188
|
+
function createRestSearch(spec) {
|
|
189
|
+
return class {
|
|
190
|
+
options;
|
|
191
|
+
id = spec.id;
|
|
192
|
+
constructor(options) {
|
|
193
|
+
this.options = options;
|
|
194
|
+
}
|
|
195
|
+
available() {
|
|
196
|
+
try {
|
|
197
|
+
httpsEndpoint(this.options.baseURL ?? spec.fallback, spec.path);
|
|
198
|
+
return Boolean(this.options.apiKey.trim());
|
|
199
|
+
} catch {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
async search(request, signal) {
|
|
204
|
+
if (!this.available()) throw new WebError(`${spec.id} 配置不可用。`, "WEB_CREDENTIAL_MISSING");
|
|
205
|
+
if (!request.query?.trim() || request.query.length > 4e3) throw new WebError("查询无效。", "WEB_INVALID_REQUEST");
|
|
206
|
+
const count = request.maxResults ?? 5;
|
|
207
|
+
if (!Number.isInteger(count) || count < 1 || count > 20) throw new WebError("结果数须为 1 至 20。", "WEB_INVALID_REQUEST");
|
|
208
|
+
const query = request.query.trim();
|
|
209
|
+
try {
|
|
210
|
+
signal?.throwIfAborted();
|
|
211
|
+
const url = new URL(httpsEndpoint(this.options.baseURL ?? spec.fallback, spec.path));
|
|
212
|
+
for (const [key, value] of Object.entries(spec.query?.({
|
|
213
|
+
query,
|
|
214
|
+
count
|
|
215
|
+
}) ?? {})) url.searchParams.set(key, value);
|
|
216
|
+
const response = await (this.options.fetch ?? fetch)(url, {
|
|
217
|
+
method: spec.method,
|
|
218
|
+
redirect: "error",
|
|
219
|
+
signal,
|
|
220
|
+
headers: {
|
|
221
|
+
accept: "application/json",
|
|
222
|
+
...spec.headers(this.options.apiKey)
|
|
223
|
+
},
|
|
224
|
+
...spec.body ? { body: JSON.stringify(spec.body({
|
|
225
|
+
query,
|
|
226
|
+
count
|
|
227
|
+
})) } : {}
|
|
228
|
+
});
|
|
229
|
+
if (!response.ok) {
|
|
230
|
+
await response.body?.cancel().catch(() => {});
|
|
231
|
+
throw new WebError(`搜索请求失败(HTTP ${response.status})。`, "WEB_PROVIDER_ERROR");
|
|
232
|
+
}
|
|
233
|
+
const sources = spec.parse(await readJSON$1(response, signal));
|
|
234
|
+
return {
|
|
235
|
+
sources: sources.slice(0, count),
|
|
236
|
+
truncated: sources.length > count
|
|
237
|
+
};
|
|
238
|
+
} catch (error) {
|
|
239
|
+
if (signal?.aborted) throw new WebError("搜索已取消。", "WEB_ABORTED");
|
|
240
|
+
if (error instanceof WebError) throw error;
|
|
241
|
+
throw new WebError("无法完成搜索请求。", "WEB_PROVIDER_ERROR");
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
const BraveSearchProvider = createRestSearch({
|
|
247
|
+
id: "brave",
|
|
248
|
+
fallback: "https://api.search.brave.com",
|
|
249
|
+
path: "/res/v1/web/search",
|
|
250
|
+
method: "GET",
|
|
251
|
+
headers: (apiKey) => ({ "x-subscription-token": apiKey }),
|
|
252
|
+
query: ({ query, count }) => ({
|
|
253
|
+
q: query,
|
|
254
|
+
count: String(count)
|
|
255
|
+
}),
|
|
256
|
+
parse(payload) {
|
|
257
|
+
const results = record$1(record$1(payload)?.web)?.results;
|
|
258
|
+
return Array.isArray(results) ? results.flatMap((row) => sourceFrom(row, "url", "title", "description") ?? []) : [];
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
const BochaSearchProvider = createRestSearch({
|
|
262
|
+
id: "bocha",
|
|
263
|
+
fallback: "https://api.bochaai.com",
|
|
264
|
+
path: "/v1/web-search",
|
|
265
|
+
method: "POST",
|
|
266
|
+
headers: (apiKey) => ({
|
|
267
|
+
authorization: `Bearer ${apiKey}`,
|
|
268
|
+
"content-type": "application/json"
|
|
269
|
+
}),
|
|
270
|
+
body: ({ query, count }) => ({
|
|
271
|
+
query,
|
|
272
|
+
count,
|
|
273
|
+
freshness: "noLimit",
|
|
274
|
+
summary: false
|
|
275
|
+
}),
|
|
276
|
+
parse(payload) {
|
|
277
|
+
const pages = record$1(record$1(record$1(payload)?.data)?.webPages)?.value ?? record$1(record$1(payload)?.webPages)?.value;
|
|
278
|
+
return Array.isArray(pages) ? pages.flatMap((row) => sourceFrom(row, "url", "name", "snippet") ?? []) : [];
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
const SerperSearchProvider = createRestSearch({
|
|
282
|
+
id: "serper",
|
|
283
|
+
fallback: "https://google.serper.dev",
|
|
284
|
+
path: "/search",
|
|
285
|
+
method: "POST",
|
|
286
|
+
headers: (apiKey) => ({
|
|
287
|
+
"x-api-key": apiKey,
|
|
288
|
+
"content-type": "application/json"
|
|
289
|
+
}),
|
|
290
|
+
body: ({ query, count }) => ({
|
|
291
|
+
q: query,
|
|
292
|
+
num: count
|
|
293
|
+
}),
|
|
294
|
+
parse(payload) {
|
|
295
|
+
const organic = record$1(payload)?.organic;
|
|
296
|
+
return Array.isArray(organic) ? organic.flatMap((row) => sourceFrom(row, "link", "title", "snippet") ?? []) : [];
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
const FirecrawlSearchProvider = createRestSearch({
|
|
300
|
+
id: "firecrawl",
|
|
301
|
+
fallback: "https://api.firecrawl.dev",
|
|
302
|
+
path: "/v1/search",
|
|
303
|
+
method: "POST",
|
|
304
|
+
headers: (apiKey) => ({
|
|
305
|
+
authorization: `Bearer ${apiKey}`,
|
|
306
|
+
"content-type": "application/json"
|
|
307
|
+
}),
|
|
308
|
+
body: ({ query, count }) => ({
|
|
309
|
+
query,
|
|
310
|
+
limit: count
|
|
311
|
+
}),
|
|
312
|
+
parse(payload) {
|
|
313
|
+
const data = record$1(payload)?.data;
|
|
314
|
+
return Array.isArray(data) ? data.flatMap((row) => sourceFrom(row, "url", "title", "description") ?? []) : [];
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
//#endregion
|
|
318
|
+
//#region src/tavily.ts
|
|
319
|
+
const MAX_RESPONSE_BYTES = 2e6;
|
|
320
|
+
function endpoint(base) {
|
|
321
|
+
const url = new URL(base);
|
|
322
|
+
if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash) throw new Error("Invalid endpoint");
|
|
323
|
+
return `${url.href.replace(/\/+$/, "")}/search`;
|
|
324
|
+
}
|
|
325
|
+
function record(value) {
|
|
326
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : void 0;
|
|
327
|
+
}
|
|
328
|
+
async function readJSON(response, signal) {
|
|
329
|
+
const reader = response.body?.getReader();
|
|
330
|
+
if (!reader) throw new Error("Missing response");
|
|
331
|
+
let size = 0;
|
|
332
|
+
const chunks = [];
|
|
333
|
+
try {
|
|
334
|
+
while (true) {
|
|
335
|
+
signal?.throwIfAborted();
|
|
336
|
+
const { value, done } = await reader.read();
|
|
337
|
+
if (done) break;
|
|
338
|
+
size += value.length;
|
|
339
|
+
if (size > MAX_RESPONSE_BYTES) throw new Error("Response too large");
|
|
340
|
+
chunks.push(value);
|
|
341
|
+
}
|
|
342
|
+
const bytes = new Uint8Array(size);
|
|
343
|
+
let offset = 0;
|
|
344
|
+
for (const chunk of chunks) {
|
|
345
|
+
bytes.set(chunk, offset);
|
|
346
|
+
offset += chunk.length;
|
|
347
|
+
}
|
|
348
|
+
return JSON.parse(new TextDecoder().decode(bytes));
|
|
349
|
+
} finally {
|
|
350
|
+
await reader.cancel().catch(() => {});
|
|
351
|
+
reader.releaseLock();
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
/** Direct REST adapter. No auxiliary LLM, retries, anonymous fallback or auto-depth. */
|
|
355
|
+
var TavilySearchProvider = class {
|
|
356
|
+
options;
|
|
357
|
+
id = "tavily";
|
|
358
|
+
constructor(options) {
|
|
359
|
+
this.options = options;
|
|
360
|
+
}
|
|
361
|
+
available() {
|
|
362
|
+
try {
|
|
363
|
+
endpoint(this.options.baseURL ?? "https://api.tavily.com");
|
|
364
|
+
return Boolean(this.options.apiKey.trim());
|
|
365
|
+
} catch {
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
async search(request, signal) {
|
|
370
|
+
if (!this.available()) throw new WebError("Tavily 配置不可用。", "WEB_CREDENTIAL_MISSING");
|
|
371
|
+
if (!request.query?.trim() || request.query.length > 4e3) throw new WebError("查询无效。", "WEB_INVALID_REQUEST");
|
|
372
|
+
const count = request.maxResults ?? 5;
|
|
373
|
+
if (!Number.isInteger(count) || count < 1 || count > 20) throw new WebError("结果数须为 1 至 20。", "WEB_INVALID_REQUEST");
|
|
374
|
+
try {
|
|
375
|
+
signal?.throwIfAborted();
|
|
376
|
+
const response = await (this.options.fetch ?? fetch)(endpoint(this.options.baseURL ?? "https://api.tavily.com"), {
|
|
377
|
+
method: "POST",
|
|
378
|
+
redirect: "error",
|
|
379
|
+
signal,
|
|
380
|
+
headers: {
|
|
381
|
+
authorization: `Bearer ${this.options.apiKey}`,
|
|
382
|
+
"content-type": "application/json",
|
|
383
|
+
accept: "application/json"
|
|
384
|
+
},
|
|
385
|
+
body: JSON.stringify({
|
|
386
|
+
query: request.query.trim(),
|
|
387
|
+
max_results: count,
|
|
388
|
+
search_depth: "basic",
|
|
389
|
+
topic: "general",
|
|
390
|
+
auto_parameters: false,
|
|
391
|
+
include_answer: false,
|
|
392
|
+
include_raw_content: false,
|
|
393
|
+
include_images: false
|
|
394
|
+
})
|
|
395
|
+
});
|
|
396
|
+
if (!response.ok) {
|
|
397
|
+
await response.body?.cancel().catch(() => {});
|
|
398
|
+
throw new WebError(`Tavily 请求失败(HTTP ${response.status})。`, "WEB_PROVIDER_ERROR");
|
|
399
|
+
}
|
|
400
|
+
const payload = record(await readJSON(response, signal));
|
|
401
|
+
if (!Array.isArray(payload?.results)) throw new Error("Invalid response shape");
|
|
402
|
+
const sources = [];
|
|
403
|
+
for (const raw of payload.results) {
|
|
404
|
+
const row = record(raw);
|
|
405
|
+
if (typeof row?.url !== "string") continue;
|
|
406
|
+
let url;
|
|
407
|
+
try {
|
|
408
|
+
url = new URL(row.url);
|
|
409
|
+
} catch {
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
if (!["https:", "http:"].includes(url.protocol) || url.username || url.password) continue;
|
|
413
|
+
sources.push({
|
|
414
|
+
url: url.href,
|
|
415
|
+
...typeof row.title === "string" ? { title: row.title.slice(0, 1e3) } : {},
|
|
416
|
+
...typeof row.content === "string" ? { snippet: row.content.slice(0, 8e3) } : {},
|
|
417
|
+
...typeof row.published_date === "string" && Number.isFinite(Date.parse(row.published_date)) ? { publishedAt: new Date(row.published_date).toISOString() } : {}
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
return {
|
|
421
|
+
sources: sources.slice(0, count),
|
|
422
|
+
truncated: sources.length > count
|
|
423
|
+
};
|
|
424
|
+
} catch (error) {
|
|
425
|
+
if (signal?.aborted) throw new WebError("Tavily 搜索已取消。", "WEB_ABORTED");
|
|
426
|
+
if (error instanceof WebError) throw error;
|
|
427
|
+
throw new WebError("Tavily 返回无效响应或网络连接失败。", "WEB_PROVIDER_ERROR");
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
//#endregion
|
|
432
|
+
//#region src/builtins.ts
|
|
433
|
+
function registerBuiltins(manager) {
|
|
434
|
+
const offEngine = manager.registerSearchProvider({
|
|
435
|
+
id: "ddg",
|
|
436
|
+
label: "DuckDuckGo",
|
|
437
|
+
billing: "none",
|
|
438
|
+
description: "无需注册。默认后端。"
|
|
439
|
+
}, () => new SearchEngineProvider());
|
|
440
|
+
const offDeepSeek = manager.registerSearchProvider({
|
|
441
|
+
id: "deepseek-official",
|
|
442
|
+
label: "DeepSeek 搜索",
|
|
443
|
+
description: "使用已配置的 DeepSeek API Key。可能产生额外搜索费用。",
|
|
444
|
+
defaultBaseURL: DEEPSEEK_DEFAULT_BASE_URL,
|
|
445
|
+
credentialRef: "DEEPSEEK_API_KEY",
|
|
446
|
+
credentialShared: true,
|
|
447
|
+
billing: "model-and-tools",
|
|
448
|
+
signupUrl: "https://platform.deepseek.com/api_keys"
|
|
449
|
+
}, (options) => new DeepSeekSearchProvider(() => ({
|
|
450
|
+
apiKey: options.apiKey,
|
|
451
|
+
baseURL: options.baseURL ?? DEEPSEEK_DEFAULT_BASE_URL,
|
|
452
|
+
model: DEEPSEEK_DEFAULT_MODEL,
|
|
453
|
+
apiVersion: DEEPSEEK_DEFAULT_API_VERSION,
|
|
454
|
+
maxTokens: DEEPSEEK_DEFAULT_MAX_TOKENS,
|
|
455
|
+
maxUses: DEEPSEEK_DEFAULT_MAX_USES
|
|
456
|
+
})));
|
|
457
|
+
const offExa = manager.registerSearchProvider({
|
|
458
|
+
id: "exa",
|
|
459
|
+
label: "Exa",
|
|
460
|
+
description: "独立 Search API。",
|
|
461
|
+
defaultBaseURL: "https://api.exa.ai",
|
|
462
|
+
credentialRef: "DSH_EDITOR_WEB_EXA_API_KEY",
|
|
463
|
+
billing: "request",
|
|
464
|
+
signupUrl: "https://dashboard.exa.ai/api-keys"
|
|
465
|
+
}, (options) => new ExaSearchProvider({
|
|
466
|
+
apiKey: options.apiKey ?? "",
|
|
467
|
+
baseURL: options.baseURL ?? "https://api.exa.ai",
|
|
468
|
+
searchType: "auto",
|
|
469
|
+
highlightsPerResult: 1
|
|
470
|
+
}));
|
|
471
|
+
const offBrave = manager.registerSearchProvider({
|
|
472
|
+
id: "brave",
|
|
473
|
+
label: "Brave",
|
|
474
|
+
description: "Brave Search API。",
|
|
475
|
+
defaultBaseURL: "https://api.search.brave.com",
|
|
476
|
+
credentialRef: "DSH_EDITOR_WEB_BRAVE_API_KEY",
|
|
477
|
+
billing: "request",
|
|
478
|
+
signupUrl: "https://api.search.brave.com"
|
|
479
|
+
}, (options) => new BraveSearchProvider({
|
|
480
|
+
apiKey: options.apiKey ?? "",
|
|
481
|
+
baseURL: options.baseURL
|
|
482
|
+
}));
|
|
483
|
+
const offBocha = manager.registerSearchProvider({
|
|
484
|
+
id: "bocha",
|
|
485
|
+
label: "博查",
|
|
486
|
+
description: "中文网页搜索 API。",
|
|
487
|
+
defaultBaseURL: "https://api.bochaai.com",
|
|
488
|
+
credentialRef: "DSH_EDITOR_WEB_BOCHA_API_KEY",
|
|
489
|
+
billing: "request",
|
|
490
|
+
signupUrl: "https://open.bochaai.com"
|
|
491
|
+
}, (options) => new BochaSearchProvider({
|
|
492
|
+
apiKey: options.apiKey ?? "",
|
|
493
|
+
baseURL: options.baseURL
|
|
494
|
+
}));
|
|
495
|
+
const offSerper = manager.registerSearchProvider({
|
|
496
|
+
id: "serper",
|
|
497
|
+
label: "Serper",
|
|
498
|
+
description: "Google 结果的 Search API。",
|
|
499
|
+
defaultBaseURL: "https://google.serper.dev",
|
|
500
|
+
credentialRef: "DSH_EDITOR_WEB_SERPER_API_KEY",
|
|
501
|
+
billing: "request",
|
|
502
|
+
signupUrl: "https://serper.dev"
|
|
503
|
+
}, (options) => new SerperSearchProvider({
|
|
504
|
+
apiKey: options.apiKey ?? "",
|
|
505
|
+
baseURL: options.baseURL
|
|
506
|
+
}));
|
|
507
|
+
const offFirecrawl = manager.registerSearchProvider({
|
|
508
|
+
id: "firecrawl",
|
|
509
|
+
label: "Firecrawl",
|
|
510
|
+
description: "网页搜索与提取 API。",
|
|
511
|
+
defaultBaseURL: "https://api.firecrawl.dev",
|
|
512
|
+
credentialRef: "DSH_EDITOR_WEB_FIRECRAWL_API_KEY",
|
|
513
|
+
billing: "request",
|
|
514
|
+
signupUrl: "https://www.firecrawl.dev"
|
|
515
|
+
}, (options) => new FirecrawlSearchProvider({
|
|
516
|
+
apiKey: options.apiKey ?? "",
|
|
517
|
+
baseURL: options.baseURL
|
|
518
|
+
}));
|
|
519
|
+
const offTavily = manager.registerSearchProvider({
|
|
520
|
+
id: "tavily",
|
|
521
|
+
label: "Tavily",
|
|
522
|
+
defaultBaseURL: "https://api.tavily.com",
|
|
523
|
+
description: "Search API;固定 basic 检索,不自动升级搜索深度。",
|
|
524
|
+
credentialRef: "DSH_EDITOR_WEB_TAVILY_API_KEY",
|
|
525
|
+
billing: "request",
|
|
526
|
+
signupUrl: "https://app.tavily.com"
|
|
527
|
+
}, (options) => new TavilySearchProvider({
|
|
528
|
+
apiKey: options.apiKey ?? "",
|
|
529
|
+
baseURL: options.baseURL
|
|
530
|
+
}));
|
|
531
|
+
const offHttp = manager.registerFetchProvider({
|
|
532
|
+
id: "http",
|
|
533
|
+
label: "HTTP 读取",
|
|
534
|
+
billing: "none",
|
|
535
|
+
description: "本机直连公开网页,无需 Key,不产生搜索费用。"
|
|
536
|
+
}, (options) => new HttpFetchProvider({
|
|
537
|
+
maxResponseBytes: 5e6,
|
|
538
|
+
maxBodyChars: options.maxFetchChars,
|
|
539
|
+
timeoutMs: options.timeoutMs,
|
|
540
|
+
maxRedirects: 5,
|
|
541
|
+
userAgent: "dsh-editor/managed-web"
|
|
542
|
+
}));
|
|
543
|
+
return () => {
|
|
544
|
+
offHttp();
|
|
545
|
+
offTavily();
|
|
546
|
+
offFirecrawl();
|
|
547
|
+
offSerper();
|
|
548
|
+
offBocha();
|
|
549
|
+
offBrave();
|
|
550
|
+
offExa();
|
|
551
|
+
offDeepSeek();
|
|
552
|
+
offEngine();
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
//#endregion
|
|
556
|
+
//#region src/host-rpc.ts
|
|
557
|
+
function registerHostRpc(ctx, channel, call) {
|
|
558
|
+
return ctx.webServer.register({
|
|
559
|
+
kind: "prefix",
|
|
560
|
+
path: channel,
|
|
561
|
+
handler: async (req, res) => {
|
|
562
|
+
if (!ctx.connection.requestRejection) {
|
|
563
|
+
res.writeHead(503);
|
|
564
|
+
res.end();
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
const rejection = ctx.connection.requestRejection(req);
|
|
568
|
+
if (rejection !== void 0) {
|
|
569
|
+
res.writeHead(rejection);
|
|
570
|
+
res.end();
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
const pathname = new URL(req.url ?? "/", "http://dsh.internal").pathname;
|
|
574
|
+
const endpoint = pathname.startsWith(`${channel}/`) ? pathname.slice(channel.length + 1) : "";
|
|
575
|
+
if (req.method !== "POST" || !/^[a-z.]+$/.test(endpoint)) {
|
|
576
|
+
res.writeHead(404);
|
|
577
|
+
res.end();
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
if (String(req.headers["content-type"] ?? "").split(";")[0]?.trim() !== "application/json") {
|
|
581
|
+
res.writeHead(415);
|
|
582
|
+
res.end();
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
const controller = new AbortController();
|
|
586
|
+
res.on("close", () => {
|
|
587
|
+
if (!res.writableEnded) controller.abort();
|
|
588
|
+
});
|
|
589
|
+
try {
|
|
590
|
+
const chunks = [];
|
|
591
|
+
let size = 0;
|
|
592
|
+
for await (const chunk of req) {
|
|
593
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
594
|
+
size += buffer.length;
|
|
595
|
+
if (size > 65536) {
|
|
596
|
+
res.writeHead(413, { connection: "close" });
|
|
597
|
+
res.end();
|
|
598
|
+
req.destroy();
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
chunks.push(buffer);
|
|
602
|
+
}
|
|
603
|
+
let request;
|
|
604
|
+
try {
|
|
605
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
606
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error();
|
|
607
|
+
request = parsed;
|
|
608
|
+
} catch {
|
|
609
|
+
res.writeHead(400);
|
|
610
|
+
res.end();
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
if (request.type !== "client-request" || request.method !== endpoint || typeof request.rpcId !== "string") {
|
|
614
|
+
res.writeHead(400);
|
|
615
|
+
res.end();
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
const result = await call(endpoint, request.payload, controller.signal);
|
|
619
|
+
if (!res.destroyed) {
|
|
620
|
+
res.writeHead(200, {
|
|
621
|
+
"content-type": "application/json",
|
|
622
|
+
"cache-control": "no-store"
|
|
623
|
+
});
|
|
624
|
+
res.end(JSON.stringify({
|
|
625
|
+
type: "server-response",
|
|
626
|
+
rpcId: request.rpcId,
|
|
627
|
+
result
|
|
628
|
+
}));
|
|
629
|
+
}
|
|
630
|
+
} catch {
|
|
631
|
+
if (!res.destroyed) {
|
|
632
|
+
res.writeHead(500);
|
|
633
|
+
res.end("network settings request failed");
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
//#endregion
|
|
640
|
+
//#region src/manager.ts
|
|
641
|
+
function fail(code, message) {
|
|
642
|
+
throw new WebError(message, code);
|
|
643
|
+
}
|
|
644
|
+
function bounded(value, min, max, label) {
|
|
645
|
+
if (!Number.isInteger(value) || value < min || value > max) fail("WEB_INVALID_CONFIG", `${label}须为 ${min} 至 ${max} 的整数。`);
|
|
646
|
+
return value;
|
|
647
|
+
}
|
|
648
|
+
/** Enforce cancellation even when an extension ignores its cooperative signal. */
|
|
649
|
+
async function abortable(work, signal) {
|
|
650
|
+
signal.throwIfAborted();
|
|
651
|
+
let off = () => {};
|
|
652
|
+
const aborted = new Promise((_, reject) => {
|
|
653
|
+
const onAbort = () => reject(new WebError("网络请求已取消或超时。", "WEB_ABORTED"));
|
|
654
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
655
|
+
off = () => signal.removeEventListener("abort", onAbort);
|
|
656
|
+
});
|
|
657
|
+
try {
|
|
658
|
+
return await Promise.race([Promise.resolve().then(() => {
|
|
659
|
+
signal.throwIfAborted();
|
|
660
|
+
return work();
|
|
661
|
+
}), aborted]);
|
|
662
|
+
} finally {
|
|
663
|
+
off();
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
/** Manages consent and provider lifecycle. Selection and execution stay in ctx.web. */
|
|
667
|
+
var WebSearchManager = class {
|
|
668
|
+
options;
|
|
669
|
+
settings;
|
|
670
|
+
entries = /* @__PURE__ */ new Map();
|
|
671
|
+
listeners = /* @__PURE__ */ new Set();
|
|
672
|
+
pending = Promise.resolve();
|
|
673
|
+
epoch = 0;
|
|
674
|
+
suspended = false;
|
|
675
|
+
disposed = false;
|
|
676
|
+
storageFailed = false;
|
|
677
|
+
constructor(options) {
|
|
678
|
+
this.options = options;
|
|
679
|
+
const initial = structuredClone(options.initial ?? defaultSettings());
|
|
680
|
+
this.settings = {
|
|
681
|
+
...defaultSettings(),
|
|
682
|
+
...initial,
|
|
683
|
+
searchOrder: migrateSearchOrder(initial)
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
subscribe(listener) {
|
|
687
|
+
this.listeners.add(listener);
|
|
688
|
+
return () => {
|
|
689
|
+
this.listeners.delete(listener);
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
notify() {
|
|
693
|
+
for (const listener of this.listeners) try {
|
|
694
|
+
listener();
|
|
695
|
+
} catch {}
|
|
696
|
+
}
|
|
697
|
+
knownSearchIds() {
|
|
698
|
+
return [...this.entries.values()].filter((entry) => entry.kind === "search").map((entry) => entry.descriptor.id);
|
|
699
|
+
}
|
|
700
|
+
keyed(id) {
|
|
701
|
+
return Boolean(this.entries.get(providerKey("search", id))?.descriptor.credentialRef);
|
|
702
|
+
}
|
|
703
|
+
configured(id) {
|
|
704
|
+
return Boolean(this.entries.get(providerKey("search", id))?.configured);
|
|
705
|
+
}
|
|
706
|
+
searchOrder(settings = this.settings) {
|
|
707
|
+
return resolveSearchOrder(this.knownSearchIds(), settings, (id) => this.keyed(id));
|
|
708
|
+
}
|
|
709
|
+
activeSearchId(settings = this.settings) {
|
|
710
|
+
return pickActiveSearch(this.searchOrder(settings), (id) => this.configured(id));
|
|
711
|
+
}
|
|
712
|
+
selected(entry) {
|
|
713
|
+
if (this.disposed || this.suspended || !entry.configured) return false;
|
|
714
|
+
if (entry.kind === "fetch") return this.settings.fetchEnabled && this.settings.fetchProvider === entry.descriptor.id && this.entries.get(providerKey("fetch", entry.descriptor.id)) === entry;
|
|
715
|
+
return this.settings.searchEnabled && this.activeSearchId() === entry.descriptor.id;
|
|
716
|
+
}
|
|
717
|
+
status() {
|
|
718
|
+
const entries = [...this.entries.values()];
|
|
719
|
+
return {
|
|
720
|
+
settings: structuredClone(this.settings),
|
|
721
|
+
storageFailed: this.storageFailed,
|
|
722
|
+
searchActive: entries.some((entry) => entry.kind === "search" && this.selected(entry)),
|
|
723
|
+
fetchActive: entries.some((entry) => entry.kind === "fetch" && this.selected(entry)),
|
|
724
|
+
providers: entries.map((entry) => ({
|
|
725
|
+
...entry.descriptor,
|
|
726
|
+
kind: entry.kind,
|
|
727
|
+
configured: entry.configured,
|
|
728
|
+
baseURL: this.settings.endpoints[providerKey(entry.kind, entry.descriptor.id)] ?? entry.descriptor.defaultBaseURL,
|
|
729
|
+
calls: entry.calls,
|
|
730
|
+
failures: entry.failures
|
|
731
|
+
})).sort((a, b) => providerKey(a.kind, a.id).localeCompare(providerKey(b.kind, b.id)))
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
async refresh() {
|
|
735
|
+
const epoch = this.epoch;
|
|
736
|
+
await Promise.all([...this.entries.entries()].map(async ([key, entry]) => {
|
|
737
|
+
let configured = !entry.descriptor.credentialRef;
|
|
738
|
+
if (entry.descriptor.credentialRef) try {
|
|
739
|
+
configured = Boolean((await this.options.resolveCredential(entry.descriptor.credentialRef))?.trim());
|
|
740
|
+
} catch {
|
|
741
|
+
configured = false;
|
|
742
|
+
}
|
|
743
|
+
if (this.disposed || epoch !== this.epoch || this.entries.get(key) !== entry) return;
|
|
744
|
+
if (entry.configured !== configured) {
|
|
745
|
+
entry.configured = configured;
|
|
746
|
+
if (!configured) this.abort(entry);
|
|
747
|
+
}
|
|
748
|
+
}));
|
|
749
|
+
this.notify();
|
|
750
|
+
return this.status();
|
|
751
|
+
}
|
|
752
|
+
abort(entry) {
|
|
753
|
+
for (const target of entry ? [entry] : this.entries.values()) for (const controller of target.active) controller.abort();
|
|
754
|
+
}
|
|
755
|
+
add(kind, descriptor) {
|
|
756
|
+
if (this.disposed) fail("WEB_DISABLED", "网络搜索插件已停止。");
|
|
757
|
+
const key = providerKey(kind, descriptor.id);
|
|
758
|
+
if (!/^[a-z][a-z0-9-]{0,63}$/.test(descriptor.id) || !descriptor.label.trim()) fail("WEB_INVALID_PROVIDER", "供应商标识或名称无效。");
|
|
759
|
+
if (descriptor.credentialRef && !/^[A-Z][A-Z0-9_]{1,127}$/.test(descriptor.credentialRef)) fail("WEB_INVALID_PROVIDER", "供应商凭据引用无效。");
|
|
760
|
+
if (descriptor.defaultBaseURL) validateBaseURL(descriptor.defaultBaseURL);
|
|
761
|
+
if (descriptor.signupUrl) try {
|
|
762
|
+
const url = new URL(descriptor.signupUrl);
|
|
763
|
+
if (url.protocol !== "https:" || url.username || url.password) fail("WEB_INVALID_PROVIDER", "注册地址须为 HTTPS。");
|
|
764
|
+
} catch {
|
|
765
|
+
fail("WEB_INVALID_PROVIDER", "注册地址须为 HTTPS。");
|
|
766
|
+
}
|
|
767
|
+
if (this.entries.has(key)) fail("WEB_DUPLICATE_PROVIDER", "供应商标识已注册。");
|
|
768
|
+
const entry = {
|
|
769
|
+
descriptor: Object.freeze({ ...descriptor }),
|
|
770
|
+
kind,
|
|
771
|
+
configured: !descriptor.credentialRef,
|
|
772
|
+
calls: 0,
|
|
773
|
+
failures: 0,
|
|
774
|
+
unregister() {},
|
|
775
|
+
active: /* @__PURE__ */ new Set()
|
|
776
|
+
};
|
|
777
|
+
this.entries.set(key, entry);
|
|
778
|
+
return entry;
|
|
779
|
+
}
|
|
780
|
+
registration(entry, register) {
|
|
781
|
+
const key = providerKey(entry.kind, entry.descriptor.id);
|
|
782
|
+
try {
|
|
783
|
+
entry.unregister = register();
|
|
784
|
+
} catch (error) {
|
|
785
|
+
this.entries.delete(key);
|
|
786
|
+
throw error;
|
|
787
|
+
}
|
|
788
|
+
this.refresh();
|
|
789
|
+
let removed = false;
|
|
790
|
+
return () => {
|
|
791
|
+
if (removed) return;
|
|
792
|
+
removed = true;
|
|
793
|
+
this.abort(entry);
|
|
794
|
+
this.entries.delete(key);
|
|
795
|
+
entry.unregister();
|
|
796
|
+
this.notify();
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
registerSearchProvider(descriptor, factory) {
|
|
800
|
+
const entry = this.add("search", descriptor);
|
|
801
|
+
return this.registration(entry, () => this.options.web.registerSearchProvider({
|
|
802
|
+
id: descriptor.id,
|
|
803
|
+
available: () => this.selected(entry),
|
|
804
|
+
search: (request, signal) => {
|
|
805
|
+
if (typeof request.query !== "string" || !request.query.trim() || request.query.length > 4e3) fail("WEB_INVALID_REQUEST", "查询须为 1 至 4000 字符。");
|
|
806
|
+
const count = Math.min(bounded(request.maxResults ?? this.settings.maxResults, 1, 100, "结果数"), this.settings.maxResults);
|
|
807
|
+
return this.execute(entry, signal, async (options, combined) => {
|
|
808
|
+
const provider = factory(options);
|
|
809
|
+
if (!provider.available()) fail("WEB_PROVIDER_ERROR", "供应商配置不可用。");
|
|
810
|
+
const result = await provider.search({
|
|
811
|
+
query: request.query.trim(),
|
|
812
|
+
maxResults: count
|
|
813
|
+
}, combined);
|
|
814
|
+
const sources = result.sources.slice(0, count).filter((source) => {
|
|
815
|
+
try {
|
|
816
|
+
const url = new URL(source.url);
|
|
817
|
+
return ["http:", "https:"].includes(url.protocol) && !url.username && !url.password;
|
|
818
|
+
} catch {
|
|
819
|
+
return false;
|
|
820
|
+
}
|
|
821
|
+
}).map((source) => ({
|
|
822
|
+
...source,
|
|
823
|
+
title: source.title?.slice(0, 1e3),
|
|
824
|
+
snippet: source.snippet?.slice(0, 8e3)
|
|
825
|
+
}));
|
|
826
|
+
return {
|
|
827
|
+
...result,
|
|
828
|
+
content: result.content?.slice(0, 4e4),
|
|
829
|
+
sources,
|
|
830
|
+
truncated: result.truncated || result.sources.length > count
|
|
831
|
+
};
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
}));
|
|
835
|
+
}
|
|
836
|
+
registerFetchProvider(descriptor, factory) {
|
|
837
|
+
const entry = this.add("fetch", descriptor);
|
|
838
|
+
return this.registration(entry, () => this.options.web.registerFetchProvider({
|
|
839
|
+
id: descriptor.id,
|
|
840
|
+
available: () => this.selected(entry),
|
|
841
|
+
fetch: (request, signal) => this.execute(entry, signal, async (options, combined) => {
|
|
842
|
+
const provider = factory(options);
|
|
843
|
+
if (!provider.available()) fail("WEB_PROVIDER_ERROR", "供应商配置不可用。");
|
|
844
|
+
const result = await provider.fetch(request, combined);
|
|
845
|
+
const content = result.body.content.slice(0, options.maxFetchChars);
|
|
846
|
+
return {
|
|
847
|
+
...result,
|
|
848
|
+
body: {
|
|
849
|
+
...result.body,
|
|
850
|
+
content
|
|
851
|
+
},
|
|
852
|
+
truncated: result.truncated || content.length !== result.body.content.length
|
|
853
|
+
};
|
|
854
|
+
})
|
|
855
|
+
}));
|
|
856
|
+
}
|
|
857
|
+
async execute(entry, signal, run) {
|
|
858
|
+
if (!this.selected(entry)) fail("WEB_DISABLED", "请在设置 → 网络搜索中配置凭据并主动启用。");
|
|
859
|
+
const controller = new AbortController();
|
|
860
|
+
const timeout = setTimeout(() => controller.abort(), this.settings.timeoutMs);
|
|
861
|
+
const combined = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
|
|
862
|
+
const epoch = this.epoch;
|
|
863
|
+
entry.active.add(controller);
|
|
864
|
+
let started = false;
|
|
865
|
+
try {
|
|
866
|
+
const ref = entry.descriptor.credentialRef;
|
|
867
|
+
const apiKey = ref ? await abortable(() => this.options.resolveCredential(ref), combined) : void 0;
|
|
868
|
+
if (ref && !apiKey?.trim()) {
|
|
869
|
+
entry.configured = false;
|
|
870
|
+
this.abort(entry);
|
|
871
|
+
this.notify();
|
|
872
|
+
fail("WEB_CREDENTIAL_MISSING", "搜索凭据已删除或不可用,请重新配置。");
|
|
873
|
+
}
|
|
874
|
+
if (epoch !== this.epoch || !this.selected(entry)) fail("WEB_DISABLED", "网络访问设置已改变,请重新发起请求。");
|
|
875
|
+
const options = {
|
|
876
|
+
apiKey,
|
|
877
|
+
baseURL: this.settings.endpoints[providerKey(entry.kind, entry.descriptor.id)] ?? entry.descriptor.defaultBaseURL,
|
|
878
|
+
timeoutMs: this.settings.timeoutMs,
|
|
879
|
+
maxFetchChars: this.settings.maxFetchChars
|
|
880
|
+
};
|
|
881
|
+
started = true;
|
|
882
|
+
entry.calls += 1;
|
|
883
|
+
const result = await abortable(() => run(options, combined), combined);
|
|
884
|
+
if (epoch !== this.epoch || !this.selected(entry)) fail("WEB_ABORTED", "网络请求已取消。");
|
|
885
|
+
return result;
|
|
886
|
+
} catch (error) {
|
|
887
|
+
if (started) entry.failures += 1;
|
|
888
|
+
const code = error instanceof WebError ? error.code : void 0;
|
|
889
|
+
if (code === "WEB_CREDENTIAL_MISSING") throw new WebError("搜索凭据不可用,请重新配置。", "WEB_CREDENTIAL_MISSING");
|
|
890
|
+
if (code === "WEB_DISABLED") throw new WebError("网络搜索未启用或设置已改变。", "WEB_DISABLED");
|
|
891
|
+
if (combined.aborted || code === "WEB_ABORTED") throw new WebError("网络请求已取消或超时。", "WEB_ABORTED");
|
|
892
|
+
throw new WebError("网络供应商请求失败,请检查凭据、额度和连接。未切换到其他供应商。", "WEB_PROVIDER_ERROR");
|
|
893
|
+
} finally {
|
|
894
|
+
clearTimeout(timeout);
|
|
895
|
+
entry.active.delete(controller);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
update(next, expectedRevision) {
|
|
899
|
+
const task = this.pending.then(async () => {
|
|
900
|
+
if (this.disposed) fail("WEB_DISABLED", "网络搜索插件已停止。");
|
|
901
|
+
if (expectedRevision !== this.settings.revision) fail("WEB_CONFIG_CONFLICT", "设置已被其他页面修改,请刷新后重试。");
|
|
902
|
+
const proposed = {
|
|
903
|
+
...structuredClone(next),
|
|
904
|
+
revision: expectedRevision + 1
|
|
905
|
+
};
|
|
906
|
+
bounded(proposed.maxResults, 1, 20, "结果上限");
|
|
907
|
+
bounded(proposed.maxQueries, 1, 5, "查询上限");
|
|
908
|
+
bounded(proposed.timeoutMs, 1e3, 12e4, "超时毫秒数");
|
|
909
|
+
bounded(proposed.maxFetchChars, 1e3, 2e5, "正文长度");
|
|
910
|
+
for (const [key, value] of Object.entries(proposed.endpoints)) {
|
|
911
|
+
const entry = this.entries.get(key);
|
|
912
|
+
if (entry && !entry.descriptor.defaultBaseURL) fail("WEB_INVALID_CONFIG", "供应商不支持自定义地址。");
|
|
913
|
+
try {
|
|
914
|
+
proposed.endpoints[key] = validateBaseURL(value);
|
|
915
|
+
} catch {
|
|
916
|
+
fail("WEB_INVALID_CONFIG", "供应商地址须为无凭据、无查询参数的 HTTPS 地址。");
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
await this.refresh();
|
|
920
|
+
proposed.searchOrder = this.searchOrder(proposed);
|
|
921
|
+
proposed.searchProvider = this.activeSearchId(proposed);
|
|
922
|
+
if (proposed.searchEnabled && !proposed.searchProvider) fail("WEB_CREDENTIAL_MISSING", "请先选择已安装的供应商并填写有效凭据。");
|
|
923
|
+
if (proposed.searchEnabled && this.entries.get(providerKey("fetch", "http"))) {
|
|
924
|
+
proposed.fetchEnabled = true;
|
|
925
|
+
proposed.fetchProvider = "http";
|
|
926
|
+
}
|
|
927
|
+
if (!proposed.searchEnabled) proposed.fetchEnabled = false;
|
|
928
|
+
if (proposed.fetchEnabled) {
|
|
929
|
+
const entry = this.entries.get(providerKey("fetch", proposed.fetchProvider));
|
|
930
|
+
if (!entry) proposed.fetchEnabled = false;
|
|
931
|
+
else if (!entry.configured) fail("WEB_CREDENTIAL_MISSING", "请先选择已安装的供应商并填写有效凭据。");
|
|
932
|
+
}
|
|
933
|
+
this.suspended = true;
|
|
934
|
+
this.epoch += 1;
|
|
935
|
+
this.abort();
|
|
936
|
+
this.notify();
|
|
937
|
+
try {
|
|
938
|
+
await this.options.save(proposed);
|
|
939
|
+
} catch {
|
|
940
|
+
this.storageFailed = true;
|
|
941
|
+
this.notify();
|
|
942
|
+
fail("WEB_CONFIG_SAVE_FAILED", "设置保存失败。为避免意外联网,本次运行已暂停网络访问。");
|
|
943
|
+
}
|
|
944
|
+
this.settings = proposed;
|
|
945
|
+
this.storageFailed = false;
|
|
946
|
+
this.suspended = false;
|
|
947
|
+
this.notify();
|
|
948
|
+
return this.status();
|
|
949
|
+
});
|
|
950
|
+
this.pending = task.then(() => {}, () => {});
|
|
951
|
+
return task;
|
|
952
|
+
}
|
|
953
|
+
async dispose() {
|
|
954
|
+
this.disposed = true;
|
|
955
|
+
this.epoch += 1;
|
|
956
|
+
this.abort();
|
|
957
|
+
this.notify();
|
|
958
|
+
for (const entry of this.entries.values()) entry.unregister();
|
|
959
|
+
this.entries.clear();
|
|
960
|
+
this.listeners.clear();
|
|
961
|
+
await this.pending;
|
|
962
|
+
}
|
|
963
|
+
};
|
|
964
|
+
//#endregion
|
|
965
|
+
//#region src/storage.ts
|
|
966
|
+
const settingsSchema = z.object({
|
|
967
|
+
revision: z.number().int().nonnegative(),
|
|
968
|
+
searchEnabled: z.boolean(),
|
|
969
|
+
fetchEnabled: z.boolean(),
|
|
970
|
+
searchProvider: z.string().max(64),
|
|
971
|
+
fetchProvider: z.string().max(64),
|
|
972
|
+
searchOrder: z.array(z.string().min(1).max(64)).max(32),
|
|
973
|
+
maxResults: z.number().int().min(1).max(20),
|
|
974
|
+
maxQueries: z.number().int().min(1).max(5),
|
|
975
|
+
timeoutMs: z.number().int().min(1e3).max(12e4),
|
|
976
|
+
maxFetchChars: z.number().int().min(1e3).max(2e5),
|
|
977
|
+
endpoints: z.record(z.string().regex(/^(search|fetch):[a-z][a-z0-9-]{0,63}$/), z.string().max(2048)).refine((value) => Object.keys(value).length <= 50)
|
|
978
|
+
}).strict();
|
|
979
|
+
const updateSchema = z.object({
|
|
980
|
+
expectedRevision: z.number().int().nonnegative(),
|
|
981
|
+
settings: settingsSchema.omit({ revision: true })
|
|
982
|
+
}).strict();
|
|
983
|
+
const storedSettingsSchema = settingsSchema.extend({ searchOrder: settingsSchema.shape.searchOrder.optional() }).transform((settings) => ({
|
|
984
|
+
...settings,
|
|
985
|
+
searchOrder: migrateSearchOrder(settings)
|
|
986
|
+
}));
|
|
987
|
+
const webSettingsDomain = defineDomain({
|
|
988
|
+
name: "dsh_editor_web_search",
|
|
989
|
+
version: 1,
|
|
990
|
+
tables: { settings: domainTable(storedSettingsSchema) }
|
|
991
|
+
});
|
|
992
|
+
//#endregion
|
|
993
|
+
//#region src/index.ts
|
|
994
|
+
const name = "@klarkxy/dsh-web-search-manager";
|
|
995
|
+
const inject = [
|
|
996
|
+
"web",
|
|
997
|
+
"credentials",
|
|
998
|
+
"storageDomain",
|
|
999
|
+
"connection",
|
|
1000
|
+
"webServer"
|
|
1001
|
+
];
|
|
1002
|
+
async function apply(ctx) {
|
|
1003
|
+
const host = ctx;
|
|
1004
|
+
const domain = await ctx.storageDomain.open(webSettingsDomain);
|
|
1005
|
+
const table = domain.table("settings");
|
|
1006
|
+
const manager = new WebSearchManager({
|
|
1007
|
+
web: ctx.web,
|
|
1008
|
+
initial: table.get("global"),
|
|
1009
|
+
resolveCredential: async (ref) => (await host.credentials.resolve(credentialRef(ref)))?.value,
|
|
1010
|
+
save: (settings) => table.put("global", settings)
|
|
1011
|
+
});
|
|
1012
|
+
ctx.effect(() => async () => {
|
|
1013
|
+
await manager.dispose();
|
|
1014
|
+
await domain.close();
|
|
1015
|
+
}, "web-search-manager.dispose");
|
|
1016
|
+
ctx.provide("webSearchManager", manager);
|
|
1017
|
+
ctx.effect(() => registerBuiltins(manager), "web-search-manager.providers");
|
|
1018
|
+
ctx.effect(() => registerHostRpc(host, WEB_SEARCH_RPC_CHANNEL, async (endpoint, payload, signal) => {
|
|
1019
|
+
try {
|
|
1020
|
+
signal.throwIfAborted();
|
|
1021
|
+
if (endpoint === "status") return {
|
|
1022
|
+
ok: true,
|
|
1023
|
+
value: await manager.refresh()
|
|
1024
|
+
};
|
|
1025
|
+
if (endpoint === "update") {
|
|
1026
|
+
const parsed = updateSchema.safeParse(payload);
|
|
1027
|
+
if (!parsed.success) return {
|
|
1028
|
+
ok: false,
|
|
1029
|
+
error: {
|
|
1030
|
+
code: "WEB_INVALID_CONFIG",
|
|
1031
|
+
message: "网络搜索设置格式无效。",
|
|
1032
|
+
details: {}
|
|
1033
|
+
}
|
|
1034
|
+
};
|
|
1035
|
+
return {
|
|
1036
|
+
ok: true,
|
|
1037
|
+
value: await manager.update(parsed.data.settings, parsed.data.expectedRevision)
|
|
1038
|
+
};
|
|
1039
|
+
}
|
|
1040
|
+
if (endpoint === "test") return {
|
|
1041
|
+
ok: true,
|
|
1042
|
+
value: { sources: (await ctx.web.search({
|
|
1043
|
+
query: "IANA example domains",
|
|
1044
|
+
maxResults: 1
|
|
1045
|
+
}, signal)).sources.length }
|
|
1046
|
+
};
|
|
1047
|
+
return {
|
|
1048
|
+
ok: false,
|
|
1049
|
+
error: {
|
|
1050
|
+
code: "WEB_INVALID_REQUEST",
|
|
1051
|
+
message: "未知操作。",
|
|
1052
|
+
details: {}
|
|
1053
|
+
}
|
|
1054
|
+
};
|
|
1055
|
+
} catch (error) {
|
|
1056
|
+
return {
|
|
1057
|
+
ok: false,
|
|
1058
|
+
error: {
|
|
1059
|
+
code: error instanceof WebError ? error.code : "WEB_REQUEST_FAILED",
|
|
1060
|
+
message: error instanceof WebError ? error.message : "网络设置操作失败,请刷新后重试。",
|
|
1061
|
+
details: {}
|
|
1062
|
+
}
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
}), "web-search-manager.rpc");
|
|
1066
|
+
}
|
|
1067
|
+
//#endregion
|
|
1068
|
+
export { WebSearchManager, apply, inject, name };
|
|
1069
|
+
|
|
1070
|
+
//# sourceMappingURL=index.js.map
|