@dsh-plus/web-search-services 0.1.20 → 0.2.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/lib/index.js CHANGED
@@ -1,659 +1,24 @@
1
1
  import { unwrapVolatile } from "@dsh-plus/shared";
2
- import { existsSync, readFileSync, readdirSync } from "node:fs";
3
- import { fileURLToPath } from "node:url";
4
- import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
5
- import { WebError } from "@deepseek-ai/dsh-web";
6
- import { homedir } from "node:os";
7
- import { join } from "node:path";
8
- import { spawn } from "node:child_process";
2
+ import { DEFAULT_SEARCH_ENTRIES, SEARCH_ENTRY_PROVIDER_PREFIX as ENTRY_PROVIDER_PREFIX, SEARCH_PROVIDER_ID as PROVIDER_ID, SearchEntrySchema, createBackendHost, probeCredentialsFace, searchEntryAvailable, searchProvidersOf } from "@dsh-plus/web-providers-core";
9
3
  import z from "@deepseek-ai/schemastery";
10
- /** 由 web 请求 + 插件配置生成 search.py 的 argv(不含解释器与脚本路径)。 */
11
- function buildSearchArgv(cfg, request) {
12
- const query = request.query.trim();
13
- if (query === "") throw new Error("query must be a non-empty string");
14
- const maxResults = request.maxResults ?? 8;
15
- return [
16
- "search",
17
- "--query",
18
- query,
19
- "--service",
20
- cfg.priority.join(","),
21
- "--max-results",
22
- String(maxResults)
23
- ];
24
- }
25
- //#endregion
26
- //#region src/env-file.ts
27
- /**
28
- * search-services skill env 文件解析(纯函数)。
29
- * 只解析不执行:按行提取 KEY=VALUE,不展开 $ 变量、不运行 shell,畸形行跳过;
30
- * 文件缺失/不可读返回空表(envFile 是优化来源,不是硬依赖)。
31
- * @module web-search-services/env-file
32
- */
33
- /** 展开路径开头的 ~(支持 `~`、`~/`、`~\` 三种形态,与官方 dsh-home-paths 对齐,不借 shell)。 */
34
- function expandHome(path) {
35
- if (path === "~") return homedir();
36
- if (path.startsWith("~/") || path.startsWith("~\\")) return join(homedir(), path.slice(2));
37
- return path;
38
- }
39
- function unquote(value) {
40
- const first = value.charAt(0);
41
- const last = value.charAt(value.length - 1);
42
- if (value.length >= 2 && first === last && (first === "\"" || first === "'")) return value.slice(1, -1);
43
- return value;
44
- }
45
- function parseEnvText(text) {
46
- const out = {};
47
- for (const rawLine of text.split(/\r?\n/)) {
48
- const line = rawLine.trim();
49
- if (line === "" || line.startsWith("#")) continue;
50
- const body = line.startsWith("export ") ? line.slice(7).trim() : line;
51
- const eq = body.indexOf("=");
52
- if (eq <= 0) continue;
53
- const key = body.slice(0, eq).trim();
54
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
55
- out[key] = unquote(body.slice(eq + 1).trim());
56
- }
57
- return out;
58
- }
59
- function loadEnvFile(path) {
60
- if (path.trim() === "") return {};
61
- try {
62
- return parseEnvText(readFileSync(expandHome(path), "utf8"));
63
- } catch {
64
- return {};
65
- }
66
- }
67
- //#endregion
68
- //#region src/normalize.ts
69
- function asObject(value) {
70
- return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
71
- }
72
- function asNonEmptyString(value) {
73
- return typeof value === "string" && value.trim() !== "" ? value : void 0;
74
- }
75
- function asArray(value) {
76
- return Array.isArray(value) ? value : [];
77
- }
78
- /** snippet 截断上限:官方 deepseek provider 的摘录是短引文;Tavily content /
79
- * Exa highlights 原始值可达整页文本,直接透传会撑爆工具结果上下文。 */
80
- const SNIPPET_MAX_CHARS = 500;
81
- function truncateSnippet(text) {
82
- return text.length <= SNIPPET_MAX_CHARS ? text : `${text.slice(0, SNIPPET_MAX_CHARS)}…`;
83
- }
84
- function makeSource(url, title, snippet, publishedAt) {
85
- return {
86
- url,
87
- ...title !== void 0 ? { title } : {},
88
- ...snippet !== void 0 ? { snippet } : {},
89
- ...publishedAt !== void 0 ? { publishedAt } : {}
90
- };
91
- }
92
- /** skill 各服务 request() 统一信封 {ok, service, status, data: <API 响应>};
93
- * 取 data 为业务 payload,data 缺失/空对象时回退顶层(旧形状容差)。 */
94
- function payloadOf(raw) {
95
- const data = asObject(raw.data);
96
- return Object.keys(data).length > 0 ? data : raw;
97
- }
98
- function finish(content, sources) {
99
- const seen = /* @__PURE__ */ new Set();
100
- const deduped = sources.filter((source) => {
101
- if (seen.has(source.url)) return false;
102
- seen.add(source.url);
103
- return true;
104
- });
105
- return {
106
- ...content !== void 0 ? { content } : {},
107
- sources: deduped,
108
- truncated: false
109
- };
110
- }
111
- function sourcesFromResults(results, pick) {
112
- const out = [];
113
- for (const entry of asArray(results)) {
114
- const source = pick(asObject(entry));
115
- if (source !== void 0) out.push(source);
116
- }
117
- return out;
118
- }
119
- function normalizeTavily(raw) {
120
- const payload = payloadOf(raw);
121
- const sources = sourcesFromResults(payload.results, (item) => {
122
- const url = asNonEmptyString(item.url);
123
- const content = asNonEmptyString(item.content);
124
- if (url === void 0) return void 0;
125
- return makeSource(url, asNonEmptyString(item.title), content !== void 0 ? truncateSnippet(content) : void 0, asNonEmptyString(item.published_date));
126
- });
127
- return finish(asNonEmptyString(payload.answer), sources);
128
- }
129
- function normalizeExa(raw) {
130
- return finish(void 0, sourcesFromResults(payloadOf(raw).results, (item) => {
131
- const url = asNonEmptyString(item.url) ?? asNonEmptyString(item.id);
132
- if (url === void 0) return void 0;
133
- const highlights = asArray(item.highlights).map((h) => asNonEmptyString(h)).filter((h) => h !== void 0);
134
- return makeSource(url, asNonEmptyString(item.title), highlights.length > 0 ? truncateSnippet(highlights.join(" ")) : void 0, asNonEmptyString(item.publishedDate));
135
- }));
136
- }
137
- const MARKDOWN_LINK = /\[([^\]]*)\]\((https?:\/\/[^)\s]+)\)/g;
138
- /** 从生成式回答的 markdown 链接提取可引用来源(DeepSeek 官方 provider 同样只给
139
- * URL/标题)。无链接时返回空表——content-only 结果在 WebSearchResult 合法。 */
140
- function extractMarkdownLinks(markdown, limit) {
141
- const seen = /* @__PURE__ */ new Set();
142
- const out = [];
143
- for (const match of markdown.matchAll(MARKDOWN_LINK)) {
144
- const url = match[2];
145
- if (url === void 0 || seen.has(url)) continue;
146
- seen.add(url);
147
- const label = match[1] ?? "";
148
- out.push(makeSource(url, label.trim() !== "" ? label : void 0));
149
- if (out.length >= limit) break;
150
- }
151
- return out;
152
- }
153
- function normalizeOpenAiChat(raw) {
154
- const answer = asNonEmptyString(asObject(raw.data).answer);
155
- if (answer === void 0) throw new Error("openai-chat 结果缺少 data.answer");
156
- return finish(answer, extractMarkdownLinks(answer, 16));
157
- }
158
- function formatAttempts(attempts) {
159
- const summary = asArray(attempts).map((entry) => {
160
- const item = asObject(entry);
161
- return `${asNonEmptyString(item.service) ?? "?"}: ${asNonEmptyString(item.error) ?? "unknown"}`;
162
- }).join("; ");
163
- return summary === "" ? "" : `(尝试过: ${summary})`;
164
- }
165
- /** 归一化 search.py 的完整 stdout JSON;失败以 Error 抛出(provider 层包 WebError)。 */
166
- function normalizeSearchOutput(raw) {
167
- const obj = asObject(raw);
168
- if (obj.ok === false) {
169
- const detail = asNonEmptyString(obj.error) ?? "search.py 返回 ok=false";
170
- throw new Error(`${detail}${formatAttempts(obj.attempts)}`);
171
- }
172
- const service = asNonEmptyString(obj.selectedService) ?? asNonEmptyString(obj.service);
173
- switch (service) {
174
- case "tavily": return normalizeTavily(obj);
175
- case "exa": return normalizeExa(obj);
176
- case "openai-chat": return normalizeOpenAiChat(obj);
177
- default: throw new Error(`search.py 返回未知或不支持的 selectedService: ${JSON.stringify(service)}`);
178
- }
179
- }
180
- //#endregion
181
- //#region src/python.ts
182
- /**
183
- * Python 解释器发现:Windows 与桌面端常无 `python3` 命令(桌面端自带的捆绑
184
- * 运行时落在 `$DSH_HOME/dsh-runtimes/dsh-primary-runtime`),按候选链解析——
185
- * 显式配置(可含参数,如 `py -3`)→ 捆绑运行时(浅层 BFS 找解释器)→
186
- * PATH 扫描 → 平台兜底名(未验证,交 spawn 时再判)。
187
- *
188
- * 全部为同步纯逻辑 + 注入文件系统视图,零 spawn:`available()` 是廉价本地检查,
189
- * 具体候选 spawn ENOENT 时由调用方逐个换下一个(见 provider.run)。
190
- * @module web-search-services/python
191
- */
192
- const POSIX_SPEC = {
193
- pathNames: [{
194
- name: "python3",
195
- argsPrefix: []
196
- }, {
197
- name: "python",
198
- argsPrefix: []
199
- }],
200
- fallbacks: [{
201
- name: "python3",
202
- argsPrefix: []
203
- }, {
204
- name: "python",
205
- argsPrefix: []
206
- }],
207
- bundledNames: /* @__PURE__ */ new Set(["python3", "python"])
208
- };
209
- const WIN32_SPEC = {
210
- pathNames: [
211
- {
212
- name: "python.exe",
213
- argsPrefix: []
214
- },
215
- {
216
- name: "python3.exe",
217
- argsPrefix: []
218
- },
219
- {
220
- name: "py.exe",
221
- argsPrefix: ["-3"]
222
- }
223
- ],
224
- fallbacks: [
225
- {
226
- name: "python",
227
- argsPrefix: []
228
- },
229
- {
230
- name: "py",
231
- argsPrefix: ["-3"]
232
- },
233
- {
234
- name: "python3",
235
- argsPrefix: []
236
- }
237
- ],
238
- bundledNames: /* @__PURE__ */ new Set([
239
- "python.exe",
240
- "python3.exe",
241
- "python3",
242
- "python"
243
- ])
244
- };
245
- /** 捆绑运行时 BFS 的边界:层数与访问目录数双上限,防畸形布局拖慢 available()。 */
246
- const BUNDLED_MAX_DEPTH = 3;
247
- const BUNDLED_MAX_VISITS = 512;
248
- /** 与解释器无关的大目录(Windows CPython 的 Lib/site-packages 等),跳过。 */
249
- const NOISE_DIRS = /* @__PURE__ */ new Set([
250
- "Lib",
251
- "lib",
252
- "lib64",
253
- "include",
254
- "share",
255
- "site-packages",
256
- "test",
257
- "tests",
258
- "doc",
259
- "docs"
260
- ]);
261
- function specOf(platform) {
262
- return platform === "win32" ? WIN32_SPEC : POSIX_SPEC;
263
- }
264
- /** 显式配置 → 单候选(按空白拆分,支持 `py -3` 形态)。 */
265
- function configCandidate(configPython) {
266
- const [command, ...argsPrefix] = configPython.trim().split(/\s+/).filter((part) => part.length > 0);
267
- if (command === void 0) return void 0;
268
- return {
269
- command,
270
- argsPrefix,
271
- source: "config"
272
- };
273
- }
274
- /** 在捆绑运行时根下浅层 BFS 找解释器文件(先按层扫文件名,再入队子目录)。 */
275
- function findBundled(root, env) {
276
- if (!env.fs.exists(root)) return void 0;
277
- const spec = specOf(env.platform);
278
- const queue = [{
279
- dir: root,
280
- depth: 0
281
- }];
282
- let visits = 0;
283
- while (queue.length > 0 && visits < BUNDLED_MAX_VISITS) {
284
- const item = queue.shift();
285
- if (item === void 0) break;
286
- visits += 1;
287
- for (const entry of env.fs.list(item.dir)) {
288
- if (!entry.dir && spec.bundledNames.has(entry.name)) return join(item.dir, entry.name);
289
- if (entry.dir && item.depth < BUNDLED_MAX_DEPTH && !entry.name.startsWith(".") && !NOISE_DIRS.has(entry.name) && !spec.bundledNames.has(entry.name)) queue.push({
290
- dir: join(item.dir, entry.name),
291
- depth: item.depth + 1
292
- });
293
- }
294
- }
295
- }
296
- /** PATH 逐目录扫描(名字在外层:win32 任一处的 python.exe 优先于 python3.exe)。 */
297
- function scanPath(env, spec) {
298
- const listSep = env.platform === "win32" ? ";" : ":";
299
- const dirs = (env.pathValue ?? "").split(listSep).filter((dir) => dir.length > 0);
300
- const out = [];
301
- const seen = /* @__PURE__ */ new Set();
302
- for (const { name, argsPrefix } of spec.pathNames) for (const dir of dirs) {
303
- const full = join(dir, name);
304
- if (seen.has(full)) continue;
305
- seen.add(full);
306
- if (env.fs.exists(full)) out.push({
307
- command: full,
308
- argsPrefix,
309
- source: "path"
310
- });
311
- }
312
- return out;
313
- }
314
- /**
315
- * 解析解释器候选链(有序):显式配置独占;否则 捆绑运行时 → PATH 命中 → 平台兜底名。
316
- * 兜底名永远追加在末尾(供 spawn ENOENT 时逐个回退),但 `source: 'fallback'`
317
- * 不算「已验证」——`available()` 只信任 config/bundled/path。
318
- */
319
- function pythonCandidates(env, configPython) {
320
- const explicit = configCandidate(configPython);
321
- if (explicit !== void 0) return [explicit];
322
- const spec = specOf(env.platform);
323
- const out = [];
324
- if (env.bundledRoot !== void 0) {
325
- const bundled = findBundled(env.bundledRoot, env);
326
- if (bundled !== void 0) out.push({
327
- command: bundled,
328
- argsPrefix: [],
329
- source: "bundled"
330
- });
331
- }
332
- out.push(...scanPath(env, spec));
333
- for (const { name, argsPrefix } of spec.fallbacks) if (!out.some((candidate) => candidate.command === name)) out.push({
334
- command: name,
335
- argsPrefix,
336
- source: "fallback"
337
- });
338
- return out;
339
- }
340
- /** 生产文件系统视图(真实 fs;list 对不可读目录返回空表)。 */
341
- function realPythonFs() {
342
- return {
343
- exists: (path) => existsSync(path),
344
- list: (path) => {
345
- try {
346
- return readdirSync(path, { withFileTypes: true }).map((entry) => ({
347
- name: entry.name,
348
- dir: entry.isDirectory()
349
- }));
350
- } catch {
351
- return [];
352
- }
353
- }
354
- };
355
- }
356
- //#endregion
357
- //#region src/runner.ts
358
- /**
359
- * 子进程执行器:spawn 脚本、收集 stdout、超时/abort 杀进程。
360
- * spawn 依赖注入以便单测 mock(禁网络铁律下测试绝不真实启动搜索脚本)。
361
- * @module web-search-services/runner
362
- */
363
- const DEFAULT_MAX_BUFFER = 8388608;
364
- const KILL_GRACE_MS = 2e3;
365
- /**
366
- * Windows 进程树终止:`taskkill /PID <pid> /T /F`(Windows 无 POSIX 信号语义,
367
- * 单纯 `child.kill()` 会遗留 npx/playwright 派生的子进程树;上游
368
- * dsh-subprocess-local 同款做法)。error 监听防 taskkill 缺席成为未捕获异常。
369
- */
370
- const killWindowsTree = (pid) => {
371
- const killer = spawn("taskkill", [
372
- "/PID",
373
- String(pid),
374
- "/T",
375
- "/F"
376
- ], { stdio: "ignore" });
377
- killer.on("error", (error) => {
378
- console.warn(`[web-search-services] taskkill 失败(pid ${pid}): ${error.message}`);
379
- });
380
- killer.unref();
381
- };
382
- function abortError() {
383
- const err = /* @__PURE__ */ new Error("search aborted");
384
- err.name = "AbortError";
385
- return err;
386
- }
387
- function isAbortError(err) {
388
- return err instanceof Error && err.name === "AbortError";
389
- }
390
- function tail(text, max = 500) {
391
- const trimmed = text.trim();
392
- return trimmed.length <= max ? trimmed : `…${trimmed.slice(-max)}`;
393
- }
394
- /** 运行子进程并返回完整 stdout;任何失败以 Error 拒绝(消息含 stderr/退出码上下文)。 */
395
- function runProcess(opts) {
396
- const spawnFn = opts.spawnFn ?? spawn;
397
- const platform = opts.platform ?? process.platform;
398
- const killTree = opts.killTree ?? killWindowsTree;
399
- return new Promise((resolve, reject) => {
400
- if (opts.signal?.aborted === true) {
401
- reject(abortError());
402
- return;
403
- }
404
- const child = spawnFn(opts.command, [...opts.args], {
405
- env: opts.env,
406
- stdio: [
407
- "ignore",
408
- "pipe",
409
- "pipe"
410
- ],
411
- ...opts.cwd !== void 0 ? { cwd: opts.cwd } : {}
412
- });
413
- const maxBuffer = opts.maxBufferBytes ?? DEFAULT_MAX_BUFFER;
414
- let stdout = "";
415
- let stderr = "";
416
- let settled = false;
417
- let graceTimer;
418
- const cleanup = () => {
419
- clearTimeout(timer);
420
- if (graceTimer !== void 0) clearTimeout(graceTimer);
421
- opts.signal?.removeEventListener("abort", onAbort);
422
- };
423
- const settle = (fn) => {
424
- if (settled) return;
425
- settled = true;
426
- cleanup();
427
- fn();
428
- };
429
- const kill = () => {
430
- if (platform === "win32") {
431
- if (child.pid !== void 0) killTree(child.pid);
432
- else child.kill();
433
- return;
434
- }
435
- child.kill("SIGTERM");
436
- graceTimer = setTimeout(() => child.kill("SIGKILL"), KILL_GRACE_MS);
437
- graceTimer.unref();
438
- };
439
- const onAbort = () => {
440
- kill();
441
- settle(() => reject(abortError()));
442
- };
443
- const timer = setTimeout(() => {
444
- kill();
445
- settle(() => reject(/* @__PURE__ */ new Error(`process timed out after ${opts.timeoutMs}ms`)));
446
- }, opts.timeoutMs);
447
- timer.unref();
448
- opts.signal?.addEventListener("abort", onAbort, { once: true });
449
- child.stdout?.on("data", (chunk) => {
450
- stdout += chunk.toString();
451
- if (stdout.length > maxBuffer) {
452
- kill();
453
- settle(() => reject(/* @__PURE__ */ new Error(`stdout exceeded ${maxBuffer} bytes`)));
454
- }
455
- });
456
- child.stderr?.on("data", (chunk) => {
457
- stderr += chunk.toString();
458
- });
459
- child.on("error", (err) => {
460
- settle(() => reject(new Error(`failed to start ${opts.command}: ${err.message}`, { cause: err })));
461
- });
462
- child.on("close", (code) => {
463
- if (settled) return;
464
- if (code === 0) {
465
- settle(() => resolve(stdout));
466
- return;
467
- }
468
- const detail = tail(stderr) !== "" ? tail(stderr) : tail(stdout);
469
- settle(() => reject(/* @__PURE__ */ new Error(`exited with code ${code ?? "null"}: ${detail}`)));
470
- });
471
- });
472
- }
473
- //#endregion
474
- //#region src/provider.ts
475
- /**
476
- * dsh-web 搜索 provider:把 ctx.web.search 桥接到 search-services skill 的
477
- * search.py(Tavily / Exa / OpenAI Chat 免费额度后端)。官方 web_search 工具、
478
- * 渲染与提示词零修改——本 provider 只产出标准 WebSearchResult,格式化天然一致。
479
- * @module web-search-services/provider
480
- */
481
- const PROVIDER_ID = "search-services";
482
- /**
483
- * scriptPath 解析:空串 = 随包内置脚本副本(tgz 内 scripts/search.py,与 lib/
484
- * 同级;src 直跑时为包根 scripts/)。非空按 ~ 展开。
485
- */
486
- function resolveScriptPath(scriptPath) {
487
- if (scriptPath.trim() !== "") return expandHome(scriptPath);
488
- return fileURLToPath(new URL("../scripts/search.py", import.meta.url));
489
- }
490
- /** 各后端在合并环境(进程 env ⊕ envFile ⊕ 行级 keys)中的可用性判定变量。 */
491
- const BACKEND_KEY_VARS = {
492
- tavily: ["TAVILY_API_KEY", "TAVILY_API_KEYS"],
493
- exa: ["EXA_API_KEY"],
494
- "openai-chat": ["SEARCH_OPENAI_API_KEY"]
495
- };
496
- /** 行级 keys 覆盖 → 子进程环境变量;空串不注入(保持 envFile 单事实源优先)。 */
497
- function keyOverridesEnv(keys) {
498
- const out = {};
499
- if (keys.tavily !== "") out.TAVILY_API_KEY = keys.tavily;
500
- if (keys.exa !== "") out.EXA_API_KEY = keys.exa;
501
- if (keys.openaiApiKey !== "") out.SEARCH_OPENAI_API_KEY = keys.openaiApiKey;
502
- if (keys.openaiBaseUrl !== "") out.SEARCH_OPENAI_BASE_URL = keys.openaiBaseUrl;
503
- if (keys.openaiModel !== "") out.SEARCH_OPENAI_MODEL = keys.openaiModel;
504
- return out;
505
- }
506
- function messageOf(err) {
507
- return err instanceof Error ? err.message : String(err);
508
- }
509
- /**
510
- * 判定「解释器缺失」类失败(spawn ENOENT,runProcess 包装时保留 cause):
511
- * 候选链据此换下一个解释器;其余错误照常上抛。
512
- */
513
- function isMissingInterpreter(err) {
514
- if (!(err instanceof Error)) return false;
515
- return err.cause?.code === "ENOENT";
516
- }
517
- function toWebError(err) {
518
- if (err instanceof WebError) return err;
519
- if (isAbortError(err)) return new WebError("搜索已取消", "WEB_ABORTED", { cause: err });
520
- return new WebError(`search-services 搜索失败: ${messageOf(err)}`, "WEB_PROVIDER_ERROR", { cause: err });
521
- }
522
- var SearchServicesProvider = class {
523
- id = PROVIDER_ID;
524
- current;
525
- spawnFn;
526
- discovery;
527
- /** 候选链按 `${python}|${bundledRoot}` 记忆:PATH 与捆绑布局在进程内静态。 */
528
- candidateCache = /* @__PURE__ */ new Map();
529
- constructor(current, spawnFn, discovery) {
530
- this.current = current;
531
- this.spawnFn = spawnFn;
532
- this.discovery = discovery ?? (() => ({
533
- platform: process.platform,
534
- pathValue: process.env.PATH ?? process.env.Path,
535
- bundledRoot: dshHomePath("dsh-runtimes", "dsh-primary-runtime"),
536
- fs: realPythonFs()
537
- }));
538
- }
539
- /** 解释器候选链(有序、记忆化)。 */
540
- candidates() {
541
- const cfg = this.current();
542
- const env = this.discovery();
543
- const key = `${cfg.python}|${env.bundledRoot ?? ""}`;
544
- const cached = this.candidateCache.get(key);
545
- if (cached !== void 0) return cached;
546
- const resolved = pythonCandidates(env, cfg.python);
547
- this.candidateCache.set(key, resolved);
548
- return resolved;
549
- }
550
- /**
551
- * 廉价本地检查:脚本存在 + 至少一个【已验证】解释器候选(配置/捆绑/PATH,
552
- * 未验证兜底名不算)+ 优先级内至少一个后端有 key;不做网络调用、不 spawn。
553
- */
554
- available() {
555
- const cfg = this.current();
556
- if (!existsSync(resolveScriptPath(cfg.scriptPath))) return false;
557
- if (!this.candidates().some((candidate) => candidate.source !== "fallback")) return false;
558
- const env = this.childEnv(cfg);
559
- return cfg.priority.some((backend) => BACKEND_KEY_VARS[backend].some((name) => (env[name]?.trim() ?? "") !== ""));
560
- }
561
- async search(request, signal) {
562
- const cfg = this.current();
563
- const argv = buildSearchArgv(cfg, request);
564
- const stdout = await this.run(cfg, argv, signal);
565
- return this.normalize(stdout);
566
- }
567
- childEnv(cfg) {
568
- const fromFile = cfg.envFile.trim() === "" ? {} : loadEnvFile(cfg.envFile);
569
- return {
570
- ...process.env,
571
- ...fromFile,
572
- ...keyOverridesEnv(cfg.keys)
573
- };
574
- }
575
- async run(cfg, argv, signal) {
576
- const script = resolveScriptPath(cfg.scriptPath);
577
- const env = this.childEnv(cfg);
578
- let lastMissing;
579
- for (const candidate of this.candidates()) try {
580
- return await runProcess({
581
- command: candidate.command,
582
- args: [
583
- ...candidate.argsPrefix,
584
- script,
585
- ...argv
586
- ],
587
- env,
588
- timeoutMs: cfg.timeoutMs,
589
- ...signal !== void 0 ? { signal } : {},
590
- ...this.spawnFn !== void 0 ? { spawnFn: this.spawnFn } : {}
591
- });
592
- } catch (err) {
593
- if (!isMissingInterpreter(err)) throw toWebError(err);
594
- lastMissing = err;
595
- }
596
- throw toWebError(lastMissing ?? /* @__PURE__ */ new Error("未找到可用的 python 解释器(配置 python 或安装 python3)"));
597
- }
598
- normalize(stdout) {
599
- let raw;
600
- try {
601
- raw = JSON.parse(stdout);
602
- } catch (err) {
603
- const preview = stdout.trim().slice(-200);
604
- throw new WebError(`search.py 输出非 JSON: …${preview}`, "WEB_PROVIDER_ERROR", { cause: err });
605
- }
606
- try {
607
- return normalizeSearchOutput(raw);
608
- } catch (err) {
609
- throw new WebError(`search.py 结果归一化失败: ${messageOf(err)}`, "WEB_PROVIDER_ERROR", { cause: err });
610
- }
611
- }
612
- };
613
4
  //#endregion
