dsh-web-search-plugin 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  本项目的重要变更都记录在此。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循 [Semantic Versioning](https://semver.org/lang/zh-CN/)。
4
4
 
5
+ ## [0.2.0] - 2026-08-19
6
+
7
+ ### 新增
8
+
9
+ - **DeepSeek(官方)后端**(`lib/deepseek.js`)。设置项 `provider: deepseek-official` 走 DeepSeek 的 Anthropic 兼容 Messages API(原生 `web_search_20250305` 工具,凭据名 `DEEPSEEK_API_KEY`)。bundle 层顺带禁用内置 `web-search-deepseek` Host 插件,从而移除旧的「插件 → 网页搜索」卡片、避免重复注册 provider。
10
+ - **顶层「设置 → 网页搜索」分区**。配置入口从「设置 → 插件 → 插件配置」里的 keyed 卡片改为 `settings.section`(id `web-search`)——与「插件」分区同级,一张卡内切换 DeepSeek / Tavily / Brave,并各自展开对应配置项(模型、API 版本、max tokens / max uses 等)。
11
+
12
+ ### 变更
13
+
14
+ - `provider` 取值扩展为 `deepseek-official` / `tavily`(默认)/ `brave`;接缝 id 仍为 `dsh-web-search`。
15
+ - 新增 `lib/deepseek.js`;`package.json` 版本号 bump 到 `0.2.0`,`check` 脚本纳入新文件。
16
+
5
17
  ## [0.1.2] - 2026-08-18
6
18
 
7
19
  ### 新增
@@ -44,6 +56,7 @@
44
56
  - Keyless 模式(`X-Tavily-Access-Mode: keyless`)与 keyed 模式(通过 `TAVILY_API_KEY` 凭据引用发送 Bearer token)。
45
57
  - DSH 插件配置页上的浏览器设置卡片。
46
58
 
59
+ [0.2.0]: https://github.com/X-C1811/dsh-web-search-plugin/compare/v0.1.2...v0.2.0
47
60
  [0.1.2]: https://github.com/X-C1811/dsh-web-search-plugin/compare/v0.1.1...v0.1.2
48
61
  [0.1.1]: https://github.com/X-C1811/dsh-web-search-plugin/compare/v0.1.0...v0.1.1
49
62
  [0.1.0]: https://github.com/X-C1811/dsh-web-search-plugin/releases/tag/v0.1.0
package/README.md CHANGED
@@ -1,10 +1,11 @@
1
1
  # dsh-web-search-plugin
2
2
 
3
- 面向 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) web 能力接缝(`ctx.web`)的 Tavily / [Brave Search](https://brave.com/search/api/) 搜索插件。本包只向接缝注册 **一个** `WebSearchProvider`,稳定 id 为 `dsh-web-search`。在设置卡里切换引擎即可,不必再改 `web.searchProvider`。
3
+ 面向 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) web 能力接缝(`ctx.web`)的统一网页搜索插件,内置 **DeepSeek(官方)/ Tavily / [Brave Search](https://brave.com/search/api/) 三个后端**。本包只向接缝注册 **一个** `WebSearchProvider`,稳定 id 为 `dsh-web-search`。在设置卡里切换引擎即可,不必再改 `web.searchProvider`。
4
4
 
5
+ - **DeepSeek(官方)** — 走 DeepSeek 的 Anthropic 兼容 Messages API(原生 `web_search_20250305` 工具,凭据名 `DEEPSEEK_API_KEY`),一次搜索消耗一个模型轮次。
5
6
  - **Tavily(默认)** — `keyless`(免费、限流、无需账号)或 `keyed`(`TAVILY_API_KEY`,Bearer token)。
6
7
  - **Brave Search** — `GET https://api.search.brave.com/res/v1/web/search`,请求头 `X-Subscription-Token`(凭据名 `BRAVE_API_KEY`)。一次搜索就是一次 HTTP 请求,不走模型轮次。
7
- - **不写自定义 session 事件** — 独立 Brave 插件每次调用都会写入 `web/brave-search-request`。该类型不在 DSH 的 `KNOWN_SESSION_EVENT_TYPES` 里,且 `Session.append` 无法加上 `ignorable: true`,冷加载会整段拒绝会话。本插件不会写这类信封。
8
+ - **不写自定义 session 事件** — 工具结果已经走接缝自己的事件,无需多余信封。
8
9
 
9
10
  ## 特性
10
11
 
@@ -21,7 +22,7 @@
21
22
 
22
23
  ## 安装
23
24
 
24
- 本包是 **bundle**:`dsh.bundle.patch` + `cordis.patch.yml` 会插入 Host 行,并把 `web.searchProvider` 设为 `dsh-web-search`。没有这一声明时,`dsh plugin add` 只写入依赖,插件不会挂载。
25
+ 本包是 **bundle**:`dsh.bundle.patch` + `cordis.patch.yml` 会插入 Host 行、把 `web.searchProvider` 设为 `dsh-web-search`,并**禁用内置 `web-search-deepseek` Host 插件**(本插件已自行托管 DeepSeek 后端,禁用它能移除旧的「插件 → 网页搜索」卡片、避免重复注册 provider)。没有这一声明时,`dsh plugin add` 只写入依赖,插件不会挂载。
25
26
 
26
27
  ### 从 npm 安装
27
28
 
@@ -52,7 +53,7 @@ dsh plugin --profile web add "path/to/dsh-web-search-plugin"
52
53
  dsh plugin --profile web remove @dsh-ltctfer/dsh-web-search-brave
53
54
  ```
54
55
 
55
- 然后在「设置 → 插件 → 插件配置 → 网页搜索」里把引擎切到 Brave,即可继续使用已有的 `BRAVE_API_KEY`。
56
+ 然后在 **设置 → 网页搜索** 里把引擎切到想要的提供方,即可继续使用对应的 API key。
56
57
 
57
58
  > **关于 `file:`(本地)安装** — pnpm 会把 `file:path` 依赖做成快照。改完本仓库后,需要再执行一次 `dsh plugin --profile web add "path/to/dsh-web-search-plugin"` 并重启。
58
59
 
@@ -62,15 +63,22 @@ dsh plugin --profile web remove @dsh-ltctfer/dsh-web-search-brave
62
63
 
63
64
  | 键 | 默认值 | 含义 |
64
65
  |---|---|---|
65
- | `provider` | `tavily` | `tavily` 或 `brave` |
66
+ | `provider` | `tavily` | `tavily`、`brave` 或 `deepseek-official` |
66
67
  | `mode` | `keyless` | 仅 Tavily:`keyless` 或 `keyed` |
67
68
  | `apiKey` | — | Tavily API key 字面量(走凭据域,不会回显) |
68
69
  | `apiKeyEnv` | `TAVILY_API_KEY` | keyed Tavily 使用的凭据/环境变量名 |
69
70
  | `baseURL` | `https://api.tavily.com` | Tavily REST 基址;会再拼 `/search` |
70
- | `maxResults` | `8` | 每次搜索返回的源数量(1–20),两个引擎共用 |
71
+ | `maxResults` | `8` | 每次搜索返回的源数量(1–20),三个后端共用 |
71
72
  | `searchDepth` | `basic` | 仅 Tavily:`basic` 或 `advanced` |
72
73
  | `includeAnswer` | `true` | 仅 Tavily:请求生成摘要,写入结果 `content` |
73
74
  | `topic` | `general` | 仅 Tavily:`general` 或 `news` |
75
+ | `deepseekApiKey` | — | DeepSeek API key 字面量(走凭据域) |
76
+ | `deepseekApiKeyEnv` | `DEEPSEEK_API_KEY` | DeepSeek 使用的凭据/环境变量名 |
77
+ | `deepseekBaseURL` | `https://api.deepseek.com/anthropic/v1` | DeepSeek Anthropic 兼容 Messages 基址;再拼 `/messages` |
78
+ | `model` | `deepseek-v4-flash` | Anthropic 格式模型名 |
79
+ | `apiVersion` | `2023-06-01` | `anthropic-version` 请求头 |
80
+ | `maxTokens` | `4096` | Messages 请求生成 token 上限 |
81
+ | `maxUses` | `5` | 每次请求 `web_search` 工具的最大调用次数 |
74
82
  | `braveApiKey` | — | Brave 订阅 token 字面量(走凭据域) |
75
83
  | `braveApiKeyEnv` | `BRAVE_API_KEY` | Brave 使用的凭据/环境变量名 |
76
84
  | `braveBaseURL` | `https://api.search.brave.com/res/v1/web/search` | Brave 网页搜索接口 |
@@ -81,12 +89,13 @@ dsh plugin --profile web remove @dsh-ltctfer/dsh-web-search-brave
81
89
 
82
90
  环境变量:启动时 `DSH_WEB_SEARCH_PROVIDER=dsh-web-search` 会选中本接缝 id。未设置 `baseURL` 时,Tavily 基址回退 `$TAVILY_BASE_URL`。
83
91
 
84
- ## 切回内置搜索
92
+ ## 切回内置 DeepSeek
85
93
 
86
- 在 profile 的 patch 里把 `searchProvider` 改回 `deepseek-official`。本插件可以继续挂着,只是不会被选中。
94
+ DeepSeek 已并入本插件(`provider: deepseek-official`),无需切回。若确要恢复 DSH 内置的 DeepSeek host 插件,请在 profile patch 里去掉对 `web-search-deepseek` 的 `disabled` 并把 `searchProvider` 设回 `deepseek-official`;本插件可以继续挂着,只是不会被选中。
87
95
 
88
96
  ## 工作方式
89
97
 
98
+ - **DeepSeek** — `POST {deepseekBaseURL}/messages`,请求头 `x-api-key` / `authorization: Bearer`,工具 `web_search_20250305`。
90
99
  - **Tavily** — `POST {baseURL}/search`。keyless 发送 `x-tavily-access-mode: keyless`;keyed 发送 `authorization: Bearer <key>`。
91
100
  - **Brave** — `GET {braveBaseURL}?q=&count=`,请求头 `x-subscription-token`。不向 session 追加自定义事件。
92
101
  - 非 2xx 映射为 `WEB_PROVIDER_ERROR`;调用方取消映射为 `WEB_ABORTED`。
@@ -95,11 +104,12 @@ dsh plugin --profile web remove @dsh-ltctfer/dsh-web-search-brave
95
104
 
96
105
  ```
97
106
  lib/index.js Host 插件:Config、分发提供方、设置段
107
+ lib/deepseek.js DeepSeek 后端(Anthropic Messages + web_search_20250305)
98
108
  lib/tavily.js Tavily 后端
99
109
  lib/brave.js Brave 后端(不写自定义 session 事件)
100
110
  lib/shared.js 中止 / 凭据解析辅助
101
- lib/client.js 浏览器 bundle:引擎切换 + 配置卡片
102
- cordis.patch.yml Bundle patch:插入 Host 行,并把 searchProvider 设为 dsh-web-search
111
+ lib/client.js 浏览器 bundle:顶层「网页搜索」分区 + 引擎切换表单
112
+ cordis.patch.yml Bundle patch:插入 Host 行、设 searchProvider、禁用内置 deepseek
103
113
  ```
104
114
 
105
115
  ## 开发
@@ -108,19 +118,19 @@ cordis.patch.yml Bundle patch:插入 Host 行,并把 searchProvider 设为
108
118
  npm run check
109
119
  ```
110
120
 
111
- 客户端 bundle 必须保持 `window.__ModuleLoader__.load({ id, factory })` 线格式 — 由 `dsh-client-modules` 加载,不是打包器。必须同时导出 `apply` 和 `inject`(`slots`、`locale`、`connection`、`remote`、`settingsScope`),并以带 `key` 的方式把卡片注册进 keyed 的 `settings.plugin.item` slot。
121
+ 客户端 bundle 必须保持 `window.__ModuleLoader__.load({ id, factory })` 线格式 — 由 `dsh-client-modules` 加载,不是打包器。必须同时导出 `apply` 和 `inject`(`slots`、`locale`、`connection`、`remote`、`settingsScope`),并把分区注册进 `settings.section`(`id: "web-search"`)。
112
122
 
113
123
  ## 发布
114
124
 
115
- 发布由 GitHub Actions(`.github/workflows/publish.yml`)自动完成:**推送 `v*` 标签**(例如 `v0.1.2`)会带 provenance 发到 npm。向 `main` 的普通推送不会发布。
125
+ 发布由 GitHub Actions(`.github/workflows/publish.yml`)自动完成:**推送 `v*` 标签**(例如 `v0.2.0`)会带 provenance 发到 npm。向 `main` 的普通推送不会发布。
116
126
 
117
- 1. 确认 `package.json` 的 `version` 与即将推送的标签一致(例如 `0.1.2` → `v0.1.2`)。
127
+ 1. 确认 `package.json` 的 `version` 与即将推送的标签一致(例如 `0.2.0` → `v0.2.0`)。
118
128
  2. 在 GitHub **Settings → Secrets and variables → Actions** 里配置有 publish 权限的 npm automation token(仓库密钥 `NPM_TOKEN`),并允许 Actions 运行。
119
129
  3. 打标签并推送:
120
130
 
121
131
  ```powershell
122
- git tag v0.1.2
123
- git push origin v0.1.2
132
+ git tag v0.2.0
133
+ git push origin v0.2.0
124
134
  ```
125
135
 
126
136
  工作流会先核对标签与 `package.json` 的 `version`,再执行 `npm publish --provenance --access public`。
package/cordis.patch.yml CHANGED
@@ -5,13 +5,19 @@
5
5
  #
6
6
  # `searchProvider` is restated in full because a Cordis patch replaces the
7
7
  # targeted row's whole `config`. The profile's own cordis.patch.yml still
8
- # wins if it later overrides `web`. The seam id is `dsh-web-search`; Tavily
9
- # vs Brave is the plugin's own `provider` setting, not a second web patch.
8
+ # wins if it later overrides `web`. The seam id is `dsh-web-search`; the
9
+ # backend (DeepSeek / Tavily / Brave) is the plugin's own `provider` setting.
10
10
  - id: web
11
11
  name: '@deepseek-ai/dsh-web'
12
12
  config:
13
13
  searchProvider: dsh-web-search
14
14
 
15
+ # This plugin re-hosts the DeepSeek backend, so the in-box DeepSeek host
16
+ # plugin is redundant: disabling it removes its `deepseek-official` provider
17
+ # (no duplicate) and hides the legacy "Plugins → web search" card.
18
+ - id: web-search-deepseek
19
+ disabled: true
20
+
15
21
  - insert:
16
22
  - id: dsh-web-search-plugin
17
23
  name: dsh-web-search-plugin
package/lib/client.js CHANGED
@@ -1,9 +1,9 @@
1
1
  /**
2
- * Browser half of `dsh-web-search-plugin`: a plugin card under the
3
- * "settings → Plugins → Plugin configuration" surface that edits the
4
- * `dsh-web-search-plugin` settings namespace. The card switches the
5
- * Tavily / Brave backend and writes each engine's credential through the
6
- * credentials domain (`TAVILY_API_KEY` / `BRAVE_API_KEY` by default).
2
+ * Browser half of `dsh-web-search-plugin`: a top-level "Web search" settings
3
+ * section (settings → Web search) that edits the `dsh-web-search-plugin`
4
+ * settings namespace. The card switches the DeepSeek / Tavily / Brave backend
5
+ * and writes each engine's credential through the credentials domain
6
+ * (`DEEPSEEK_API_KEY` / `TAVILY_API_KEY` / `BRAVE_API_KEY` by default).
7
7
  *
8
8
  * This bundle is a self-contained hand-written module in the exact wire format
9
9
  * the client module system expects: one `window.__ModuleLoader__.load` call
@@ -29,11 +29,15 @@ window.__ModuleLoader__.load({
29
29
  const DEFAULT_TAVILY_KEY_REF = "TAVILY_API_KEY";
30
30
  /** Credential reference Brave resolves when the section names none. */
31
31
  const DEFAULT_BRAVE_KEY_REF = "BRAVE_API_KEY";
32
+ /** Credential reference DeepSeek resolves when the section names none. */
33
+ const DEFAULT_DEEPSEEK_KEY_REF = "DEEPSEEK_API_KEY";
32
34
  /** Form field the Tavily write-only credential control stages under. */
33
35
  const TAVILY_API_KEY_FIELD = "apiKey";
34
36
  /** Form field the Brave write-only credential control stages under. */
35
37
  const BRAVE_API_KEY_FIELD = "braveApiKey";
36
- const SECRET_FIELDS = new Set([TAVILY_API_KEY_FIELD, BRAVE_API_KEY_FIELD]);
38
+ /** Form field the DeepSeek write-only credential control stages under. */
39
+ const DEEPSEEK_API_KEY_FIELD = "deepseekApiKey";
40
+ const SECRET_FIELDS = new Set([TAVILY_API_KEY_FIELD, BRAVE_API_KEY_FIELD, DEEPSEEK_API_KEY_FIELD]);
37
41
 
38
42
  /** Locale bundles for the card. */
39
43
  const en = {
@@ -42,6 +46,7 @@ window.__ModuleLoader__.load({
42
46
  provider: "Search engine",
43
47
  providerTavily: "Tavily",
44
48
  providerBrave: "Brave Search",
49
+ providerDeepseek: "DeepSeek (official)",
45
50
  mode: "Auth mode",
46
51
  modeKeyless: "Keyless (free, rate-limited)",
47
52
  modeKeyed: "API key",
@@ -79,6 +84,18 @@ window.__ModuleLoader__.load({
79
84
  freshnessYear: "Past year",
80
85
  proxy: "Proxy",
81
86
  proxyHint: "HTTP(S) proxy URL. Leave blank to use HTTPS_PROXY / HTTP_PROXY.",
87
+ deepseekApiKeyEnv: "Credential reference",
88
+ deepseekApiKeyEnvHint: "Name of the credential/env var resolved for DeepSeek search.",
89
+ deepseekBaseURL: "Endpoint",
90
+ deepseekBaseURLHint: "Leave blank to use the DeepSeek Anthropic-compatible API default.",
91
+ model: "Model",
92
+ modelHint: "Anthropic-format model name for the search request.",
93
+ apiVersion: "API version",
94
+ apiVersionHint: "anthropic-version header value.",
95
+ maxTokens: "Max tokens",
96
+ maxTokensHint: "Upper bound on generated tokens for the search request.",
97
+ maxUses: "Max uses",
98
+ maxUsesHint: "Maximum web_search server-tool uses per request.",
82
99
  expand: "Show settings",
83
100
  collapse: "Hide settings",
84
101
  save: "Save",
@@ -96,6 +113,7 @@ window.__ModuleLoader__.load({
96
113
  provider: "搜索引擎",
97
114
  providerTavily: "Tavily",
98
115
  providerBrave: "Brave Search",
116
+ providerDeepseek: "DeepSeek(官方)",
99
117
  mode: "认证模式",
100
118
  modeKeyless: "Keyless(免费、限流)",
101
119
  modeKeyed: "API key",
@@ -133,6 +151,18 @@ window.__ModuleLoader__.load({
133
151
  freshnessYear: "过去一年",
134
152
  proxy: "代理",
135
153
  proxyHint: "HTTP(S) 代理 URL。留空则使用 HTTPS_PROXY / HTTP_PROXY。",
154
+ deepseekApiKeyEnv: "凭据引用名",
155
+ deepseekApiKeyEnvHint: "DeepSeek 搜索解析的凭据/环境变量名。",
156
+ deepseekBaseURL: "接口地址",
157
+ deepseekBaseURLHint: "留空则使用 DeepSeek Anthropic 兼容 API 默认地址。",
158
+ model: "模型",
159
+ modelHint: "搜索请求使用的 Anthropic 格式模型名。",
160
+ apiVersion: "API 版本",
161
+ apiVersionHint: "anthropic-version 请求头值。",
162
+ maxTokens: "最大 token",
163
+ maxTokensHint: "搜索请求生成 token 的上限。",
164
+ maxUses: "最大调用次数",
165
+ maxUsesHint: "每次请求 web_search 服务端工具的最大调用次数。",
136
166
  expand: "展开设置",
137
167
  collapse: "收起设置",
138
168
  save: "保存",
@@ -208,7 +238,13 @@ window.__ModuleLoader__.load({
208
238
  textField("country"),
209
239
  textField("searchLang"),
210
240
  choiceField("freshness"),
211
- textField("proxy")
241
+ textField("proxy"),
242
+ textField("deepseekApiKeyEnv"),
243
+ textField("deepseekBaseURL"),
244
+ textField("model"),
245
+ textField("apiVersion"),
246
+ numberField("maxTokens"),
247
+ numberField("maxUses")
212
248
  ].map((spec) => [spec.field, spec]));
213
249
  this.staged = new Map();
214
250
  this.saving = false;
@@ -351,7 +387,7 @@ window.__ModuleLoader__.load({
351
387
  const staged = this.staged.get("provider");
352
388
  if (staged !== void 0 && !staged.clear && staged.text.trim() !== "") return staged.text.trim();
353
389
  const declared = this.sectionValue("provider");
354
- return declared === "brave" ? "brave" : "tavily";
390
+ return declared === "brave" || declared === "deepseek-official" ? declared : "tavily";
355
391
  }
356
392
  tavilyRef() {
357
393
  const declared = this.sectionValue("apiKeyEnv");
@@ -361,9 +397,14 @@ window.__ModuleLoader__.load({
361
397
  const declared = this.sectionValue("braveApiKeyEnv");
362
398
  return declared !== void 0 && declared.length > 0 ? declared : DEFAULT_BRAVE_KEY_REF;
363
399
  }
400
+ deepseekRef() {
401
+ const declared = this.sectionValue("deepseekApiKeyEnv");
402
+ return declared !== void 0 && declared.length > 0 ? declared : DEFAULT_DEEPSEEK_KEY_REF;
403
+ }
364
404
  /** The credential reference the currently visible engine uses. */
365
405
  refOf() {
366
- return this.providerOf() === "brave" ? this.braveRef() : this.tavilyRef();
406
+ const provider = this.providerOf();
407
+ return provider === "brave" ? this.braveRef() : provider === "deepseek-official" ? this.deepseekRef() : this.tavilyRef();
367
408
  }
368
409
  async readCredential() {
369
410
  const ref = this.refOf();
@@ -393,7 +434,7 @@ window.__ModuleLoader__.load({
393
434
  this.readCredential();
394
435
  }
395
436
  async writeKey(field, value) {
396
- const ref = field === BRAVE_API_KEY_FIELD ? this.braveRef() : this.tavilyRef();
437
+ const ref = field === BRAVE_API_KEY_FIELD ? this.braveRef() : field === DEEPSEEK_API_KEY_FIELD ? this.deepseekRef() : this.tavilyRef();
397
438
  try {
398
439
  await this.api.credentials.set({ ref, value });
399
440
  } catch (_credentialWriteFailure) {}
@@ -432,6 +473,13 @@ window.__ModuleLoader__.load({
432
473
  searchLang: this.form.field("searchLang"),
433
474
  freshness: this.form.field("freshness"),
434
475
  proxy: this.form.field("proxy"),
476
+ deepseekApiKey: this.form.field(DEEPSEEK_API_KEY_FIELD),
477
+ deepseekApiKeyEnv: this.form.field("deepseekApiKeyEnv"),
478
+ deepseekBaseURL: this.form.field("deepseekBaseURL"),
479
+ model: this.form.field("model"),
480
+ apiVersion: this.form.field("apiVersion"),
481
+ maxTokens: this.form.field("maxTokens"),
482
+ maxUses: this.form.field("maxUses"),
435
483
  apiKeyConfigured: this.form.credential.configured,
436
484
  apiKeyWritable: this.form.credential.writable
437
485
  };
@@ -454,63 +502,84 @@ window.__ModuleLoader__.load({
454
502
  const selectInput = (props) => react.createElement("select", { style: { border: "1px solid var(--dsw-alias-border-l2)", background: "var(--dsw-alias-bg-layer-3)", height: "34px", font: "inherit", color: "var(--dsw-alias-label-primary)", borderRadius: "8px", padding: "0 12px", fontSize: "13px", lineHeight: "1.5" }, ...props });
455
503
  const checkboxInput = (props) => react.createElement("input", { type: "checkbox", style: { width: "16px", height: "16px", accentColor: "var(--dsw-alias-brand-primary)" }, ...props });
456
504
 
457
- /** Render one plugin card: engine switch plus the engine-specific form. */
458
- function SearchCard(props) {
459
- const { t } = props;
460
- const state = props.usePluginSearchCard((snapshot) => snapshot);
461
- const [open, setOpen] = react.useState(false);
505
+ /** Render the provider selector plus the backend-specific staged form. */
506
+ function WebSearchCard(props) {
507
+ const { t, state, actions } = props;
462
508
  if (!state.available) return null;
463
509
  const disabled = !state.writable;
464
510
  const blocked = !state.dirty || state.invalid || state.saving;
465
- const brave = state.provider.text === "brave";
511
+ const provider = state.provider.text === "brave" ? "brave" : state.provider.text === "deepseek-official" ? "deepseek-official" : "tavily";
512
+ const brave = provider === "brave";
513
+ const deepseek = provider === "deepseek-official";
466
514
  const keyed = state.mode.text === "keyed";
467
- return react.createElement("li", { style: { border: "1px solid var(--dsw-alias-border-l2)", background: "var(--dsw-alias-bg-layer-3)", borderRadius: "12px", listStyle: "none" } },
468
- react.createElement("button", { type: "button", "aria-expanded": open, onClick: () => setOpen(!open), style: { appearance: "none", width: "100%", font: "inherit", color: "inherit", textAlign: "left", cursor: "pointer", background: "0 0", border: "0", borderRadius: "12px", alignItems: "center", gap: "12px", padding: "14px 16px", display: "flex" } },
469
- react.createElement("span", { style: { flexDirection: "column", flex: 1, gap: "4px", minWidth: 0, display: "flex" } },
470
- react.createElement("span", { style: { color: "var(--dsw-alias-label-primary)", fontSize: "15px", fontWeight: 600, lineHeight: "1.4" } }, t("nav")),
471
- react.createElement("span", { style: { color: "var(--dsw-alias-label-tertiary)", fontSize: "13px", lineHeight: "1.5" } }, t("description"))),
472
- state.dirty ? react.createElement("span", { style: { whiteSpace: "nowrap", background: "var(--dsw-alias-bg-module-platform)", color: "var(--dsw-alias-label-secondary)", borderRadius: "999px", flex: "none", padding: "1px 8px", fontSize: "11px", fontWeight: 500, lineHeight: "17px" } }, t("unsaved")) : null,
473
- react.createElement("span", { style: { color: "var(--dsw-alias-label-tertiary)", flex: "none" } }, open ? "▾" : "▸")),
474
- open ? react.createElement("div", { style: { borderTop: "1px solid var(--dsw-alias-border-l2)", margin: "0 16px", paddingBottom: "8px" } },
475
- !state.writable ? react.createElement("p", { style: { color: "var(--dsw-alias-label-tertiary)", margin: "12px 0 0", fontSize: "12px", lineHeight: "1.5" } }, t("readOnly")) : null,
476
- fieldRow(t("provider"), null, selectInput({ id: "plugin-config-web-search-provider", value: brave ? "brave" : "tavily", disabled, onChange: (e) => props.edit("provider", e.target.value), children: [
477
- react.createElement("option", { value: "tavily", key: "tavily" }, t("providerTavily")),
478
- react.createElement("option", { value: "brave", key: "brave" }, t("providerBrave"))
479
- ] })),
480
- brave ? null : fieldRow(t("mode"), null, selectInput({ id: "plugin-config-web-search-mode", value: state.mode.text, disabled, onChange: (e) => props.edit("mode", e.target.value), children: [
481
- react.createElement("option", { value: "keyless", key: "keyless" }, t("modeKeyless")),
482
- react.createElement("option", { value: "keyed", key: "keyed" }, t("modeKeyed"))
483
- ] })),
484
- (!brave && keyed) || brave ? fieldRow(t("apiKey"), t("apiKeyHint"), secretInput({ id: "plugin-config-web-search-key", value: brave ? state.braveApiKey.text : state.apiKey.text, disabled: !state.apiKeyWritable, placeholder: state.apiKeyConfigured ? t("apiKeySet") : t("apiKeyUnset"), onChange: (e) => props.edit(brave ? BRAVE_API_KEY_FIELD : TAVILY_API_KEY_FIELD, e.target.value) })) : null,
485
- (!brave && keyed) || brave ? fieldRow(brave ? t("braveApiKeyEnv") : t("apiKeyEnv"), brave ? t("braveApiKeyEnvHint") : t("apiKeyEnvHint"), textInput({ id: "plugin-config-web-search-keyref", value: brave ? state.braveApiKeyEnv.text : state.apiKeyEnv.text, disabled, onChange: (e) => props.edit(brave ? "braveApiKeyEnv" : "apiKeyEnv", e.target.value), onReset: () => props.resetField(brave ? "braveApiKeyEnv" : "apiKeyEnv") })) : null,
486
- brave ? fieldRow(t("braveBaseURL"), t("braveBaseURLHint"), textInput({ id: "plugin-config-web-search-brave-base", value: state.braveBaseURL.text, disabled, onChange: (e) => props.edit("braveBaseURL", e.target.value), onReset: () => props.resetField("braveBaseURL") })) : fieldRow(t("baseURL"), t("baseURLHint"), textInput({ id: "plugin-config-web-search-base", value: state.baseURL.text, disabled, onChange: (e) => props.edit("baseURL", e.target.value), onReset: () => props.resetField("baseURL") })),
487
- brave ? null : fieldRow(t("searchDepth"), null, selectInput({ id: "plugin-config-web-search-depth", value: state.searchDepth.text, disabled, onChange: (e) => props.edit("searchDepth", e.target.value), children: [
488
- react.createElement("option", { value: "basic", key: "basic" }, t("searchDepthBasic")),
489
- react.createElement("option", { value: "advanced", key: "advanced" }, t("searchDepthAdvanced"))
490
- ] })),
491
- brave ? null : fieldRow(t("topic"), null, selectInput({ id: "plugin-config-web-search-topic", value: state.topic.text, disabled, onChange: (e) => props.edit("topic", e.target.value), children: [
492
- react.createElement("option", { value: "general", key: "general" }, t("topicGeneral")),
493
- react.createElement("option", { value: "news", key: "news" }, t("topicNews"))
494
- ] })),
495
- brave ? null : fieldRow(t("includeAnswer"), t("includeAnswerHint"), checkboxInput({ id: "plugin-config-web-search-answer", checked: state.includeAnswer.text === "true", disabled, onChange: (e) => props.edit("includeAnswer", e.target.checked ? "true" : "") })),
496
- brave ? fieldRow(t("country"), t("countryHint"), textInput({ id: "plugin-config-web-search-country", value: state.country.text, disabled, onChange: (e) => props.edit("country", e.target.value), onReset: () => props.resetField("country") })) : null,
497
- brave ? fieldRow(t("searchLang"), t("searchLangHint"), textInput({ id: "plugin-config-web-search-lang", value: state.searchLang.text, disabled, onChange: (e) => props.edit("searchLang", e.target.value), onReset: () => props.resetField("searchLang") })) : null,
498
- brave ? fieldRow(t("freshness"), null, selectInput({ id: "plugin-config-web-search-freshness", value: state.freshness.text, disabled, onChange: (e) => props.edit("freshness", e.target.value), children: [
499
- react.createElement("option", { value: "", key: "any" }, t("freshnessAny")),
500
- react.createElement("option", { value: "pd", key: "pd" }, t("freshnessDay")),
501
- react.createElement("option", { value: "pw", key: "pw" }, t("freshnessWeek")),
502
- react.createElement("option", { value: "pm", key: "pm" }, t("freshnessMonth")),
503
- react.createElement("option", { value: "py", key: "py" }, t("freshnessYear"))
504
- ] })) : null,
505
- brave ? fieldRow(t("proxy"), t("proxyHint"), textInput({ id: "plugin-config-web-search-proxy", value: state.proxy.text, disabled, onChange: (e) => props.edit("proxy", e.target.value), onReset: () => props.resetField("proxy") })) : null,
506
- fieldRow(t("maxResults"), t("maxResultsHint"), textInput({ id: "plugin-config-web-search-max", numeric: true, value: state.maxResults.text, disabled, onChange: (e) => props.edit("maxResults", e.target.value), onReset: () => props.resetField("maxResults") })),
507
- react.createElement("div", { style: { borderTop: "1px solid var(--dsw-alias-border-l2)", justifyContent: "flex-end", alignItems: "center", gap: "8px", padding: "12px 0 4px", display: "flex" } },
508
- state.failed ? react.createElement("p", { role: "status", style: { minWidth: 0, color: "var(--dsw-alias-label-error)", flex: 1, margin: 0, fontSize: "12px", lineHeight: "1.5" } }, t("saveFailed")) : null,
509
- react.createElement("button", { type: "button", disabled: !state.dirty || state.saving, onClick: props.discard, style: { appearance: "none", font: "inherit", cursor: "pointer", border: "1px solid var(--dsw-alias-border-l2)", borderRadius: "8px", padding: "5px 14px", fontSize: "13px", lineHeight: "1.5", color: "var(--dsw-alias-label-secondary)", background: "0 0" } }, t("discard")),
510
- react.createElement("button", { type: "button", disabled: blocked, onClick: props.save, style: { appearance: "none", font: "inherit", cursor: "pointer", border: "1px solid transparent", borderRadius: "8px", padding: "5px 14px", fontSize: "13px", lineHeight: "1.5", color: "var(--dsw-alias-label-on-brand)", background: "var(--dsw-alias-bg-brand-solid)" } }, t(state.saving ? "saving" : "save")))) : null);
515
+ return react.createElement("div", { style: { display: "flex", flexDirection: "column", gap: "4px" } },
516
+ !state.writable ? react.createElement("p", { style: { color: "var(--dsw-alias-label-tertiary)", margin: "0", fontSize: "12px", lineHeight: "1.5" } }, t("readOnly")) : null,
517
+ fieldRow(t("provider"), null, selectInput({ id: "ws-provider", value: provider, disabled, onChange: (e) => actions.edit("provider", e.target.value), children: [
518
+ react.createElement("option", { value: "tavily", key: "tavily" }, t("providerTavily")),
519
+ react.createElement("option", { value: "brave", key: "brave" }, t("providerBrave")),
520
+ react.createElement("option", { value: "deepseek-official", key: "deepseek-official" }, t("providerDeepseek"))
521
+ ] })),
522
+ deepseek ? null : brave ? null : fieldRow(t("mode"), null, selectInput({ id: "ws-mode", value: state.mode.text, disabled, onChange: (e) => actions.edit("mode", e.target.value), children: [
523
+ react.createElement("option", { value: "keyless", key: "keyless" }, t("modeKeyless")),
524
+ react.createElement("option", { value: "keyed", key: "keyed" }, t("modeKeyed"))
525
+ ] })),
526
+ // API key + credential reference for the visible backend.
527
+ deepseek ? fieldRow(t("apiKey"), t("apiKeyHint"), secretInput({ id: "ws-deepseek-key", value: state.deepseekApiKey.text, disabled: !state.apiKeyWritable, placeholder: state.apiKeyConfigured ? t("apiKeySet") : t("apiKeyUnset"), onChange: (e) => actions.edit(DEEPSEEK_API_KEY_FIELD, e.target.value) })) : null,
528
+ deepseek ? fieldRow(t("deepseekApiKeyEnv"), t("deepseekApiKeyEnvHint"), textInput({ id: "ws-deepseek-keyref", value: state.deepseekApiKeyEnv.text, disabled, onChange: (e) => actions.edit("deepseekApiKeyEnv", e.target.value), onReset: () => actions.resetField("deepseekApiKeyEnv") })) : null,
529
+ brave ? fieldRow(t("apiKey"), t("apiKeyHint"), secretInput({ id: "ws-brave-key", value: state.braveApiKey.text, disabled: !state.apiKeyWritable, placeholder: state.apiKeyConfigured ? t("apiKeySet") : t("apiKeyUnset"), onChange: (e) => actions.edit(BRAVE_API_KEY_FIELD, e.target.value) })) : null,
530
+ brave ? fieldRow(t("braveApiKeyEnv"), t("braveApiKeyEnvHint"), textInput({ id: "ws-brave-keyref", value: state.braveApiKeyEnv.text, disabled, onChange: (e) => actions.edit("braveApiKeyEnv", e.target.value), onReset: () => actions.resetField("braveApiKeyEnv") })) : null,
531
+ (!brave && !deepseek && keyed) ? fieldRow(t("apiKey"), t("apiKeyHint"), secretInput({ id: "ws-tavily-key", value: state.apiKey.text, disabled: !state.apiKeyWritable, placeholder: state.apiKeyConfigured ? t("apiKeySet") : t("apiKeyUnset"), onChange: (e) => actions.edit(TAVILY_API_KEY_FIELD, e.target.value) })) : null,
532
+ (!brave && !deepseek && keyed) ? fieldRow(t("apiKeyEnv"), t("apiKeyEnvHint"), textInput({ id: "ws-tavily-keyref", value: state.apiKeyEnv.text, disabled, onChange: (e) => actions.edit("apiKeyEnv", e.target.value), onReset: () => actions.resetField("apiKeyEnv") })) : null,
533
+ // Endpoint.
534
+ deepseek ? fieldRow(t("deepseekBaseURL"), t("deepseekBaseURLHint"), textInput({ id: "ws-deepseek-base", value: state.deepseekBaseURL.text, disabled, onChange: (e) => actions.edit("deepseekBaseURL", e.target.value), onReset: () => actions.resetField("deepseekBaseURL") })) : null,
535
+ brave ? fieldRow(t("braveBaseURL"), t("braveBaseURLHint"), textInput({ id: "ws-brave-base", value: state.braveBaseURL.text, disabled, onChange: (e) => actions.edit("braveBaseURL", e.target.value), onReset: () => actions.resetField("braveBaseURL") })) : null,
536
+ (!brave && !deepseek) ? fieldRow(t("baseURL"), t("baseURLHint"), textInput({ id: "ws-tavily-base", value: state.baseURL.text, disabled, onChange: (e) => actions.edit("baseURL", e.target.value), onReset: () => actions.resetField("baseURL") })) : null,
537
+ // DeepSeek options.
538
+ deepseek ? fieldRow(t("model"), t("modelHint"), textInput({ id: "ws-model", value: state.model.text, disabled, onChange: (e) => actions.edit("model", e.target.value), onReset: () => actions.resetField("model") })) : null,
539
+ deepseek ? fieldRow(t("apiVersion"), t("apiVersionHint"), textInput({ id: "ws-apiversion", value: state.apiVersion.text, disabled, onChange: (e) => actions.edit("apiVersion", e.target.value), onReset: () => actions.resetField("apiVersion") })) : null,
540
+ deepseek ? fieldRow(t("maxTokens"), t("maxTokensHint"), textInput({ id: "ws-maxtokens", numeric: true, value: state.maxTokens.text, disabled, onChange: (e) => actions.edit("maxTokens", e.target.value), onReset: () => actions.resetField("maxTokens") })) : null,
541
+ deepseek ? fieldRow(t("maxUses"), t("maxUsesHint"), textInput({ id: "ws-maxuses", numeric: true, value: state.maxUses.text, disabled, onChange: (e) => actions.edit("maxUses", e.target.value), onReset: () => actions.resetField("maxUses") })) : null,
542
+ // Tavily options.
543
+ (!brave && !deepseek) ? fieldRow(t("searchDepth"), null, selectInput({ id: "ws-depth", value: state.searchDepth.text, disabled, onChange: (e) => actions.edit("searchDepth", e.target.value), children: [
544
+ react.createElement("option", { value: "basic", key: "basic" }, t("searchDepthBasic")),
545
+ react.createElement("option", { value: "advanced", key: "advanced" }, t("searchDepthAdvanced"))
546
+ ] })) : null,
547
+ (!brave && !deepseek) ? fieldRow(t("topic"), null, selectInput({ id: "ws-topic", value: state.topic.text, disabled, onChange: (e) => actions.edit("topic", e.target.value), children: [
548
+ react.createElement("option", { value: "general", key: "general" }, t("topicGeneral")),
549
+ react.createElement("option", { value: "news", key: "news" }, t("topicNews"))
550
+ ] })) : null,
551
+ (!brave && !deepseek) ? fieldRow(t("includeAnswer"), t("includeAnswerHint"), checkboxInput({ id: "ws-answer", checked: state.includeAnswer.text === "true", disabled, onChange: (e) => actions.edit("includeAnswer", e.target.checked ? "true" : "") })) : null,
552
+ // Brave options.
553
+ brave ? fieldRow(t("country"), t("countryHint"), textInput({ id: "ws-country", value: state.country.text, disabled, onChange: (e) => actions.edit("country", e.target.value), onReset: () => actions.resetField("country") })) : null,
554
+ brave ? fieldRow(t("searchLang"), t("searchLangHint"), textInput({ id: "ws-lang", value: state.searchLang.text, disabled, onChange: (e) => actions.edit("searchLang", e.target.value), onReset: () => actions.resetField("searchLang") })) : null,
555
+ brave ? fieldRow(t("freshness"), null, selectInput({ id: "ws-freshness", value: state.freshness.text, disabled, onChange: (e) => actions.edit("freshness", e.target.value), children: [
556
+ react.createElement("option", { value: "", key: "any" }, t("freshnessAny")),
557
+ react.createElement("option", { value: "pd", key: "pd" }, t("freshnessDay")),
558
+ react.createElement("option", { value: "pw", key: "pw" }, t("freshnessWeek")),
559
+ react.createElement("option", { value: "pm", key: "pm" }, t("freshnessMonth")),
560
+ react.createElement("option", { value: "py", key: "py" }, t("freshnessYear"))
561
+ ] })) : null,
562
+ brave ? fieldRow(t("proxy"), t("proxyHint"), textInput({ id: "ws-proxy", value: state.proxy.text, disabled, onChange: (e) => actions.edit("proxy", e.target.value), onReset: () => actions.resetField("proxy") })) : null,
563
+ fieldRow(t("maxResults"), t("maxResultsHint"), textInput({ id: "ws-max", numeric: true, value: state.maxResults.text, disabled, onChange: (e) => actions.edit("maxResults", e.target.value), onReset: () => actions.resetField("maxResults") })),
564
+ react.createElement("div", { style: { borderTop: "1px solid var(--dsw-alias-border-l2)", justifyContent: "flex-end", alignItems: "center", gap: "8px", padding: "12px 0 4px", display: "flex" } },
565
+ state.failed ? react.createElement("p", { role: "status", style: { minWidth: 0, color: "var(--dsw-alias-label-error)", flex: 1, margin: 0, fontSize: "12px", lineHeight: "1.5" } }, t("saveFailed")) : null,
566
+ react.createElement("button", { type: "button", disabled: !state.dirty || state.saving, onClick: actions.discard, style: { appearance: "none", font: "inherit", cursor: "pointer", border: "1px solid var(--dsw-alias-border-l2)", borderRadius: "8px", padding: "5px 14px", fontSize: "13px", lineHeight: "1.5", color: "var(--dsw-alias-label-secondary)", background: "0 0" } }, t("discard")),
567
+ react.createElement("button", { type: "button", disabled: blocked, onClick: actions.save, style: { appearance: "none", font: "inherit", cursor: "pointer", border: "1px solid transparent", borderRadius: "8px", padding: "5px 14px", fontSize: "13px", lineHeight: "1.5", color: "var(--dsw-alias-label-on-brand)", background: "var(--dsw-alias-bg-brand-solid)" } }, t(state.saving ? "saving" : "save"))));
568
+ }
569
+
570
+ /** Section component: the "Web search" page body, driven by useSyncExternalStore. */
571
+ function makeSection(ctx, controller) {
572
+ return function WebSearchSection() {
573
+ const state = react.useSyncExternalStore(controller.store.subscribe, controller.store.getSnapshot);
574
+ const t = ctx.locale.bind(NS);
575
+ if (!state.available) return react.createElement("p", { style: { padding: "16px" } }, t("readOnly"));
576
+ return react.createElement("div", { style: { padding: "16px" } },
577
+ react.createElement("div", { style: { border: "1px solid var(--dsw-alias-border-l2)", background: "var(--dsw-alias-bg-layer-3)", borderRadius: "12px", padding: "4px 16px" } },
578
+ react.createElement(WebSearchCard, { t: t, state: state, actions: controller.form.actions() })));
579
+ };
511
580
  }
512
581
 
513
- /** Mount the card into the plugin configuration surface. */
582
+ /** Mount the top-level "Web search" settings section. */
514
583
  function apply(ctx) {
515
584
  const { api } = ctx.get("connection");
516
585
  const t = ctx.locale.bind(NS);
@@ -519,14 +588,13 @@ window.__ModuleLoader__.load({
519
588
  ctx.effect(() => ctx.remote.$on("credentials/updated", (ref) => {
520
589
  controller.form.refreshCredential(ref);
521
590
  }), "dsh-web-search-plugin: credential invalidations");
522
- ctx.slots.inject("settings.plugin.item", function* () {
523
- yield ctx.slots.register({
524
- name: "settings.plugin.item",
525
- key: "dsh-web-search-plugin",
526
- locale: NS,
527
- inject: () => controller.inject()
528
- }, SearchCard);
529
- });
591
+ ctx.slots.inject("settings.section", () => ctx.slots.register({
592
+ name: "settings.section",
593
+ id: "web-search",
594
+ order: 10,
595
+ label: () => t("nav"),
596
+ locale: NS
597
+ }, makeSection(ctx, controller)));
530
598
  }
531
599
 
532
600
  const inject = ["slots", "locale", "connection", "remote", "settingsScope"];
@@ -0,0 +1,148 @@
1
+ /**
2
+ * DeepSeek search backend: the Anthropic-compatible Messages API with the
3
+ * native `web_search_20250305` server tool. Each search costs a model turn
4
+ * and returns structured `web_search_tool_result` blocks; absence of those
5
+ * blocks is an error rather than a prose-scraping fallback.
6
+ *
7
+ * This plugin re-hosts the `deepseek-official` provider so the DeepSeek
8
+ * configuration can sit beside Tavily and Brave. The bundle patch disables
9
+ * the in-box `web-search-deepseek` host plugin (its namespace feed the legacy
10
+ * "plugins → web search" card, and re-registering its provider would collide).
11
+ *
12
+ * @module dsh-web-search-plugin/deepseek
13
+ */
14
+ import { WebError } from "@deepseek-ai/dsh-web";
15
+ import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
16
+ import { MAX_RESULTS_CAP, USER_AGENT, isAbortError, resolveApiKey, resolveSecret, searchAborted, throwIfSearchAborted } from "./shared.js";
17
+
18
+ /** Default endpoint: DeepSeek's Anthropic-compatible API (`/messages` appended). */
19
+ export const DEEPSEEK_DEFAULT_BASE_URL = "https://api.deepseek.com/anthropic/v1";
20
+ /** Default credential reference for this backend. */
21
+ export const DEEPSEEK_DEFAULT_API_KEY_ENV = "DEEPSEEK_API_KEY";
22
+ /** Environment variable naming this backend's endpoint override. */
23
+ const DEEPSEEK_SEARCH_BASE_URL_ENV = "DEEPSEEK_SEARCH_BASE_URL";
24
+ const DEFAULT_MODEL = "deepseek-v4-flash";
25
+ const DEFAULT_API_VERSION = "2023-06-01";
26
+ const DEFAULT_MAX_TOKENS = 4096;
27
+ const DEFAULT_MAX_USES = 5;
28
+
29
+ /**
30
+ * Build a `url → cited_text` map from `text` blocks' `citations[]` (the snippet
31
+ * source for Anthropic `web_search_result` items, which carry url/title but no
32
+ * inline excerpt).
33
+ */
34
+ function citationSnippets(blocks) {
35
+ const map = /* @__PURE__ */ new Map();
36
+ for (const block of blocks) {
37
+ if (block.type !== "text") continue;
38
+ for (const cite of block.citations ?? []) if (cite.url != null && cite.url.length > 0 && cite.cited_text != null && cite.cited_text.length > 0 && !map.has(cite.url)) map.set(cite.url, cite.cited_text);
39
+ }
40
+ return map;
41
+ }
42
+
43
+ /** Map an Anthropic Messages response to a normalized search result. */
44
+ function mapAnthropicResponse(response) {
45
+ const blocks = response.content ?? [];
46
+ const resultBlocks = blocks.filter((block) => block.type === "web_search_tool_result");
47
+ if (resultBlocks.length === 0) throw new WebError("DeepSeek returned no web_search_tool_result blocks; the request may not have triggered native web search", "WEB_PROVIDER_ERROR");
48
+ const snippets = citationSnippets(blocks);
49
+ const seen = /* @__PURE__ */ new Set();
50
+ const sources = [];
51
+ for (const block of resultBlocks) for (const item of block.content ?? []) {
52
+ if (item.type !== "web_search_result" || typeof item.url !== "string" || item.url.length === 0 || seen.has(item.url)) continue;
53
+ seen.add(item.url);
54
+ const snippet = snippets.get(item.url);
55
+ sources.push({
56
+ url: item.url,
57
+ ...typeof item.title === "string" && item.title.length > 0 ? { title: item.title } : {},
58
+ ...snippet !== void 0 ? { snippet } : {},
59
+ ...typeof item.page_age === "string" && item.page_age.length > 0 ? { publishedAt: item.page_age } : {}
60
+ });
61
+ }
62
+ return { sources, truncated: false };
63
+ }
64
+
65
+ /** The DeepSeek-backed search backend; HTTP redirects fail as `WEB_PROVIDER_ERROR`. */
66
+ export class DeepSeekSearchProvider {
67
+ constructor(resolveOptions) {
68
+ this.resolveOptions = resolveOptions;
69
+ }
70
+
71
+ available() {
72
+ const options = this.resolveOptions();
73
+ return ((options.apiKey?.length ?? 0) > 0 || options.resolveApiKey !== void 0)
74
+ && URL.canParse(options.baseURL)
75
+ && Number.isInteger(options.maxTokens) && options.maxTokens > 0
76
+ && Number.isInteger(options.maxUses) && options.maxUses > 0;
77
+ }
78
+
79
+ async search(request, signal) {
80
+ const options = this.resolveOptions();
81
+ const apiKey = await resolveApiKey(options, signal, `DeepSeek search has no API key for "${options.apiKeyEnv ?? DEEPSEEK_DEFAULT_API_KEY_ENV}"; store it through the credentials service, export it in the launching environment, or set a literal "deepseekApiKey" in the dsh-web-search-plugin config`);
82
+ throwIfSearchAborted(signal);
83
+ const endpoint = `${options.baseURL.replace(/\/+$/u, "")}/messages`;
84
+ const body = {
85
+ model: options.model,
86
+ max_tokens: options.maxTokens,
87
+ messages: [{
88
+ role: "user",
89
+ content: [{ type: "text", text: `Perform a web search for the query: ${request.query}` }]
90
+ }],
91
+ tools: [{ type: "web_search_20250305", name: "web_search", max_uses: options.maxUses }]
92
+ };
93
+ throwIfSearchAborted(signal);
94
+ let response;
95
+ try {
96
+ response = await fetch(endpoint, {
97
+ method: "POST",
98
+ redirect: "error",
99
+ headers: {
100
+ "x-api-key": apiKey,
101
+ "authorization": `Bearer ${apiKey}`,
102
+ "anthropic-version": options.apiVersion,
103
+ "content-type": "application/json",
104
+ "accept": "application/json",
105
+ "user-agent": USER_AGENT
106
+ },
107
+ body: JSON.stringify(body),
108
+ ...signal !== void 0 ? { signal } : {}
109
+ });
110
+ } catch (error) {
111
+ if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
112
+ throw new WebError(`DeepSeek search request failed: ${String(error)}`, "WEB_PROVIDER_ERROR", { cause: error });
113
+ }
114
+ if (!response.ok) {
115
+ let message = `DeepSeek API error (HTTP ${response.status})`;
116
+ try {
117
+ const parsed = await response.json();
118
+ const detail = typeof parsed.error === "string" ? parsed.error : parsed.error?.message ?? parsed.message;
119
+ if (typeof detail === "string" && detail.length > 0) message = detail;
120
+ } catch {
121
+ /* non-JSON error body — keep the status-line message */
122
+ }
123
+ throw new WebError(message, "WEB_PROVIDER_ERROR");
124
+ }
125
+ try {
126
+ return mapAnthropicResponse(await response.json());
127
+ } catch (error) {
128
+ if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
129
+ if (error instanceof WebError) throw error;
130
+ throw new WebError(`DeepSeek returned an unprocessable response body: ${String(error)}`, "WEB_PROVIDER_ERROR", { cause: error });
131
+ }
132
+ }
133
+ }
134
+
135
+ /** Project the plugin section into DeepSeek backend options. */
136
+ export function resolveDeepseekOptions(ctx, config) {
137
+ return {
138
+ ...resolveSecret(ctx, {
139
+ literal: config.deepseekApiKey,
140
+ envName: config.deepseekApiKeyEnv ?? DEEPSEEK_DEFAULT_API_KEY_ENV
141
+ }),
142
+ baseURL: config.deepseekBaseURL ?? launchEnvironmentOf(ctx).get(DEEPSEEK_SEARCH_BASE_URL_ENV)?.value ?? DEEPSEEK_DEFAULT_BASE_URL,
143
+ model: config.model ?? DEFAULT_MODEL,
144
+ apiVersion: config.apiVersion ?? DEFAULT_API_VERSION,
145
+ maxTokens: config.maxTokens ?? DEFAULT_MAX_TOKENS,
146
+ maxUses: config.maxUses ?? DEFAULT_MAX_USES
147
+ };
148
+ }
package/lib/index.js CHANGED
@@ -1,17 +1,18 @@
1
1
  /**
2
2
  * Web-search provider plugin for the DeepSeek Harness web capability seam
3
3
  * (`ctx.web`). Registers a single `WebSearchProvider` under the stable id
4
- * `dsh-web-search` and dispatches each search to Tavily or Brave according
5
- * to the `provider` setting — so switching backends does not require a
6
- * `web.searchProvider` patch change.
4
+ * `dsh-web-search` and dispatches each search to DeepSeek (official), Tavily,
5
+ * or Brave according to the `provider` setting — so switching backends does
6
+ * not require a `web.searchProvider` patch change.
7
7
  *
8
- * Tavily supports `keyless` (free, rate-limited) and `keyed` modes.
9
- * Brave always needs a subscription token (`BRAVE_API_KEY` by default).
8
+ * Tavily supports `keyless` (free, rate-limited) and `keyed` modes. Brave
9
+ * always needs a subscription token (`BRAVE_API_KEY` by default). DeepSeek
10
+ * uses the Anthropic-compatible Messages API native `web_search_20250305`
11
+ * tool and reuses `DEEPSEEK_API_KEY` (the in-box `web-search-deepseek` host
12
+ * plugin is disabled by the bundle patch).
10
13
  *
11
- * Neither backend writes custom session events. The community Brave plugin
12
- * used to append `web/brave-search-request`, which is outside DSH's known
13
- * event vocabulary and cannot be marked `ignorable`, so a cold load refused
14
- * the conversation. Tool results already go through the seam's own events.
14
+ * No backend writes custom session events; tool results already go through
15
+ * the seam's own events.
15
16
  *
16
17
  * @module dsh-web-search-plugin
17
18
  */
@@ -20,6 +21,7 @@ import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-sett
20
21
  import { MAX_RESULTS_CAP } from "./shared.js";
21
22
  import { TAVILY_DEFAULT_API_KEY_ENV, TAVILY_DEFAULT_BASE_URL, TavilySearchProvider, resolveTavilyOptions } from "./tavily.js";
22
23
  import { BRAVE_DEFAULT_API_KEY_ENV, BRAVE_DEFAULT_BASE_URL, BraveSearchProvider, resolveBraveOptions } from "./brave.js";
24
+ import { DEEPSEEK_DEFAULT_API_KEY_ENV, DEEPSEEK_DEFAULT_BASE_URL, DeepSeekSearchProvider, resolveDeepseekOptions } from "./deepseek.js";
23
25
 
24
26
  /** Stable id this plugin registers under on `ctx.web`. */
25
27
  const SEARCH_PROVIDER_ID = "dsh-web-search";
@@ -31,7 +33,7 @@ const inject = ["web"];
31
33
 
32
34
  /** Plugin config (all optional — `apply` fills defaults). */
33
35
  const Config = z.object({
34
- /** Active backend: `tavily` (default) or `brave`. */
36
+ /** Active backend: `deepseek-official`, `tavily` (default), or `brave`. */
35
37
  provider: z.string().default("tavily"),
36
38
  /** Tavily auth mode: `keyless` (default) or `keyed`. */
37
39
  mode: z.string().default("keyless"),
@@ -62,7 +64,21 @@ const Config = z.object({
62
64
  /** Optional Brave `freshness` (`pd` / `pw` / `pm` / `py`). */
63
65
  freshness: z.string().default(""),
64
66
  /** Optional HTTP(S) proxy URL for Brave (falls back to HTTPS_PROXY / HTTP_PROXY). */
65
- proxy: z.string().default("")
67
+ proxy: z.string().default(""),
68
+ /** Literal DeepSeek API key; prefer {@link deepseekApiKeyEnv}. */
69
+ deepseekApiKey: z.string().role("secret").default(""),
70
+ /** Credential reference for DeepSeek search. */
71
+ deepseekApiKeyEnv: z.string().role("credential-ref").default(DEEPSEEK_DEFAULT_API_KEY_ENV),
72
+ /** DeepSeek Anthropic-compatible Messages base URL. `/messages` is appended. */
73
+ deepseekBaseURL: z.string().default(DEEPSEEK_DEFAULT_BASE_URL),
74
+ /** Anthropic-format model name for the Messages request. */
75
+ model: z.string().default("deepseek-v4-flash"),
76
+ /** `anthropic-version` header value. */
77
+ apiVersion: z.string().default("2023-06-01"),
78
+ /** Upper bound on generated tokens for the Messages request. */
79
+ maxTokens: z.number().step(1).min(1).default(4096),
80
+ /** Maximum `web_search` server-tool uses per request. */
81
+ maxUses: z.number().step(1).min(1).default(5)
66
82
  });
67
83
 
68
84
  /** Settings namespace carrying this plugin's backend switch and options. */
@@ -80,6 +96,7 @@ class PluginSearchProvider {
80
96
 
81
97
  constructor(resolveOptions) {
82
98
  this.resolveOptions = resolveOptions;
99
+ this.deepseek = new DeepSeekSearchProvider(() => this.resolveOptions().deepseek);
83
100
  this.tavily = new TavilySearchProvider(() => this.resolveOptions().tavily);
84
101
  this.brave = new BraveSearchProvider(() => this.resolveOptions().brave);
85
102
  }
@@ -93,14 +110,19 @@ class PluginSearchProvider {
93
110
  }
94
111
 
95
112
  backend() {
96
- return this.resolveOptions().provider === "brave" ? this.brave : this.tavily;
113
+ const provider = this.resolveOptions().provider;
114
+ if (provider === "brave") return this.brave;
115
+ if (provider === "deepseek-official") return this.deepseek;
116
+ return this.tavily;
97
117
  }
98
118
  }
99
119
 
100
- /** Project one resolved section into options for both backends. */
120
+ /** Project one resolved section into options for each backend. */
101
121
  function resolveOptions(ctx, config) {
122
+ const provider = config.provider === "brave" || config.provider === "deepseek-official" ? config.provider : "tavily";
102
123
  return {
103
- provider: config.provider === "brave" ? "brave" : "tavily",
124
+ provider,
125
+ deepseek: resolveDeepseekOptions(ctx, config),
104
126
  tavily: resolveTavilyOptions(ctx, config),
105
127
  brave: resolveBraveOptions(ctx, config)
106
128
  };
@@ -123,6 +145,9 @@ export {
123
145
  BRAVE_DEFAULT_BASE_URL,
124
146
  BraveSearchProvider,
125
147
  Config,
148
+ DEEPSEEK_DEFAULT_API_KEY_ENV,
149
+ DEEPSEEK_DEFAULT_BASE_URL,
150
+ DeepSeekSearchProvider,
126
151
  SEARCH_PROVIDER_ID,
127
152
  TAVILY_DEFAULT_API_KEY_ENV,
128
153
  TAVILY_DEFAULT_BASE_URL,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-web-search-plugin",
3
- "version": "0.1.2",
4
- "description": "Tavily and Brave Search providers for the DeepSeek Harness web capability seam (ctx.web): switch engines from the settings card without a custom session event",
3
+ "version": "0.2.0",
4
+ "description": "Unified web search for the DeepSeek Harness seam (ctx.web): DeepSeek (official), Tavily, and Brave backends with an in-browser selector",
5
5
  "keywords": [
6
6
  "dsh-plugin",
7
7
  "deepseek-harness",
@@ -53,7 +53,7 @@
53
53
  }
54
54
  },
55
55
  "scripts": {
56
- "check": "node --check lib/index.js && node --check lib/client.js && node --check lib/shared.js && node --check lib/tavily.js && node --check lib/brave.js"
56
+ "check": "node --check lib/index.js && node --check lib/client.js && node --check lib/shared.js && node --check lib/tavily.js && node --check lib/brave.js && node --check lib/deepseek.js"
57
57
  },
58
58
  "dependencies": {
59
59
  "undici": "^6.21.0"