dsh-web-search-plugin 0.1.1 → 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
@@ -1,54 +1,62 @@
1
- # Changelog
2
-
3
- All notable changes to this project are documented here. The format is based
4
- on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
- adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
-
7
- ## [0.1.1] - 2026-08-19
8
-
9
- ### Added
10
-
11
- - **GitHub Actions publishing** (`.github/workflows/publish.yml`): pushing a
12
- `v*` tag publishes to npm with provenance; `ci.yml` runs the syntax checks
13
- and a pack dry-run on every push/PR.
14
-
15
- ### Fixed
16
-
17
- - **Client bundle: missing `exports.inject`** (`lib/client.js`). The browser
18
- plugin only exported `apply`, so cordis had an empty `fiber.inject` and any
19
- `ctx.*` service access (e.g. `ctx.locale`) threw
20
- `cannot get property "locale" without inject`. Now exports
21
- `inject = ["slots", "locale", "connection", "remote", "settingsScope"]`.
22
- - **Plugin-card slot registration** (`lib/client.js`). The card registered into
23
- `settings.plugin.item` with the rc.6 `id`/`order` shape; the slot is keyed
24
- since rc.6+ and requires `key: "dsh-web-search-plugin"`.
25
- - **`CardForm.field("apiKey")` crash** (`lib/client.js`). The write-only
26
- credential field has no section spec; `spec.format(...)` on `undefined`
27
- threw `Cannot read properties of undefined (reading 'format')` during the
28
- card projection. Added the secret-field branch (mirroring the official
29
- `CardForm`).
30
- - **`dsh.client.inject` now includes `@deepseek-ai/dsh-client-ui-slots`**
31
- (`package.json`), matching the load set used by community plugins.
32
-
33
- ### Changed
34
-
35
- - No longer requires the `dsh-host-apiproxy` settings-namespace allowlist
36
- patch that rc.6 needed: rc.7's `settings.describe()` exposes registered
37
- namespaces dynamically.
38
- - **Renamed to `dsh-web-search-plugin`**: the package name (matching the
39
- repository name); the plugin id, settings namespace and card key were
40
- renamed in lockstep so a single identity is used everywhere. No version
41
- has shipped yet, so the rename carries no migration cost.
42
-
43
- ## [0.1.0] - 2026-08-19
44
-
45
- ### Added
46
-
47
- - Initial release: `TavilySearchProvider` (`id: "tavily"`) registered into
48
- `ctx.web`, with `dsh-web-search-plugin` settings section.
49
- - Keyless mode (`X-Tavily-Access-Mode: keyless`) and keyed mode
50
- (Bearer token via `TAVILY_API_KEY` credential reference).
51
- - Browser configuration card for the DSH plugin configuration surface.
1
+ # 更新日志
52
2
 
