@dcrays/web-search 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/README.md ADDED
@@ -0,0 +1,5 @@
1
+ # @dcrays/web-search
2
+
3
+ 书灵墨宝的 DSH 联网搜索插件。加载后注册 `web_search` 工具,把模型搜索请求映射到配置的 HTTP API。
4
+
5
+ 本包对应 `mbh-chat` 环境,端点地址从 Cordis 插件配置读取。
@@ -0,0 +1,5 @@
1
+ - insert:
2
+ - id: mbh-web-search
3
+ name: '@dcrays/web-search'
4
+ config:
5
+ enabled: true
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@dcrays/web-search",
3
+ "version": "0.1.0",
4
+ "description": "DeepSeek Harness web search plugin for Mobook (production)",
5
+ "type": "module",
6
+ "main": "plugin/index.js",
7
+ "types": "plugin/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./plugin/index.d.ts",
11
+ "default": "./plugin/index.js"
12
+ },
13
+ "./cordis.patch.yml": "./cordis.patch.yml",
14
+ "./package.json": "./package.json"
15
+ },
16
+ "files": [
17
+ "plugin/**",
18
+ "src/**",
19
+ "cordis.patch.yml",
20
+ "README.md",
21
+ "!**/*.map"
22
+ ],
23
+ "dsh": {
24
+ "bundle": {
25
+ "patch": "./cordis.patch.yml"
26
+ }
27
+ },
28
+ "engines": {
29
+ "node": ">=24.0.0"
30
+ },
31
+ "dependencies": {
32
+ "@deepseek-ai/schemastery": "^3.18.1"
33
+ },
34
+ "peerDependencies": {
35
+ "@deepseek-ai/cordis": "^4.0.1",
36
+ "@deepseek-ai/dsh-tools": "^0.1.1-rc.2"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public"
40
+ }
41
+ }
@@ -0,0 +1,30 @@
1
+ import type { Context } from '@deepseek-ai/cordis';
2
+ import { Service } from '@deepseek-ai/cordis';
3
+ import z from '@deepseek-ai/schemastery';
4
+ import { type WebSearchRequest, type WebSearchResult } from '../src/index.js';
5
+ export { DEFAULT_BUILD_ENV } from '../src/config/env-config.js';
6
+ export declare const name = "mbh-web-search";
7
+ export declare const inject: string[];
8
+ export interface Config {
9
+ enabled: boolean;
10
+ endpointUrl: string;
11
+ metadataPath: string;
12
+ timeoutMs: number;
13
+ maxResponseBytes: number;
14
+ defaultCount: number;
15
+ maxCount: number;
16
+ }
17
+ export declare const Config: z<Config>;
18
+ declare module '@deepseek-ai/cordis' {
19
+ interface Context {
20
+ mbhWebSearch: MbhWebSearchService;
21
+ }
22
+ }
23
+ export declare class MbhWebSearchService extends Service {
24
+ private readonly config;
25
+ private readonly operations;
26
+ constructor(ctx: Context, config: Config);
27
+ isEnabled(): boolean;
28
+ search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>;
29
+ }
30
+ export declare function apply(ctx: Context, config: Config): void;
@@ -0,0 +1,332 @@
1
+ import { createRequire as __mobookCreateRequire } from 'node:module'; const require = __mobookCreateRequire(import.meta.url);
2
+
3
+ // plugins/web-search/dist-npm/plugin/index.js
4
+ import { homedir } from "node:os";
5
+ import { resolve } from "node:path";
6
+ import { Service } from "@deepseek-ai/cordis";
7
+ import z from "@deepseek-ai/schemastery";
8
+
9
+ // plugins/web-search/dist-npm/src/api-client.js
10
+ import { createHmac, randomUUID } from "node:crypto";
11
+ import { readFile } from "node:fs/promises";
12
+ var WebSearchApiError = class extends Error {
13
+ code;
14
+ constructor(code, message, options) {
15
+ super(message, options);
16
+ this.code = code;
17
+ this.name = "WebSearchApiError";
18
+ }
19
+ };
20
+ function record(value) {
21
+ return value && typeof value === "object" && !Array.isArray(value) ? value : void 0;
22
+ }
23
+ function nonEmptyString(value) {
24
+ return typeof value === "string" && value.trim() ? value.trim() : void 0;
25
+ }
26
+ function resolveSearchCredentials(metadata, channelId, legacyChannelId) {
27
+ const root = record(metadata);
28
+ const plugins = record(root?.plugins);
29
+ const hardware = record(root?.hardware);
30
+ const deviceId = nonEmptyString(hardware?.fingerprint);
31
+ for (const plugin of [record(plugins?.[channelId]), record(plugins?.[legacyChannelId])]) {
32
+ const userIdValue = plugin?.userId;
33
+ const userId = typeof userIdValue === "number" && Number.isSafeInteger(userIdValue) ? String(userIdValue) : nonEmptyString(userIdValue);
34
+ const botToken = nonEmptyString(plugin?.botToken);
35
+ if (userId && botToken && deviceId)
36
+ return { userId, botToken, deviceId };
37
+ }
38
+ throw new WebSearchApiError("MISSING_CREDENTIALS", `mobook metadata must contain plugins.${channelId}.userId, botToken, and hardware.fingerprint`);
39
+ }
40
+ function requestSignal(signal, timeoutMs) {
41
+ const timeout = AbortSignal.timeout(timeoutMs);
42
+ return signal ? AbortSignal.any([signal, timeout]) : timeout;
43
+ }
44
+ async function responseText(response, maxBytes) {
45
+ const declared = Number(response.headers.get("content-length"));
46
+ if (Number.isFinite(declared) && declared > maxBytes) {
47
+ await response.body?.cancel().catch(() => void 0);
48
+ throw new WebSearchApiError("RESPONSE_TOO_LARGE", `web search response exceeds ${maxBytes} bytes`);
49
+ }
50
+ if (!response.body)
51
+ return "";
52
+ const reader = response.body.getReader();
53
+ const decoder = new TextDecoder();
54
+ let size = 0;
55
+ let text = "";
56
+ try {
57
+ for (; ; ) {
58
+ const item = await reader.read();
59
+ if (item.done)
60
+ break;
61
+ size += item.value.byteLength;
62
+ if (size > maxBytes) {
63
+ await reader.cancel().catch(() => void 0);
64
+ throw new WebSearchApiError("RESPONSE_TOO_LARGE", `web search response exceeds ${maxBytes} bytes`);
65
+ }
66
+ text += decoder.decode(item.value, { stream: true });
67
+ }
68
+ return text + decoder.decode();
69
+ } finally {
70
+ reader.releaseLock();
71
+ }
72
+ }
73
+ function parseResponse(text, fallbackQuery) {
74
+ let payload;
75
+ try {
76
+ payload = JSON.parse(text);
77
+ } catch (error) {
78
+ throw new WebSearchApiError("INVALID_RESPONSE", "web search API returned invalid JSON", { cause: error });
79
+ }
80
+ const wrapper = record(payload);
81
+ const upstream = record(wrapper?.data);
82
+ if (wrapper?.success !== true || typeof upstream?.code === "number" && upstream.code !== 200) {
83
+ throw new WebSearchApiError("API_ERROR", "web search API reported an unsuccessful response");
84
+ }
85
+ const searchData = record(upstream?.data);
86
+ const pages = record(searchData?.webPages);
87
+ 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
+ const result = {
108
+ query: nonEmptyString(queryContext?.originalQuery) ?? fallbackQuery,
109
+ results
110
+ };
111
+ if (typeof pages?.totalEstimatedMatches === "number" && Number.isFinite(pages.totalEstimatedMatches)) {
112
+ result.totalEstimatedMatches = pages.totalEstimatedMatches;
113
+ }
114
+ return result;
115
+ }
116
+ var WebSearchApiClient = class {
117
+ config;
118
+ endpoint;
119
+ fetchImpl;
120
+ readFileImpl;
121
+ nowSeconds;
122
+ nonce;
123
+ constructor(config, dependencies = {}) {
124
+ 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
+ }
133
+ for (const [name2, value] of [
134
+ ["timeoutMs", config.timeoutMs],
135
+ ["maxResponseBytes", config.maxResponseBytes],
136
+ ["defaultCount", config.defaultCount],
137
+ ["maxCount", config.maxCount]
138
+ ]) {
139
+ if (!Number.isSafeInteger(value) || value <= 0) {
140
+ throw new WebSearchApiError("INVALID_CONFIG", `${name2} must be a positive integer`);
141
+ }
142
+ }
143
+ if (config.defaultCount > config.maxCount) {
144
+ throw new WebSearchApiError("INVALID_CONFIG", "defaultCount must not exceed maxCount");
145
+ }
146
+ this.fetchImpl = dependencies.fetch ?? fetch;
147
+ this.readFileImpl = dependencies.readFile ?? readFile;
148
+ this.nowSeconds = dependencies.nowSeconds ?? (() => Math.floor(Date.now() / 1e3));
149
+ this.nonce = dependencies.nonce ?? (() => randomUUID().replaceAll("-", ""));
150
+ }
151
+ async search(request, signal) {
152
+ const query = request.query.trim();
153
+ if (!query)
154
+ throw new WebSearchApiError("INVALID_REQUEST", "web search query must not be empty");
155
+ const count = request.count ?? this.config.defaultCount;
156
+ const offset = request.offset ?? 0;
157
+ 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}`);
159
+ }
160
+ if (!Number.isSafeInteger(offset) || offset < 0) {
161
+ throw new WebSearchApiError("INVALID_REQUEST", "web search offset must be a non-negative integer");
162
+ }
163
+ let metadata;
164
+ try {
165
+ metadata = JSON.parse(await this.readFileImpl(this.config.metadataPath, "utf8"));
166
+ } catch (error) {
167
+ throw new WebSearchApiError("MISSING_CREDENTIALS", "unable to read Mobook runtime metadata", { cause: error });
168
+ }
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}
173
+ ${credentials.deviceId}
174
+ ${timestamp}
175
+ ${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"
189
+ };
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
+ 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
+ });
203
+ } catch (error) {
204
+ if (signal?.aborted)
205
+ 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
+ }
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);
217
+ }
218
+ };
219
+
220
+ // plugins/web-search/dist-npm/src/tools.js
221
+ import { defineTool } from "@deepseek-ai/dsh-tools";
222
+ function createWebSearchTool(search) {
223
+ return defineTool({
224
+ name: "web_search",
225
+ description: "Search the public web with the configured Mobook Bocha search service. Use this for current information, source discovery, fact verification, news, weather, prices, schedules, or any question that requires internet access. Cite result URLs when answering.",
226
+ parameters: {
227
+ query: { type: "string", required: true, description: "A focused web search query." },
228
+ count: { type: "integer", description: "Number of results to return. Defaults to 5; maximum 20." },
229
+ offset: { type: "integer", description: "Zero-based result offset for pagination." },
230
+ language: { type: "string", description: "Search language, for example zh-CN or en-US." },
231
+ includeDomains: {
232
+ type: "array",
233
+ items: { type: "string" },
234
+ description: 'Optional domains to prefer or restrict, for example ["openai.com"].'
235
+ },
236
+ excludeDomains: {
237
+ type: "array",
238
+ items: { type: "string" },
239
+ description: "Optional domains to exclude from results."
240
+ }
241
+ },
242
+ output: {
243
+ schema: { type: "json" },
244
+ render: (_args, value) => [{ type: "text", text: JSON.stringify(value) }]
245
+ },
246
+ timeoutMs: 35e3,
247
+ async execute(args, exec) {
248
+ return JSON.parse(JSON.stringify(await search.search(args, exec.signal)));
249
+ }
250
+ });
251
+ }
252
+
253
+ // plugins/web-search/dist-npm/src/index.js
254
+ var MBH_WEB_SEARCH_SERVICE_KEY = "mbhWebSearch";
255
+
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
275
+ var name = "@dcrays/web-search";
276
+ var inject = ["tools"];
277
+ var Config = z.object({
278
+ enabled: z.boolean().default(true),
279
+ endpointUrl: z.string().default(""),
280
+ metadataPath: z.string().default(""),
281
+ timeoutMs: z.natural().min(1).default(3e4),
282
+ maxResponseBytes: z.natural().min(1).default(1024 * 1024),
283
+ defaultCount: z.natural().min(1).default(5),
284
+ maxCount: z.natural().min(1).default(20)
285
+ });
286
+ var MbhWebSearchService = class extends Service {
287
+ config;
288
+ operations;
289
+ constructor(ctx, config) {
290
+ super(ctx, MBH_WEB_SEARCH_SERVICE_KEY);
291
+ this.config = config;
292
+ if (!config.enabled)
293
+ return;
294
+ const dshHome = process.env.DSH_HOME?.trim() || resolve(homedir(), ".mobook-harness");
295
+ 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,
300
+ timeoutMs: config.timeoutMs,
301
+ maxResponseBytes: config.maxResponseBytes,
302
+ defaultCount: config.defaultCount,
303
+ maxCount: config.maxCount
304
+ });
305
+ }
306
+ isEnabled() {
307
+ return this.config.enabled;
308
+ }
309
+ search(request, signal) {
310
+ if (!this.operations)
311
+ return Promise.reject(new Error("mbh-web-search is disabled"));
312
+ return this.operations.search(request, signal);
313
+ }
314
+ };
315
+ function apply(ctx, config) {
316
+ const service = new MbhWebSearchService(ctx, config);
317
+ ctx.effect(() => {
318
+ if (!service.isEnabled())
319
+ return () => void 0;
320
+ 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);
322
+ return unregister;
323
+ }, `${name} lifecycle`);
324
+ }
325
+ export {
326
+ Config,
327
+ DEFAULT_BUILD_ENV,
328
+ MbhWebSearchService,
329
+ apply,
330
+ inject,
331
+ name
332
+ };
@@ -0,0 +1,62 @@
1
+ export interface WebSearchRequest {
2
+ query: string;
3
+ count?: number;
4
+ offset?: number;
5
+ language?: string;
6
+ includeDomains?: string[];
7
+ excludeDomains?: string[];
8
+ }
9
+ export interface WebSearchItem {
10
+ title: string;
11
+ url: string;
12
+ snippet: string;
13
+ siteName?: string;
14
+ publishedAt?: string;
15
+ }
16
+ export interface WebSearchResult {
17
+ query: string;
18
+ totalEstimatedMatches?: number;
19
+ results: WebSearchItem[];
20
+ }
21
+ export interface WebSearchOperations {
22
+ search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>;
23
+ }
24
+ export interface WebSearchApiClientConfig {
25
+ endpointUrl: string;
26
+ metadataPath: string;
27
+ channelId: string;
28
+ legacyChannelId: string;
29
+ timeoutMs: number;
30
+ maxResponseBytes: number;
31
+ defaultCount: number;
32
+ maxCount: number;
33
+ }
34
+ export declare class WebSearchApiError extends Error {
35
+ readonly code: 'INVALID_CONFIG' | 'INVALID_REQUEST' | 'MISSING_CREDENTIALS' | 'HTTP_ERROR' | 'API_ERROR' | 'INVALID_RESPONSE' | 'RESPONSE_TOO_LARGE' | 'NETWORK_ERROR' | 'TIMEOUT';
36
+ constructor(code: 'INVALID_CONFIG' | 'INVALID_REQUEST' | 'MISSING_CREDENTIALS' | 'HTTP_ERROR' | 'API_ERROR' | 'INVALID_RESPONSE' | 'RESPONSE_TOO_LARGE' | 'NETWORK_ERROR' | 'TIMEOUT', message: string, options?: ErrorOptions);
37
+ }
38
+ type Fetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
39
+ type ReadFile = (path: string, encoding: BufferEncoding) => Promise<string>;
40
+ interface Dependencies {
41
+ fetch?: Fetch;
42
+ readFile?: ReadFile;
43
+ nowSeconds?: () => number;
44
+ nonce?: () => string;
45
+ }
46
+ interface SearchCredentials {
47
+ userId: string;
48
+ botToken: string;
49
+ deviceId: string;
50
+ }
51
+ export declare function resolveSearchCredentials(metadata: unknown, channelId: string, legacyChannelId: string): SearchCredentials;
52
+ export declare class WebSearchApiClient implements WebSearchOperations {
53
+ private readonly config;
54
+ private readonly endpoint;
55
+ private readonly fetchImpl;
56
+ private readonly readFileImpl;
57
+ private readonly nowSeconds;
58
+ private readonly nonce;
59
+ constructor(config: WebSearchApiClientConfig, dependencies?: Dependencies);
60
+ search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>;
61
+ }
62
+ export {};
@@ -0,0 +1,211 @@
1
+ import { createHmac, randomUUID } from 'node:crypto';
2
+ import { readFile } from 'node:fs/promises';
3
+ export class WebSearchApiError extends Error {
4
+ code;
5
+ constructor(code, message, options) {
6
+ super(message, options);
7
+ this.code = code;
8
+ this.name = 'WebSearchApiError';
9
+ }
10
+ }
11
+ function record(value) {
12
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
13
+ }
14
+ function nonEmptyString(value) {
15
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
16
+ }
17
+ export function resolveSearchCredentials(metadata, channelId, legacyChannelId) {
18
+ const root = record(metadata);
19
+ const plugins = record(root?.plugins);
20
+ const hardware = record(root?.hardware);
21
+ const deviceId = nonEmptyString(hardware?.fingerprint);
22
+ for (const plugin of [record(plugins?.[channelId]), record(plugins?.[legacyChannelId])]) {
23
+ const userIdValue = plugin?.userId;
24
+ const userId = typeof userIdValue === 'number' && Number.isSafeInteger(userIdValue) ? String(userIdValue) : nonEmptyString(userIdValue);
25
+ const botToken = nonEmptyString(plugin?.botToken);
26
+ if (userId && botToken && deviceId)
27
+ return { userId, botToken, deviceId };
28
+ }
29
+ throw new WebSearchApiError('MISSING_CREDENTIALS', `mobook metadata must contain plugins.${channelId}.userId, botToken, and hardware.fingerprint`);
30
+ }
31
+ function requestSignal(signal, timeoutMs) {
32
+ const timeout = AbortSignal.timeout(timeoutMs);
33
+ return signal ? AbortSignal.any([signal, timeout]) : timeout;
34
+ }
35
+ async function responseText(response, maxBytes) {
36
+ const declared = Number(response.headers.get('content-length'));
37
+ if (Number.isFinite(declared) && declared > maxBytes) {
38
+ await response.body?.cancel().catch(() => undefined);
39
+ throw new WebSearchApiError('RESPONSE_TOO_LARGE', `web search response exceeds ${maxBytes} bytes`);
40
+ }
41
+ if (!response.body)
42
+ return '';
43
+ const reader = response.body.getReader();
44
+ const decoder = new TextDecoder();
45
+ let size = 0;
46
+ let text = '';
47
+ try {
48
+ for (;;) {
49
+ const item = await reader.read();
50
+ if (item.done)
51
+ break;
52
+ size += item.value.byteLength;
53
+ if (size > maxBytes) {
54
+ await reader.cancel().catch(() => undefined);
55
+ throw new WebSearchApiError('RESPONSE_TOO_LARGE', `web search response exceeds ${maxBytes} bytes`);
56
+ }
57
+ text += decoder.decode(item.value, { stream: true });
58
+ }
59
+ return text + decoder.decode();
60
+ }
61
+ finally {
62
+ reader.releaseLock();
63
+ }
64
+ }
65
+ function parseResponse(text, fallbackQuery) {
66
+ let payload;
67
+ try {
68
+ payload = JSON.parse(text);
69
+ }
70
+ catch (error) {
71
+ throw new WebSearchApiError('INVALID_RESPONSE', 'web search API returned invalid JSON', { cause: error });
72
+ }
73
+ const wrapper = record(payload);
74
+ const upstream = record(wrapper?.data);
75
+ if (wrapper?.success !== true || (typeof upstream?.code === 'number' && upstream.code !== 200)) {
76
+ throw new WebSearchApiError('API_ERROR', 'web search API reported an unsuccessful response');
77
+ }
78
+ const searchData = record(upstream?.data);
79
+ const pages = record(searchData?.webPages);
80
+ 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
+ const result = {
101
+ query: nonEmptyString(queryContext?.originalQuery) ?? fallbackQuery,
102
+ results
103
+ };
104
+ if (typeof pages?.totalEstimatedMatches === 'number' && Number.isFinite(pages.totalEstimatedMatches)) {
105
+ result.totalEstimatedMatches = pages.totalEstimatedMatches;
106
+ }
107
+ return result;
108
+ }
109
+ export class WebSearchApiClient {
110
+ config;
111
+ endpoint;
112
+ fetchImpl;
113
+ readFileImpl;
114
+ nowSeconds;
115
+ nonce;
116
+ constructor(config, dependencies = {}) {
117
+ 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
+ }
127
+ for (const [name, value] of [
128
+ ['timeoutMs', config.timeoutMs],
129
+ ['maxResponseBytes', config.maxResponseBytes],
130
+ ['defaultCount', config.defaultCount],
131
+ ['maxCount', config.maxCount]
132
+ ]) {
133
+ if (!Number.isSafeInteger(value) || value <= 0) {
134
+ throw new WebSearchApiError('INVALID_CONFIG', `${name} must be a positive integer`);
135
+ }
136
+ }
137
+ if (config.defaultCount > config.maxCount) {
138
+ throw new WebSearchApiError('INVALID_CONFIG', 'defaultCount must not exceed maxCount');
139
+ }
140
+ this.fetchImpl = dependencies.fetch ?? fetch;
141
+ this.readFileImpl = dependencies.readFile ?? readFile;
142
+ this.nowSeconds = dependencies.nowSeconds ?? (() => Math.floor(Date.now() / 1000));
143
+ this.nonce = dependencies.nonce ?? (() => randomUUID().replaceAll('-', ''));
144
+ }
145
+ async search(request, signal) {
146
+ const query = request.query.trim();
147
+ if (!query)
148
+ throw new WebSearchApiError('INVALID_REQUEST', 'web search query must not be empty');
149
+ const count = request.count ?? this.config.defaultCount;
150
+ const offset = request.offset ?? 0;
151
+ 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}`);
153
+ }
154
+ if (!Number.isSafeInteger(offset) || offset < 0) {
155
+ throw new WebSearchApiError('INVALID_REQUEST', 'web search offset must be a non-negative integer');
156
+ }
157
+ let metadata;
158
+ try {
159
+ metadata = JSON.parse(await this.readFileImpl(this.config.metadataPath, 'utf8'));
160
+ }
161
+ catch (error) {
162
+ throw new WebSearchApiError('MISSING_CREDENTIALS', 'unable to read Mobook runtime metadata', { cause: error });
163
+ }
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'
181
+ };
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
+ 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
+ });
195
+ }
196
+ catch (error) {
197
+ if (signal?.aborted)
198
+ 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
+ }
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);
210
+ }
211
+ }
@@ -0,0 +1,10 @@
1
+ export type BuildEnv = 'production' | 'test';
2
+ export interface EnvConfig {
3
+ packageName: string;
4
+ channelId: string;
5
+ legacyChannelId: string;
6
+ endpointUrl: string;
7
+ }
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;
@@ -0,0 +1,16 @@
1
+ export const ENV_CONFIG = {
2
+ production: {
3
+ packageName: '@dcrays/web-search',
4
+ channelId: 'mbh-chat',
5
+ legacyChannelId: 'mbhchat',
6
+ endpointUrl: 'https://api-gateway.shuwenda.com/apikey-manage/api/search'
7
+ },
8
+ test: {
9
+ packageName: '@dcrays/web-search-test',
10
+ channelId: 'mbh-chat-test',
11
+ legacyChannelId: 'mbhchat-test',
12
+ endpointUrl: 'https://api-gateway.shuwenda.icu/apikey-manage/api/search'
13
+ }
14
+ };
15
+ export const DEFAULT_BUILD_ENV = 'production';
16
+ export const ACTIVE_ENV_CONFIG = ENV_CONFIG[DEFAULT_BUILD_ENV];
package/src/index.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ export declare const MBH_WEB_SEARCH_PLUGIN_NAME = "mbh-web-search";
2
+ export declare const MBH_WEB_SEARCH_SERVICE_KEY = "mbhWebSearch";
3
+ export * from './api-client.js';
4
+ export * from './tools.js';
package/src/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export const MBH_WEB_SEARCH_PLUGIN_NAME = 'mbh-web-search';
2
+ export const MBH_WEB_SEARCH_SERVICE_KEY = 'mbhWebSearch';
3
+ export * from './api-client.js';
4
+ export * from './tools.js';
package/src/tools.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ import { type ToolDefinition } from '@deepseek-ai/dsh-tools';
2
+ import type { WebSearchOperations } from './api-client.js';
3
+ export declare function createWebSearchTool(search: WebSearchOperations): ToolDefinition;
package/src/tools.js ADDED
@@ -0,0 +1,31 @@
1
+ import { defineTool } from '@deepseek-ai/dsh-tools';
2
+ export function createWebSearchTool(search) {
3
+ return defineTool({
4
+ name: 'web_search',
5
+ description: 'Search the public web with the configured Mobook Bocha search service. Use this for current information, source discovery, fact verification, news, weather, prices, schedules, or any question that requires internet access. Cite result URLs when answering.',
6
+ parameters: {
7
+ query: { type: 'string', required: true, description: 'A focused web search query.' },
8
+ count: { type: 'integer', description: 'Number of results to return. Defaults to 5; maximum 20.' },
9
+ offset: { type: 'integer', description: 'Zero-based result offset for pagination.' },
10
+ language: { type: 'string', description: 'Search language, for example zh-CN or en-US.' },
11
+ includeDomains: {
12
+ type: 'array',
13
+ items: { type: 'string' },
14
+ description: 'Optional domains to prefer or restrict, for example ["openai.com"].'
15
+ },
16
+ excludeDomains: {
17
+ type: 'array',
18
+ items: { type: 'string' },
19
+ description: 'Optional domains to exclude from results.'
20
+ }
21
+ },
22
+ output: {
23
+ schema: { type: 'json' },
24
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }]
25
+ },
26
+ timeoutMs: 35_000,
27
+ async execute(args, exec) {
28
+ return JSON.parse(JSON.stringify(await search.search(args, exec.signal)));
29
+ }
30
+ });
31
+ }