@dsh-plus/web-search-services 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 +21 -0
- package/lib/index.d.ts +72 -0
- package/lib/index.js +419 -0
- package/package.json +38 -0
- package/src/args.ts +30 -0
- package/src/config.ts +80 -0
- package/src/env-file.ts +49 -0
- package/src/index.ts +51 -0
- package/src/normalize.ts +154 -0
- package/src/ns.ts +7 -0
- package/src/provider.ts +119 -0
- package/src/runner.ts +111 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 agguy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import z from "@deepseek-ai/schemastery";
|
|
2
|
+
import { WebSearchProvider, WebSearchRequest, WebSearchResult } from "@deepseek-ai/dsh-web";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { Context } from "@deepseek-ai/cordis";
|
|
5
|
+
//#region src/config.d.ts
|
|
6
|
+
declare const SETTINGS_NS = "dsh-plus-web-search-services";
|
|
7
|
+
declare const Config: z<Schemastery.ObjectS<{
|
|
8
|
+
scriptPath: z<string, string>;
|
|
9
|
+
envFile: z<string, string>;
|
|
10
|
+
python: z<string, string>;
|
|
11
|
+
priority: z<("exa" | "openai-chat" | "tavily")[], ("exa" | "openai-chat" | "tavily")[]>;
|
|
12
|
+
timeoutMs: z<number, number>;
|
|
13
|
+
keys: z<Schemastery.ObjectS<{
|
|
14
|
+
tavily: z<string, string>;
|
|
15
|
+
exa: z<string, string>;
|
|
16
|
+
openaiApiKey: z<string, string>;
|
|
17
|
+
openaiBaseUrl: z<string, string>;
|
|
18
|
+
openaiModel: z<string, string>;
|
|
19
|
+
}>, Schemastery.ObjectT<{
|
|
20
|
+
tavily: z<string, string>;
|
|
21
|
+
exa: z<string, string>;
|
|
22
|
+
openaiApiKey: z<string, string>;
|
|
23
|
+
openaiBaseUrl: z<string, string>;
|
|
24
|
+
openaiModel: z<string, string>;
|
|
25
|
+
}>>;
|
|
26
|
+
}>, Schemastery.ObjectT<{
|
|
27
|
+
scriptPath: z<string, string>;
|
|
28
|
+
envFile: z<string, string>;
|
|
29
|
+
python: z<string, string>;
|
|
30
|
+
priority: z<("exa" | "openai-chat" | "tavily")[], ("exa" | "openai-chat" | "tavily")[]>;
|
|
31
|
+
timeoutMs: z<number, number>;
|
|
32
|
+
keys: z<Schemastery.ObjectS<{
|
|
33
|
+
tavily: z<string, string>;
|
|
34
|
+
exa: z<string, string>;
|
|
35
|
+
openaiApiKey: z<string, string>;
|
|
36
|
+
openaiBaseUrl: z<string, string>;
|
|
37
|
+
openaiModel: z<string, string>;
|
|
38
|
+
}>, Schemastery.ObjectT<{
|
|
39
|
+
tavily: z<string, string>;
|
|
40
|
+
exa: z<string, string>;
|
|
41
|
+
openaiApiKey: z<string, string>;
|
|
42
|
+
openaiBaseUrl: z<string, string>;
|
|
43
|
+
openaiModel: z<string, string>;
|
|
44
|
+
}>>;
|
|
45
|
+
}>>;
|
|
46
|
+
type WebSearchServicesConfig = Schemastery.TypeT<typeof Config>;
|
|
47
|
+
//#endregion
|
|
48
|
+
//#region src/runner.d.ts
|
|
49
|
+
type SpawnFn = typeof spawn;
|
|
50
|
+
//#endregion
|
|
51
|
+
//#region src/provider.d.ts
|
|
52
|
+
declare const PROVIDER_ID = "search-services";
|
|
53
|
+
declare class SearchServicesProvider implements WebSearchProvider {
|
|
54
|
+
readonly id = "search-services";
|
|
55
|
+
private readonly current;
|
|
56
|
+
private readonly spawnFn;
|
|
57
|
+
constructor(current: () => WebSearchServicesConfig, spawnFn?: SpawnFn | undefined);
|
|
58
|
+
/** 廉价本地检查:脚本存在 + 优先级内至少一个后端有 key;不做网络调用。 */
|
|
59
|
+
available(): boolean;
|
|
60
|
+
search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>;
|
|
61
|
+
private childEnv;
|
|
62
|
+
private run;
|
|
63
|
+
private normalize;
|
|
64
|
+
}
|
|
65
|
+
//#endregion
|
|
66
|
+
//#region src/index.d.ts
|
|
67
|
+
declare const name = "dsh-plus-web-search-services";
|
|
68
|
+
/** 行级 inject:dsh-web 服务激活后才应用本行(provider 注册即需 ctx.web)。 */
|
|
69
|
+
declare const inject: readonly ['web'];
|
|
70
|
+
declare function apply(ctx: Context, config: WebSearchServicesConfig): void;
|
|
71
|
+
//#endregion
|
|
72
|
+
export { Config, PROVIDER_ID, SETTINGS_NS, SearchServicesProvider, apply, inject, name };
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import z from "@deepseek-ai/schemastery";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { WebError } from "@deepseek-ai/dsh-web";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
//#endregion
|
|
8
|
+
//#region src/config.ts
|
|
9
|
+
/**
|
|
10
|
+
* 配置单一事实源:cordis 行级 Config(组合默认值,patch 层可覆盖)与 settings
|
|
11
|
+
* namespace(用户层 $DSH_HOME/settings.yaml,热生效)共用同一 schemastery schema。
|
|
12
|
+
*
|
|
13
|
+
* 密钥主来源是 search-services skill 的 env 文件(envFile,默认
|
|
14
|
+
* ~/.config/search-services/env),保持 skill 单事实源、零搬迁。行级 keys 仅为
|
|
15
|
+
* 覆盖逃生舱;按存储规范 role('secret') 字段只允许行级注入,settings 用户层勿填。
|
|
16
|
+
* @module web-search-services/config
|
|
17
|
+
*/
|
|
18
|
+
const SETTINGS_NS = "dsh-plus-web-search-services";
|
|
19
|
+
const DEFAULT_SCRIPT_PATH = "~/.dsh/skills/search-services/scripts/search.py";
|
|
20
|
+
const DEFAULT_ENV_FILE = "~/.config/search-services/env";
|
|
21
|
+
const DEFAULT_TIMEOUT_MS = 55e3;
|
|
22
|
+
const DEFAULT_PRIORITY = [
|
|
23
|
+
"tavily",
|
|
24
|
+
"exa",
|
|
25
|
+
"openai-chat"
|
|
26
|
+
];
|
|
27
|
+
const PRIORITY_ENTRY = z.union([
|
|
28
|
+
"tavily",
|
|
29
|
+
"exa",
|
|
30
|
+
"openai-chat"
|
|
31
|
+
]);
|
|
32
|
+
const KeysSchema = z.object({
|
|
33
|
+
tavily: z.string().role("secret").description("Tavily API key(覆盖 envFile 的 TAVILY_API_KEY;行级注入专用)").default(""),
|
|
34
|
+
exa: z.string().role("secret").description("Exa API key(覆盖 envFile 的 EXA_API_KEY;行级注入专用)").default(""),
|
|
35
|
+
openaiApiKey: z.string().role("secret").description("OpenAI-compatible API key(覆盖 SEARCH_OPENAI_API_KEY;行级注入专用)").default(""),
|
|
36
|
+
openaiBaseUrl: z.string().description("OpenAI-compatible base URL(覆盖 SEARCH_OPENAI_BASE_URL)").default(""),
|
|
37
|
+
openaiModel: z.string().description("OpenAI-compatible 搜索模型名(覆盖 SEARCH_OPENAI_MODEL)").default("")
|
|
38
|
+
});
|
|
39
|
+
const Config = z.object({
|
|
40
|
+
scriptPath: z.string().description("search-services skill 的 search.py 路径(支持 ~ 展开)").default(DEFAULT_SCRIPT_PATH),
|
|
41
|
+
envFile: z.string().description("skill 密钥 env 文件路径(支持 ~ 展开);空串表示不读 envFile").default(DEFAULT_ENV_FILE),
|
|
42
|
+
python: z.string().description("python 可执行文件").default("python3"),
|
|
43
|
+
priority: z.array(PRIORITY_ENTRY).description("后端优先级:靠前的先尝试,失败自动回退到下一个").default([...DEFAULT_PRIORITY]),
|
|
44
|
+
timeoutMs: z.natural().min(1e3).description("单次搜索子进程超时(毫秒),应低于 dsh-tool-web 的 searchTimeoutMs").default(DEFAULT_TIMEOUT_MS),
|
|
45
|
+
keys: KeysSchema.default({
|
|
46
|
+
tavily: "",
|
|
47
|
+
exa: "",
|
|
48
|
+
openaiApiKey: "",
|
|
49
|
+
openaiBaseUrl: "",
|
|
50
|
+
openaiModel: ""
|
|
51
|
+
})
|
|
52
|
+
});
|
|
53
|
+
/** 由 web 请求 + 插件配置生成 search.py 的 argv(不含解释器与脚本路径)。 */
|
|
54
|
+
function buildSearchArgv(cfg, request) {
|
|
55
|
+
const query = request.query.trim();
|
|
56
|
+
if (query === "") throw new Error("query must be a non-empty string");
|
|
57
|
+
const maxResults = request.maxResults ?? 8;
|
|
58
|
+
return [
|
|
59
|
+
"search",
|
|
60
|
+
"--query",
|
|
61
|
+
query,
|
|
62
|
+
"--service",
|
|
63
|
+
cfg.priority.join(","),
|
|
64
|
+
"--max-results",
|
|
65
|
+
String(maxResults)
|
|
66
|
+
];
|
|
67
|
+
}
|
|
68
|
+
//#endregion
|
|
69
|
+
//#region src/env-file.ts
|
|
70
|
+
/**
|
|
71
|
+
* search-services skill env 文件解析(纯函数)。
|
|
72
|
+
* 只解析不执行:按行提取 KEY=VALUE,不展开 $ 变量、不运行 shell,畸形行跳过;
|
|
73
|
+
* 文件缺失/不可读返回空表(envFile 是优化来源,不是硬依赖)。
|
|
74
|
+
* @module web-search-services/env-file
|
|
75
|
+
*/
|
|
76
|
+
/** 展开路径开头的 ~(仅 ~ 与 ~/ 两种形态,不借 shell)。 */
|
|
77
|
+
function expandHome(path) {
|
|
78
|
+
if (path === "~") return homedir();
|
|
79
|
+
if (path.startsWith("~/")) return join(homedir(), path.slice(2));
|
|
80
|
+
return path;
|
|
81
|
+
}
|
|
82
|
+
function unquote(value) {
|
|
83
|
+
const first = value.charAt(0);
|
|
84
|
+
const last = value.charAt(value.length - 1);
|
|
85
|
+
if (value.length >= 2 && first === last && (first === "\"" || first === "'")) return value.slice(1, -1);
|
|
86
|
+
return value;
|
|
87
|
+
}
|
|
88
|
+
function parseEnvText(text) {
|
|
89
|
+
const out = {};
|
|
90
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
91
|
+
const line = rawLine.trim();
|
|
92
|
+
if (line === "" || line.startsWith("#")) continue;
|
|
93
|
+
const body = line.startsWith("export ") ? line.slice(7).trim() : line;
|
|
94
|
+
const eq = body.indexOf("=");
|
|
95
|
+
if (eq <= 0) continue;
|
|
96
|
+
const key = body.slice(0, eq).trim();
|
|
97
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
|
98
|
+
out[key] = unquote(body.slice(eq + 1).trim());
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
function loadEnvFile(path) {
|
|
103
|
+
if (path.trim() === "") return {};
|
|
104
|
+
try {
|
|
105
|
+
return parseEnvText(readFileSync(expandHome(path), "utf8"));
|
|
106
|
+
} catch {
|
|
107
|
+
return {};
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
//#endregion
|
|
111
|
+
//#region src/normalize.ts
|
|
112
|
+
function asObject(value) {
|
|
113
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
|
|
114
|
+
}
|
|
115
|
+
function asNonEmptyString(value) {
|
|
116
|
+
return typeof value === "string" && value.trim() !== "" ? value : void 0;
|
|
117
|
+
}
|
|
118
|
+
function asArray(value) {
|
|
119
|
+
return Array.isArray(value) ? value : [];
|
|
120
|
+
}
|
|
121
|
+
function makeSource(url, title, snippet, publishedAt) {
|
|
122
|
+
return {
|
|
123
|
+
url,
|
|
124
|
+
...title !== void 0 ? { title } : {},
|
|
125
|
+
...snippet !== void 0 ? { snippet } : {},
|
|
126
|
+
...publishedAt !== void 0 ? { publishedAt } : {}
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
function finish(content, sources) {
|
|
130
|
+
const seen = /* @__PURE__ */ new Set();
|
|
131
|
+
const deduped = sources.filter((source) => {
|
|
132
|
+
if (seen.has(source.url)) return false;
|
|
133
|
+
seen.add(source.url);
|
|
134
|
+
return true;
|
|
135
|
+
});
|
|
136
|
+
return {
|
|
137
|
+
...content !== void 0 ? { content } : {},
|
|
138
|
+
sources: deduped,
|
|
139
|
+
truncated: false
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
function sourcesFromResults(results, pick) {
|
|
143
|
+
const out = [];
|
|
144
|
+
for (const entry of asArray(results)) {
|
|
145
|
+
const source = pick(asObject(entry));
|
|
146
|
+
if (source !== void 0) out.push(source);
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
function normalizeTavily(raw) {
|
|
151
|
+
const sources = sourcesFromResults(raw.results, (item) => {
|
|
152
|
+
const url = asNonEmptyString(item.url);
|
|
153
|
+
if (url === void 0) return void 0;
|
|
154
|
+
return makeSource(url, asNonEmptyString(item.title), asNonEmptyString(item.content), asNonEmptyString(item.published_date));
|
|
155
|
+
});
|
|
156
|
+
return finish(asNonEmptyString(raw.answer), sources);
|
|
157
|
+
}
|
|
158
|
+
function normalizeExa(raw) {
|
|
159
|
+
return finish(void 0, sourcesFromResults(raw.results, (item) => {
|
|
160
|
+
const url = asNonEmptyString(item.url) ?? asNonEmptyString(item.id);
|
|
161
|
+
if (url === void 0) return void 0;
|
|
162
|
+
const highlights = asArray(item.highlights).map((h) => asNonEmptyString(h)).filter((h) => h !== void 0);
|
|
163
|
+
return makeSource(url, asNonEmptyString(item.title), highlights.length > 0 ? highlights.join(" ") : void 0, asNonEmptyString(item.publishedDate));
|
|
164
|
+
}));
|
|
165
|
+
}
|
|
166
|
+
const MARKDOWN_LINK = /\[([^\]]*)\]\((https?:\/\/[^)\s]+)\)/g;
|
|
167
|
+
/** 从生成式回答的 markdown 链接提取可引用来源(DeepSeek 官方 provider 同样只给
|
|
168
|
+
* URL/标题)。无链接时返回空表——content-only 结果在 WebSearchResult 合法。 */
|
|
169
|
+
function extractMarkdownLinks(markdown, limit) {
|
|
170
|
+
const seen = /* @__PURE__ */ new Set();
|
|
171
|
+
const out = [];
|
|
172
|
+
for (const match of markdown.matchAll(MARKDOWN_LINK)) {
|
|
173
|
+
const url = match[2];
|
|
174
|
+
if (url === void 0 || seen.has(url)) continue;
|
|
175
|
+
seen.add(url);
|
|
176
|
+
const label = match[1] ?? "";
|
|
177
|
+
out.push(makeSource(url, label.trim() !== "" ? label : void 0));
|
|
178
|
+
if (out.length >= limit) break;
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
function normalizeOpenAiChat(raw) {
|
|
183
|
+
const answer = asNonEmptyString(asObject(raw.data).answer);
|
|
184
|
+
if (answer === void 0) throw new Error("openai-chat 结果缺少 data.answer");
|
|
185
|
+
return finish(answer, extractMarkdownLinks(answer, 16));
|
|
186
|
+
}
|
|
187
|
+
function formatAttempts(attempts) {
|
|
188
|
+
const summary = asArray(attempts).map((entry) => {
|
|
189
|
+
const item = asObject(entry);
|
|
190
|
+
return `${asNonEmptyString(item.service) ?? "?"}: ${asNonEmptyString(item.error) ?? "unknown"}`;
|
|
191
|
+
}).join("; ");
|
|
192
|
+
return summary === "" ? "" : `(尝试过: ${summary})`;
|
|
193
|
+
}
|
|
194
|
+
/** 归一化 search.py 的完整 stdout JSON;失败以 Error 抛出(provider 层包 WebError)。 */
|
|
195
|
+
function normalizeSearchOutput(raw) {
|
|
196
|
+
const obj = asObject(raw);
|
|
197
|
+
if (obj.ok === false) {
|
|
198
|
+
const detail = asNonEmptyString(obj.error) ?? "search.py 返回 ok=false";
|
|
199
|
+
throw new Error(`${detail}${formatAttempts(obj.attempts)}`);
|
|
200
|
+
}
|
|
201
|
+
const service = asNonEmptyString(obj.selectedService) ?? asNonEmptyString(obj.service);
|
|
202
|
+
switch (service) {
|
|
203
|
+
case "tavily": return normalizeTavily(obj);
|
|
204
|
+
case "exa": return normalizeExa(obj);
|
|
205
|
+
case "openai-chat": return normalizeOpenAiChat(obj);
|
|
206
|
+
default: throw new Error(`search.py 返回未知或不支持的 selectedService: ${JSON.stringify(service)}`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
//#endregion
|
|
210
|
+
//#region src/runner.ts
|
|
211
|
+
/**
|
|
212
|
+
* 子进程执行器:spawn 脚本、收集 stdout、超时/abort 杀进程。
|
|
213
|
+
* spawn 依赖注入以便单测 mock(禁网络铁律下测试绝不真实启动搜索脚本)。
|
|
214
|
+
* @module web-search-services/runner
|
|
215
|
+
*/
|
|
216
|
+
const DEFAULT_MAX_BUFFER = 8388608;
|
|
217
|
+
const KILL_GRACE_MS = 2e3;
|
|
218
|
+
function abortError() {
|
|
219
|
+
const err = /* @__PURE__ */ new Error("search aborted");
|
|
220
|
+
err.name = "AbortError";
|
|
221
|
+
return err;
|
|
222
|
+
}
|
|
223
|
+
function isAbortError(err) {
|
|
224
|
+
return err instanceof Error && err.name === "AbortError";
|
|
225
|
+
}
|
|
226
|
+
function tail(text, max = 500) {
|
|
227
|
+
const trimmed = text.trim();
|
|
228
|
+
return trimmed.length <= max ? trimmed : `…${trimmed.slice(-max)}`;
|
|
229
|
+
}
|
|
230
|
+
/** 运行子进程并返回完整 stdout;任何失败以 Error 拒绝(消息含 stderr/退出码上下文)。 */
|
|
231
|
+
function runProcess(opts) {
|
|
232
|
+
const spawnFn = opts.spawnFn ?? spawn;
|
|
233
|
+
return new Promise((resolve, reject) => {
|
|
234
|
+
if (opts.signal?.aborted === true) {
|
|
235
|
+
reject(abortError());
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const child = spawnFn(opts.command, [...opts.args], {
|
|
239
|
+
env: opts.env,
|
|
240
|
+
stdio: [
|
|
241
|
+
"ignore",
|
|
242
|
+
"pipe",
|
|
243
|
+
"pipe"
|
|
244
|
+
],
|
|
245
|
+
...opts.cwd !== void 0 ? { cwd: opts.cwd } : {}
|
|
246
|
+
});
|
|
247
|
+
const maxBuffer = opts.maxBufferBytes ?? DEFAULT_MAX_BUFFER;
|
|
248
|
+
let stdout = "";
|
|
249
|
+
let stderr = "";
|
|
250
|
+
let settled = false;
|
|
251
|
+
let graceTimer;
|
|
252
|
+
const cleanup = () => {
|
|
253
|
+
clearTimeout(timer);
|
|
254
|
+
if (graceTimer !== void 0) clearTimeout(graceTimer);
|
|
255
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
256
|
+
};
|
|
257
|
+
const settle = (fn) => {
|
|
258
|
+
if (settled) return;
|
|
259
|
+
settled = true;
|
|
260
|
+
cleanup();
|
|
261
|
+
fn();
|
|
262
|
+
};
|
|
263
|
+
const kill = () => {
|
|
264
|
+
child.kill("SIGTERM");
|
|
265
|
+
graceTimer = setTimeout(() => child.kill("SIGKILL"), KILL_GRACE_MS);
|
|
266
|
+
graceTimer.unref();
|
|
267
|
+
};
|
|
268
|
+
const onAbort = () => {
|
|
269
|
+
kill();
|
|
270
|
+
settle(() => reject(abortError()));
|
|
271
|
+
};
|
|
272
|
+
const timer = setTimeout(() => {
|
|
273
|
+
kill();
|
|
274
|
+
settle(() => reject(/* @__PURE__ */ new Error(`process timed out after ${opts.timeoutMs}ms`)));
|
|
275
|
+
}, opts.timeoutMs);
|
|
276
|
+
timer.unref();
|
|
277
|
+
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
278
|
+
child.stdout?.on("data", (chunk) => {
|
|
279
|
+
stdout += chunk.toString();
|
|
280
|
+
if (stdout.length > maxBuffer) {
|
|
281
|
+
kill();
|
|
282
|
+
settle(() => reject(/* @__PURE__ */ new Error(`stdout exceeded ${maxBuffer} bytes`)));
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
child.stderr?.on("data", (chunk) => {
|
|
286
|
+
stderr += chunk.toString();
|
|
287
|
+
});
|
|
288
|
+
child.on("error", (err) => {
|
|
289
|
+
settle(() => reject(new Error(`failed to start ${opts.command}: ${err.message}`, { cause: err })));
|
|
290
|
+
});
|
|
291
|
+
child.on("close", (code) => {
|
|
292
|
+
if (settled) return;
|
|
293
|
+
if (code === 0) {
|
|
294
|
+
settle(() => resolve(stdout));
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
const detail = tail(stderr) !== "" ? tail(stderr) : tail(stdout);
|
|
298
|
+
settle(() => reject(/* @__PURE__ */ new Error(`exited with code ${code ?? "null"}: ${detail}`)));
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
//#endregion
|
|
303
|
+
//#region src/provider.ts
|
|
304
|
+
/**
|
|
305
|
+
* dsh-web 搜索 provider:把 ctx.web.search 桥接到 search-services skill 的
|
|
306
|
+
* search.py(Tavily / Exa / OpenAI Chat 免费额度后端)。官方 web_search 工具、
|
|
307
|
+
* 渲染与提示词零修改——本 provider 只产出标准 WebSearchResult,格式化天然一致。
|
|
308
|
+
* @module web-search-services/provider
|
|
309
|
+
*/
|
|
310
|
+
const PROVIDER_ID = "search-services";
|
|
311
|
+
/** 各后端在合并环境(进程 env ⊕ envFile ⊕ 行级 keys)中的可用性判定变量。 */
|
|
312
|
+
const BACKEND_KEY_VARS = {
|
|
313
|
+
tavily: ["TAVILY_API_KEY", "TAVILY_API_KEYS"],
|
|
314
|
+
exa: ["EXA_API_KEY"],
|
|
315
|
+
"openai-chat": ["SEARCH_OPENAI_API_KEY"]
|
|
316
|
+
};
|
|
317
|
+
/** 行级 keys 覆盖 → 子进程环境变量;空串不注入(保持 envFile 单事实源优先)。 */
|
|
318
|
+
function keyOverridesEnv(keys) {
|
|
319
|
+
const out = {};
|
|
320
|
+
if (keys.tavily !== "") out.TAVILY_API_KEY = keys.tavily;
|
|
321
|
+
if (keys.exa !== "") out.EXA_API_KEY = keys.exa;
|
|
322
|
+
if (keys.openaiApiKey !== "") out.SEARCH_OPENAI_API_KEY = keys.openaiApiKey;
|
|
323
|
+
if (keys.openaiBaseUrl !== "") out.SEARCH_OPENAI_BASE_URL = keys.openaiBaseUrl;
|
|
324
|
+
if (keys.openaiModel !== "") out.SEARCH_OPENAI_MODEL = keys.openaiModel;
|
|
325
|
+
return out;
|
|
326
|
+
}
|
|
327
|
+
function messageOf(err) {
|
|
328
|
+
return err instanceof Error ? err.message : String(err);
|
|
329
|
+
}
|
|
330
|
+
function toWebError(err) {
|
|
331
|
+
if (err instanceof WebError) return err;
|
|
332
|
+
if (isAbortError(err)) return new WebError("搜索已取消", "WEB_ABORTED", { cause: err });
|
|
333
|
+
return new WebError(`search-services 搜索失败: ${messageOf(err)}`, "WEB_PROVIDER_ERROR", { cause: err });
|
|
334
|
+
}
|
|
335
|
+
var SearchServicesProvider = class {
|
|
336
|
+
id = PROVIDER_ID;
|
|
337
|
+
current;
|
|
338
|
+
spawnFn;
|
|
339
|
+
constructor(current, spawnFn) {
|
|
340
|
+
this.current = current;
|
|
341
|
+
this.spawnFn = spawnFn;
|
|
342
|
+
}
|
|
343
|
+
/** 廉价本地检查:脚本存在 + 优先级内至少一个后端有 key;不做网络调用。 */
|
|
344
|
+
available() {
|
|
345
|
+
const cfg = this.current();
|
|
346
|
+
if (!existsSync(expandHome(cfg.scriptPath))) return false;
|
|
347
|
+
const env = this.childEnv(cfg);
|
|
348
|
+
return cfg.priority.some((backend) => BACKEND_KEY_VARS[backend].some((name) => (env[name]?.trim() ?? "") !== ""));
|
|
349
|
+
}
|
|
350
|
+
async search(request, signal) {
|
|
351
|
+
const cfg = this.current();
|
|
352
|
+
const argv = buildSearchArgv(cfg, request);
|
|
353
|
+
const stdout = await this.run(cfg, argv, signal);
|
|
354
|
+
return this.normalize(stdout);
|
|
355
|
+
}
|
|
356
|
+
childEnv(cfg) {
|
|
357
|
+
const fromFile = cfg.envFile.trim() === "" ? {} : loadEnvFile(cfg.envFile);
|
|
358
|
+
return {
|
|
359
|
+
...process.env,
|
|
360
|
+
...fromFile,
|
|
361
|
+
...keyOverridesEnv(cfg.keys)
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
async run(cfg, argv, signal) {
|
|
365
|
+
try {
|
|
366
|
+
return await runProcess({
|
|
367
|
+
command: cfg.python,
|
|
368
|
+
args: [expandHome(cfg.scriptPath), ...argv],
|
|
369
|
+
env: this.childEnv(cfg),
|
|
370
|
+
timeoutMs: cfg.timeoutMs,
|
|
371
|
+
...signal !== void 0 ? { signal } : {},
|
|
372
|
+
...this.spawnFn !== void 0 ? { spawnFn: this.spawnFn } : {}
|
|
373
|
+
});
|
|
374
|
+
} catch (err) {
|
|
375
|
+
throw toWebError(err);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
normalize(stdout) {
|
|
379
|
+
let raw;
|
|
380
|
+
try {
|
|
381
|
+
raw = JSON.parse(stdout);
|
|
382
|
+
} catch (err) {
|
|
383
|
+
const preview = stdout.trim().slice(-200);
|
|
384
|
+
throw new WebError(`search.py 输出非 JSON: …${preview}`, "WEB_PROVIDER_ERROR", { cause: err });
|
|
385
|
+
}
|
|
386
|
+
try {
|
|
387
|
+
return normalizeSearchOutput(raw);
|
|
388
|
+
} catch (err) {
|
|
389
|
+
throw new WebError(`search.py 结果归一化失败: ${messageOf(err)}`, "WEB_PROVIDER_ERROR", { cause: err });
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
//#endregion
|
|
394
|
+
//#region src/index.ts
|
|
395
|
+
const name = "dsh-plus-web-search-services";
|
|
396
|
+
/** 行级 inject:dsh-web 服务激活后才应用本行(provider 注册即需 ctx.web)。 */
|
|
397
|
+
const inject = ["web"];
|
|
398
|
+
function apply(ctx, config) {
|
|
399
|
+
const provider = new SearchServicesProvider(configSource(ctx, config));
|
|
400
|
+
ctx.effect(() => ctx.web.registerSearchProvider(provider), "dsh-plus-web-search-services: register search provider");
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* 配置源:settings 用户层($DSH_HOME/settings.yaml,热生效)在时优先生效,
|
|
404
|
+
* 缺席/detach 回落 cordis 行级 config(官方 installSection 可选消费范式)。
|
|
405
|
+
*/
|
|
406
|
+
function configSource(ctx, rowConfig) {
|
|
407
|
+
let current = () => rowConfig;
|
|
408
|
+
ctx.inject(["settings"], (settingsCtx) => {
|
|
409
|
+
settingsCtx.settings.installSection(ctx, SETTINGS_NS, Config, rowConfig, {
|
|
410
|
+
setSource: (source) => {
|
|
411
|
+
current = source;
|
|
412
|
+
},
|
|
413
|
+
onChange: () => {}
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
return () => current();
|
|
417
|
+
}
|
|
418
|
+
//#endregion
|
|
419
|
+
export { Config, PROVIDER_ID, SETTINGS_NS, SearchServicesProvider, apply, inject, name };
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dsh-plus/web-search-services",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "dsh-plus service plugin: web_search 免费后端聚合——经官方 ctx.web provider 扩展点桥接 search-services skill(Tavily/Exa/OpenAI Chat 免费额度,优先级+自动回退+key 配置),官方 web_search 工具与输出格式零修改",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"types": "lib/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./lib/index.d.ts",
|
|
11
|
+
"default": "./lib/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./src/*": "./src/*",
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"lib",
|
|
18
|
+
"src"
|
|
19
|
+
],
|
|
20
|
+
"peerDependencies": {
|
|
21
|
+
"@deepseek-ai/cordis": "^4.0.2",
|
|
22
|
+
"@deepseek-ai/dsh-settings": "^0.1.6-alpha.2",
|
|
23
|
+
"@deepseek-ai/dsh-web": "^0.1.6-alpha.2",
|
|
24
|
+
"@deepseek-ai/schemastery": "^3.18.2"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@deepseek-ai/cordis": "4.0.2",
|
|
28
|
+
"@deepseek-ai/dsh-settings": "0.1.6-alpha.2",
|
|
29
|
+
"@deepseek-ai/dsh-web": "0.1.6-alpha.2",
|
|
30
|
+
"@deepseek-ai/schemastery": "3.18.2"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsdown src/index.ts -d lib --format esm --dts --no-fixed-extension"
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/args.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* search.py 子命令参数组装(纯函数)。
|
|
3
|
+
* 优先级直接透传 --service:search.py 自带链内 fallback 与 Tavily 多 key 轮转,
|
|
4
|
+
* 插件不重复实现回退编排。
|
|
5
|
+
* @module web-search-services/args
|
|
6
|
+
*/
|
|
7
|
+
import type { WebSearchRequest } from '@deepseek-ai/dsh-web'
|
|
8
|
+
|
|
9
|
+
import type { WebSearchServicesConfig } from './config.ts'
|
|
10
|
+
|
|
11
|
+
/** dsh-tool-web 之外的调用方未给 maxResults 时的请求层缺省。 */
|
|
12
|
+
export const FALLBACK_MAX_RESULTS = 8
|
|
13
|
+
|
|
14
|
+
/** 由 web 请求 + 插件配置生成 search.py 的 argv(不含解释器与脚本路径)。 */
|
|
15
|
+
export function buildSearchArgv(cfg: WebSearchServicesConfig, request: WebSearchRequest): string[] {
|
|
16
|
+
const query = request.query.trim()
|
|
17
|
+
if (query === '') {
|
|
18
|
+
throw new Error('query must be a non-empty string')
|
|
19
|
+
}
|
|
20
|
+
const maxResults = request.maxResults ?? FALLBACK_MAX_RESULTS
|
|
21
|
+
return [
|
|
22
|
+
'search',
|
|
23
|
+
'--query',
|
|
24
|
+
query,
|
|
25
|
+
'--service',
|
|
26
|
+
cfg.priority.join(','),
|
|
27
|
+
'--max-results',
|
|
28
|
+
String(maxResults),
|
|
29
|
+
]
|
|
30
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 配置单一事实源:cordis 行级 Config(组合默认值,patch 层可覆盖)与 settings
|
|
3
|
+
* namespace(用户层 $DSH_HOME/settings.yaml,热生效)共用同一 schemastery schema。
|
|
4
|
+
*
|
|
5
|
+
* 密钥主来源是 search-services skill 的 env 文件(envFile,默认
|
|
6
|
+
* ~/.config/search-services/env),保持 skill 单事实源、零搬迁。行级 keys 仅为
|
|
7
|
+
* 覆盖逃生舱;按存储规范 role('secret') 字段只允许行级注入,settings 用户层勿填。
|
|
8
|
+
* @module web-search-services/config
|
|
9
|
+
*/
|
|
10
|
+
import z from '@deepseek-ai/schemastery'
|
|
11
|
+
|
|
12
|
+
import { SETTINGS_NS as NS_LITERAL } from './ns.ts'
|
|
13
|
+
|
|
14
|
+
export const SETTINGS_NS = NS_LITERAL
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_SCRIPT_PATH = '~/.dsh/skills/search-services/scripts/search.py'
|
|
17
|
+
export const DEFAULT_ENV_FILE = '~/.config/search-services/env'
|
|
18
|
+
export const DEFAULT_TIMEOUT_MS = 55_000
|
|
19
|
+
export const DEFAULT_PRIORITY: readonly SearchBackend[] = ['tavily', 'exa', 'openai-chat']
|
|
20
|
+
|
|
21
|
+
export type SearchBackend = 'tavily' | 'exa' | 'openai-chat'
|
|
22
|
+
|
|
23
|
+
const PRIORITY_ENTRY = z.union(['tavily', 'exa', 'openai-chat'])
|
|
24
|
+
|
|
25
|
+
const KeysSchema = z.object({
|
|
26
|
+
tavily: z
|
|
27
|
+
.string()
|
|
28
|
+
.role('secret')
|
|
29
|
+
.description('Tavily API key(覆盖 envFile 的 TAVILY_API_KEY;行级注入专用)')
|
|
30
|
+
.default(''),
|
|
31
|
+
exa: z
|
|
32
|
+
.string()
|
|
33
|
+
.role('secret')
|
|
34
|
+
.description('Exa API key(覆盖 envFile 的 EXA_API_KEY;行级注入专用)')
|
|
35
|
+
.default(''),
|
|
36
|
+
openaiApiKey: z
|
|
37
|
+
.string()
|
|
38
|
+
.role('secret')
|
|
39
|
+
.description('OpenAI-compatible API key(覆盖 SEARCH_OPENAI_API_KEY;行级注入专用)')
|
|
40
|
+
.default(''),
|
|
41
|
+
openaiBaseUrl: z
|
|
42
|
+
.string()
|
|
43
|
+
.description('OpenAI-compatible base URL(覆盖 SEARCH_OPENAI_BASE_URL)')
|
|
44
|
+
.default(''),
|
|
45
|
+
openaiModel: z
|
|
46
|
+
.string()
|
|
47
|
+
.description('OpenAI-compatible 搜索模型名(覆盖 SEARCH_OPENAI_MODEL)')
|
|
48
|
+
.default(''),
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
export const Config = z.object({
|
|
52
|
+
scriptPath: z
|
|
53
|
+
.string()
|
|
54
|
+
.description('search-services skill 的 search.py 路径(支持 ~ 展开)')
|
|
55
|
+
.default(DEFAULT_SCRIPT_PATH),
|
|
56
|
+
envFile: z
|
|
57
|
+
.string()
|
|
58
|
+
.description('skill 密钥 env 文件路径(支持 ~ 展开);空串表示不读 envFile')
|
|
59
|
+
.default(DEFAULT_ENV_FILE),
|
|
60
|
+
python: z.string().description('python 可执行文件').default('python3'),
|
|
61
|
+
priority: z
|
|
62
|
+
.array(PRIORITY_ENTRY)
|
|
63
|
+
.description('后端优先级:靠前的先尝试,失败自动回退到下一个')
|
|
64
|
+
.default([...DEFAULT_PRIORITY]),
|
|
65
|
+
timeoutMs: z
|
|
66
|
+
.natural()
|
|
67
|
+
.min(1000)
|
|
68
|
+
.description('单次搜索子进程超时(毫秒),应低于 dsh-tool-web 的 searchTimeoutMs')
|
|
69
|
+
.default(DEFAULT_TIMEOUT_MS),
|
|
70
|
+
keys: KeysSchema.default({
|
|
71
|
+
tavily: '',
|
|
72
|
+
exa: '',
|
|
73
|
+
openaiApiKey: '',
|
|
74
|
+
openaiBaseUrl: '',
|
|
75
|
+
openaiModel: '',
|
|
76
|
+
}),
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
export type WebSearchServicesConfig = Schemastery.TypeT<typeof Config>
|
|
80
|
+
export type KeysConfig = WebSearchServicesConfig['keys']
|
package/src/env-file.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* search-services skill env 文件解析(纯函数)。
|
|
3
|
+
* 只解析不执行:按行提取 KEY=VALUE,不展开 $ 变量、不运行 shell,畸形行跳过;
|
|
4
|
+
* 文件缺失/不可读返回空表(envFile 是优化来源,不是硬依赖)。
|
|
5
|
+
* @module web-search-services/env-file
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync } from 'node:fs'
|
|
8
|
+
import { homedir } from 'node:os'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
|
|
11
|
+
/** 展开路径开头的 ~(仅 ~ 与 ~/ 两种形态,不借 shell)。 */
|
|
12
|
+
export function expandHome(path: string): string {
|
|
13
|
+
if (path === '~') return homedir()
|
|
14
|
+
if (path.startsWith('~/')) return join(homedir(), path.slice(2))
|
|
15
|
+
return path
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function unquote(value: string): string {
|
|
19
|
+
const first = value.charAt(0)
|
|
20
|
+
const last = value.charAt(value.length - 1)
|
|
21
|
+
if (value.length >= 2 && first === last && (first === '"' || first === "'")) {
|
|
22
|
+
return value.slice(1, -1)
|
|
23
|
+
}
|
|
24
|
+
return value
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function parseEnvText(text: string): Record<string, string> {
|
|
28
|
+
const out: Record<string, string> = {}
|
|
29
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
30
|
+
const line = rawLine.trim()
|
|
31
|
+
if (line === '' || line.startsWith('#')) continue
|
|
32
|
+
const body = line.startsWith('export ') ? line.slice('export '.length).trim() : line
|
|
33
|
+
const eq = body.indexOf('=')
|
|
34
|
+
if (eq <= 0) continue
|
|
35
|
+
const key = body.slice(0, eq).trim()
|
|
36
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue
|
|
37
|
+
out[key] = unquote(body.slice(eq + 1).trim())
|
|
38
|
+
}
|
|
39
|
+
return out
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function loadEnvFile(path: string): Record<string, string> {
|
|
43
|
+
if (path.trim() === '') return {}
|
|
44
|
+
try {
|
|
45
|
+
return parseEnvText(readFileSync(expandHome(path), 'utf8'))
|
|
46
|
+
} catch {
|
|
47
|
+
return {}
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-plus 插件:web_search 免费后端聚合(search-services skill 桥接)。
|
|
3
|
+
*
|
|
4
|
+
* 经官方 ctx.web provider 扩展点注册 id=search-services 的搜索后端:web_search /
|
|
5
|
+
* web_fetch 工具、渲染层与提示词零修改,模型可见输出格式与官方 provider 逐字节
|
|
6
|
+
* 一致。优先级与自动回退由 skill 的 search.py 编排(含 Tavily 多 key 轮转);
|
|
7
|
+
* Context7 仅支持带库名的 docs 查询,web_search 契约无 library 概念,不进搜索链。
|
|
8
|
+
* @module @dsh-plus/web-search-services
|
|
9
|
+
*/
|
|
10
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
11
|
+
import type {} from '@deepseek-ai/dsh-settings'
|
|
12
|
+
import type {} from '@deepseek-ai/dsh-web'
|
|
13
|
+
|
|
14
|
+
import { Config, SETTINGS_NS, type WebSearchServicesConfig } from './config.ts'
|
|
15
|
+
import { SearchServicesProvider } from './provider.ts'
|
|
16
|
+
|
|
17
|
+
export const name = 'dsh-plus-web-search-services'
|
|
18
|
+
|
|
19
|
+
/** 行级 inject:dsh-web 服务激活后才应用本行(provider 注册即需 ctx.web)。 */
|
|
20
|
+
export const inject = ['web'] as const
|
|
21
|
+
|
|
22
|
+
export { PROVIDER_ID, SearchServicesProvider } from './provider.ts'
|
|
23
|
+
export { Config, SETTINGS_NS }
|
|
24
|
+
|
|
25
|
+
export function apply(ctx: Context, config: WebSearchServicesConfig): void {
|
|
26
|
+
const provider = new SearchServicesProvider(configSource(ctx, config))
|
|
27
|
+
ctx.effect(
|
|
28
|
+
() => ctx.web.registerSearchProvider(provider),
|
|
29
|
+
'dsh-plus-web-search-services: register search provider',
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 配置源:settings 用户层($DSH_HOME/settings.yaml,热生效)在时优先生效,
|
|
35
|
+
* 缺席/detach 回落 cordis 行级 config(官方 installSection 可选消费范式)。
|
|
36
|
+
*/
|
|
37
|
+
function configSource(
|
|
38
|
+
ctx: Context,
|
|
39
|
+
rowConfig: WebSearchServicesConfig,
|
|
40
|
+
): () => WebSearchServicesConfig {
|
|
41
|
+
let current: () => WebSearchServicesConfig = () => rowConfig
|
|
42
|
+
ctx.inject(['settings'], (settingsCtx) => {
|
|
43
|
+
settingsCtx.settings.installSection(ctx, SETTINGS_NS, Config, rowConfig, {
|
|
44
|
+
setSource: (source) => {
|
|
45
|
+
current = source
|
|
46
|
+
},
|
|
47
|
+
onChange: () => {},
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
return () => current()
|
|
51
|
+
}
|
package/src/normalize.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* search.py 输出 → dsh-web 标准 WebSearchResult 归一化(纯函数)。
|
|
3
|
+
*
|
|
4
|
+
* skill 各后端 payload 形状不同:tavily/exa 是原生 API 形状(results[]),
|
|
5
|
+
* openai-chat 是生成式回答(data.answer)。按 selectedService 归一;
|
|
6
|
+
* 字段缺失降级而非报错(对 skill 脚本版本漂移保持容差)。
|
|
7
|
+
* @module web-search-services/normalize
|
|
8
|
+
*/
|
|
9
|
+
import type { WebSearchResult, WebSearchSource } from '@deepseek-ai/dsh-web'
|
|
10
|
+
|
|
11
|
+
type JsonObject = Record<string, unknown>
|
|
12
|
+
|
|
13
|
+
function asObject(value: unknown): JsonObject {
|
|
14
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
15
|
+
? (value as JsonObject)
|
|
16
|
+
: {}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function asNonEmptyString(value: unknown): string | undefined {
|
|
20
|
+
return typeof value === 'string' && value.trim() !== '' ? value : undefined
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function asArray(value: unknown): unknown[] {
|
|
24
|
+
return Array.isArray(value) ? value : []
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function makeSource(
|
|
28
|
+
url: string,
|
|
29
|
+
title?: string,
|
|
30
|
+
snippet?: string,
|
|
31
|
+
publishedAt?: string,
|
|
32
|
+
): WebSearchSource {
|
|
33
|
+
return {
|
|
34
|
+
url,
|
|
35
|
+
...(title !== undefined ? { title } : {}),
|
|
36
|
+
...(snippet !== undefined ? { snippet } : {}),
|
|
37
|
+
...(publishedAt !== undefined ? { publishedAt } : {}),
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function finish(content: string | undefined, sources: WebSearchSource[]): WebSearchResult {
|
|
42
|
+
const seen = new Set<string>()
|
|
43
|
+
const deduped = sources.filter((source) => {
|
|
44
|
+
if (seen.has(source.url)) return false
|
|
45
|
+
seen.add(source.url)
|
|
46
|
+
return true
|
|
47
|
+
})
|
|
48
|
+
return {
|
|
49
|
+
...(content !== undefined ? { content } : {}),
|
|
50
|
+
sources: deduped,
|
|
51
|
+
truncated: false,
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function sourcesFromResults(
|
|
56
|
+
results: unknown,
|
|
57
|
+
pick: (item: JsonObject) => WebSearchSource | undefined,
|
|
58
|
+
): WebSearchSource[] {
|
|
59
|
+
const out: WebSearchSource[] = []
|
|
60
|
+
for (const entry of asArray(results)) {
|
|
61
|
+
const source = pick(asObject(entry))
|
|
62
|
+
if (source !== undefined) out.push(source)
|
|
63
|
+
}
|
|
64
|
+
return out
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function normalizeTavily(raw: JsonObject): WebSearchResult {
|
|
68
|
+
const sources = sourcesFromResults(raw.results, (item) => {
|
|
69
|
+
const url = asNonEmptyString(item.url)
|
|
70
|
+
if (url === undefined) return undefined
|
|
71
|
+
return makeSource(
|
|
72
|
+
url,
|
|
73
|
+
asNonEmptyString(item.title),
|
|
74
|
+
asNonEmptyString(item.content),
|
|
75
|
+
asNonEmptyString(item.published_date),
|
|
76
|
+
)
|
|
77
|
+
})
|
|
78
|
+
return finish(asNonEmptyString(raw.answer), sources)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function normalizeExa(raw: JsonObject): WebSearchResult {
|
|
82
|
+
const sources = sourcesFromResults(raw.results, (item) => {
|
|
83
|
+
const url = asNonEmptyString(item.url) ?? asNonEmptyString(item.id)
|
|
84
|
+
if (url === undefined) return undefined
|
|
85
|
+
const highlights = asArray(item.highlights)
|
|
86
|
+
.map((h) => asNonEmptyString(h))
|
|
87
|
+
.filter((h): h is string => h !== undefined)
|
|
88
|
+
return makeSource(
|
|
89
|
+
url,
|
|
90
|
+
asNonEmptyString(item.title),
|
|
91
|
+
highlights.length > 0 ? highlights.join(' ') : undefined,
|
|
92
|
+
asNonEmptyString(item.publishedDate),
|
|
93
|
+
)
|
|
94
|
+
})
|
|
95
|
+
return finish(undefined, sources)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const MARKDOWN_LINK = /\[([^\]]*)\]\((https?:\/\/[^)\s]+)\)/g
|
|
99
|
+
|
|
100
|
+
/** 从生成式回答的 markdown 链接提取可引用来源(DeepSeek 官方 provider 同样只给
|
|
101
|
+
* URL/标题)。无链接时返回空表——content-only 结果在 WebSearchResult 合法。 */
|
|
102
|
+
export function extractMarkdownLinks(markdown: string, limit: number): WebSearchSource[] {
|
|
103
|
+
const seen = new Set<string>()
|
|
104
|
+
const out: WebSearchSource[] = []
|
|
105
|
+
for (const match of markdown.matchAll(MARKDOWN_LINK)) {
|
|
106
|
+
const url = match[2]
|
|
107
|
+
if (url === undefined || seen.has(url)) continue
|
|
108
|
+
seen.add(url)
|
|
109
|
+
const label = match[1] ?? ''
|
|
110
|
+
out.push(makeSource(url, label.trim() !== '' ? label : undefined))
|
|
111
|
+
if (out.length >= limit) break
|
|
112
|
+
}
|
|
113
|
+
return out
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function normalizeOpenAiChat(raw: JsonObject): WebSearchResult {
|
|
117
|
+
const answer = asNonEmptyString(asObject(raw.data).answer)
|
|
118
|
+
if (answer === undefined) {
|
|
119
|
+
throw new Error('openai-chat 结果缺少 data.answer')
|
|
120
|
+
}
|
|
121
|
+
return finish(answer, extractMarkdownLinks(answer, 16))
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function formatAttempts(attempts: unknown): string {
|
|
125
|
+
const summary = asArray(attempts)
|
|
126
|
+
.map((entry) => {
|
|
127
|
+
const item = asObject(entry)
|
|
128
|
+
const service = asNonEmptyString(item.service) ?? '?'
|
|
129
|
+
const message = asNonEmptyString(item.error) ?? 'unknown'
|
|
130
|
+
return `${service}: ${message}`
|
|
131
|
+
})
|
|
132
|
+
.join('; ')
|
|
133
|
+
return summary === '' ? '' : `(尝试过: ${summary})`
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** 归一化 search.py 的完整 stdout JSON;失败以 Error 抛出(provider 层包 WebError)。 */
|
|
137
|
+
export function normalizeSearchOutput(raw: unknown): WebSearchResult {
|
|
138
|
+
const obj = asObject(raw)
|
|
139
|
+
if (obj.ok === false) {
|
|
140
|
+
const detail = asNonEmptyString(obj.error) ?? 'search.py 返回 ok=false'
|
|
141
|
+
throw new Error(`${detail}${formatAttempts(obj.attempts)}`)
|
|
142
|
+
}
|
|
143
|
+
const service = asNonEmptyString(obj.selectedService) ?? asNonEmptyString(obj.service)
|
|
144
|
+
switch (service) {
|
|
145
|
+
case 'tavily':
|
|
146
|
+
return normalizeTavily(obj)
|
|
147
|
+
case 'exa':
|
|
148
|
+
return normalizeExa(obj)
|
|
149
|
+
case 'openai-chat':
|
|
150
|
+
return normalizeOpenAiChat(obj)
|
|
151
|
+
default:
|
|
152
|
+
throw new Error(`search.py 返回未知或不支持的 selectedService: ${JSON.stringify(service)}`)
|
|
153
|
+
}
|
|
154
|
+
}
|
package/src/ns.ts
ADDED
package/src/provider.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-web 搜索 provider:把 ctx.web.search 桥接到 search-services skill 的
|
|
3
|
+
* search.py(Tavily / Exa / OpenAI Chat 免费额度后端)。官方 web_search 工具、
|
|
4
|
+
* 渲染与提示词零修改——本 provider 只产出标准 WebSearchResult,格式化天然一致。
|
|
5
|
+
* @module web-search-services/provider
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync } from 'node:fs'
|
|
8
|
+
import type { WebSearchProvider, WebSearchRequest, WebSearchResult } from '@deepseek-ai/dsh-web'
|
|
9
|
+
import { WebError } from '@deepseek-ai/dsh-web'
|
|
10
|
+
|
|
11
|
+
import { buildSearchArgv } from './args.ts'
|
|
12
|
+
import type { SearchBackend, WebSearchServicesConfig } from './config.ts'
|
|
13
|
+
import { expandHome, loadEnvFile } from './env-file.ts'
|
|
14
|
+
import { normalizeSearchOutput } from './normalize.ts'
|
|
15
|
+
import { isAbortError, runProcess, type SpawnFn } from './runner.ts'
|
|
16
|
+
|
|
17
|
+
export const PROVIDER_ID = 'search-services'
|
|
18
|
+
|
|
19
|
+
/** 各后端在合并环境(进程 env ⊕ envFile ⊕ 行级 keys)中的可用性判定变量。 */
|
|
20
|
+
const BACKEND_KEY_VARS: Readonly<Record<SearchBackend, readonly string[]>> = {
|
|
21
|
+
tavily: ['TAVILY_API_KEY', 'TAVILY_API_KEYS'],
|
|
22
|
+
exa: ['EXA_API_KEY'],
|
|
23
|
+
'openai-chat': ['SEARCH_OPENAI_API_KEY'],
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 行级 keys 覆盖 → 子进程环境变量;空串不注入(保持 envFile 单事实源优先)。 */
|
|
27
|
+
export function keyOverridesEnv(keys: WebSearchServicesConfig['keys']): Record<string, string> {
|
|
28
|
+
const out: Record<string, string> = {}
|
|
29
|
+
if (keys.tavily !== '') out.TAVILY_API_KEY = keys.tavily
|
|
30
|
+
if (keys.exa !== '') out.EXA_API_KEY = keys.exa
|
|
31
|
+
if (keys.openaiApiKey !== '') out.SEARCH_OPENAI_API_KEY = keys.openaiApiKey
|
|
32
|
+
if (keys.openaiBaseUrl !== '') out.SEARCH_OPENAI_BASE_URL = keys.openaiBaseUrl
|
|
33
|
+
if (keys.openaiModel !== '') out.SEARCH_OPENAI_MODEL = keys.openaiModel
|
|
34
|
+
return out
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function messageOf(err: unknown): string {
|
|
38
|
+
return err instanceof Error ? err.message : String(err)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function toWebError(err: unknown): WebError {
|
|
42
|
+
if (err instanceof WebError) return err
|
|
43
|
+
if (isAbortError(err)) return new WebError('搜索已取消', 'WEB_ABORTED', { cause: err })
|
|
44
|
+
return new WebError(`search-services 搜索失败: ${messageOf(err)}`, 'WEB_PROVIDER_ERROR', {
|
|
45
|
+
cause: err,
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class SearchServicesProvider implements WebSearchProvider {
|
|
50
|
+
readonly id = PROVIDER_ID
|
|
51
|
+
|
|
52
|
+
private readonly current: () => WebSearchServicesConfig
|
|
53
|
+
private readonly spawnFn: SpawnFn | undefined
|
|
54
|
+
|
|
55
|
+
constructor(current: () => WebSearchServicesConfig, spawnFn?: SpawnFn | undefined) {
|
|
56
|
+
this.current = current
|
|
57
|
+
this.spawnFn = spawnFn
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 廉价本地检查:脚本存在 + 优先级内至少一个后端有 key;不做网络调用。 */
|
|
61
|
+
available(): boolean {
|
|
62
|
+
const cfg = this.current()
|
|
63
|
+
if (!existsSync(expandHome(cfg.scriptPath))) return false
|
|
64
|
+
const env = this.childEnv(cfg)
|
|
65
|
+
return cfg.priority.some((backend) =>
|
|
66
|
+
BACKEND_KEY_VARS[backend].some((name) => (env[name]?.trim() ?? '') !== ''),
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult> {
|
|
71
|
+
const cfg = this.current()
|
|
72
|
+
const argv = buildSearchArgv(cfg, request)
|
|
73
|
+
const stdout = await this.run(cfg, argv, signal)
|
|
74
|
+
return this.normalize(stdout)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private childEnv(cfg: WebSearchServicesConfig): NodeJS.ProcessEnv {
|
|
78
|
+
const fromFile = cfg.envFile.trim() === '' ? {} : loadEnvFile(cfg.envFile)
|
|
79
|
+
return { ...process.env, ...fromFile, ...keyOverridesEnv(cfg.keys) }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private async run(
|
|
83
|
+
cfg: WebSearchServicesConfig,
|
|
84
|
+
argv: string[],
|
|
85
|
+
signal: AbortSignal | undefined,
|
|
86
|
+
): Promise<string> {
|
|
87
|
+
try {
|
|
88
|
+
return await runProcess({
|
|
89
|
+
command: cfg.python,
|
|
90
|
+
args: [expandHome(cfg.scriptPath), ...argv],
|
|
91
|
+
env: this.childEnv(cfg),
|
|
92
|
+
timeoutMs: cfg.timeoutMs,
|
|
93
|
+
...(signal !== undefined ? { signal } : {}),
|
|
94
|
+
...(this.spawnFn !== undefined ? { spawnFn: this.spawnFn } : {}),
|
|
95
|
+
})
|
|
96
|
+
} catch (err) {
|
|
97
|
+
throw toWebError(err)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private normalize(stdout: string): WebSearchResult {
|
|
102
|
+
let raw: unknown
|
|
103
|
+
try {
|
|
104
|
+
raw = JSON.parse(stdout)
|
|
105
|
+
} catch (err) {
|
|
106
|
+
const preview = stdout.trim().slice(-200)
|
|
107
|
+
throw new WebError(`search.py 输出非 JSON: …${preview}`, 'WEB_PROVIDER_ERROR', {
|
|
108
|
+
cause: err,
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
return normalizeSearchOutput(raw)
|
|
113
|
+
} catch (err) {
|
|
114
|
+
throw new WebError(`search.py 结果归一化失败: ${messageOf(err)}`, 'WEB_PROVIDER_ERROR', {
|
|
115
|
+
cause: err,
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
package/src/runner.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 子进程执行器:spawn 脚本、收集 stdout、超时/abort 杀进程。
|
|
3
|
+
* spawn 依赖注入以便单测 mock(禁网络铁律下测试绝不真实启动搜索脚本)。
|
|
4
|
+
* @module web-search-services/runner
|
|
5
|
+
*/
|
|
6
|
+
import { spawn } from 'node:child_process'
|
|
7
|
+
|
|
8
|
+
export type SpawnFn = typeof spawn
|
|
9
|
+
|
|
10
|
+
export interface RunProcessOptions {
|
|
11
|
+
command: string
|
|
12
|
+
args: readonly string[]
|
|
13
|
+
env: NodeJS.ProcessEnv
|
|
14
|
+
cwd?: string | undefined
|
|
15
|
+
timeoutMs: number
|
|
16
|
+
signal?: AbortSignal | undefined
|
|
17
|
+
maxBufferBytes?: number | undefined
|
|
18
|
+
spawnFn?: SpawnFn | undefined
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const DEFAULT_MAX_BUFFER = 8 * 1024 * 1024
|
|
22
|
+
const KILL_GRACE_MS = 2_000
|
|
23
|
+
|
|
24
|
+
export function abortError(): Error {
|
|
25
|
+
const err = new Error('search aborted')
|
|
26
|
+
err.name = 'AbortError'
|
|
27
|
+
return err
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function isAbortError(err: unknown): boolean {
|
|
31
|
+
return err instanceof Error && err.name === 'AbortError'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function tail(text: string, max = 500): string {
|
|
35
|
+
const trimmed = text.trim()
|
|
36
|
+
return trimmed.length <= max ? trimmed : `…${trimmed.slice(-max)}`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 运行子进程并返回完整 stdout;任何失败以 Error 拒绝(消息含 stderr/退出码上下文)。 */
|
|
40
|
+
export function runProcess(opts: RunProcessOptions): Promise<string> {
|
|
41
|
+
const spawnFn = opts.spawnFn ?? spawn
|
|
42
|
+
return new Promise<string>((resolve, reject) => {
|
|
43
|
+
if (opts.signal?.aborted === true) {
|
|
44
|
+
reject(abortError())
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
const child = spawnFn(opts.command, [...opts.args], {
|
|
48
|
+
env: opts.env,
|
|
49
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
50
|
+
...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
const maxBuffer = opts.maxBufferBytes ?? DEFAULT_MAX_BUFFER
|
|
54
|
+
let stdout = ''
|
|
55
|
+
let stderr = ''
|
|
56
|
+
let settled = false
|
|
57
|
+
let graceTimer: NodeJS.Timeout | undefined
|
|
58
|
+
|
|
59
|
+
const cleanup = (): void => {
|
|
60
|
+
clearTimeout(timer)
|
|
61
|
+
if (graceTimer !== undefined) clearTimeout(graceTimer)
|
|
62
|
+
opts.signal?.removeEventListener('abort', onAbort)
|
|
63
|
+
}
|
|
64
|
+
const settle = (fn: () => void): void => {
|
|
65
|
+
if (settled) return
|
|
66
|
+
settled = true
|
|
67
|
+
cleanup()
|
|
68
|
+
fn()
|
|
69
|
+
}
|
|
70
|
+
const kill = (): void => {
|
|
71
|
+
child.kill('SIGTERM')
|
|
72
|
+
graceTimer = setTimeout(() => child.kill('SIGKILL'), KILL_GRACE_MS)
|
|
73
|
+
graceTimer.unref()
|
|
74
|
+
}
|
|
75
|
+
const onAbort = (): void => {
|
|
76
|
+
kill()
|
|
77
|
+
settle(() => reject(abortError()))
|
|
78
|
+
}
|
|
79
|
+
const timer = setTimeout(() => {
|
|
80
|
+
kill()
|
|
81
|
+
settle(() => reject(new Error(`process timed out after ${opts.timeoutMs}ms`)))
|
|
82
|
+
}, opts.timeoutMs)
|
|
83
|
+
timer.unref()
|
|
84
|
+
opts.signal?.addEventListener('abort', onAbort, { once: true })
|
|
85
|
+
|
|
86
|
+
child.stdout?.on('data', (chunk: Buffer | string) => {
|
|
87
|
+
stdout += chunk.toString()
|
|
88
|
+
if (stdout.length > maxBuffer) {
|
|
89
|
+
kill()
|
|
90
|
+
settle(() => reject(new Error(`stdout exceeded ${maxBuffer} bytes`)))
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
child.stderr?.on('data', (chunk: Buffer | string) => {
|
|
94
|
+
stderr += chunk.toString()
|
|
95
|
+
})
|
|
96
|
+
child.on('error', (err) => {
|
|
97
|
+
settle(() =>
|
|
98
|
+
reject(new Error(`failed to start ${opts.command}: ${err.message}`, { cause: err })),
|
|
99
|
+
)
|
|
100
|
+
})
|
|
101
|
+
child.on('close', (code) => {
|
|
102
|
+
if (settled) return
|
|
103
|
+
if (code === 0) {
|
|
104
|
+
settle(() => resolve(stdout))
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
const detail = tail(stderr) !== '' ? tail(stderr) : tail(stdout)
|
|
108
|
+
settle(() => reject(new Error(`exited with code ${code ?? 'null'}: ${detail}`)))
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
}
|