3
+ 本项目的重要变更都记录在此。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循 [Semantic Versioning](https://semver.org/lang/zh-CN/)。
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
+
17
+ ## [0.1.2] - 2026-08-18
18
+
19
+ ### 新增
20
+
21
+ - **Brave Search 后端**(`lib/brave.js`)。设置项 `provider: brave` 会调用 Brave Search API(`X-Subscription-Token`,凭据名 `BRAVE_API_KEY`),与 Tavily 共用同一张设置卡。默认仍是 Tavily。
22
+ - **Bundle 层**(`cordis.patch.yml` + `dsh.bundle.patch`):`dsh plugin add` 会把本包加入 profile 层栈、插入 Host 行,并把 `web.searchProvider` 设为 `dsh-web-search`。没有这一层时,DSH 只把它当普通依赖安装(没有 loader 行、Host 不会 `apply`、也不会提供 `/plugins/dsh-web-search-plugin/client.js`)。
23
+ - 接缝 id 固定为 **`dsh-web-search`**。Tavily 与 Brave 的切换是插件自己的设置,不必再改一次 `web.searchProvider`。
24
+
25
+ ### 修复
26
+
27
+ - **不再写入自定义 session 事件。** 社区 Brave 插件每次搜索都会 `append("web/brave-search-request")`;该类型不在 DSH 已知事件表里,且 `Session.append` 无法加上 `ignorable: true`,冷加载会整段拒绝会话。
28
+
29
+ ### 变更
30
+
31
+ - Host 代码拆到 `lib/index.js` / `lib/tavily.js` / `lib/brave.js` / `lib/shared.js`。
32
+
33
+ ## [0.1.1] - 2026-08-18
34
+
35
+ ### 新增
36
+
37
+ - **GitHub Actions 发布**(`.github/workflows/publish.yml`):推送 `v*` 标签会带 provenance 发布到 npm;`ci.yml` 在每次推送/PR 上跑语法检查和 pack dry-run。
38
+
39
+ ### 修复
40
+
41
+ - **客户端 bundle 缺少 `exports.inject`**(`lib/client.js`)。浏览器插件原先只导出 `apply`,cordis 的 `fiber.inject` 为空,访问 `ctx.locale` 等服务会抛 `cannot get property "locale" without inject`。现在导出 `inject = ["slots", "locale", "connection", "remote", "settingsScope"]`。
42
+ - **插件卡片的 slot 注册**(`lib/client.js`)。卡片按 rc.6 的 `id`/`order` 形状注册进 `settings.plugin.item`;该 slot 从 rc.6+ 起是 keyed 的,必须带 `key: "dsh-web-search-plugin"`。
43
+ - **`CardForm.field("apiKey")` 崩溃**(`lib/client.js`)。只写凭据字段没有 section spec,对 `undefined` 调用 `spec.format(...)` 会在卡片投影时抛 `Cannot read properties of undefined (reading 'format')`。已加上 secret 字段分支(对齐官方 `CardForm`)。
44
+ - **`dsh.client.inject` 补上 `@deepseek-ai/dsh-client-ui-slots`**(`package.json`),与社区插件的加载集合一致。
45
+
46
+ ### 变更
47
+
48
+ - 不再需要 rc.6 那种给 `dsh-host-apiproxy` 打设置命名空间白名单的补丁:rc.7 的 `settings.describe()` 会动态暴露已注册的 namespace。
49
+ - **更名为 `dsh-web-search-plugin`**:包名与仓库名对齐;插件 id、设置 namespace、卡片 key 同步改名,全程只用一个身份。当时尚未对外发过版,这次改名没有迁移成本。
50
+
51
+ ## [0.1.0] - 2026-08-18
52
+
53
+ ### 新增
54
+
55
+ - 首个版本:`TavilySearchProvider`(id 为 `tavily`)注册进 `ctx.web`,带 `dsh-web-search-plugin` 设置段。
56
+ - Keyless 模式(`X-Tavily-Access-Mode: keyless`)与 keyed 模式(通过 `TAVILY_API_KEY` 凭据引用发送 Bearer token)。
57
+ - DSH 插件配置页上的浏览器设置卡片。
58
+
59
+ [0.2.0]: https://github.com/X-C1811/dsh-web-search-plugin/compare/v0.1.2...v0.2.0
60
+ [0.1.2]: https://github.com/X-C1811/dsh-web-search-plugin/compare/v0.1.1...v0.1.2
53
61
  [0.1.1]: https://github.com/X-C1811/dsh-web-search-plugin/compare/v0.1.0...v0.1.1
54
- [0.1.0]: https://github.com/X-C1811/dsh-web-search-plugin/releases/tag/v0.1.0
62
+ [0.1.0]: https://github.com/X-C1811/dsh-web-search-plugin/releases/tag/v0.1.0
package/README.md CHANGED
@@ -1,119 +1,144 @@
1
1
  # dsh-web-search-plugin
2
2
 
3
- A [Tavily](https://tavily.com)-backed web search provider for the [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) web capability seam (`ctx.web`). It registers a `WebSearchProvider` under the stable id `tavily`, so the model-facing `web_search` tool runs against Tavily instead of the built-in DeepSeek search endpoint — with or without an API key.
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
- - **Keyless mode (default)** — free and rate-limited; no account or API key required. Activated by a single `X-Tavily-Access-Mode: keyless` request header.
6
- - **Keyed mode** — uses a Tavily API key resolved from the `TAVILY_API_KEY` credential / launch-environment reference (or a literal `apiKey`), sent as a Bearer token.
7
- - **UI-configurable** — ships a configuration card in the DSH web settings (Settings → Plugins → Plugin configuration → Web search (Tavily)) to switch modes and edit options live.
5
+ - **DeepSeek(官方)** — 走 DeepSeek 的 Anthropic 兼容 Messages API(原生 `web_search_20250305` 工具,凭据名 `DEEPSEEK_API_KEY`),一次搜索消耗一个模型轮次。
6
+ - **Tavily(默认)** — `keyless`(免费、限流、无需账号)或 `keyed`(`TAVILY_API_KEY`,Bearer token)。
7
+ - **Brave Search** — `GET https://api.search.brave.com/res/v1/web/search`,请求头 `X-Subscription-Token`(凭据名 `BRAVE_API_KEY`)。一次搜索就是一次 HTTP 请求,不走模型轮次。
8
+ - **不写自定义 session 事件** — 工具结果已经走接缝自己的事件,无需多余信封。
8
9
 
9
- ## Features
10
+ ## 特性
10
11
 
11
- - Implements the same provider contract as the official `@deepseek-ai/dsh-web-search-deepseek` plugin: `inject: ['web']` + `installSettingsSection` + `ctx.web.registerSearchProvider`.
12
- - Zero-config keyless search works out of the box; switch to an API key in one click for higher limits.
13
- - Normalizes Tavily's `answer` into the result `content` and `results[]` into citeable `sources[]` (url, title, snippet, published date), deduplicated by URL.
14
- - Settings section `dsh-web-search-plugin` is exposed dynamically through rc.7's `settings.describe()` — no host allowlist patch required.
15
- - Maps provider errors and caller cancellation to the seam's `WEB_PROVIDER_ERROR` / `WEB_ABORTED` codes; credentials are resolved per search, so a key stored or rotated in the web credentials domain applies to the next search without a restart.
12
+ - 与官方 `@deepseek-ai/dsh-web-search-deepseek` 相同的提供方约定:`inject: ['web']` + `installSettingsSection` + `ctx.web.registerSearchProvider`。
13
+ - Tavily keyless 无需密钥即可用;Brave 需要订阅 token(若本机已有 `BRAVE_API_KEY` 凭据,可直接复用)。
14
+ - 各引擎结果都规范化为接缝的 `WebSearchResult`(可选 `content` + `sources[]`),按 URL 去重。
15
+ - 设置段 `dsh-web-search-plugin` 通过 rc.7 的 `settings.describe()` 动态暴露,不需要宿主白名单补丁,也不需要自建回环 settings 桥。
16
+ - 错误映射为 `WEB_PROVIDER_ERROR` / `WEB_ABORTED` / `WEB_PROVIDER_CREDENTIAL_MISSING`。
16
17
 
17
- ## Requirements
18
+ ## 运行要求
18
19
 
19
- - DeepSeek Harness `0.1.0-rc.7` or newer (keyed plugin slots and dynamic settings exposure)
20
- - pnpm, for installing plugins into a profile via `dsh plugin`
20
+ - DeepSeek Harness `0.1.0-rc.7` 或更新
21
+ - pnpm,用于通过 `dsh plugin` 把插件装进 profile
21
22
 
22
- ## Install
23
+ ## 安装
23
24
 
24
- ### From npm
25
+ 本包是 **bundle**:`dsh.bundle.patch` + `cordis.patch.yml` 会插入 Host 行、把 `web.searchProvider` 设为 `dsh-web-search`,并**禁用内置 `web-search-deepseek` Host 插件**(本插件已自行托管 DeepSeek 后端,禁用它能移除旧的「插件 → 网页搜索」卡片、避免重复注册 provider)。没有这一声明时,`dsh plugin add` 只写入依赖,插件不会挂载。
26
+
27
+ ### 从 npm 安装
25
28
 
26
29
  ```powershell
27
30
  dsh plugin --profile web add dsh-web-search-plugin
28
31
  ```
29
32
 
30
- ### From a local checkout
33
+ ### 从本地目录安装
31
34
 
32
35
  ```powershell
33
36
  dsh plugin --profile web add "path/to/dsh-web-search-plugin"
34
37
  ```
35
38
 
36
- Then route the web seam to the Tavily provider and enable the plugin in `%USERPROFILE%\.dsh\profiles\web\cordis.patch.yml`:
39
+ 重启 DSH web 进程;浏览器刷新后会加载客户端 bundle。
40
+
41
+ 用 `dsh --profile web --dump-config` 确认:组成树里应有 `dsh-web-search-plugin` 行,且 `web.searchProvider` 为 `dsh-web-search`。
42
+
43
+ 若 profile 已经覆盖了 `web`(例如旧包 `@dsh-ltctfer/dsh-web-search-brave` 留下的 `brave-official`),后打的 patch 仍会生效。请改指向本插件,并卸掉旧包:
37
44
 
38
45
  ```yaml
39
- # Replaces the base `web` config, which pins `searchProvider: deepseek-official`.
40
46
  - id: web
41
47
  name: '@deepseek-ai/dsh-web'
42
48
  config:
43
- searchProvider: tavily
49
+ searchProvider: dsh-web-search
50
+ ```
44
51
 
45
- - insert:
46
- - id: dsh-web-search-plugin
47
- name: dsh-web-search-plugin
48
- config:
49
- mode: keyless
52
+ ```powershell
53
+ dsh plugin --profile web remove @dsh-ltctfer/dsh-web-search-brave
50
54
  ```
51
55
 
52
- Restart the DSH web process; the browser picks up the plugin's client bundle on the next page refresh.
56
+ 然后在 **设置 → 网页搜索** 里把引擎切到想要的提供方,即可继续使用对应的 API key。
53
57
 
54
- > **Note on `file:` (local) installs** — pnpm treats a `file:path` dependency as a snapshot: changes made to the source directory are **not** propagated into the profile's `node_modules` automatically. After editing the plugin, re-run `dsh plugin --profile web add "path/to/dsh-web-search-plugin"` (or copy the files over) and restart.
58
+ > **关于 `file:`(本地)安装** — pnpm 会把 `file:path` 依赖做成快照。改完本仓库后,需要再执行一次 `dsh plugin --profile web add "path/to/dsh-web-search-plugin"` 并重启。
55
59
 
56
- ## Configuration
60
+ ## 配置
57
61
 
58
- The settings card (Settings → Plugins → Plugin configuration → Web search (Tavily)) edits the `dsh-web-search-plugin` namespace:
62
+ 设置卡编辑的是 `dsh-web-search-plugin` 命名空间:
59
63
 
60
- | Key | Default | Meaning |
64
+ | 键 | 默认值 | 含义 |
61
65
  |---|---|---|
62
- | `mode` | `keyless` | `keyless` (free, rate-limited) or `keyed` (Tavily API key) |
63
- | `apiKey` | — | Literal Tavily API key (never echoed back; stored via the credentials domain) |
64
- | `apiKeyEnv` | `TAVILY_API_KEY` | Credential/env reference resolved on each keyed search |
65
- | `baseURL` | `https://api.tavily.com` | Tavily REST base URL; `/search` is appended |
66
- | `maxResults` | `8` | Sources per search (1–20) |
67
- | `searchDepth` | `basic` | `basic` (faster) or `advanced` (deeper) |
68
- | `includeAnswer` | `true` | Request Tavily's generated answer; surfaced as the result `content` |
69
- | `topic` | `general` | `general` or `news` |
70
-
71
- Environment overrides: `DSH_WEB_SEARCH_PROVIDER=tavily` selects this provider at boot; the plugin's base URL falls back to `$TAVILY_BASE_URL` when `baseURL` is unset.
72
-
73
- ## Switching back to the built-in search
74
-
75
- Change `searchProvider` back to `deepseek-official` in the profile patch. The plugin may stay registered; it is then simply not selected.
76
-
77
- ## How it works
78
-
79
- - One `POST https://api.tavily.com/search` per search; keyless requests send `x-tavily-access-mode: keyless`, keyed requests send `authorization: Bearer <key>`.
80
- - Non-2xx responses become `WEB_PROVIDER_ERROR` (Tavily's `detail.error` text is preserved); caller cancellation becomes `WEB_ABORTED`.
81
-
82
- ## Repository layout
66
+ | `provider` | `tavily` | `tavily`、`brave` 或 `deepseek-official` |
67
+ | `mode` | `keyless` | 仅 Tavily:`keyless` 或 `keyed` |
68
+ | `apiKey` | — | Tavily API key 字面量(走凭据域,不会回显) |
69
+ | `apiKeyEnv` | `TAVILY_API_KEY` | keyed Tavily 使用的凭据/环境变量名 |
70
+ | `baseURL` | `https://api.tavily.com` | Tavily REST 基址;会再拼 `/search` |
71
+ | `maxResults` | `8` | 每次搜索返回的源数量(1–20),三个后端共用 |
72
+ | `searchDepth` | `basic` | 仅 Tavily:`basic` 或 `advanced` |
73
+ | `includeAnswer` | `true` | 仅 Tavily:请求生成摘要,写入结果 `content` |
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` 工具的最大调用次数 |
82
+ | `braveApiKey` | — | Brave 订阅 token 字面量(走凭据域) |
83
+ | `braveApiKeyEnv` | `BRAVE_API_KEY` | Brave 使用的凭据/环境变量名 |
84
+ | `braveBaseURL` | `https://api.search.brave.com/res/v1/web/search` | Brave 网页搜索接口 |
85
+ | `country` | — | Brave 的 `country`(ISO 两位码,如 `cn`) |
86
+ | `searchLang` | — | Brave 的 `search_lang`(如 `zh-hans`) |
87
+ | `freshness` | — | Brave 的 `freshness`:`pd` / `pw` / `pm` / `py` |
88
+ | `proxy` | — | Brave 使用的 HTTP(S) 代理;未填则回退 `HTTPS_PROXY` / `HTTP_PROXY` |
89
+
90
+ 环境变量:启动时 `DSH_WEB_SEARCH_PROVIDER=dsh-web-search` 会选中本接缝 id。未设置 `baseURL` 时,Tavily 基址回退 `$TAVILY_BASE_URL`。
91
+
92
+ ## 切回内置 DeepSeek
93
+
94
+ DeepSeek 已并入本插件(`provider: deepseek-official`),无需切回。若确要恢复 DSH 内置的 DeepSeek host 插件,请在 profile patch 里去掉对 `web-search-deepseek` 的 `disabled` 并把 `searchProvider` 设回 `deepseek-official`;本插件可以继续挂着,只是不会被选中。
95
+
96
+ ## 工作方式
97
+
98
+ - **DeepSeek** — `POST {deepseekBaseURL}/messages`,请求头 `x-api-key` / `authorization: Bearer`,工具 `web_search_20250305`。
99
+ - **Tavily** — `POST {baseURL}/search`。keyless 发送 `x-tavily-access-mode: keyless`;keyed 发送 `authorization: Bearer <key>`。
100
+ - **Brave** — `GET {braveBaseURL}?q=&count=`,请求头 `x-subscription-token`。不向 session 追加自定义事件。
101
+ - 非 2xx 映射为 `WEB_PROVIDER_ERROR`;调用方取消映射为 `WEB_ABORTED`。
102
+
103
+ ## 仓库布局
83
104
 
84
105
  ```
85
- lib/index.js Host plugin: schemastery Config, TavilySearchProvider, settings section
86
- lib/client.js Browser bundle (window.__ModuleLoader__.load): the configuration card
106
+ lib/index.js Host 插件:Config、分发提供方、设置段
107
+ lib/deepseek.js DeepSeek 后端(Anthropic Messages + web_search_20250305)
108
+ lib/tavily.js Tavily 后端
109
+ lib/brave.js Brave 后端(不写自定义 session 事件)
110
+ lib/shared.js 中止 / 凭据解析辅助
111
+ lib/client.js 浏览器 bundle:顶层「网页搜索」分区 + 引擎切换表单
112
+ cordis.patch.yml Bundle patch:插入 Host 行、设 searchProvider、禁用内置 deepseek
87
113
  ```
88
114
 
89
- ## Development
115
+ ## 开发
90
116
 
91
117
  ```powershell
92
- node --check lib/index.js
93
- node --check lib/client.js
118
+ npm run check
94
119
  ```
95
120
 
96
- The client bundle must stay in the `window.__ModuleLoader__.load({ id, factory })` wire format — it is loaded by `dsh-client-modules`, not by a bundler. It must export **both** `apply` and `inject` (the array of cordis service names it reads: `slots`, `locale`, `connection`, `remote`, `settingsScope`) and register its card into the keyed `settings.plugin.item` slot with a `key`.
121
+ 客户端 bundle 必须保持 `window.__ModuleLoader__.load({ id, factory })` 线格式 — 由 `dsh-client-modules` 加载,不是打包器。必须同时导出 `apply` 和 `inject`(`slots`、`locale`、`connection`、`remote`、`settingsScope`),并把分区注册进 `settings.section`(`id: "web-search"`)。
97
122
 
98
- ## Publishing
123
+ ## 发布
99
124
 
100
- Releases are published automatically by GitHub Actions (`.github/workflows/publish.yml`): **pushing a `v*` tag** (e.g. `v0.1.1`) publishes the package to npm with provenance. Plain pushes to `main` never publish.
125
+ 发布由 GitHub Actions(`.github/workflows/publish.yml`)自动完成:**推送 `v*` 标签**(例如 `v0.2.0`)会带 provenance 发到 npm。向 `main` 的普通推送不会发布。
101
126
 
102
- 1. Make sure `package.json` `version` matches the tag you are about to push (e.g. `0.1.1` → `v0.1.1`).
103
- 2. On GitHub, set an npm automation token (publish permission) as the `NPM_TOKEN` repository secret under **Settings → Secrets and variables → Actions**, and allow workflow runs under **Settings → Actions**.
104
- 3. Tag and push:
127
+ 1. 确认 `package.json` 的 `version` 与即将推送的标签一致(例如 `0.2.0` → `v0.2.0`)。
128
+ 2. 在 GitHub **Settings → Secrets and variables → Actions** 里配置有 publish 权限的 npm automation token(仓库密钥 `NPM_TOKEN`),并允许 Actions 运行。
129
+ 3. 打标签并推送:
105
130
 
106
131
  ```powershell
107
- git tag v0.1.1
108
- git push origin v0.1.1
132
+ git tag v0.2.0
133
+ git push origin v0.2.0
109
134
  ```
110
135
 
111
- The workflow verifies the tag against `package.json` `version` before publishing (`npm publish --provenance --access public`), so an accidental mismatch fails fast instead of shipping the wrong version. Users can then install with `dsh plugin --profile web add dsh-web-search-plugin`.
136
+ 工作流会先核对标签与 `package.json` 的 `version`,再执行 `npm publish --provenance --access public`。
112
137
 
113
- ## Contributing
138
+ ## 参与贡献
114
139
 
115
- Issues and pull requests are welcome. See the [issue tracker](https://github.com/X-C1811/dsh-web-search-plugin/issues) for known limitations and the roadmap; the provider is shaped so additional search APIs can be added beside Tavily.
140
+ 欢迎提 issue 和 pull request,见 [issue tracker](https://github.com/X-C1811/dsh-web-search-plugin/issues)。还可以在同一接缝 id 下继续加搜索后端。
116
141
 
117
- ## License
142
+ ## 许可证
118
143
 
119
- [MIT](LICENSE)
144
+ [MIT](LICENSE)
@@ -0,0 +1,26 @@
1
+ # Bundle layer applied by `dsh plugin add` once this package is a profile
2
+ # dependency. Without this file (and package.json `dsh.bundle.patch`), DSH
3
+ # installs the package as a plain library: no loader row, no Host apply, no
4
+ # `/plugins/<name>/client.js`.
5
+ #
6
+ # `searchProvider` is restated in full because a Cordis patch replaces the
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`; the
9
+ # backend (DeepSeek / Tavily / Brave) is the plugin's own `provider` setting.
10
+ - id: web
11
+ name: '@deepseek-ai/dsh-web'
12
+ config:
13
+ searchProvider: dsh-web-search
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
+
21
+ - insert:
22
+ - id: dsh-web-search-plugin
23
+ name: dsh-web-search-plugin
24
+ config:
25
+ provider: tavily
26
+ mode: keyless
package/lib/brave.js ADDED
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Brave Search API backend (`GET {baseURL}?q=`).
3
+ *
4
+ * Intentionally does **not** append a custom session event. The standalone
5
+ * `@dsh-ltctfer/dsh-web-search-brave` plugin wrote `web/brave-search-request`
6
+ * on every call; that type is outside DSH's `KNOWN_SESSION_EVENT_TYPES`, and
7
+ * `Session.append` cannot set `ignorable: true`, so a cold load refused the
8
+ * whole conversation. Search traffic already lands on the seam's own
9
+ * `web_search` tool events — extra telemetry is not worth a broken log.
10
+ *
11
+ * @module dsh-web-search-plugin/brave
12
+ */
13
+ import { ProxyAgent } from "undici";
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, isPositiveInteger, resolveApiKey, resolveSecret, searchAborted, throwIfSearchAborted } from "./shared.js";
17
+
18
+ /** Default Brave Search API web-results endpoint. */
19
+ export const BRAVE_DEFAULT_BASE_URL = "https://api.search.brave.com/res/v1/web/search";
20
+ /** Default credential reference resolved for every Brave search. */
21
+ export const BRAVE_DEFAULT_API_KEY_ENV = "BRAVE_API_KEY";
22
+
23
+ /**
24
+ * Map a Brave Search API response to a normalized search result. Walks
25
+ * `web.results[]`, joins `description` / first `extra_snippets` as the snippet,
26
+ * and dedupes by `url`.
27
+ * @param body - the parsed Brave Search API response body.
28
+ * @returns the normalized result with deduped sources.
29
+ */
30
+ function mapBraveResponse(body) {
31
+ const results = body?.web?.results;
32
+ if (!Array.isArray(results)) {
33
+ throw new WebError("Brave returned no web.results array; the response body is not a Brave Search API web response", "WEB_PROVIDER_ERROR");
34
+ }
35
+ const seen = /* @__PURE__ */ new Set();
36
+ const sources = [];
37
+ for (const item of results) {
38
+ if (item == null || typeof item.url !== "string" || item.url.length === 0 || seen.has(item.url)) continue;
39
+ seen.add(item.url);
40
+ const snippet = typeof item.description === "string" && item.description.length > 0
41
+ ? item.description
42
+ : Array.isArray(item.extra_snippets) && typeof item.extra_snippets[0] === "string" && item.extra_snippets[0].length > 0
43
+ ? item.extra_snippets[0]
44
+ : void 0;
45
+ sources.push({
46
+ url: item.url,
47
+ ...typeof item.title === "string" && item.title.length > 0 ? { title: item.title } : {},
48
+ ...snippet !== void 0 ? { snippet } : {},
49
+ ...typeof item.page_age === "string" && item.page_age.length > 0 ? { publishedAt: item.page_age } : {}
50
+ });
51
+ }
52
+ return {
53
+ sources,
54
+ truncated: false
55
+ };
56
+ }
57
+
58
+ /** The Brave Search API-backed backend; HTTP redirects fail as `WEB_PROVIDER_ERROR`. */
59
+ export class BraveSearchProvider {
60
+ constructor(resolveOptions) {
61
+ this.resolveOptions = resolveOptions;
62
+ this.cachedProxy = void 0;
63
+ this.cachedDispatcher = void 0;
64
+ }
65
+
66
+ available() {
67
+ const options = this.resolveOptions();
68
+ return ((options.apiKey?.length ?? 0) > 0 || options.resolveApiKey !== void 0)
69
+ && URL.canParse(options.baseURL)
70
+ && isPositiveInteger(options.maxResults);
71
+ }
72
+
73
+ async search(request, signal) {
74
+ const options = this.resolveOptions();
75
+ const apiKey = await resolveApiKey(options, signal, `Brave search has no API key for "${options.apiKeyEnv ?? BRAVE_DEFAULT_API_KEY_ENV}"; store it through the credentials service, export it in the launching environment, or set a literal "braveApiKey" in the dsh-web-search-plugin config`);
76
+ throwIfSearchAborted(signal);
77
+ const count = Math.min(options.maxResults, isPositiveInteger(request.maxResults) ? request.maxResults : options.maxResults, MAX_RESULTS_CAP);
78
+ const params = new URLSearchParams();
79
+ params.set("q", request.query);
80
+ params.set("count", String(count));
81
+ if (options.country != null && options.country.length > 0) params.set("country", options.country);
82
+ if (options.searchLang != null && options.searchLang.length > 0) params.set("search_lang", options.searchLang);
83
+ if (options.freshness != null && options.freshness.length > 0) params.set("freshness", options.freshness);
84
+ const endpoint = `${options.baseURL}?${params.toString()}`;
85
+ throwIfSearchAborted(signal);
86
+ const dispatcher = this.dispatcher(options);
87
+ let response;
88
+ try {
89
+ response = await fetch(endpoint, {
90
+ method: "GET",
91
+ redirect: "error",
92
+ headers: {
93
+ "x-subscription-token": apiKey,
94
+ "accept": "application/json",
95
+ "user-agent": USER_AGENT
96
+ },
97
+ ...signal !== void 0 ? { signal } : {},
98
+ ...dispatcher !== void 0 ? { dispatcher } : {}
99
+ });
100
+ } catch (error) {
101
+ if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
102
+ throw new WebError(`Brave search request failed: ${String(error)}`, "WEB_PROVIDER_ERROR", { cause: error });
103
+ }
104
+ if (!response.ok) {
105
+ let message = `Brave Search API error (HTTP ${response.status})`;
106
+ try {
107
+ const parsed = await response.json();
108
+ if (typeof parsed?.error === "string" && parsed.error.length > 0) message = parsed.error;
109
+ else if (typeof parsed?.error?.message === "string" && parsed.error.message.length > 0) message = parsed.error.message;
110
+ } catch (error) {
111
+ if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
112
+ }
113
+ throw new WebError(message, "WEB_PROVIDER_ERROR");
114
+ }
115
+ try {
116
+ return mapBraveResponse(await response.json());
117
+ } catch (error) {
118
+ if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
119
+ if (error instanceof WebError) throw error;
120
+ throw new WebError(`Brave returned an unprocessable response body: ${String(error)}`, "WEB_PROVIDER_ERROR", { cause: error });
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Cached undici ProxyAgent for the current proxy URL. Node's fetch does not
126
+ * read `HTTPS_PROXY` itself; a configured proxy (or the launch-environment
127
+ * fallback) is how DNS for api.search.brave.com can go through a tunnel.
128
+ */
129
+ dispatcher(options) {
130
+ const proxy = options.proxy;
131
+ if (proxy === void 0 || proxy.length === 0) return void 0;
132
+ if (this.cachedProxy === proxy) return this.cachedDispatcher;
133
+ if (this.cachedDispatcher !== void 0) {
134
+ this.cachedDispatcher.close?.().catch(() => {});
135
+ }
136
+ this.cachedProxy = proxy;
137
+ this.cachedDispatcher = new ProxyAgent(proxy);
138
+ return this.cachedDispatcher;
139
+ }
140
+ }
141
+
142
+ /** Project the plugin section into Brave backend options. */
143
+ export function resolveBraveOptions(ctx, config) {
144
+ const environment = launchEnvironmentOf(ctx);
145
+ return {
146
+ ...resolveSecret(ctx, {
147
+ literal: config.braveApiKey,
148
+ envName: config.braveApiKeyEnv ?? BRAVE_DEFAULT_API_KEY_ENV
149
+ }),
150
+ baseURL: config.braveBaseURL ?? BRAVE_DEFAULT_BASE_URL,
151
+ maxResults: config.maxResults ?? 8,
152
+ country: config.country,
153
+ searchLang: config.searchLang,
154
+ freshness: config.freshness,
155
+ proxy: config.proxy ?? environment.get("HTTPS_PROXY")?.value ?? environment.get("HTTP_PROXY")?.value
156
+ };
157
+ }