614
5
  //#region src/config.ts
615
6
  /**
616
- * 配置单一事实源:cordis 行级 Config(组合默认值,patch 层可覆盖)与 settings
617
- * namespace(用户层=profile 行级覆盖层 cordis.patch.yml,热生效)共用同一 schemastery schema。
7
+ * 配置单一事实源:cordis 行级 Config(组合默认值,profile patch 层可覆盖)与 settings
8
+ * namespace(用户层=profile 行级覆盖层 cordis.patch.yml,热生效)共用同一份
9
+ * schemastery schema。
618
10
  *
619
- * 密钥主来源是 search-services skill 的 env 文件(envFile,默认
620
- * ~/.config/search-services/env),保持 skill 单事实源、零搬迁。行级 keys 仅为
621
- * 覆盖逃生舱;按存储规范 role('secret') 字段只允许行级注入,settings 用户层勿填。
11
+ * 条目 schema 来自 `@dsh-plus/web-providers-core`(与后端实现同源,改后端不动插件);
12
+ * 密钥全部走官方 credentials seam(`$DSH_HOME/.credentials.yaml`),**不进 settings**
13
+ * ——卡片只展示引用名,值经 `remote.credentials` 写入。
622
14
  * @module web-search-services/config
623
15
  */
624
16
  const SETTINGS_NS = "dsh-plus-web-search-services";
