@dcrays/web-search 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  # @dcrays/web-search
2
2
 
3
- 书灵墨宝的 DSH 联网搜索插件。加载后注册 `web_search` 工具,把模型搜索请求映射到配置的 HTTP API。
3
+ 书灵墨宝的 DSH 联网搜索插件。加载后注册 `web_search` 工具,把模型搜索请求映射到搜索网关。
4
4
 
5
- 本包对应 `mbh-chat` 环境,端点地址从 Cordis 插件配置读取。
5
+ 单一发布包同时适用于生产与测试桌面:运行时根据 `mobook.json` 中记录的环境选择搜索网关与 MBHChat 凭据通道;`endpointUrl` 可在 Cordis 插件配置中覆盖。
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@dcrays/web-search",
3
- "version": "0.1.0",
4
- "description": "DeepSeek Harness web search plugin for Mobook (production)",
3
+ "version": "0.1.2",
4
+ "description": "DeepSeek Harness web search plugin for Mobook",
5
5
  "type": "module",
6
6
  "main": "plugin/index.js",
7
7
  "types": "plugin/index.d.ts",
@@ -28,12 +28,12 @@
28
28
  "engines": {
29
29
  "node": ">=24.0.0"
30
30
  },
31
- "dependencies": {
32
- "@deepseek-ai/schemastery": "^3.18.1"
33
- },
31
+ "dependencies": {},
34
32
  "peerDependencies": {
35
- "@deepseek-ai/cordis": "^4.0.1",
36
- "@deepseek-ai/dsh-tools": "^0.1.1-rc.2"
33
+ "@deepseek-ai/cordis": "^4.0.2",
34
+ "@deepseek-ai/dsh-home-paths": "0.1.2-rc.1",
35
+ "@deepseek-ai/dsh-tools": "^0.1.2-rc.1",
36
+ "@deepseek-ai/schemastery": "^3.18.1"
37
37
  },
