dsh-web-search-plugin 0.1.1
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 +54 -0
- package/LICENSE +21 -0
- package/README.md +119 -0
- package/lib/client.js +451 -0
- package/lib/index.js +279 -0
- package/package.json +66 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
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.
|
|
52
|
+
|
|
53
|
+
[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
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dsh-web-search-plugin contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# dsh-web-search-plugin
|
|
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.
|
|
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.
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
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.
|
|
16
|
+
|
|
17
|
+
## Requirements
|
|
18
|
+
|
|
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`
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
### From npm
|
|
25
|
+
|
|
26
|
+
```powershell
|
|
27
|
+
dsh plugin --profile web add dsh-web-search-plugin
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### From a local checkout
|
|
31
|
+
|
|
32
|
+
```powershell
|
|
33
|
+
dsh plugin --profile web add "path/to/dsh-web-search-plugin"
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Then route the web seam to the Tavily provider and enable the plugin in `%USERPROFILE%\.dsh\profiles\web\cordis.patch.yml`:
|
|
37
|
+
|
|
38
|
+
```yaml
|
|
39
|
+
# Replaces the base `web` config, which pins `searchProvider: deepseek-official`.
|
|
40
|
+
- id: web
|
|
41
|
+
name: '@deepseek-ai/dsh-web'
|
|
42
|
+
config:
|
|
43
|
+
searchProvider: tavily
|
|
44
|
+
|
|
45
|
+
- insert:
|
|
46
|
+
- id: dsh-web-search-plugin
|
|
47
|
+
name: dsh-web-search-plugin
|
|
48
|
+
config:
|
|
49
|
+
mode: keyless
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Restart the DSH web process; the browser picks up the plugin's client bundle on the next page refresh.
|
|
53
|
+
|
|
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.
|
|
55
|
+
|
|
56
|
+
## Configuration
|
|
57
|
+
|
|
58
|
+
The settings card (Settings → Plugins → Plugin configuration → Web search (Tavily)) edits the `dsh-web-search-plugin` namespace:
|
|
59
|
+
|
|
60
|
+
| Key | Default | Meaning |
|
|
61
|
+
|---|---|---|
|
|
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
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
lib/index.js Host plugin: schemastery Config, TavilySearchProvider, settings section
|
|
86
|
+
lib/client.js Browser bundle (window.__ModuleLoader__.load): the configuration card
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Development
|
|
90
|
+
|
|
91
|
+
```powershell
|
|
92
|
+
node --check lib/index.js
|
|
93
|
+
node --check lib/client.js
|
|
94
|
+
```
|
|
95
|
+
|
|
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`.
|
|
97
|
+
|
|
98
|
+
## Publishing
|
|
99
|
+
|
|
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.
|
|
101
|
+
|
|
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:
|
|
105
|
+
|
|
106
|
+
```powershell
|
|
107
|
+
git tag v0.1.1
|
|
108
|
+
git push origin v0.1.1
|
|
109
|
+
```
|
|
110
|
+
|
|
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`.
|
|
112
|
+
|
|
113
|
+
## Contributing
|
|
114
|
+
|
|
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.
|
|
116
|
+
|
|
117
|
+
## License
|
|
118
|
+
|
|
119
|
+
[MIT](LICENSE)
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
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 and its `TAVILY_API_KEY` credential.
|
|
5
|
+
*
|
|
6
|
+
* This bundle is a self-contained hand-written module in the exact wire format
|
|
7
|
+
* the client module system expects: one `window.__ModuleLoader__.load` call
|
|
8
|
+
* whose factory registers a Cordis plugin through `exports.apply`. It mirrors
|
|
9
|
+
* the official `dsh-client-ui-settings-plugins` cards (staged edits, save
|
|
10
|
+
* writes once, credential written through the credentials domain) without
|
|
11
|
+
* depending on that package's internal `CardForm`.
|
|
12
|
+
*
|
|
13
|
+
* @module dsh-web-search-plugin/client
|
|
14
|
+
*/
|
|
15
|
+
window.__ModuleLoader__.load({
|
|
16
|
+
id: "dsh-web-search-plugin",
|
|
17
|
+
factory: (require) => {
|
|
18
|
+
var module = { exports: {} };
|
|
19
|
+
var exports = module.exports;
|
|
20
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
21
|
+
let react = require("react");
|
|
22
|
+
let _deepseek_ai_dsh_client_runtime_client = require("@deepseek-ai/dsh-client-runtime/client");
|
|
23
|
+
|
|
24
|
+
/** Settings namespace of the Tavily search provider. */
|
|
25
|
+
const NS = "dsh-web-search-plugin";
|
|
26
|
+
/** Credential reference the provider resolves when the section names none. */
|
|
27
|
+
const DEFAULT_API_KEY_REF = "TAVILY_API_KEY";
|
|
28
|
+
/** Form field the credential control stages under. */
|
|
29
|
+
const API_KEY_FIELD = "apiKey";
|
|
30
|
+
|
|
31
|
+
/** Locale bundles for the card. */
|
|
32
|
+
const en = {
|
|
33
|
+
nav: "Web search (Tavily)",
|
|
34
|
+
description: "The Tavily search provider. Keyless by default; switch to keyed and add a Tavily API key for higher limits.",
|
|
35
|
+
mode: "Auth mode",
|
|
36
|
+
modeKeyless: "Keyless (free, rate-limited)",
|
|
37
|
+
modeKeyed: "API key",
|
|
38
|
+
apiKey: "API key",
|
|
39
|
+
apiKeyHint: "Stored outside the settings file. Leave blank to keep the current key.",
|
|
40
|
+
apiKeySet: "A key is configured.",
|
|
41
|
+
apiKeyUnset: "No key is configured; keyed search is unavailable until one is.",
|
|
42
|
+
apiKeyEnv: "Credential reference",
|
|
43
|
+
apiKeyEnvHint: "Name of the credential/env var resolved for keyed search.",
|
|
44
|
+
baseURL: "Endpoint",
|
|
45
|
+
baseURLHint: "Leave blank to use the provider default.",
|
|
46
|
+
maxResults: "Max results",
|
|
47
|
+
maxResultsHint: "Sources returned per search (1-20).",
|
|
48
|
+
searchDepth: "Search depth",
|
|
49
|
+
searchDepthBasic: "Basic (faster)",
|
|
50
|
+
searchDepthAdvanced: "Advanced (slower, deeper)",
|
|
51
|
+
includeAnswer: "Include answer",
|
|
52
|
+
includeAnswerHint: "Request Tavily's generated answer text shown above the sources.",
|
|
53
|
+
topic: "Topic",
|
|
54
|
+
topicGeneral: "General",
|
|
55
|
+
topicNews: "News",
|
|
56
|
+
expand: "Show settings",
|
|
57
|
+
collapse: "Hide settings",
|
|
58
|
+
save: "Save",
|
|
59
|
+
saving: "Saving…",
|
|
60
|
+
discard: "Discard",
|
|
61
|
+
unsaved: "Unsaved",
|
|
62
|
+
saveFailed: "The deployment did not accept these values; they were left for you to correct.",
|
|
63
|
+
readOnly: "This deployment stores settings read-only.",
|
|
64
|
+
invalidNumber: "Enter a number, or leave blank to use the default."
|
|
65
|
+
};
|
|
66
|
+
/** Simplified Chinese copy. */
|
|
67
|
+
const zh = {
|
|
68
|
+
nav: "网页搜索(Tavily)",
|
|
69
|
+
description: "Tavily 搜索提供方。默认 keyless 免费使用;切换到 API key 并填入 Tavily key 可提高限额。",
|
|
70
|
+
mode: "认证模式",
|
|
71
|
+
modeKeyless: "Keyless(免费、限流)",
|
|
72
|
+
modeKeyed: "API key",
|
|
73
|
+
apiKey: "API Key",
|
|
74
|
+
apiKeyHint: "不写入设置文件。留空表示保持当前密钥。",
|
|
75
|
+
apiKeySet: "已配置密钥。",
|
|
76
|
+
apiKeyUnset: "未配置密钥;keyed 模式下搜索不可用。",
|
|
77
|
+
apiKeyEnv: "凭据引用名",
|
|
78
|
+
apiKeyEnvHint: "keyed 模式解析的凭据/环境变量名。",
|
|
79
|
+
baseURL: "接口地址",
|
|
80
|
+
baseURLHint: "留空则使用提供方默认地址。",
|
|
81
|
+
maxResults: "结果数量",
|
|
82
|
+
maxResultsHint: "每次搜索返回的源数量(1-20)。",
|
|
83
|
+
searchDepth: "搜索深度",
|
|
84
|
+
searchDepthBasic: "Basic(较快)",
|
|
85
|
+
searchDepthAdvanced: "Advanced(较慢、更深)",
|
|
86
|
+
includeAnswer: "包含摘要回答",
|
|
87
|
+
includeAnswerHint: "请求 Tavily 生成摘要回答,显示在结果上方。",
|
|
88
|
+
topic: "主题",
|
|
89
|
+
topicGeneral: "通用",
|
|
90
|
+
topicNews: "新闻",
|
|
91
|
+
expand: "展开设置",
|
|
92
|
+
collapse: "收起设置",
|
|
93
|
+
save: "保存",
|
|
94
|
+
saving: "保存中…",
|
|
95
|
+
discard: "放弃修改",
|
|
96
|
+
unsaved: "未保存",
|
|
97
|
+
saveFailed: "本部署没有接受这些值,已保留供你修改。",
|
|
98
|
+
readOnly: "本部署的设置为只读。",
|
|
99
|
+
invalidNumber: "请填数字;留空表示使用默认值。"
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/** A whole-number field spec: empty draft clears, any other invalid draft blocks save. */
|
|
103
|
+
function numberField(field) {
|
|
104
|
+
return {
|
|
105
|
+
field,
|
|
106
|
+
format: (value) => typeof value === "number" ? String(value) : "",
|
|
107
|
+
parse: (text) => {
|
|
108
|
+
const trimmed = text.trim();
|
|
109
|
+
if (trimmed === "") return { kind: "clear" };
|
|
110
|
+
const parsed = Number(trimmed);
|
|
111
|
+
return Number.isFinite(parsed) ? { kind: "set", value: parsed } : void 0;
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/** A free-text field spec: empty draft clears. */
|
|
116
|
+
function textField(field) {
|
|
117
|
+
return {
|
|
118
|
+
field,
|
|
119
|
+
format: (value) => typeof value === "string" ? value : "",
|
|
120
|
+
parse: (text) => {
|
|
121
|
+
const trimmed = text.trim();
|
|
122
|
+
return trimmed === "" ? { kind: "clear" } : { kind: "set", value: trimmed };
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
/** A choice field spec (mode/searchDepth/topic): staged as its raw string. */
|
|
127
|
+
function choiceField(field) {
|
|
128
|
+
return {
|
|
129
|
+
field,
|
|
130
|
+
format: (value) => typeof value === "string" ? value : "",
|
|
131
|
+
parse: (text) => text.trim() === "" ? { kind: "clear" } : { kind: "set", value: text.trim() }
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/** A boolean field spec: a checkbox draft sets or clears. */
|
|
135
|
+
function booleanField(field) {
|
|
136
|
+
return {
|
|
137
|
+
field,
|
|
138
|
+
format: (value) => value === true ? "true" : "",
|
|
139
|
+
parse: (text) => text === "true" ? { kind: "set", value: true } : { kind: "clear" }
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Staged form over the `dsh-web-search-plugin` settings namespace. Modeled on
|
|
145
|
+
* the official cards' `CardForm` contract: a draft map, save writes every
|
|
146
|
+
* staged edit once, and the API key is written through the credentials
|
|
147
|
+
* domain addressed by the reference the section names.
|
|
148
|
+
*/
|
|
149
|
+
var TavilyCardForm = class {
|
|
150
|
+
constructor(scope, api) {
|
|
151
|
+
this.scope = scope;
|
|
152
|
+
this.api = api;
|
|
153
|
+
this.specs = new Map([numberField("maxResults"), textField("apiKeyEnv"), textField("baseURL"), choiceField("mode"), choiceField("searchDepth"), choiceField("topic"), booleanField("includeAnswer")].map((spec) => [spec.field, spec]));
|
|
154
|
+
this.staged = new Map();
|
|
155
|
+
this.saving = false;
|
|
156
|
+
this.failed = false;
|
|
157
|
+
this.credential = { ref: "", configured: false, writable: true };
|
|
158
|
+
this.listeners = new Set();
|
|
159
|
+
scope.subscribe(() => {
|
|
160
|
+
this.publish();
|
|
161
|
+
});
|
|
162
|
+
this.readCredential();
|
|
163
|
+
}
|
|
164
|
+
bind(project) {
|
|
165
|
+
const store = (0, _deepseek_ai_dsh_client_runtime_client.createSnapshotStore)(project());
|
|
166
|
+
this.listeners.add(() => {
|
|
167
|
+
store.set(project());
|
|
168
|
+
});
|
|
169
|
+
return store;
|
|
170
|
+
}
|
|
171
|
+
shell() {
|
|
172
|
+
const snapshot = this.scope.getSnapshot();
|
|
173
|
+
const plan = this.plan();
|
|
174
|
+
return {
|
|
175
|
+
available: snapshot.status === "ready",
|
|
176
|
+
writable: snapshot.writable,
|
|
177
|
+
dirty: plan.length > 0,
|
|
178
|
+
invalid: plan.some((item) => item.run === void 0),
|
|
179
|
+
saving: this.saving,
|
|
180
|
+
failed: this.failed
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
field(field) {
|
|
184
|
+
const staged = this.staged.get(field);
|
|
185
|
+
// Write-only credential controls have no section spec; they
|
|
186
|
+
// report only their staged draft (mirroring the official
|
|
187
|
+
// cards' secret-field branch in CardForm.field).
|
|
188
|
+
if (field === API_KEY_FIELD) return {
|
|
189
|
+
text: staged?.text ?? "",
|
|
190
|
+
overridden: false,
|
|
191
|
+
invalid: false
|
|
192
|
+
};
|
|
193
|
+
const spec = this.specs.get(field);
|
|
194
|
+
if (staged === void 0) return {
|
|
195
|
+
text: spec.format(this.sectionValue(field)),
|
|
196
|
+
overridden: this.stored(field),
|
|
197
|
+
invalid: false
|
|
198
|
+
};
|
|
199
|
+
const write = staged.clear ? { kind: "clear" } : spec.parse(staged.text);
|
|
200
|
+
return {
|
|
201
|
+
text: staged.text,
|
|
202
|
+
overridden: write?.kind === "set",
|
|
203
|
+
invalid: write === void 0
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
plan() {
|
|
207
|
+
const plan = [];
|
|
208
|
+
for (const [field, staged] of this.staged) {
|
|
209
|
+
if (field === API_KEY_FIELD) {
|
|
210
|
+
const value = staged.text.trim();
|
|
211
|
+
if (value !== "") plan.push({ field, run: () => this.writeKey(value) });
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
const spec = this.specs.get(field);
|
|
215
|
+
if (staged.clear) {
|
|
216
|
+
if (this.stored(field)) plan.push({ field, run: () => this.clear(field) });
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (staged.text === spec.format(this.sectionValue(field))) continue;
|
|
220
|
+
const write = spec.parse(staged.text);
|
|
221
|
+
if (write === void 0) plan.push({ field, run: void 0 });
|
|
222
|
+
else if (write.kind === "clear") plan.push({ field, run: () => this.clear(field) });
|
|
223
|
+
else plan.push({ field, run: () => this.store(field, write.value) });
|
|
224
|
+
}
|
|
225
|
+
return plan;
|
|
226
|
+
}
|
|
227
|
+
async save() {
|
|
228
|
+
const plan = this.plan();
|
|
229
|
+
const writes = plan.flatMap((item) => item.run === void 0 ? [] : [item.run]);
|
|
230
|
+
if (plan.length === 0 || this.saving || writes.length !== plan.length) return;
|
|
231
|
+
this.saving = true;
|
|
232
|
+
this.failed = false;
|
|
233
|
+
this.publish();
|
|
234
|
+
let landed = true;
|
|
235
|
+
for (const write of writes) landed = await write() && landed;
|
|
236
|
+
if (landed) this.staged.clear();
|
|
237
|
+
this.saving = false;
|
|
238
|
+
this.failed = !landed;
|
|
239
|
+
this.publish();
|
|
240
|
+
}
|
|
241
|
+
actions() {
|
|
242
|
+
return {
|
|
243
|
+
edit: (field, text) => {
|
|
244
|
+
this.stage(field, { text, clear: false });
|
|
245
|
+
},
|
|
246
|
+
resetField: (field) => {
|
|
247
|
+
this.stage(field, { text: this.baseText(field), clear: true });
|
|
248
|
+
},
|
|
249
|
+
save: () => {
|
|
250
|
+
this.save();
|
|
251
|
+
},
|
|
252
|
+
discard: () => {
|
|
253
|
+
if (this.staged.size === 0 && !this.failed) return;
|
|
254
|
+
this.staged.clear();
|
|
255
|
+
this.failed = false;
|
|
256
|
+
this.publish();
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
async clear(field) {
|
|
261
|
+
await this.scope.unset(field);
|
|
262
|
+
return !this.stored(field);
|
|
263
|
+
}
|
|
264
|
+
async store(field, value) {
|
|
265
|
+
await this.scope.set(field, value);
|
|
266
|
+
return this.userLayer()?.[field] === value;
|
|
267
|
+
}
|
|
268
|
+
stage(field, edit) {
|
|
269
|
+
this.staged.set(field, edit);
|
|
270
|
+
this.failed = false;
|
|
271
|
+
this.publish();
|
|
272
|
+
}
|
|
273
|
+
snapshotOf() {
|
|
274
|
+
return this.scope.getSnapshot();
|
|
275
|
+
}
|
|
276
|
+
sectionValue(field) {
|
|
277
|
+
return this.snapshotOf().value?.[field];
|
|
278
|
+
}
|
|
279
|
+
baseText(field) {
|
|
280
|
+
return this.specs.get(field).format(this.snapshotOf().base?.[field]);
|
|
281
|
+
}
|
|
282
|
+
userLayer() {
|
|
283
|
+
return this.snapshotOf().user;
|
|
284
|
+
}
|
|
285
|
+
stored(field) {
|
|
286
|
+
const user = this.userLayer();
|
|
287
|
+
return user !== void 0 && Object.hasOwn(user, field);
|
|
288
|
+
}
|
|
289
|
+
publish() {
|
|
290
|
+
for (const listener of this.listeners) listener();
|
|
291
|
+
}
|
|
292
|
+
/** The credential reference the section names, or the provider default. */
|
|
293
|
+
refOf() {
|
|
294
|
+
const declared = this.sectionValue("apiKeyEnv");
|
|
295
|
+
return declared !== void 0 && declared.length > 0 ? declared : DEFAULT_API_KEY_REF;
|
|
296
|
+
}
|
|
297
|
+
/** Ask the credentials domain about the reference the section currently names. */
|
|
298
|
+
async readCredential() {
|
|
299
|
+
const ref = this.refOf();
|
|
300
|
+
if (ref !== this.credential.ref) {
|
|
301
|
+
this.credential = { ref, configured: false, writable: true };
|
|
302
|
+
this.publish();
|
|
303
|
+
}
|
|
304
|
+
let response;
|
|
305
|
+
try {
|
|
306
|
+
response = await this.api.credentials.describe({ refs: [ref] });
|
|
307
|
+
} catch (_credentialReadFailure) {
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
if (!response.result.ok || ref !== this.refOf()) return;
|
|
311
|
+
const view = response.result.value.credentials[ref];
|
|
312
|
+
const next = {
|
|
313
|
+
ref,
|
|
314
|
+
configured: view?.configured ?? false,
|
|
315
|
+
writable: view?.writable ?? true
|
|
316
|
+
};
|
|
317
|
+
if (next.configured === this.credential.configured && next.writable === this.credential.writable) return;
|
|
318
|
+
this.credential = next;
|
|
319
|
+
this.publish();
|
|
320
|
+
}
|
|
321
|
+
/** Re-read after the Host reports a change to the reference this card watches. */
|
|
322
|
+
refreshCredential(ref) {
|
|
323
|
+
if (ref !== this.credential.ref) return;
|
|
324
|
+
this.readCredential();
|
|
325
|
+
}
|
|
326
|
+
/** Write the staged key, then re-read whether the Host now holds one. */
|
|
327
|
+
async writeKey(value) {
|
|
328
|
+
try {
|
|
329
|
+
await this.api.credentials.set({ ref: this.refOf(), value });
|
|
330
|
+
} catch (_credentialWriteFailure) {}
|
|
331
|
+
await this.readCredential();
|
|
332
|
+
return this.credential.configured;
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
/** Bridges the `dsh-web-search-plugin` scope and the credentials domain onto the card. */
|
|
337
|
+
var TavilyCardController = class {
|
|
338
|
+
constructor(scope, api) {
|
|
339
|
+
this.scope = scope;
|
|
340
|
+
this.api = api;
|
|
341
|
+
this.form = new TavilyCardForm(scope, api);
|
|
342
|
+
this.store = this.form.bind(() => this.projection());
|
|
343
|
+
scope.subscribe(() => {
|
|
344
|
+
this.form.readCredential();
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
projection() {
|
|
348
|
+
return {
|
|
349
|
+
...this.form.shell(),
|
|
350
|
+
mode: this.form.field("mode"),
|
|
351
|
+
apiKey: this.form.field(API_KEY_FIELD),
|
|
352
|
+
apiKeyEnv: this.form.field("apiKeyEnv"),
|
|
353
|
+
baseURL: this.form.field("baseURL"),
|
|
354
|
+
maxResults: this.form.field("maxResults"),
|
|
355
|
+
searchDepth: this.form.field("searchDepth"),
|
|
356
|
+
includeAnswer: this.form.field("includeAnswer"),
|
|
357
|
+
topic: this.form.field("topic"),
|
|
358
|
+
apiKeyConfigured: this.form.credential.configured,
|
|
359
|
+
apiKeyWritable: this.form.credential.writable
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
inject() {
|
|
363
|
+
return {
|
|
364
|
+
hooks: { tavilySearchCard: this.store },
|
|
365
|
+
...this.form.actions()
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
/** Row style: one labelled field with label, hint, and staged input. */
|
|
371
|
+
const fieldRow = (label, hint, children) => react.createElement("div", { style: { display: "flex", flexDirection: "column", gap: "4px", padding: "12px 0", borderTop: "1px solid var(--dsw-alias-border-l2)" } },
|
|
372
|
+
react.createElement("label", { style: { color: "var(--dsw-alias-label-primary)", fontSize: "13px", fontWeight: 500, lineHeight: "1.5" } }, label),
|
|
373
|
+
children,
|
|
374
|
+
hint ? react.createElement("p", { style: { color: "var(--dsw-alias-label-tertiary)", margin: 0, fontSize: "12px", lineHeight: "1.5" } }, hint) : null);
|
|
375
|
+
/** Text/number input. */
|
|
376
|
+
const textInput = (props) => react.createElement("input", { 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" }, type: "text", ...props.numeric ? { inputMode: "numeric" } : {}, ...props });
|
|
377
|
+
/** Password input for the write-only credential. */
|
|
378
|
+
const secretInput = (props) => react.createElement("input", { 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" }, type: "password", autoComplete: "off", ...props });
|
|
379
|
+
/** Choice select. */
|
|
380
|
+
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 });
|
|
381
|
+
/** Checkbox for booleans. */
|
|
382
|
+
const checkboxInput = (props) => react.createElement("input", { type: "checkbox", style: { width: "16px", height: "16px", accentColor: "var(--dsw-alias-brand-primary)" }, ...props });
|
|
383
|
+
|
|
384
|
+
/** Render one plugin card: header disclosure plus the staged form. */
|
|
385
|
+
function TavilyCard(props) {
|
|
386
|
+
const { t } = props;
|
|
387
|
+
const state = props.useTavilySearchCard((snapshot) => snapshot);
|
|
388
|
+
const [open, setOpen] = react.useState(false);
|
|
389
|
+
if (!state.available) return null;
|
|
390
|
+
const disabled = !state.writable;
|
|
391
|
+
const blocked = !state.dirty || state.invalid || state.saving;
|
|
392
|
+
return react.createElement("li", { style: { border: "1px solid var(--dsw-alias-border-l2)", background: "var(--dsw-alias-bg-layer-3)", borderRadius: "12px", listStyle: "none" } },
|
|
393
|
+
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" } },
|
|
394
|
+
react.createElement("span", { style: { flexDirection: "column", flex: 1, gap: "4px", minWidth: 0, display: "flex" } },
|
|
395
|
+
react.createElement("span", { style: { color: "var(--dsw-alias-label-primary)", fontSize: "15px", fontWeight: 600, lineHeight: "1.4" } }, t("nav")),
|
|
396
|
+
react.createElement("span", { style: { color: "var(--dsw-alias-label-tertiary)", fontSize: "13px", lineHeight: "1.5" } }, t("description"))),
|
|
397
|
+
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,
|
|
398
|
+
react.createElement("span", { style: { color: "var(--dsw-alias-label-tertiary)", flex: "none" } }, open ? "▾" : "▸")),
|
|
399
|
+
open ? react.createElement("div", { style: { borderTop: "1px solid var(--dsw-alias-border-l2)", margin: "0 16px", paddingBottom: "8px" } },
|
|
400
|
+
!state.writable ? react.createElement("p", { style: { color: "var(--dsw-alias-label-tertiary)", margin: "12px 0 0", fontSize: "12px", lineHeight: "1.5" } }, t("readOnly")) : null,
|
|
401
|
+
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: [
|
|
402
|
+
react.createElement("option", { value: "keyless", key: "keyless" }, t("modeKeyless")),
|
|
403
|
+
react.createElement("option", { value: "keyed", key: "keyed" }, t("modeKeyed"))
|
|
404
|
+
] })),
|
|
405
|
+
state.mode.text === "keyed" ? fieldRow(t("apiKey"), t("apiKeyHint"), secretInput({ id: "plugin-config-web-search-key", value: state.apiKey.text, disabled: !state.apiKeyWritable, placeholder: state.apiKeyConfigured ? t("apiKeySet") : t("apiKeyUnset"), onChange: (e) => props.edit("apiKey", e.target.value) })) : null,
|
|
406
|
+
state.mode.text === "keyed" ? fieldRow(t("apiKeyEnv"), t("apiKeyEnvHint"), textInput({ id: "plugin-config-web-search-keyref", value: state.apiKeyEnv.text, disabled, onChange: (e) => props.edit("apiKeyEnv", e.target.value), onReset: () => props.resetField("apiKeyEnv") })) : null,
|
|
407
|
+
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") })),
|
|
408
|
+
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: [
|
|
409
|
+
react.createElement("option", { value: "basic", key: "basic" }, t("searchDepthBasic")),
|
|
410
|
+
react.createElement("option", { value: "advanced", key: "advanced" }, t("searchDepthAdvanced"))
|
|
411
|
+
] })),
|
|
412
|
+
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: [
|
|
413
|
+
react.createElement("option", { value: "general", key: "general" }, t("topicGeneral")),
|
|
414
|
+
react.createElement("option", { value: "news", key: "news" }, t("topicNews"))
|
|
415
|
+
] })),
|
|
416
|
+
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" : "") })),
|
|
417
|
+
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") })),
|
|
418
|
+
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" } },
|
|
419
|
+
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,
|
|
420
|
+
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")),
|
|
421
|
+
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);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Mount the Tavily card into the plugin configuration surface. */
|
|
425
|
+
function apply(ctx) {
|
|
426
|
+
const { api } = ctx.get("connection");
|
|
427
|
+
const t = ctx.locale.bind(NS);
|
|
428
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), "dsh-web-search-plugin: card dictionaries");
|
|
429
|
+
const controller = new TavilyCardController(ctx.settingsScope.bind({ namespace: NS }), api);
|
|
430
|
+
ctx.effect(() => ctx.remote.$on("credentials/updated", (ref) => {
|
|
431
|
+
controller.form.refreshCredential(ref);
|
|
432
|
+
}), "dsh-web-search-plugin: credential invalidations");
|
|
433
|
+
ctx.slots.inject("settings.plugin.item", function* () {
|
|
434
|
+
yield ctx.slots.register({
|
|
435
|
+
name: "settings.plugin.item",
|
|
436
|
+
key: "dsh-web-search-plugin",
|
|
437
|
+
locale: NS,
|
|
438
|
+
inject: () => controller.inject()
|
|
439
|
+
}, TavilyCard);
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** Cordis service names this browser plugin's apply reads (fiber inject). */
|
|
444
|
+
const inject = ["slots", "locale", "connection", "remote", "settingsScope"];
|
|
445
|
+
|
|
446
|
+
exports.apply = apply;
|
|
447
|
+
exports.inject = inject;
|
|
448
|
+
return module.exports;
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
//# sourceMappingURL=client.js.map
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tavily-backed search provider for the DeepSeek Harness web capability seam
|
|
3
|
+
* (`ctx.web`). Registers a `WebSearchProvider` under the stable id `tavily`
|
|
4
|
+
* that calls the Tavily REST API (`POST {baseURL}/search`).
|
|
5
|
+
*
|
|
6
|
+
* Two authentication modes are supported, switchable through this plugin's
|
|
7
|
+
* settings section (`dsh-web-search-plugin`):
|
|
8
|
+
* - `keyless`: free rate-limited access, no account or key. A single
|
|
9
|
+
* `X-Tavily-Access-Mode: keyless` header activates it.
|
|
10
|
+
* - `keyed`: a Tavily API key resolved through the credentials service
|
|
11
|
+
* (`TAVILY_API_KEY` by default), the launching environment, or a literal
|
|
12
|
+
* `apiKey` in the config; sent as a Bearer token.
|
|
13
|
+
*
|
|
14
|
+
* Responses follow the standard Tavily schema and are normalized into the
|
|
15
|
+
* seam's `WebSearchResult` (`answer` -> `content`, `results[]` -> `sources[]`).
|
|
16
|
+
*
|
|
17
|
+
* The plugin mirrors the structure of `@deepseek-ai/dsh-web-search-deepseek`:
|
|
18
|
+
* a function/namespace Cordis plugin (`inject: ['web']`) that registers its
|
|
19
|
+
* own settings section and never registers a model-facing tool.
|
|
20
|
+
*
|
|
21
|
+
* @module dsh-web-search-plugin
|
|
22
|
+
*/
|
|
23
|
+
import z from "@deepseek-ai/schemastery";
|
|
24
|
+
import { credentialRef } from "@deepseek-ai/dsh-credentials";
|
|
25
|
+
import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
26
|
+
import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
|
|
27
|
+
import { WebError } from "@deepseek-ai/dsh-web";
|
|
28
|
+
|
|
29
|
+
/** Stable id this provider registers under. */
|
|
30
|
+
const TAVILY_PROVIDER_ID = "tavily";
|
|
31
|
+
/** Default Tavily REST API base; `/search` is appended. */
|
|
32
|
+
const TAVILY_DEFAULT_BASE_URL = "https://api.tavily.com";
|
|
33
|
+
/** Default credential reference resolved for every keyed search. */
|
|
34
|
+
const DEFAULT_API_KEY_ENV = "TAVILY_API_KEY";
|
|
35
|
+
/** Attribution header sent on every request. */
|
|
36
|
+
const USER_AGENT = "dsh-web-search-plugin/0.1.1";
|
|
37
|
+
/** Tavily's hard cap on `max_results`. */
|
|
38
|
+
const TAVILY_MAX_RESULTS_CAP = 20;
|
|
39
|
+
|
|
40
|
+
/** Cordis plugin name used by loader diagnostics. */
|
|
41
|
+
const name = "dsh-web-search-plugin";
|
|
42
|
+
/** The web seam this provider registers into. */
|
|
43
|
+
const inject = ["web"];
|
|
44
|
+
|
|
45
|
+
/** Plugin config (all optional — `apply` fills defaults). */
|
|
46
|
+
const Config = z.object({
|
|
47
|
+
/** Authentication mode: `keyless` (default, free rate-limited) or `keyed`. */
|
|
48
|
+
mode: z.string().default("keyless"),
|
|
49
|
+
/** Literal Tavily API key; prefer {@link apiKeyEnv} so no secret enters configuration files. */
|
|
50
|
+
apiKey: z.string().role("secret").default(""),
|
|
51
|
+
/** Credential reference resolved for each keyed search; defaults to `TAVILY_API_KEY`. */
|
|
52
|
+
apiKeyEnv: z.string().role("credential-ref").default(DEFAULT_API_KEY_ENV),
|
|
53
|
+
/** Tavily REST API base. Defaults to `https://api.tavily.com`. */
|
|
54
|
+
baseURL: z.string().default(TAVILY_DEFAULT_BASE_URL),
|
|
55
|
+
/** Upper bound on returned sources; Tavily accepts 1..20. */
|
|
56
|
+
maxResults: z.number().step(1).min(1).max(TAVILY_MAX_RESULTS_CAP).default(8),
|
|
57
|
+
/** Search depth: `basic` (default) or `advanced`. */
|
|
58
|
+
searchDepth: z.string().default("basic"),
|
|
59
|
+
/** Request Tavily's generated answer text; surfaced as the result `content`. */
|
|
60
|
+
includeAnswer: z.boolean().default(true),
|
|
61
|
+
/** Search topic: `general` (default) or `news`. */
|
|
62
|
+
topic: z.string().default("general"),
|
|
63
|
+
/** Settings namespace carrying this provider's mode, endpoint, and key reference. */
|
|
64
|
+
});
|
|
65
|
+
/** Settings namespace carrying this provider's mode, endpoint, and key reference. */
|
|
66
|
+
const WEB_SEARCH_TAVILY_SETTINGS_NAMESPACE = settingsNamespace("dsh-web-search-plugin");
|
|
67
|
+
/** Environment variable naming this provider's endpoint override. */
|
|
68
|
+
const TAVILY_BASE_URL_ENV = "TAVILY_BASE_URL";
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Normalize a standard Tavily search response into the seam's
|
|
72
|
+
* `WebSearchResult`. Sources are deduped by `url` (Tavily can surface the same
|
|
73
|
+
* page twice); the provider-generated `answer` becomes `content` only when the
|
|
74
|
+
* `includeAnswer` option asked for it. The seam owns the final `maxResults`
|
|
75
|
+
* truncation, so `truncated` is always `false` here.
|
|
76
|
+
*
|
|
77
|
+
* @param json - the parsed Tavily response body.
|
|
78
|
+
* @param includeAnswer - whether the request requested `include_answer`.
|
|
79
|
+
* @returns the normalized search result.
|
|
80
|
+
* @throws {@link WebError} when the body is not a usable object.
|
|
81
|
+
*/
|
|
82
|
+
function mapTavilyResponse(json, includeAnswer) {
|
|
83
|
+
if (json === null || typeof json !== "object") throw new WebError("Tavily returned a non-object response body", "WEB_PROVIDER_ERROR");
|
|
84
|
+
const results = Array.isArray(json.results) ? json.results : [];
|
|
85
|
+
if (results.length === 0) throw new WebError("Tavily returned no results", "WEB_PROVIDER_ERROR");
|
|
86
|
+
const seen = /* @__PURE__ */ new Set();
|
|
87
|
+
const sources = [];
|
|
88
|
+
for (const item of results) {
|
|
89
|
+
if (item === null || typeof item !== "object") continue;
|
|
90
|
+
if (typeof item.url !== "string" || item.url.length === 0 || seen.has(item.url)) continue;
|
|
91
|
+
seen.add(item.url);
|
|
92
|
+
sources.push({
|
|
93
|
+
url: item.url,
|
|
94
|
+
...typeof item.title === "string" && item.title.length > 0 ? { title: item.title } : {},
|
|
95
|
+
...typeof item.content === "string" && item.content.length > 0 ? { snippet: item.content } : {},
|
|
96
|
+
...typeof item.published_date === "string" && item.published_date.length > 0 ? { publishedAt: item.published_date } : {}
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
...includeAnswer === true && typeof json.answer === "string" && json.answer.length > 0 ? { content: json.answer } : {},
|
|
101
|
+
sources,
|
|
102
|
+
truncated: false
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The Tavily-backed search provider; HTTP redirects fail as `WEB_PROVIDER_ERROR`. */
|
|
107
|
+
class TavilySearchProvider {
|
|
108
|
+
/**
|
|
109
|
+
* @param resolveOptions - the options for the NEXT operation, snapshotted
|
|
110
|
+
* once at each operation's entry so one search never mixes two sections.
|
|
111
|
+
*/
|
|
112
|
+
constructor(resolveOptions) {
|
|
113
|
+
this.resolveOptions = resolveOptions;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
id = TAVILY_PROVIDER_ID;
|
|
117
|
+
|
|
118
|
+
/** Cheap local usability check; must not make network calls. */
|
|
119
|
+
available() {
|
|
120
|
+
const options = this.resolveOptions();
|
|
121
|
+
if (!URL.canParse(options.baseURL)) return false;
|
|
122
|
+
if (options.mode !== "keyed") return true;
|
|
123
|
+
return ((options.apiKey?.length ?? 0) > 0 || options.resolveApiKey !== void 0);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Run one search through the Tavily REST API. */
|
|
127
|
+
async search(request, signal) {
|
|
128
|
+
const options = this.resolveOptions();
|
|
129
|
+
throwIfSearchAborted(signal);
|
|
130
|
+
let apiKey;
|
|
131
|
+
if (options.mode === "keyed") {
|
|
132
|
+
apiKey = await this.apiKey(options, signal);
|
|
133
|
+
throwIfSearchAborted(signal);
|
|
134
|
+
}
|
|
135
|
+
const endpoint = `${options.baseURL.replace(/\/+$/u, "")}/search`;
|
|
136
|
+
const body = {
|
|
137
|
+
query: request.query,
|
|
138
|
+
max_results: Math.min(request.maxResults ?? options.maxResults, TAVILY_MAX_RESULTS_CAP),
|
|
139
|
+
search_depth: options.searchDepth,
|
|
140
|
+
topic: options.topic,
|
|
141
|
+
include_answer: options.includeAnswer === true,
|
|
142
|
+
include_images: false
|
|
143
|
+
};
|
|
144
|
+
const headers = {
|
|
145
|
+
"content-type": "application/json",
|
|
146
|
+
"accept": "application/json",
|
|
147
|
+
"user-agent": USER_AGENT,
|
|
148
|
+
...options.mode === "keyless" ? { "x-tavily-access-mode": "keyless" } : { "authorization": `Bearer ${apiKey}` }
|
|
149
|
+
};
|
|
150
|
+
let response;
|
|
151
|
+
try {
|
|
152
|
+
response = await fetch(endpoint, {
|
|
153
|
+
method: "POST",
|
|
154
|
+
redirect: "error",
|
|
155
|
+
headers,
|
|
156
|
+
body: JSON.stringify(body),
|
|
157
|
+
...signal !== void 0 ? { signal } : {}
|
|
158
|
+
});
|
|
159
|
+
} catch (error) {
|
|
160
|
+
if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
|
|
161
|
+
throw new WebError(`Tavily search request failed: ${String(error)}`, "WEB_PROVIDER_ERROR", { cause: error });
|
|
162
|
+
}
|
|
163
|
+
if (!response.ok) {
|
|
164
|
+
let message = `Tavily API error (HTTP ${response.status})`;
|
|
165
|
+
try {
|
|
166
|
+
const parsed = await response.json();
|
|
167
|
+
const detail = parsed?.detail?.error ?? parsed?.error ?? parsed?.message;
|
|
168
|
+
if (typeof detail === "string" && detail.length > 0) message = detail;
|
|
169
|
+
} catch {
|
|
170
|
+
/* non-JSON error body — keep the status-line message */
|
|
171
|
+
}
|
|
172
|
+
throw new WebError(message, "WEB_PROVIDER_ERROR");
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
return mapTavilyResponse(await response.json(), options.includeAnswer === true);
|
|
176
|
+
} catch (error) {
|
|
177
|
+
if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
|
|
178
|
+
if (error instanceof WebError) throw error;
|
|
179
|
+
throw new WebError(`Tavily returned an unprocessable response body: ${String(error)}`, "WEB_PROVIDER_ERROR", { cause: error });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Resolve one operation's credential without retaining it on the provider.
|
|
185
|
+
* @param options - the caller's snapshot; the certificate and the endpoint it is sent to come from one section.
|
|
186
|
+
* @param signal - abort signal for the surrounding search.
|
|
187
|
+
* @returns the resolved key.
|
|
188
|
+
*/
|
|
189
|
+
async apiKey(options, signal) {
|
|
190
|
+
throwIfSearchAborted(signal);
|
|
191
|
+
if (options.apiKey !== void 0 && options.apiKey.length > 0) return options.apiKey;
|
|
192
|
+
let resolved;
|
|
193
|
+
try {
|
|
194
|
+
resolved = await abortable(options.resolveApiKey?.() ?? Promise.resolve(void 0), signal);
|
|
195
|
+
} catch (error) {
|
|
196
|
+
if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
|
|
197
|
+
throw new WebError(`Tavily search credential resolution failed: ${String(error)}`, "WEB_PROVIDER_ERROR", { cause: error });
|
|
198
|
+
}
|
|
199
|
+
if (resolved !== void 0 && resolved.length > 0) return resolved;
|
|
200
|
+
throw new WebError(`Tavily search has no API key for "${options.apiKeyEnv ?? DEFAULT_API_KEY_ENV}"; store it through the credentials service, export it in the launching environment, or set a literal "apiKey" in the dsh-web-search-plugin config`, "WEB_PROVIDER_CREDENTIAL_MISSING");
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Race a same-process asynchronous preflight against caller cancellation. */
|
|
205
|
+
function abortable(operation, signal) {
|
|
206
|
+
if (signal === void 0) return operation;
|
|
207
|
+
if (signal.aborted) return Promise.reject(searchAborted(signal));
|
|
208
|
+
return new Promise((resolve, reject) => {
|
|
209
|
+
const onAbort = () => {
|
|
210
|
+
reject(searchAborted(signal));
|
|
211
|
+
};
|
|
212
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
213
|
+
operation.then((value) => {
|
|
214
|
+
signal.removeEventListener("abort", onAbort);
|
|
215
|
+
resolve(value);
|
|
216
|
+
}, (error) => {
|
|
217
|
+
signal.removeEventListener("abort", onAbort);
|
|
218
|
+
reject(new Error(String(error).replace(/^Error: /u, ""), { cause: error }));
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Throw the provider's stable cancellation error when the caller already aborted. */
|
|
224
|
+
function throwIfSearchAborted(signal) {
|
|
225
|
+
if (signal?.aborted === true) throw searchAborted(signal);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Build the provider's stable cancellation error while retaining the caller's reason. */
|
|
229
|
+
function searchAborted(signal, fallback) {
|
|
230
|
+
return new WebError("Tavily search aborted", "WEB_ABORTED", { cause: signal?.aborted === true ? signal.reason : fallback });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** True for a fetch/`AbortSignal` abort, surfaced as `WEB_ABORTED`. */
|
|
234
|
+
function isAbortError(error) {
|
|
235
|
+
return error instanceof DOMException && error.name === "AbortError";
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Project one resolved section into the options the provider serves its next
|
|
240
|
+
* search with. Environment fallbacks stay here rather than in the provider:
|
|
241
|
+
* every value it reads is already fully defaulted.
|
|
242
|
+
* @param ctx - plugin context supplying the credential and environment planes.
|
|
243
|
+
* @param config - the currently authoritative section.
|
|
244
|
+
* @returns options for one search.
|
|
245
|
+
*/
|
|
246
|
+
function resolveOptions(ctx, config) {
|
|
247
|
+
const apiKeyEnv = credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV);
|
|
248
|
+
const literalApiKey = config.apiKey !== void 0 && config.apiKey.length > 0 ? config.apiKey : void 0;
|
|
249
|
+
return {
|
|
250
|
+
mode: config.mode ?? "keyless",
|
|
251
|
+
...literalApiKey === void 0 ? {} : { apiKey: literalApiKey },
|
|
252
|
+
resolveApiKey: async () => {
|
|
253
|
+
const credentials = ctx.get("credentials");
|
|
254
|
+
if (credentials !== void 0) return (await credentials.resolve(apiKeyEnv))?.value;
|
|
255
|
+
const ambient = launchEnvironmentOf(ctx).get(apiKeyEnv);
|
|
256
|
+
return ambient !== void 0 && ambient.value.length > 0 ? ambient.value : void 0;
|
|
257
|
+
},
|
|
258
|
+
apiKeyEnv,
|
|
259
|
+
baseURL: config.baseURL ?? launchEnvironmentOf(ctx).get(TAVILY_BASE_URL_ENV)?.value ?? TAVILY_DEFAULT_BASE_URL,
|
|
260
|
+
maxResults: config.maxResults ?? 8,
|
|
261
|
+
searchDepth: config.searchDepth ?? "basic",
|
|
262
|
+
includeAnswer: config.includeAnswer ?? true,
|
|
263
|
+
topic: config.topic ?? "general"
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Register the Tavily search provider with `ctx.web`. */
|
|
268
|
+
function apply(ctx, config) {
|
|
269
|
+
let current = () => config;
|
|
270
|
+
installSettingsSection(ctx, WEB_SEARCH_TAVILY_SETTINGS_NAMESPACE, Config, config, {
|
|
271
|
+
setSource: (source) => {
|
|
272
|
+
current = source;
|
|
273
|
+
},
|
|
274
|
+
onChange: () => {}
|
|
275
|
+
});
|
|
276
|
+
ctx.web.registerSearchProvider(new TavilySearchProvider(() => resolveOptions(ctx, current())));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export { Config, TAVILY_DEFAULT_BASE_URL, TAVILY_PROVIDER_ID, TavilySearchProvider, WEB_SEARCH_TAVILY_SETTINGS_NAMESPACE, apply, inject, name };
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-web-search-plugin",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Tavily web-search provider for the DeepSeek Harness web capability seam (ctx.web): keyless or API-key modes with a UI config card; provider framework extensible to more search APIs",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"dsh-plugin",
|
|
7
|
+
"deepseek-harness",
|
|
8
|
+
"dsh",
|
|
9
|
+
"tavily",
|
|
10
|
+
"web-search",
|
|
11
|
+
"websearch",
|
|
12
|
+
"search",
|
|
13
|
+
"plugin"
|
|
14
|
+
],
|
|
15
|
+
"type": "module",
|
|
16
|
+
"main": "lib/index.js",
|
|
17
|
+
"files": [
|
|
18
|
+
"lib",
|
|
19
|
+
"CHANGELOG.md"
|
|
20
|
+
],
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=18"
|
|
23
|
+
},
|
|
24
|
+
"exports": {
|
|
25
|
+
".": {
|
|
26
|
+
"default": "./lib/index.js"
|
|
27
|
+
},
|
|
28
|
+
"./client": {
|
|
29
|
+
"default": "./lib/client.js"
|
|
30
|
+
},
|
|
31
|
+
"./package.json": "./package.json"
|
|
32
|
+
},
|
|
33
|
+
"dsh": {
|
|
34
|
+
"client": {
|
|
35
|
+
"platform": "web",
|
|
36
|
+
"inject": [
|
|
37
|
+
"@deepseek-ai/dsh-client-connection",
|
|
38
|
+
"@deepseek-ai/dsh-client-locale",
|
|
39
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
40
|
+
"@deepseek-ai/dsh-client-ui-settings",
|
|
41
|
+
"@deepseek-ai/dsh-client-ui-slots",
|
|
42
|
+
"@deepseek-ai/dsh-api-remotes"
|
|
43
|
+
]
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"check": "node --check lib/index.js && node --check lib/client.js"
|
|
48
|
+
},
|
|
49
|
+
"peerDependencies": {
|
|
50
|
+
"@deepseek-ai/dsh-web": "^0.1.0-rc.7",
|
|
51
|
+
"@deepseek-ai/dsh-credentials": "^0.1.0-rc.7",
|
|
52
|
+
"@deepseek-ai/dsh-settings": "^0.1.0-rc.7",
|
|
53
|
+
"@deepseek-ai/dsh-launch-environment": "^0.1.0-rc.7",
|
|
54
|
+
"@deepseek-ai/schemastery": "^3.18.1",
|
|
55
|
+
"@deepseek-ai/cordis": "^4.0.1"
|
|
56
|
+
},
|
|
57
|
+
"license": "MIT",
|
|
58
|
+
"repository": {
|
|
59
|
+
"type": "git",
|
|
60
|
+
"url": "https://github.com/X-C1811/dsh-web-search-plugin.git"
|
|
61
|
+
},
|
|
62
|
+
"homepage": "https://github.com/X-C1811/dsh-web-search-plugin",
|
|
63
|
+
"bugs": {
|
|
64
|
+
"url": "https://github.com/X-C1811/dsh-web-search-plugin/issues"
|
|
65
|
+
}
|
|
66
|
+
}
|