625
- const DEFAULT_ENV_FILE = "~/.config/search-services/env";
626
- const DEFAULT_TIMEOUT_MS = 55e3;
627
- const DEFAULT_PRIORITY = [
628
- "tavily",
629
- "exa",
630
- "openai-chat"
631
- ];
632
- const PRIORITY_ENTRY = z.union([
633
- "tavily",
634
- "exa",
635
- "openai-chat"
636
- ]);
637
- const KeysSchema = z.object({
638
- tavily: z.string().role("secret").description("Tavily API key(覆盖 envFile 的 TAVILY_API_KEY;行级注入专用)").default(""),
639
- exa: z.string().role("secret").description("Exa API key(覆盖 envFile 的 EXA_API_KEY;行级注入专用)").default(""),
640
- openaiApiKey: z.string().role("secret").description("OpenAI-compatible API key(覆盖 SEARCH_OPENAI_API_KEY;行级注入专用)").default(""),
641
- openaiBaseUrl: z.string().description("OpenAI-compatible base URL(覆盖 SEARCH_OPENAI_BASE_URL)").default(""),
642
- openaiModel: z.string().description("OpenAI-compatible 搜索模型名(覆盖 SEARCH_OPENAI_MODEL)").default("")
643
- });
644
17
  const Config = z.object({
645
- scriptPath: z.string().description("search.py 路径(支持 ~ 展开);空串 = 使用随包内置脚本副本").default("").volatile(),
646
- envFile: z.string().description("skill 密钥 env 文件路径(支持 ~ 展开);空串表示不读 envFile").default(DEFAULT_ENV_FILE).volatile(),
647
- python: z.string().description("python 解释器(可含参数,如 `py -3`);空串 = 自动发现(配置外按 桌面捆绑运行时 → PATH → 平台兜底名 解析)").default("").volatile(),
648
- priority: z.array(PRIORITY_ENTRY).description("后端优先级:靠前的先尝试,失败自动回退到下一个").default([...DEFAULT_PRIORITY]).volatile(),
649
- timeoutMs: z.natural().min(1e3).description("单次搜索子进程超时(毫秒),应低于 dsh-tool-web 的 searchTimeoutMs").default(DEFAULT_TIMEOUT_MS).volatile(),
650
- keys: KeysSchema.default({
651
- tavily: "",
652
- exa: "",
653
- openaiApiKey: "",
654
- openaiBaseUrl: "",
655
- openaiModel: ""
656
- }).volatile()
18
+ enabled: z.boolean().description("总开关:关闭后 web_search 不再选用本 provider(官方工具与提示词不变)").default(true).volatile(),
19
+ exposeEntries: z.boolean().description("是否额外注册每条目 provider(dsh-plus-search-<条目名>),便于按 id 钉住单个后端").default(false).volatile(),
20
+ proxy: z.string().description("全部后端共用的 HTTP(S) 代理;空 = 读环境变量 HTTP_PROXY/HTTPS_PROXY/NO_PROXY").default("").volatile(),
21
+ entries: z.array(SearchEntrySchema).description("后端链:按顺序尝试,失败自动回退到下一个(条目内 schema 见卡片)").default(DEFAULT_SEARCH_ENTRIES).volatile()
657
22
  });