38
38
  "publishConfig": {
39
39
  "access": "public"
package/plugin/index.d.ts CHANGED
@@ -2,7 +2,6 @@ import type { Context } from '@deepseek-ai/cordis';
2
2
  import { Service } from '@deepseek-ai/cordis';
3
3
  import z from '@deepseek-ai/schemastery';
4
4
  import { type WebSearchRequest, type WebSearchResult } from '../src/index.js';
5
- export { DEFAULT_BUILD_ENV } from '../src/config/env-config.js';
6
5
  export declare const name = "mbh-web-search";
7
6
  export declare const inject: string[];
8
7
  export interface Config {
@@ -27,4 +26,5 @@ export declare class MbhWebSearchService extends Service {
27
26
  isEnabled(): boolean;
28
27
  search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>;
29
28
  }
29
+ export declare function resolveMetadataPath(configuredPath: string): string;
30
30
  export declare function apply(ctx: Context, config: Config): void;
package/plugin/index.js CHANGED
@@ -1,14 +1,33 @@
1
1
  import { createRequire as __mobookCreateRequire } from 'node:module'; const require = __mobookCreateRequire(import.meta.url);
2
2
 
3
- // plugins/web-search/dist-npm/plugin/index.js
4
- import { homedir } from "node:os";
3
+ // dist-npm/plugin/index.js
5
4
  import { resolve } from "node:path";
6
5
  import { Service } from "@deepseek-ai/cordis";
6
+ import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
7
7
  import z from "@deepseek-ai/schemastery";
8
8
 
9
- // plugins/web-search/dist-npm/src/api-client.js
9
+ // dist-npm/src/api-client.js
10
10
  import { createHmac, randomUUID } from "node:crypto";
11
11
  import { readFile } from "node:fs/promises";
12
+
13
+ // dist-npm/src/config/env-config.js
14
+ var ENV_CONFIG = {
15
+ production: {
16
+ channelId: "mbh-chat",
17
+ legacyChannelId: "mbhchat",
18
+ endpointUrl: "https://api-gateway.shuwenda.com/apikey-manage/api/search"
19
+ },
20
+ test: {
21
+ channelId: "mbh-chat-test",
22
+ legacyChannelId: "mbhchat-test",
23
+ endpointUrl: "https://api-gateway.shuwenda.icu/apikey-manage/api/search"
24
+ }
25
+ };
26
+ function resolveWebSearchEnvironment(value) {
27
+ return value === "production" ? "production" : "test";
28
+ }
29
+
30
+ // dist-npm/src/api-client.js
12
31
  var WebSearchApiError = class extends Error {
13
32
  code;
14
33
  constructor(code, message, options) {
@@ -17,6 +36,10 @@ var WebSearchApiError = class extends Error {
17
36
  this.name = "WebSearchApiError";
18
37
  }
19
38
  };
39
+ var RESPONSE_TOO_LARGE_MESSAGE = "\u641C\u7D22\u670D\u52A1\u8FD4\u56DE\u7684\u5185\u5BB9\u8FC7\u5927\uFF0C\u6682\u65F6\u65E0\u6CD5\u5904\u7406\u3002\u8BF7\u7F29\u5C0F\u641C\u7D22\u8303\u56F4\u540E\u91CD\u8BD5\u3002";
40
+ function searchServiceLabel(service) {
41
+ return service === "tavily_search" ? "Tavily \u641C\u7D22" : "\u535A\u67E5\u641C\u7D22";
42
+ }
20
43
  function record(value) {
21
44
  return value && typeof value === "object" && !Array.isArray(value) ? value : void 0;
22
45
  }
@@ -35,7 +58,7 @@ function resolveSearchCredentials(metadata, channelId, legacyChannelId) {
35
58
  if (userId && botToken && deviceId)
36
59
  return { userId, botToken, deviceId };
37
60
  }
38
- throw new WebSearchApiError("MISSING_CREDENTIALS", `mobook metadata must contain plugins.${channelId}.userId, botToken, and hardware.fingerprint`);
61
+ throw new WebSearchApiError("MISSING_CREDENTIALS", "\u641C\u7D22\u670D\u52A1\u5C1A\u672A\u51C6\u5907\u597D\uFF0C\u8BF7\u786E\u8BA4\u5DF2\u767B\u5F55\u58A8\u5B9D\u5E76\u5B8C\u6210\u5BA2\u6237\u7AEF\u521D\u59CB\u5316\u540E\u91CD\u8BD5\u3002");
39
62
  }
40
63
  function requestSignal(signal, timeoutMs) {
41
64
  const timeout = AbortSignal.timeout(timeoutMs);
@@ -45,7 +68,7 @@ async function responseText(response, maxBytes) {
45
68
  const declared = Number(response.headers.get("content-length"));
46
69
  if (Number.isFinite(declared) && declared > maxBytes) {
47
70
  await response.body?.cancel().catch(() => void 0);
48
- throw new WebSearchApiError("RESPONSE_TOO_LARGE", `web search response exceeds ${maxBytes} bytes`);
71
+ throw new WebSearchApiError("RESPONSE_TOO_LARGE", RESPONSE_TOO_LARGE_MESSAGE);
49
72
  }
50
73
  if (!response.body)
51
74
  return "";
@@ -61,7 +84,7 @@ async function responseText(response, maxBytes) {
61
84
  size += item.value.byteLength;
62
85
  if (size > maxBytes) {
63
86
  await reader.cancel().catch(() => void 0);
64
- throw new WebSearchApiError("RESPONSE_TOO_LARGE", `web search response exceeds ${maxBytes} bytes`);
87
+ throw new WebSearchApiError("RESPONSE_TOO_LARGE", RESPONSE_TOO_LARGE_MESSAGE);
65
88
  }
66
89
  text += decoder.decode(item.value, { stream: true });
67
90
  }
@@ -70,66 +93,92 @@ async function responseText(response, maxBytes) {
70
93
  reader.releaseLock();
71
94
  }
72
95
  }
73
- function parseResponse(text, fallbackQuery) {
96
+ function searchItem(value, snippetKeys, publishedAtKeys) {
97
+ const item = record(value);
98
+ const url = nonEmptyString(item?.url);
99
+ if (!item || !url)
100
+ return [];
101
+ const result = {
102
+ title: nonEmptyString(item.title) ?? nonEmptyString(item.name) ?? url,
103
+ url,
104
+ snippet: snippetKeys.map((key) => nonEmptyString(item[key])).find(Boolean) ?? ""
105
+ };
106
+ const siteName = nonEmptyString(item.siteName);
107
+ const publishedAt = publishedAtKeys.map((key) => nonEmptyString(item[key])).find(Boolean);
108
+ if (siteName)
109
+ result.siteName = siteName;
110
+ if (publishedAt)
111
+ result.publishedAt = publishedAt;
112
+ return [result];
113
+ }
114
+ function parseResponse(text, fallbackQuery, service) {
74
115
  let payload;
75
116
  try {
76
117
  payload = JSON.parse(text);
77
118
  } catch (error) {
78
- throw new WebSearchApiError("INVALID_RESPONSE", "web search API returned invalid JSON", { cause: error });
119
+ throw new WebSearchApiError("INVALID_RESPONSE", "\u641C\u7D22\u670D\u52A1\u8FD4\u56DE\u7684\u6570\u636E\u683C\u5F0F\u5F02\u5E38\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002", { cause: error });
79
120
  }
80
121
  const wrapper = record(payload);
81
122
  const upstream = record(wrapper?.data);
82
123
  if (wrapper?.success !== true || typeof upstream?.code === "number" && upstream.code !== 200) {
83
- throw new WebSearchApiError("API_ERROR", "web search API reported an unsuccessful response");
124
+ throw new WebSearchApiError("API_ERROR", "\u641C\u7D22\u670D\u52A1\u6682\u65F6\u4E0D\u53EF\u7528\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002");
125
+ }
126
+ if (service === "tavily_search") {
127
+ const values = Array.isArray(upstream?.results) ? upstream.results : [];
128
+ return {
129
+ query: nonEmptyString(upstream?.query) ?? fallbackQuery,
130
+ provider: service,
131
+ results: values.flatMap((value) => searchItem(value, ["content", "snippet"], ["published_date", "publishedAt"]))
132
+ };
84
133
  }
85
134
  const searchData = record(upstream?.data);
86
135
  const pages = record(searchData?.webPages);
87
136
  const queryContext = record(searchData?.queryContext);
88
- const values = Array.isArray(pages?.value) ? pages.value : [];
89
- const results = values.flatMap((value) => {
90
- const item = record(value);
91
- const url = nonEmptyString(item?.url);
92
- if (!item || !url)
93
- return [];
94
- const result2 = {
95
- title: nonEmptyString(item.name) ?? url,
96
- url,
97
- snippet: nonEmptyString(item.snippet) ?? ""
98
- };
99
- const siteName = nonEmptyString(item.siteName);
100
- const publishedAt = nonEmptyString(item.datePublished);
101
- if (siteName)
102
- result2.siteName = siteName;
103
- if (publishedAt)
104
- result2.publishedAt = publishedAt;
105
- return [result2];
106
- });
107
137
  const result = {
108
138
  query: nonEmptyString(queryContext?.originalQuery) ?? fallbackQuery,
109
- results
139
+ provider: service,
140
+ results: (Array.isArray(pages?.value) ? pages.value : []).flatMap((value) => searchItem(value, ["snippet"], ["datePublished"]))
110
141
  };
111
142
  if (typeof pages?.totalEstimatedMatches === "number" && Number.isFinite(pages.totalEstimatedMatches)) {
112
143
  result.totalEstimatedMatches = pages.totalEstimatedMatches;
113
144
  }
114
145
  return result;
115
146
  }
147
+ function toEndpoint(endpointUrl) {
148
+ let endpoint;
149
+ try {
150
+ endpoint = new URL(endpointUrl);
151
+ } catch (error) {
152
+ throw new WebSearchApiError("INVALID_CONFIG", "\u641C\u7D22\u670D\u52A1\u5730\u5740\u914D\u7F6E\u4E0D\u6B63\u786E\uFF0C\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458\u68C0\u67E5\u63D2\u4EF6\u914D\u7F6E\u3002", { cause: error });
153
+ }
154
+ if (!["http:", "https:"].includes(endpoint.protocol)) {
155
+ throw new WebSearchApiError("INVALID_CONFIG", "\u641C\u7D22\u670D\u52A1\u5730\u5740\u4EC5\u652F\u6301 HTTP \u6216 HTTPS\uFF0C\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458\u68C0\u67E5\u63D2\u4EF6\u914D\u7F6E\u3002");
156
+ }
157
+ return endpoint;
158
+ }
116
159
  var WebSearchApiClient = class {
117
160
  config;
118
- endpoint;
161
+ environments;
119
162
  fetchImpl;
120
163
  readFileImpl;
121
164
  nowSeconds;
122
165
  nonce;
123
166
  constructor(config, dependencies = {}) {
124
167
  this.config = config;
125
- try {
126
- this.endpoint = new URL(config.endpointUrl);
127
- } catch (error) {
128
- throw new WebSearchApiError("INVALID_CONFIG", "web search endpointUrl is invalid", { cause: error });
129
- }
130
- if (!["http:", "https:"].includes(this.endpoint.protocol)) {
131
- throw new WebSearchApiError("INVALID_CONFIG", "web search endpointUrl must use http or https");
132
- }
168
+ const environments = config.environments ?? ENV_CONFIG;
169
+ const override = config.endpointOverride?.trim();
170
+ this.environments = {
171
+ production: {
172
+ endpoint: toEndpoint(override || environments.production.endpointUrl),
173
+ channelId: environments.production.channelId,
174
+ legacyChannelId: environments.production.legacyChannelId
175
+ },
176
+ test: {
177
+ endpoint: toEndpoint(override || environments.test.endpointUrl),
178
+ channelId: environments.test.channelId,
179
+ legacyChannelId: environments.test.legacyChannelId
180
+ }
181
+ };
133
182
  for (const [name2, value] of [
134
183
  ["timeoutMs", config.timeoutMs],
135
184
  ["maxResponseBytes", config.maxResponseBytes],
@@ -137,11 +186,11 @@ var WebSearchApiClient = class {
137
186
  ["maxCount", config.maxCount]
138
187
  ]) {
139
188
  if (!Number.isSafeInteger(value) || value <= 0) {
140
- throw new WebSearchApiError("INVALID_CONFIG", `${name2} must be a positive integer`);
189
+ throw new WebSearchApiError("INVALID_CONFIG", `\u641C\u7D22\u63D2\u4EF6\u914D\u7F6E\u9879 ${name2} \u5FC5\u987B\u662F\u5927\u4E8E 0 \u7684\u6574\u6570\uFF0C\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458\u68C0\u67E5\u914D\u7F6E\u3002`);
141
190
  }
142
191
  }
143
192
  if (config.defaultCount > config.maxCount) {
144
- throw new WebSearchApiError("INVALID_CONFIG", "defaultCount must not exceed maxCount");
193
+ throw new WebSearchApiError("INVALID_CONFIG", "\u9ED8\u8BA4\u641C\u7D22\u7ED3\u679C\u6570\u4E0D\u80FD\u5927\u4E8E\u6700\u5927\u7ED3\u679C\u6570\uFF0C\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458\u68C0\u67E5\u63D2\u4EF6\u914D\u7F6E\u3002");
145
194
  }
146
195
  this.fetchImpl = dependencies.fetch ?? fetch;
147
196
  this.readFileImpl = dependencies.readFile ?? readFile;
@@ -151,73 +200,87 @@ var WebSearchApiClient = class {
151
200
  async search(request, signal) {
152
201
  const query = request.query.trim();
153
202
  if (!query)
154
- throw new WebSearchApiError("INVALID_REQUEST", "web search query must not be empty");
203
+ throw new WebSearchApiError("INVALID_REQUEST", "\u8BF7\u8F93\u5165\u8981\u641C\u7D22\u7684\u5185\u5BB9\u540E\u91CD\u8BD5\u3002");
155
204
  const count = request.count ?? this.config.defaultCount;
156
205
  const offset = request.offset ?? 0;
157
206
  if (!Number.isSafeInteger(count) || count < 1 || count > this.config.maxCount) {
158
- throw new WebSearchApiError("INVALID_REQUEST", `web search count must be between 1 and ${this.config.maxCount}`);
207
+ throw new WebSearchApiError("INVALID_REQUEST", `\u641C\u7D22\u7ED3\u679C\u6570\u91CF\u9700\u8BBE\u7F6E\u4E3A 1 \u5230 ${this.config.maxCount} \u4E4B\u95F4\u7684\u6574\u6570\u3002`);
159
208
  }
160
209
  if (!Number.isSafeInteger(offset) || offset < 0) {
161
- throw new WebSearchApiError("INVALID_REQUEST", "web search offset must be a non-negative integer");
210
+ throw new WebSearchApiError("INVALID_REQUEST", "\u641C\u7D22\u7ED3\u679C\u504F\u79FB\u91CF\u5FC5\u987B\u662F\u5927\u4E8E\u6216\u7B49\u4E8E 0 \u7684\u6574\u6570\u3002");
162
211
  }
163
212
  let metadata;
164
213
  try {
165
214
  metadata = JSON.parse(await this.readFileImpl(this.config.metadataPath, "utf8"));
166
215
  } catch (error) {
167
- throw new WebSearchApiError("MISSING_CREDENTIALS", "unable to read Mobook runtime metadata", { cause: error });
216
+ throw new WebSearchApiError("MISSING_CREDENTIALS", "\u6682\u65F6\u65E0\u6CD5\u8BFB\u53D6\u58A8\u5B9D\u8FD0\u884C\u914D\u7F6E\uFF0C\u8BF7\u786E\u8BA4\u5BA2\u6237\u7AEF\u5DF2\u6B63\u5E38\u521D\u59CB\u5316\u540E\u91CD\u8BD5\u3002", { cause: error });
168
217
  }
169
- const credentials = resolveSearchCredentials(metadata, this.config.channelId, this.config.legacyChannelId);
170
- const timestamp = String(this.nowSeconds());
171
- const nonce = this.nonce();
172
- const message = `${credentials.userId}
218
+ const environment = resolveWebSearchEnvironment(record(metadata)?.environment);
219
+ const resolved = this.environments[environment];
220
+ const credentials = resolveSearchCredentials(metadata, resolved.channelId, resolved.legacyChannelId);
221
+ const call = async (service) => {
222
+ const timestamp = String(this.nowSeconds());
223
+ const nonce = this.nonce();
224
+ const message = `${credentials.userId}
173
225
  ${credentials.deviceId}
174
226
  ${timestamp}
175
227
  ${nonce}`;
176
- const signature = createHmac("sha256", credentials.botToken).update(message, "utf8").digest("hex");
177
- const body = {
178
- user_id: credentials.userId,
179
- bot_token: credentials.botToken,
180
- device_id: credentials.deviceId,
181
- signature,
182
- timestamp,
183
- nonce,
184
- service: "bocha_search",
185
- query,
186
- count,
187
- offset,
188
- language: request.language?.trim() || "zh-CN"
228
+ const signature = createHmac("sha256", credentials.botToken).update(message, "utf8").digest("hex");
229
+ const body = {
230
+ user_id: credentials.userId,
231
+ bot_token: credentials.botToken,
232
+ device_id: credentials.deviceId,
233
+ signature,
234
+ timestamp,
235
+ nonce,
236
+ service,
237
+ query,
238
+ count,
239
+ offset,
240
+ language: request.language?.trim() || "zh-CN"
241
+ };
242
+ if (request.includeDomains?.length)
243
+ body.include_domains = request.includeDomains;
244
+ if (request.excludeDomains?.length)
245
+ body.exclude_domains = request.excludeDomains;
246
+ const effectiveSignal = requestSignal(signal, this.config.timeoutMs);
247
+ let response;
248
+ try {
249
+ response = await this.fetchImpl(resolved.endpoint, {
250
+ method: "POST",
251
+ headers: { accept: "application/json", "content-type": "application/json" },
252
+ body: JSON.stringify(body),
253
+ signal: effectiveSignal
254
+ });
255
+ } catch (error) {
256
+ if (signal?.aborted)
257
+ throw error;
258
+ if (effectiveSignal.aborted) {
259
+ throw new WebSearchApiError("TIMEOUT", `${searchServiceLabel(service)}\u54CD\u5E94\u8D85\u65F6\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002`, {
260
+ cause: error
261
+ });
262
+ }
263
+ throw new WebSearchApiError("NETWORK_ERROR", `\u6682\u65F6\u65E0\u6CD5\u8FDE\u63A5${searchServiceLabel(service)}\uFF0C\u8BF7\u68C0\u67E5\u7F51\u7EDC\u540E\u91CD\u8BD5\u3002`, { cause: error });
264
+ }
265
+ const text = await responseText(response, this.config.maxResponseBytes);
266
+ if (!response.ok) {
267
+ throw new WebSearchApiError("HTTP_ERROR", `${searchServiceLabel(service)}\u6682\u65F6\u4E0D\u53EF\u7528\uFF08\u670D\u52A1\u72B6\u6001 ${response.status}\uFF09\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002`);
268
+ }
269
+ return parseResponse(text, query, service);
189
270
  };
190
- if (request.includeDomains?.length)
191
- body.include_domains = request.includeDomains;
192
- if (request.excludeDomains?.length)
193
- body.exclude_domains = request.excludeDomains;
194
- const effectiveSignal = requestSignal(signal, this.config.timeoutMs);
195
- let response;
196
271
  try {
197
- response = await this.fetchImpl(this.endpoint, {
198
- method: "POST",
199
- headers: { accept: "application/json", "content-type": "application/json" },
200
- body: JSON.stringify(body),
201
- signal: effectiveSignal
202
- });
272
+ const tavily = await call("tavily_search");
273
+ if (tavily.results.length > 0)
274
+ return tavily;
203
275
  } catch (error) {
204
276
  if (signal?.aborted)
205
277
  throw error;
206
- if (effectiveSignal.aborted) {
207
- throw new WebSearchApiError("TIMEOUT", `web search request timed out after ${this.config.timeoutMs}ms`, {
208
- cause: error
209
- });
210
- }
211
- throw new WebSearchApiError("NETWORK_ERROR", "web search request failed", { cause: error });
212
278
  }
213
- const text = await responseText(response, this.config.maxResponseBytes);
214
- if (!response.ok)
215
- throw new WebSearchApiError("HTTP_ERROR", `web search request failed with HTTP ${response.status}`);
216
- return parseResponse(text, query);
279
+ return call("bocha_search");
217
280
  }
218
281
  };
219
282
 
220
- // plugins/web-search/dist-npm/src/tools.js
283
+ // dist-npm/src/tools.js
221
284
  import { defineTool } from "@deepseek-ai/dsh-tools";
222
285
  function createWebSearchTool(search) {
223
286
  return defineTool({
@@ -250,28 +313,10 @@ function createWebSearchTool(search) {
250
313
  });
251
314
  }
252
315
 
253
- // plugins/web-search/dist-npm/src/index.js
316
+ // dist-npm/src/index.js
254
317
  var MBH_WEB_SEARCH_SERVICE_KEY = "mbhWebSearch";
255
318
 
256
- // plugins/web-search/dist-npm/src/config/env-config.js
257
- var ENV_CONFIG = {
258
- production: {
259
- packageName: "@dcrays/web-search",
260
- channelId: "mbh-chat",
261
- legacyChannelId: "mbhchat",
262
- endpointUrl: "https://api-gateway.shuwenda.com/apikey-manage/api/search"
263
- },
264
- test: {
265
- packageName: "@dcrays/web-search-test",
266
- channelId: "mbh-chat-test",
267
- legacyChannelId: "mbhchat-test",
268
- endpointUrl: "https://api-gateway.shuwenda.icu/apikey-manage/api/search"
269
- }
270
- };
271
- var DEFAULT_BUILD_ENV = "production";
272
- var ACTIVE_ENV_CONFIG = ENV_CONFIG[DEFAULT_BUILD_ENV];
273
-
274
- // plugins/web-search/dist-npm/plugin/index.js
319
+ // dist-npm/plugin/index.js
275
320
  var name = "@dcrays/web-search";
276
321
  var inject = ["tools"];
277
322
  var Config = z.object({
@@ -291,12 +336,9 @@ var MbhWebSearchService = class extends Service {
291
336
  this.config = config;
292
337
  if (!config.enabled)
293
338
  return;
294
- const dshHome = process.env.DSH_HOME?.trim() || resolve(homedir(), ".mobook-harness");
295
339
  this.operations = new WebSearchApiClient({
296
- endpointUrl: config.endpointUrl.trim() || ACTIVE_ENV_CONFIG.endpointUrl,
297
- metadataPath: config.metadataPath.trim() || resolve(dshHome, "mobook.json"),
298
- channelId: ACTIVE_ENV_CONFIG.channelId,
299
- legacyChannelId: ACTIVE_ENV_CONFIG.legacyChannelId,
340
+ metadataPath: resolveMetadataPath(config.metadataPath),
341
+ ...config.endpointUrl.trim() ? { endpointOverride: config.endpointUrl.trim() } : {},
300
342
  timeoutMs: config.timeoutMs,
301
343
  maxResponseBytes: config.maxResponseBytes,
302
344
  defaultCount: config.defaultCount,
@@ -307,26 +349,30 @@ var MbhWebSearchService = class extends Service {
307
349
  return this.config.enabled;
308
350
  }
309
351
  search(request, signal) {
310
- if (!this.operations)
311
- return Promise.reject(new Error("mbh-web-search is disabled"));
352
+ if (!this.operations) {
353
+ return Promise.reject(new Error("\u8054\u7F51\u641C\u7D22\u529F\u80FD\u5F53\u524D\u672A\u542F\u7528\uFF0C\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458\u68C0\u67E5\u63D2\u4EF6\u914D\u7F6E\u3002"));
354
+ }
312
355
  return this.operations.search(request, signal);
313
356
  }
314
357
  };
358
+ function resolveMetadataPath(configuredPath) {
359
+ return configuredPath.trim() || resolve(resolveDshHome(), "mobook.json");
360
+ }
315
361
  function apply(ctx, config) {
316
362
  const service = new MbhWebSearchService(ctx, config);
317
363
  ctx.effect(() => {
318
364
  if (!service.isEnabled())
319
365
  return () => void 0;
320
366
  const unregister = ctx.tools.register(createWebSearchTool(service));
321
- ctx.logger(name).info("%s plugin loaded with web_search tool for %s", name, DEFAULT_BUILD_ENV);
367
+ ctx.logger(name).info("%s plugin loaded with web_search tool", name);
322
368
  return unregister;
323
369
  }, `${name} lifecycle`);
324
370
  }
325
371
  export {
326
372
  Config,
327
- DEFAULT_BUILD_ENV,
328
373
  MbhWebSearchService,
329
374
  apply,
330
375
  inject,
331
- name
376
+ name,
377
+ resolveMetadataPath
332
378
  };
@@ -1,3 +1,4 @@
1
+ import { type EnvConfig, type WebSearchEnvironment } from './config/env-config.js';
1
2
  export interface WebSearchRequest {
2
3
  query: string;
3
4
  count?: number;
@@ -15,17 +16,18 @@ export interface WebSearchItem {
15
16
  }
16
17
  export interface WebSearchResult {
17
18
  query: string;
19
+ provider: SearchService;
18
20
  totalEstimatedMatches?: number;
19
21
  results: WebSearchItem[];
20
22
  }
23
+ export type SearchService = 'tavily_search' | 'bocha_search';
21
24
  export interface WebSearchOperations {
22
25
  search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>;
23
26
  }
24
27
  export interface WebSearchApiClientConfig {
25
- endpointUrl: string;
26
28
  metadataPath: string;
27
- channelId: string;
28
- legacyChannelId: string;
29
+ endpointOverride?: string;
30
+ environments?: Record<WebSearchEnvironment, EnvConfig>;
29
31
  timeoutMs: number;
30
32
  maxResponseBytes: number;
31
33
  defaultCount: number;
@@ -51,7 +53,7 @@ interface SearchCredentials {
51
53
  export declare function resolveSearchCredentials(metadata: unknown, channelId: string, legacyChannelId: string): SearchCredentials;
52
54
  export declare class WebSearchApiClient implements WebSearchOperations {
53
55
  private readonly config;
54
- private readonly endpoint;
56
+ private readonly environments;
55
57
  private readonly fetchImpl;
56
58
  private readonly readFileImpl;
57
59
  private readonly nowSeconds;
package/src/api-client.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createHmac, randomUUID } from 'node:crypto';
2
2
  import { readFile } from 'node:fs/promises';
3
+ import { ENV_CONFIG, resolveWebSearchEnvironment } from './config/env-config.js';
3
4
  export class WebSearchApiError extends Error {
4
5
  code;
5
6
  constructor(code, message, options) {
@@ -8,6 +9,10 @@ export class WebSearchApiError extends Error {
8
9
  this.name = 'WebSearchApiError';
9
10
  }
10
11
  }
12
+ const RESPONSE_TOO_LARGE_MESSAGE = '搜索服务返回的内容过大,暂时无法处理。请缩小搜索范围后重试。';
13
+ function searchServiceLabel(service) {
14
+ return service === 'tavily_search' ? 'Tavily 搜索' : '博查搜索';
15
+ }
11
16
  function record(value) {
12
17
  return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
13
18
  }
@@ -26,7 +31,7 @@ export function resolveSearchCredentials(metadata, channelId, legacyChannelId) {
26
31
  if (userId && botToken && deviceId)
27
32
  return { userId, botToken, deviceId };
28
33
  }
29
- throw new WebSearchApiError('MISSING_CREDENTIALS', `mobook metadata must contain plugins.${channelId}.userId, botToken, and hardware.fingerprint`);
34
+ throw new WebSearchApiError('MISSING_CREDENTIALS', '搜索服务尚未准备好,请确认已登录墨宝并完成客户端初始化后重试。');
30
35
  }
31
36
  function requestSignal(signal, timeoutMs) {
32
37
  const timeout = AbortSignal.timeout(timeoutMs);
@@ -36,7 +41,7 @@ async function responseText(response, maxBytes) {
36
41
  const declared = Number(response.headers.get('content-length'));
37
42
  if (Number.isFinite(declared) && declared > maxBytes) {
38
43
  await response.body?.cancel().catch(() => undefined);
39
- throw new WebSearchApiError('RESPONSE_TOO_LARGE', `web search response exceeds ${maxBytes} bytes`);
44
+ throw new WebSearchApiError('RESPONSE_TOO_LARGE', RESPONSE_TOO_LARGE_MESSAGE);
40
45
  }
41
46
  if (!response.body)
42
47
  return '';
@@ -52,7 +57,7 @@ async function responseText(response, maxBytes) {
52
57
  size += item.value.byteLength;
53
58
  if (size > maxBytes) {
54
59
  await reader.cancel().catch(() => undefined);
55
- throw new WebSearchApiError('RESPONSE_TOO_LARGE', `web search response exceeds ${maxBytes} bytes`);
60
+ throw new WebSearchApiError('RESPONSE_TOO_LARGE', RESPONSE_TOO_LARGE_MESSAGE);
56
61
  }
57
62
  text += decoder.decode(item.value, { stream: true });
58
63
  }
@@ -62,68 +67,94 @@ async function responseText(response, maxBytes) {
62
67
  reader.releaseLock();
63
68
  }
64
69
  }
65
- function parseResponse(text, fallbackQuery) {
70
+ function searchItem(value, snippetKeys, publishedAtKeys) {
71
+ const item = record(value);
72
+ const url = nonEmptyString(item?.url);
73
+ if (!item || !url)
74
+ return [];
75
+ const result = {
76
+ title: nonEmptyString(item.title) ?? nonEmptyString(item.name) ?? url,
77
+ url,
78
+ snippet: snippetKeys.map((key) => nonEmptyString(item[key])).find(Boolean) ?? ''
79
+ };
80
+ const siteName = nonEmptyString(item.siteName);
81
+ const publishedAt = publishedAtKeys.map((key) => nonEmptyString(item[key])).find(Boolean);
82
+ if (siteName)
83
+ result.siteName = siteName;
84
+ if (publishedAt)
85
+ result.publishedAt = publishedAt;
86
+ return [result];
87
+ }
88
+ function parseResponse(text, fallbackQuery, service) {
66
89
  let payload;
67
90
  try {
68
91
  payload = JSON.parse(text);
69
92
  }
70
93
  catch (error) {
71
- throw new WebSearchApiError('INVALID_RESPONSE', 'web search API returned invalid JSON', { cause: error });
94
+ throw new WebSearchApiError('INVALID_RESPONSE', '搜索服务返回的数据格式异常,请稍后重试。', { cause: error });
72
95
  }
73
96
  const wrapper = record(payload);
74
97
  const upstream = record(wrapper?.data);
75
98
  if (wrapper?.success !== true || (typeof upstream?.code === 'number' && upstream.code !== 200)) {
76
- throw new WebSearchApiError('API_ERROR', 'web search API reported an unsuccessful response');
99
+ throw new WebSearchApiError('API_ERROR', '搜索服务暂时不可用,请稍后重试。');
100
+ }
101
+ if (service === 'tavily_search') {
102
+ const values = Array.isArray(upstream?.results) ? upstream.results : [];
103
+ return {
104
+ query: nonEmptyString(upstream?.query) ?? fallbackQuery,
105
+ provider: service,
106
+ results: values.flatMap((value) => searchItem(value, ['content', 'snippet'], ['published_date', 'publishedAt']))
107
+ };
77
108
  }
78
109
  const searchData = record(upstream?.data);
79
110
  const pages = record(searchData?.webPages);
80
111
  const queryContext = record(searchData?.queryContext);
81
- const values = Array.isArray(pages?.value) ? pages.value : [];
82
- const results = values.flatMap((value) => {
83
- const item = record(value);
84
- const url = nonEmptyString(item?.url);
85
- if (!item || !url)
86
- return [];
87
- const result = {
88
- title: nonEmptyString(item.name) ?? url,
89
- url,
90
- snippet: nonEmptyString(item.snippet) ?? ''
91
- };
92
- const siteName = nonEmptyString(item.siteName);
93
- const publishedAt = nonEmptyString(item.datePublished);
94
- if (siteName)
95
- result.siteName = siteName;
96
- if (publishedAt)
97
- result.publishedAt = publishedAt;
98
- return [result];
99
- });
100
112
  const result = {
101
113
  query: nonEmptyString(queryContext?.originalQuery) ?? fallbackQuery,
102
- results
114
+ provider: service,
115
+ results: (Array.isArray(pages?.value) ? pages.value : []).flatMap((value) => searchItem(value, ['snippet'], ['datePublished']))
103
116
  };
104
117
  if (typeof pages?.totalEstimatedMatches === 'number' && Number.isFinite(pages.totalEstimatedMatches)) {
105
118
  result.totalEstimatedMatches = pages.totalEstimatedMatches;
106
119
  }
107
120
  return result;
108
121
  }
122
+ function toEndpoint(endpointUrl) {
123
+ let endpoint;
124
+ try {
125
+ endpoint = new URL(endpointUrl);
126
+ }
127
+ catch (error) {
128
+ throw new WebSearchApiError('INVALID_CONFIG', '搜索服务地址配置不正确,请联系管理员检查插件配置。', { cause: error });
129
+ }
130
+ if (!['http:', 'https:'].includes(endpoint.protocol)) {
131
+ throw new WebSearchApiError('INVALID_CONFIG', '搜索服务地址仅支持 HTTP 或 HTTPS,请联系管理员检查插件配置。');
132
+ }
133
+ return endpoint;
134
+ }
109
135
  export class WebSearchApiClient {
110
136
  config;
111
- endpoint;
137
+ environments;
112
138
  fetchImpl;
113
139
  readFileImpl;
114
140
  nowSeconds;
115
141
  nonce;
116
142
  constructor(config, dependencies = {}) {
117
143
  this.config = config;
118
- try {
119
- this.endpoint = new URL(config.endpointUrl);
120
- }
121
- catch (error) {
122
- throw new WebSearchApiError('INVALID_CONFIG', 'web search endpointUrl is invalid', { cause: error });
123
- }
124
- if (!['http:', 'https:'].includes(this.endpoint.protocol)) {
125
- throw new WebSearchApiError('INVALID_CONFIG', 'web search endpointUrl must use http or https');
126
- }
144
+ const environments = config.environments ?? ENV_CONFIG;
145
+ const override = config.endpointOverride?.trim();
146
+ this.environments = {
147
+ production: {
148
+ endpoint: toEndpoint(override || environments.production.endpointUrl),
149
+ channelId: environments.production.channelId,
150
+ legacyChannelId: environments.production.legacyChannelId
151
+ },
152
+ test: {
153
+ endpoint: toEndpoint(override || environments.test.endpointUrl),
154
+ channelId: environments.test.channelId,
155
+ legacyChannelId: environments.test.legacyChannelId
156
+ }
157
+ };
127
158
  for (const [name, value] of [
128
159
  ['timeoutMs', config.timeoutMs],
129
160
  ['maxResponseBytes', config.maxResponseBytes],
@@ -131,11 +162,11 @@ export class WebSearchApiClient {
131
162
  ['maxCount', config.maxCount]
132
163
  ]) {
133
164
  if (!Number.isSafeInteger(value) || value <= 0) {
134
- throw new WebSearchApiError('INVALID_CONFIG', `${name} must be a positive integer`);
165
+ throw new WebSearchApiError('INVALID_CONFIG', `搜索插件配置项 ${name} 必须是大于 0 的整数,请联系管理员检查配置。`);
135
166
  }
136
167
  }
137
168
  if (config.defaultCount > config.maxCount) {
138
- throw new WebSearchApiError('INVALID_CONFIG', 'defaultCount must not exceed maxCount');
169
+ throw new WebSearchApiError('INVALID_CONFIG', '默认搜索结果数不能大于最大结果数,请联系管理员检查插件配置。');
139
170
  }
140
171
  this.fetchImpl = dependencies.fetch ?? fetch;
141
172
  this.readFileImpl = dependencies.readFile ?? readFile;
@@ -145,67 +176,82 @@ export class WebSearchApiClient {
145
176
  async search(request, signal) {
146
177
  const query = request.query.trim();
147
178
  if (!query)
148
- throw new WebSearchApiError('INVALID_REQUEST', 'web search query must not be empty');
179
+ throw new WebSearchApiError('INVALID_REQUEST', '请输入要搜索的内容后重试。');
149
180
  const count = request.count ?? this.config.defaultCount;
150
181
  const offset = request.offset ?? 0;
151
182
  if (!Number.isSafeInteger(count) || count < 1 || count > this.config.maxCount) {
152
- throw new WebSearchApiError('INVALID_REQUEST', `web search count must be between 1 and ${this.config.maxCount}`);
183
+ throw new WebSearchApiError('INVALID_REQUEST', `搜索结果数量需设置为 1 ${this.config.maxCount} 之间的整数。`);
153
184
  }
154
185
  if (!Number.isSafeInteger(offset) || offset < 0) {
155
- throw new WebSearchApiError('INVALID_REQUEST', 'web search offset must be a non-negative integer');
186
+ throw new WebSearchApiError('INVALID_REQUEST', '搜索结果偏移量必须是大于或等于 0 的整数。');
156
187
  }
157
188
  let metadata;
158
189
  try {
159
190
  metadata = JSON.parse(await this.readFileImpl(this.config.metadataPath, 'utf8'));
160
191
  }
161
192
  catch (error) {
162
- throw new WebSearchApiError('MISSING_CREDENTIALS', 'unable to read Mobook runtime metadata', { cause: error });
193
+ throw new WebSearchApiError('MISSING_CREDENTIALS', '暂时无法读取墨宝运行配置,请确认客户端已正常初始化后重试。', { cause: error });
163
194
  }
164
- const credentials = resolveSearchCredentials(metadata, this.config.channelId, this.config.legacyChannelId);
165
- const timestamp = String(this.nowSeconds());
166
- const nonce = this.nonce();
167
- const message = `${credentials.userId}\n${credentials.deviceId}\n${timestamp}\n${nonce}`;
168
- const signature = createHmac('sha256', credentials.botToken).update(message, 'utf8').digest('hex');
169
- const body = {
170
- user_id: credentials.userId,
171
- bot_token: credentials.botToken,
172
- device_id: credentials.deviceId,
173
- signature,
174
- timestamp,
175
- nonce,
176
- service: 'bocha_search',
177
- query,
178
- count,
179
- offset,
180
- language: request.language?.trim() || 'zh-CN'
195
+ const environment = resolveWebSearchEnvironment(record(metadata)?.environment);
196
+ const resolved = this.environments[environment];
197
+ const credentials = resolveSearchCredentials(metadata, resolved.channelId, resolved.legacyChannelId);
198
+ const call = async (service) => {
199
+ const timestamp = String(this.nowSeconds());
200
+ const nonce = this.nonce();
201
+ const message = `${credentials.userId}\n${credentials.deviceId}\n${timestamp}\n${nonce}`;
202
+ const signature = createHmac('sha256', credentials.botToken).update(message, 'utf8').digest('hex');
203
+ const body = {
204
+ user_id: credentials.userId,
205
+ bot_token: credentials.botToken,
206
+ device_id: credentials.deviceId,
207
+ signature,
208
+ timestamp,
209
+ nonce,
210
+ service,
211
+ query,
212
+ count,
213
+ offset,
214
+ language: request.language?.trim() || 'zh-CN'
215
+ };
216
+ if (request.includeDomains?.length)
217
+ body.include_domains = request.includeDomains;
218
+ if (request.excludeDomains?.length)
219
+ body.exclude_domains = request.excludeDomains;
220
+ const effectiveSignal = requestSignal(signal, this.config.timeoutMs);
221
+ let response;
222
+ try {
223
+ response = await this.fetchImpl(resolved.endpoint, {
224
+ method: 'POST',
225
+ headers: { accept: 'application/json', 'content-type': 'application/json' },
226
+ body: JSON.stringify(body),
227
+ signal: effectiveSignal
228
+ });
229
+ }
230
+ catch (error) {
231
+ if (signal?.aborted)
232
+ throw error;
233
+ if (effectiveSignal.aborted) {
234
+ throw new WebSearchApiError('TIMEOUT', `${searchServiceLabel(service)}响应超时,请稍后重试。`, {
235
+ cause: error
236
+ });
237
+ }
238
+ throw new WebSearchApiError('NETWORK_ERROR', `暂时无法连接${searchServiceLabel(service)},请检查网络后重试。`, { cause: error });
239
+ }
240
+ const text = await responseText(response, this.config.maxResponseBytes);
241
+ if (!response.ok) {
242
+ throw new WebSearchApiError('HTTP_ERROR', `${searchServiceLabel(service)}暂时不可用(服务状态 ${response.status}),请稍后重试。`);
243
+ }
244
+ return parseResponse(text, query, service);
181
245
  };
182
- if (request.includeDomains?.length)
183
- body.include_domains = request.includeDomains;
184
- if (request.excludeDomains?.length)
185
- body.exclude_domains = request.excludeDomains;
186
- const effectiveSignal = requestSignal(signal, this.config.timeoutMs);
187
- let response;
188
246
  try {
189
- response = await this.fetchImpl(this.endpoint, {
190
- method: 'POST',
191
- headers: { accept: 'application/json', 'content-type': 'application/json' },
192
- body: JSON.stringify(body),
193
- signal: effectiveSignal
194
- });
247
+ const tavily = await call('tavily_search');
248
+ if (tavily.results.length > 0)
249
+ return tavily;
195
250
  }
196
251
  catch (error) {
197
252
  if (signal?.aborted)
198
253
  throw error;
199
- if (effectiveSignal.aborted) {
200
- throw new WebSearchApiError('TIMEOUT', `web search request timed out after ${this.config.timeoutMs}ms`, {
201
- cause: error
202
- });
203
- }
204
- throw new WebSearchApiError('NETWORK_ERROR', 'web search request failed', { cause: error });
205
254
  }
206
- const text = await responseText(response, this.config.maxResponseBytes);
207
- if (!response.ok)
208
- throw new WebSearchApiError('HTTP_ERROR', `web search request failed with HTTP ${response.status}`);
209
- return parseResponse(text, query);
255
+ return call('bocha_search');
210
256
  }
211
257
  }
@@ -1,10 +1,15 @@
1
- export type BuildEnv = 'production' | 'test';
1
+ export type WebSearchEnvironment = 'production' | 'test';
2
2
  export interface EnvConfig {
3
- packageName: string;
4
3
  channelId: string;
5
4
  legacyChannelId: string;
6
5
  endpointUrl: string;
7
6
  }
8
- export declare const ENV_CONFIG: Record<BuildEnv, EnvConfig>;
9
- export declare const DEFAULT_BUILD_ENV: BuildEnv;
10
- export declare const ACTIVE_ENV_CONFIG: EnvConfig;
7
+ export declare const PACKAGE_NAME = "@dcrays/web-search";
8
+ export declare const ENV_CONFIG: Record<WebSearchEnvironment, EnvConfig>;
9
+ /**
10
+ * Map the desktop runtime environment recorded in mobook.json to the search
11
+ * environment. Development desktops share the test gateway and MBHChat channel.
12
+ * Missing/unknown values fall back to test so a misconfigured desktop never
13
+ * silently reaches the production gateway with test credentials.
14
+ */
15
+ export declare function resolveWebSearchEnvironment(value: unknown): WebSearchEnvironment;
@@ -1,16 +1,22 @@
1
+ export const PACKAGE_NAME = '@dcrays/web-search';
1
2
  export const ENV_CONFIG = {
2
3
  production: {
3
- packageName: '@dcrays/web-search',
4
4
  channelId: 'mbh-chat',
5
5
  legacyChannelId: 'mbhchat',
6
6
  endpointUrl: 'https://api-gateway.shuwenda.com/apikey-manage/api/search'
7
7
  },
8
8
  test: {
9
- packageName: '@dcrays/web-search-test',
10
9
  channelId: 'mbh-chat-test',
11
10
  legacyChannelId: 'mbhchat-test',
12
11
  endpointUrl: 'https://api-gateway.shuwenda.icu/apikey-manage/api/search'
13
12
  }
14
13
  };
15
- export const DEFAULT_BUILD_ENV = 'production';
16
- export const ACTIVE_ENV_CONFIG = ENV_CONFIG[DEFAULT_BUILD_ENV];
14
+ /**
15
+ * Map the desktop runtime environment recorded in mobook.json to the search
16
+ * environment. Development desktops share the test gateway and MBHChat channel.
17
+ * Missing/unknown values fall back to test so a misconfigured desktop never
18
+ * silently reaches the production gateway with test credentials.
19
+ */
20
+ export function resolveWebSearchEnvironment(value) {
21
+ return value === 'production' ? 'production' : 'test';
22
+ }