658
23
  //#endregion
659
24
  //#region src/index.ts
@@ -661,8 +26,10 @@ const name = "dsh-plus-web-search-services";
661
26
  /** 行级 inject:dsh-web 服务激活后才应用本行(provider 注册即需 ctx.web)。 */
662
27
  const inject = ["web"];
663
28
  function apply(ctx, config) {
664
- const provider = new SearchServicesProvider(() => unwrapVolatile(config));
665
- ctx.effect(() => ctx.web.registerSearchProvider(provider), "dsh-plus-web-search-services: register search provider");
29
+ const current = () => unwrapVolatile(config);
30
+ const host = createBackendHost({ credentials: probeCredentialsFace(ctx) });
31
+ ctx.effect(() => () => host.dispose(), "dsh-plus-web-search-services: transport pool");
32
+ for (const provider of searchProvidersOf(current, host)) ctx.effect(() => ctx.web.registerSearchProvider(provider), `dsh-plus-web-search-services: register ${provider.id}`);
666
33
  }
667
34
  //#endregion
668
- export { Config, PROVIDER_ID, SETTINGS_NS, SearchServicesProvider, apply, inject, name };
35
+ export { Config, ENTRY_PROVIDER_PREFIX, PROVIDER_ID, SETTINGS_NS, apply, inject, name, searchEntryAvailable };