dsh-web-search-free 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.en.md +136 -0
- package/README.md +136 -0
- package/cordis.patch.yml +23 -0
- package/dist/client.d.ts +2 -0
- package/dist/client.js +267 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +127 -0
- package/dist/providers/brave.d.ts +2 -0
- package/dist/providers/brave.js +34 -0
- package/dist/providers/exa.d.ts +2 -0
- package/dist/providers/exa.js +57 -0
- package/dist/providers/firecrawl.d.ts +2 -0
- package/dist/providers/firecrawl.js +54 -0
- package/dist/providers/index.d.ts +2 -0
- package/dist/providers/index.js +16 -0
- package/dist/providers/jina.d.ts +2 -0
- package/dist/providers/jina.js +33 -0
- package/dist/providers/tavily.d.ts +2 -0
- package/dist/providers/tavily.js +48 -0
- package/dist/types.d.ts +14 -0
- package/dist/types.js +2 -0
- package/package.json +60 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 MochiNek0
|
|
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.en.md
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# dsh-web-search-free
|
|
2
|
+
|
|
3
|
+
English | [中文](README.md)
|
|
4
|
+
|
|
5
|
+
A free Web Search / web fetch plugin for [DeepSeek Harness (dsh)](https://github.com/deepseek-ai/dsh).
|
|
6
|
+
|
|
7
|
+
It replaces dsh's default `deepseek-official` search/fetch channel with a **multi-engine + automatic fallback** channel: you provide API keys for whichever engines you choose, and it tries them in the order you arrange; if one fails (or runs out of quota) it automatically falls through to the next. A single engine may also carry multiple keys (one per line), rotated in order within that engine. All requests are issued from your browser side, never through the official search backend.
|
|
8
|
+
|
|
9
|
+
- Registers as a dsh `web` capability channel (providing both `searchProvider` and `fetchProvider`, both with id `web-search-free`).
|
|
10
|
+
- Ships a Web settings card (Settings → Plugins → **Web Search Free**) with drag-to-reorder and per-engine key entry.
|
|
11
|
+
- Installs as a dsh bundle layer: installing takes over web search/fetch, uninstalling auto-reverts to the default channel — no manual profile edits.
|
|
12
|
+
|
|
13
|
+
## Why "free": the billing difference vs. the official channel
|
|
14
|
+
|
|
15
|
+
dsh's default official channel `deepseek-official` (provided by `@deepseek-ai/dsh-web-search-deepseek`) is **not a dedicated search endpoint**: every search issues a **full Messages model call** (`POST …/anthropic/v1/messages` with the native `web_search` server tool), DeepSeek runs the search server-side and returns structured result blocks. So each search burns two sets of tokens:
|
|
16
|
+
|
|
17
|
+
1. **The auxiliary search request itself** — an independent model receives `Perform a web search for the query: <query>` plus a tool definition and incurs input + output tokens (`maxTokens` defaults to 4096, `maxUses` to 5); it's a complete model turn, billed for latency and generation like any model call.
|
|
18
|
+
2. **The results re-entering the conversation** — the parsed sources (URL/title/snippet) are injected back into the conversation model's context and resent as conversation tokens until compaction.
|
|
19
|
+
|
|
20
|
+
Both are charged against your `DEEPSEEK_API_KEY` balance.
|
|
21
|
+
|
|
22
|
+
This plugin instead calls each engine's **dedicated retrieval endpoint** (e.g. Tavily `/search`, Exa `/search`, Jina `s.jina.ai`) — pure retrieval, never touching any LLM:
|
|
23
|
+
|
|
24
|
+
| | Official `deepseek-official` | This plugin `web-search-free` |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
| Retrieval method | One full LLM model call + server-side search tool | Direct calls to each engine's dedicated retrieval endpoint |
|
|
27
|
+
| Model tokens | Burned every search (input + output) | **0** (pure retrieval, no LLM involved) |
|
|
28
|
+
| Billed against | DeepSeek API balance | Each search API's own quota (most have a free tier) |
|
|
29
|
+
| Credentials | `DEEPSEEK_API_KEY` | Per-engine API keys |
|
|
30
|
+
|
|
31
|
+
That's the core reason the plugin is named "free" — the official channel burns a whole model turn per search, while this one does pure retrieval and burns zero tokens.
|
|
32
|
+
|
|
33
|
+
## Supported engines
|
|
34
|
+
|
|
35
|
+
| Engine | Search | Fetch | Get an API key |
|
|
36
|
+
|---|:---:|:---:|---|
|
|
37
|
+
| Jina AI | ✓ | ✓ | <https://jina.ai/api-key> |
|
|
38
|
+
| Exa (Metaphor) | ✓ | ✓ | <https://dashboard.exa.ai/> |
|
|
39
|
+
| Tavily | ✓ | ✓ | <https://app.tavily.com/> |
|
|
40
|
+
| Firecrawl | ✓ | ✓ | <https://www.firecrawl.dev/> |
|
|
41
|
+
| Brave Search | ✓ | ✗ | <https://api-dashboard.search.brave.com/register> |
|
|
42
|
+
|
|
43
|
+
> Brave supports search only, not URL fetch. If your fetch chain only has a Brave key configured, fetch will fail — please also configure a key for an engine that supports fetch.
|
|
44
|
+
|
|
45
|
+
## Prerequisites
|
|
46
|
+
|
|
47
|
+
- dsh installed, with the `dsh` command available (when developing inside a dsh source checkout, use `pnpm dsh ...` instead).
|
|
48
|
+
- `pnpm` on your `PATH` (`dsh plugin` manages dependencies via pnpm inside the profile directory).
|
|
49
|
+
- The target profile is usually `web` (this plugin's client half declares `platform: web`; the settings card only appears in the Web UI). The `web` profile auto-initializes from a template on first use.
|
|
50
|
+
|
|
51
|
+
## Installation
|
|
52
|
+
|
|
53
|
+
`dsh plugin --profile <name> <pnpm args>` forwards the pnpm arguments into the profile directory and, **after a successful run, automatically reconciles `dsh.profile.bundles`**: any dependency that resolves to a package declaring `dsh.bundle` is appended to the bundle layer stack — this plugin declares exactly `dsh.bundle.patch`, so installing it takes over web search/fetch with no manual profile edit.
|
|
54
|
+
|
|
55
|
+
### From npm (recommended)
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
dsh plugin --profile web add dsh-web-search-free
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### From local source (for development / further hacking)
|
|
62
|
+
|
|
63
|
+
This repo gitignores `dist/`, so build the artifacts before installing.
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
# 1) Build in the plugin repo
|
|
67
|
+
cd /path/to/dsh-web-search-free
|
|
68
|
+
pnpm install
|
|
69
|
+
pnpm build # produces dist/index.js and dist/client.js
|
|
70
|
+
|
|
71
|
+
# 2) Add it to the web profile (run inside the plugin dir; "." is anchored to the cwd)
|
|
72
|
+
dsh plugin --profile web add .
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
> You can also use an absolute path and run from anywhere:
|
|
76
|
+
> `dsh plugin --profile web add /absolute/path/to/dsh-web-search-free`
|
|
77
|
+
>
|
|
78
|
+
> pnpm installs local directories as a link by default, so **rebuilding with `pnpm build` from source is picked up by the profile immediately** — handy for iteration. After changing the client half, refresh the browser (client-plugin hot-reload requires a rebuild watcher like `pnpm run dev:web` to be running).
|
|
79
|
+
|
|
80
|
+
## Configuration
|
|
81
|
+
|
|
82
|
+
After installing, start the dsh Web UI:
|
|
83
|
+
|
|
84
|
+
```sh
|
|
85
|
+
dsh web # equivalent to dsh --profile web
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Open the **Settings → Plugins → Web Search Free** card:
|
|
89
|
+
|
|
90
|
+
1. Expand the card; each row maps to one engine.
|
|
91
|
+
2. Paste the API key into the matching engine's input; the in-row "Get API key ↗" link goes straight to each engine's signup page. An engine may carry multiple keys: **one per line**, rotated in order within the engine.
|
|
92
|
+
3. **Drag each row** to set the call order: engines higher up are tried first, failing through to the next in order; multiple keys for the same engine are also tried in turn — the first successful (engine, key) pair returns, and only an all-fail raises an error. Engines with no key never enter the call chain.
|
|
93
|
+
4. Click "Save". Keys persist through the dsh settings namespace (`web-search-free`) and take effect on the next search, with no restart.
|
|
94
|
+
|
|
95
|
+
**Configure at least one engine's key**, otherwise search/fetch fails with `No web search providers configured.`
|
|
96
|
+
|
|
97
|
+
## Verification
|
|
98
|
+
|
|
99
|
+
1. Start the Web UI with `dsh web`.
|
|
100
|
+
2. Ask the model to use web search/fetch in a conversation (e.g. "search for today's news" or "fetch the content of https://example.com").
|
|
101
|
+
3. The request flows through the `web-search-free` channel in your arranged engine order; on a failure you'll see `Provider <name> ... failed. Trying next provider ...` in the logs, then the next engine is tried automatically.
|
|
102
|
+
|
|
103
|
+
## Updating
|
|
104
|
+
|
|
105
|
+
```sh
|
|
106
|
+
# Local source: just rebuild (linked install, no reinstall needed)
|
|
107
|
+
cd /path/to/dsh-web-search-free && pnpm build
|
|
108
|
+
|
|
109
|
+
# npm: upgrade to a new version
|
|
110
|
+
dsh plugin --profile web update dsh-web-search-free
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`update` also triggers reconciliation: if the new version adds or drops a `dsh.bundle` declaration, the bundle layer stack follows automatically.
|
|
114
|
+
|
|
115
|
+
## Uninstalling
|
|
116
|
+
|
|
117
|
+
```sh
|
|
118
|
+
dsh plugin --profile web remove dsh-web-search-free
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Reconciliation removes this plugin from `dsh.profile.bundles`, and web search/fetch **auto-reverts to dsh-base's `deepseek-official` default channel** — no manual profile or patch edit.
|
|
122
|
+
|
|
123
|
+
## How it works
|
|
124
|
+
|
|
125
|
+
This plugin is a dsh **bundle layer** (`package.json` declares `dsh.bundle.patch: ./cordis.patch.yml`). `cordis.patch.yml` does two things:
|
|
126
|
+
|
|
127
|
+
- `insert`s a `web-search-free` row to bring this plugin's host half into the composition;
|
|
128
|
+
- uses a same-id `web` override layer to repoint both `searchProvider` and `fetchProvider` to `web-search-free`, overriding the `deepseek-official` that dsh-base pins.
|
|
129
|
+
|
|
130
|
+
The host half (`src/index.ts`, `inject: ['web']`) registers search and fetch providers into `ctx.web`, iterating configured-key engines in `providerOrder` order with fallback; each engine's key field may hold multiple values (one per line), rotated in order within the engine. The client half (`src/client.tsx`) registers a React card on the Plugins settings page, reading and writing the same `web-search-free` settings namespace. The two halves are aligned by that namespace string.
|
|
131
|
+
|
|
132
|
+
The build runs `tsc` to produce `dist/`, then `wrap-client.js` wraps `dist/client.js` as `window.__ModuleLoader__.load(...)` so it can be loaded by dsh's browser-side module loader.
|
|
133
|
+
|
|
134
|
+
## License
|
|
135
|
+
|
|
136
|
+
MIT, see [LICENSE](LICENSE).
|
package/README.md
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# dsh-web-search-free
|
|
2
|
+
|
|
3
|
+
中文 | [English](README.en.md)
|
|
4
|
+
|
|
5
|
+
面向 [DeepSeek Harness (dsh)](https://github.com/deepseek-ai/dsh) 的免费 Web Search / 网页抓取插件。
|
|
6
|
+
|
|
7
|
+
它把 dsh 默认的 `deepseek-official` 搜索/抓取通道,替换成一个**多引擎 + 自动 fallback** 的通道:你填入哪些引擎的 API Key,它就按你排定的顺序依次尝试,前一个失败(或额度耗尽)会自动落到下一个;同一引擎也可以填多个 Key(每行一个),引擎内 Key 同样按顺序轮换。所有请求都从你的浏览器侧发起,不经过官方搜索后端。
|
|
8
|
+
|
|
9
|
+
- 注册为 dsh 的 `web` 能力通道(同时提供 `searchProvider` 与 `fetchProvider`,id 均为 `web-search-free`)。
|
|
10
|
+
- 自带一个 Web 设置卡片(设置 → 插件 → **Web Search Free**),可拖动排序、逐个填 Key。
|
|
11
|
+
- 作为 dsh bundle 层安装:装上即接管 web 搜索/抓取,卸载即自动回落到默认通道,无需手改 profile。
|
|
12
|
+
|
|
13
|
+
## 为什么是 free 的:与官方通道的计费差异
|
|
14
|
+
|
|
15
|
+
dsh 默认的官方通道 `deepseek-official`(由 `@deepseek-ai/dsh-web-search-deepseek` 提供)**不是一个专用搜索端点**:每次搜索都会发起一次**完整的 Messages 模型调用**(`POST …/anthropic/v1/messages`,带原生 `web_search` 服务端工具),由 DeepSeek 在服务端执行搜索、返回结构化结果块。因此每次搜索都要烧两份 token:
|
|
16
|
+
|
|
17
|
+
1. **辅助搜索请求本身**——一个独立模型收到 `Perform a web search for the query: <query>` + 工具定义,产生 input + output token(`maxTokens` 默认 4096、`maxUses` 默认 5);这是一次完整的模型轮次,延迟与生成都按模型调用计。
|
|
18
|
+
2. **结果回灌对话**——解析出的 sources(URL/标题/片段)注入回对话模型上下文,作为对话 token 一直重发直到压缩。
|
|
19
|
+
|
|
20
|
+
两份都从 `DEEPSEEK_API_KEY` 余额扣除。
|
|
21
|
+
|
|
22
|
+
本插件走各引擎的**专用检索端点**(如 Tavily `/search`、Exa `/search`、Jina `s.jina.ai`),是纯检索,不经任何 LLM:
|
|
23
|
+
|
|
24
|
+
| | 官方 `deepseek-official` | 本插件 `web-search-free` |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
| 检索方式 | 一次完整 LLM 模型调用 + 服务端搜索工具 | 直接调各引擎专用检索端点 |
|
|
27
|
+
| 模型 token | 每次搜索都烧(input + output) | **0**(纯检索,不碰任何 LLM) |
|
|
28
|
+
| 计费来源 | DeepSeek API 余额 | 各搜索 API 自身额度(多数有免费层) |
|
|
29
|
+
| 凭据 | `DEEPSEEK_API_KEY` | 各引擎各自的 API Key |
|
|
30
|
+
|
|
31
|
+
这正是插件名为 "free" 的核心理由——官方每次搜索烧一整轮模型 token,这里只走检索、不烧 token。
|
|
32
|
+
|
|
33
|
+
## 支持的引擎
|
|
34
|
+
|
|
35
|
+
| 引擎 | 搜索 | 抓取 | 获取 API Key |
|
|
36
|
+
|---|:---:|:---:|---|
|
|
37
|
+
| Jina AI | ✓ | ✓ | <https://jina.ai/api-key> |
|
|
38
|
+
| Exa (Metaphor) | ✓ | ✓ | <https://dashboard.exa.ai/> |
|
|
39
|
+
| Tavily | ✓ | ✓ | <https://app.tavily.com/> |
|
|
40
|
+
| Firecrawl | ✓ | ✓ | <https://www.firecrawl.dev/> |
|
|
41
|
+
| Brave Search | ✓ | ✗ | <https://api-dashboard.search.brave.com/register> |
|
|
42
|
+
|
|
43
|
+
> Brave 仅支持搜索,不支持 URL 抓取。若你的抓取链路上只有 Brave 有 Key,抓取会失败——请再给一个支持抓取的引擎配上 Key。
|
|
44
|
+
|
|
45
|
+
## 前置条件
|
|
46
|
+
|
|
47
|
+
- 已安装 dsh,且 `dsh` 命令可用(在 dsh 源码检出里开发时用 `pnpm dsh ...` 代替)。
|
|
48
|
+
- `pnpm` 在 `PATH` 上(`dsh plugin` 通过 pnpm 在 profile 目录里管理依赖)。
|
|
49
|
+
- 目标 profile 一般是 `web`(本插件的客户端半边声明 `platform: web`,设置卡片只在 Web 界面出现)。`web` profile 首次使用时会从模板自动初始化。
|
|
50
|
+
|
|
51
|
+
## 安装
|
|
52
|
+
|
|
53
|
+
`dsh plugin --profile <name> <pnpm args>` 会把 pnpm 参数转发到 profile 目录里执行,**执行成功后自动对账 `dsh.profile.bundles`**:凡解析到声明了 `dsh.bundle` 的依赖,都会被自动追加进 bundle 层栈——本插件正好声明了 `dsh.bundle.patch`,所以装上即接管 web 搜索/抓取,无需手改 profile。
|
|
54
|
+
|
|
55
|
+
### 从 npm 安装(推荐)
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
dsh plugin --profile web add dsh-web-search-free
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### 从本地源码安装(开发 / 二次开发用)
|
|
62
|
+
|
|
63
|
+
本仓库 `dist/` 被 gitignore,安装前需要先构建出产物。
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
# 1) 在插件仓库目录里构建
|
|
67
|
+
cd /path/to/dsh-web-search-free
|
|
68
|
+
pnpm install
|
|
69
|
+
pnpm build # 生成 dist/index.js 与 dist/client.js
|
|
70
|
+
|
|
71
|
+
# 2) 装进 web profile(在插件目录里执行,"." 会被锚定到当前目录)
|
|
72
|
+
dsh plugin --profile web add .
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
> 也可以用绝对路径,从任意目录执行:
|
|
76
|
+
> `dsh plugin --profile web add /absolute/path/to/dsh-web-search-free`
|
|
77
|
+
>
|
|
78
|
+
> pnpm 对本地目录默认以链接方式安装,所以**之后在源码里重新 `pnpm build`,profile 会即时拿到新产物**,适合二次开发。改完客户端半边后刷新浏览器即可(客户端插件的热更新需要 `pnpm run dev:web` 这类重建监听在跑)。
|
|
79
|
+
|
|
80
|
+
## 配置
|
|
81
|
+
|
|
82
|
+
安装后启动 dsh Web 界面:
|
|
83
|
+
|
|
84
|
+
```sh
|
|
85
|
+
dsh web # 等价于 dsh --profile web
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
打开 **设置 → 插件 → Web Search Free** 卡片:
|
|
89
|
+
|
|
90
|
+
1. 点开卡片,每一行对应一个引擎。
|
|
91
|
+
2. 在对应引擎的输入框粘贴 API Key,点行内「获取 API Key ↗」可直达各引擎的申请页。每个引擎支持填多个 Key:**每行一个**,引擎内会按行顺序轮换。
|
|
92
|
+
3. **拖动每一行**调整调用顺序:排在前面的引擎优先调用,失败则按顺序 fallback;同一引擎的多个 Key 也会逐个尝试,任一 (引擎, Key) 成功即返回,全部失败才报错。未填 Key 的引擎不进入调用链。
|
|
93
|
+
4. 点「保存」。Key 通过 dsh 的设置命名空间(`web-search-free`)持久化,下一次搜索即时生效,无需重启。
|
|
94
|
+
|
|
95
|
+
**至少配置一个引擎的 Key**,否则搜索/抓取会以 `No web search providers configured.` 报错。
|
|
96
|
+
|
|
97
|
+
## 验证
|
|
98
|
+
|
|
99
|
+
1. `dsh web` 启动 Web 界面。
|
|
100
|
+
2. 在对话里让模型用 Web 搜索/抓取(例如「搜一下今天的新闻」或「抓取 https://example.com 的内容」)。
|
|
101
|
+
3. 请求会经 `web-search-free` 通道按你排定的引擎顺序执行;某个引擎失败时会在日志里看到 `Provider <name> ... failed. Trying next provider ...`,随后自动尝试下一个。
|
|
102
|
+
|
|
103
|
+
## 更新
|
|
104
|
+
|
|
105
|
+
```sh
|
|
106
|
+
# 本地源码:重新构建即可(链接安装,无需重装)
|
|
107
|
+
cd /path/to/dsh-web-search-free && pnpm build
|
|
108
|
+
|
|
109
|
+
# npm:升级到新版本
|
|
110
|
+
dsh plugin --profile web update dsh-web-search-free
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`update` 同样会触发对账:新版本若新增/移除了 `dsh.bundle` 声明,bundle 层栈会自动跟进。
|
|
114
|
+
|
|
115
|
+
## 卸载
|
|
116
|
+
|
|
117
|
+
```sh
|
|
118
|
+
dsh plugin --profile web remove dsh-web-search-free
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
对账会把本插件从 `dsh.profile.bundles` 移除,web 搜索/抓取**自动回落到 dsh-base 的 `deepseek-official` 默认通道**——无需手改 profile 或 patch。
|
|
122
|
+
|
|
123
|
+
## 工作原理
|
|
124
|
+
|
|
125
|
+
本插件是一个 dsh **bundle 层**(`package.json` 里声明 `dsh.bundle.patch: ./cordis.patch.yml`)。`cordis.patch.yml` 做两件事:
|
|
126
|
+
|
|
127
|
+
- `insert` 一行 `web-search-free`,把本插件的宿主半边纳入组合;
|
|
128
|
+
- 用一条同 id 的 `web` 覆盖层,把 `searchProvider` 与 `fetchProvider` 都重指到 `web-search-free`,从而盖过 `dsh-base` 里钉死的 `deepseek-official`。
|
|
129
|
+
|
|
130
|
+
宿主半边(`src/index.ts`,`inject: ['web']`)向 `ctx.web` 注册搜索与抓取 provider,内部按 `providerOrder` 顺序遍历「已配 Key」的引擎做 fallback;每个引擎的 Key 字段可填多个(每行一个),引擎内也会按行顺序逐个轮换。客户端半边(`src/client.tsx`)在「插件」设置页注册一张 React 卡片,读写同一命名空间 `web-search-free` 的设置。两层靠这个命名空间字符串对齐。
|
|
131
|
+
|
|
132
|
+
构建时 `tsc` 产出 `dist/`,随后 `wrap-client.js` 把 `dist/client.js` 包成 `window.__ModuleLoader__.load(...)`,使其能被 dsh 的浏览器侧模块加载器加载。
|
|
133
|
+
|
|
134
|
+
## 许可证
|
|
135
|
+
|
|
136
|
+
MIT,见 [LICENSE](LICENSE)。
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# dsh bundle patch for dsh-web-search-free.
|
|
2
|
+
#
|
|
3
|
+
# Applied as a profile layer in dsh.profile.bundles order; later layers
|
|
4
|
+
# override earlier ones by entry id, so the `web` row repoint below wins
|
|
5
|
+
# over dsh-base's `searchProvider: deepseek-official`. Installing this plugin
|
|
6
|
+
# into a profile's bundles routes web search/fetch through it; removing the
|
|
7
|
+
# plugin drops this layer and the base route returns — no manual profile
|
|
8
|
+
# patch needed.
|
|
9
|
+
#
|
|
10
|
+
# The web seam selects by provider id; `web-search-free` is the id this
|
|
11
|
+
# plugin's host half registers, and its own `providerOrder` setting governs
|
|
12
|
+
# the per-backend fallback sequence inside the provider.
|
|
13
|
+
- insert:
|
|
14
|
+
- id: web-search-free
|
|
15
|
+
name: 'dsh-web-search-free'
|
|
16
|
+
|
|
17
|
+
# Repoint the web capability seam at this plugin's provider. dsh-base pins
|
|
18
|
+
# `searchProvider: deepseek-official`; a non-insert patch with a matching id
|
|
19
|
+
# overrides that entry's config wholesale, so both capabilities resolve here.
|
|
20
|
+
- id: web
|
|
21
|
+
config:
|
|
22
|
+
searchProvider: web-search-free
|
|
23
|
+
fetchProvider: web-search-free
|
package/dist/client.d.ts
ADDED
package/dist/client.js
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "dsh-web-search-free",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
|
|
7
|
+
"use strict";
|
|
8
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
9
|
+
if (k2 === undefined) k2 = k;
|
|
10
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
11
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
12
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
13
|
+
}
|
|
14
|
+
Object.defineProperty(o, k2, desc);
|
|
15
|
+
}) : (function(o, m, k, k2) {
|
|
16
|
+
if (k2 === undefined) k2 = k;
|
|
17
|
+
o[k2] = m[k];
|
|
18
|
+
}));
|
|
19
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
20
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
21
|
+
}) : function(o, v) {
|
|
22
|
+
o["default"] = v;
|
|
23
|
+
});
|
|
24
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
25
|
+
var ownKeys = function(o) {
|
|
26
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
27
|
+
var ar = [];
|
|
28
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
29
|
+
return ar;
|
|
30
|
+
};
|
|
31
|
+
return ownKeys(o);
|
|
32
|
+
};
|
|
33
|
+
return function (mod) {
|
|
34
|
+
if (mod && mod.__esModule) return mod;
|
|
35
|
+
var result = {};
|
|
36
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
37
|
+
__setModuleDefault(result, mod);
|
|
38
|
+
return result;
|
|
39
|
+
};
|
|
40
|
+
})();
|
|
41
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
42
|
+
exports.inject = void 0;
|
|
43
|
+
exports.apply = apply;
|
|
44
|
+
const React = __importStar(require("react"));
|
|
45
|
+
/**
|
|
46
|
+
* Must equal the namespace the Host half registers: the Plugins settings tab
|
|
47
|
+
* dispatches `settings.plugin.item` once per served namespace, using it as the
|
|
48
|
+
* keyed slot's entry key.
|
|
49
|
+
*/
|
|
50
|
+
const NAMESPACE = 'web-search-free';
|
|
51
|
+
/** All backends the host half knows about; the order here is the default fallback. */
|
|
52
|
+
const PROVIDERS = [
|
|
53
|
+
{ key: 'jina', field: 'jinaApiKey', label: 'Jina AI', signup: 'https://jina.ai/api-key' },
|
|
54
|
+
{ key: 'exa', field: 'exaApiKey', label: 'Exa (Metaphor)', signup: 'https://dashboard.exa.ai/' },
|
|
55
|
+
{ key: 'tavily', field: 'tavilyApiKey', label: 'Tavily', signup: 'https://app.tavily.com/' },
|
|
56
|
+
{ key: 'firecrawl', field: 'firecrawlApiKey', label: 'Firecrawl', signup: 'https://www.firecrawl.dev/' },
|
|
57
|
+
{ key: 'brave', field: 'braveApiKey', label: 'Brave Search', signup: 'https://api-dashboard.search.brave.com/register' },
|
|
58
|
+
];
|
|
59
|
+
const DEFAULT_ORDER = PROVIDERS.map((p) => p.key);
|
|
60
|
+
const byKey = (key) => PROVIDERS.find((p) => p.key === key);
|
|
61
|
+
/** Split a multi-key field into trimmed, non-empty lines. */
|
|
62
|
+
const parseKeys = (raw) => (raw || '').split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
|
|
63
|
+
/** Canonical storage form: one key per line, no blanks, trimmed. */
|
|
64
|
+
const normalizeKeys = (raw) => parseKeys(raw).join('\n');
|
|
65
|
+
// `settingsScope.bind` reads `connection` and `remote` off the CALLING context,
|
|
66
|
+
// so both must be declared here alongside the services this plugin uses itself.
|
|
67
|
+
exports.inject = ['slots', 'settingsScope', 'connection', 'remote'];
|
|
68
|
+
function apply(ctx) {
|
|
69
|
+
const scope = ctx.settingsScope.bind({ namespace: NAMESPACE });
|
|
70
|
+
ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({
|
|
71
|
+
name: 'settings.plugin.item',
|
|
72
|
+
key: NAMESPACE,
|
|
73
|
+
inject: () => ({ scope }),
|
|
74
|
+
}, WebSearchFreeCard));
|
|
75
|
+
}
|
|
76
|
+
function WebSearchFreeCard({ scope }) {
|
|
77
|
+
const snapshot = React.useSyncExternalStore(React.useCallback((listener) => scope.subscribe(listener), [scope]), () => scope.getSnapshot());
|
|
78
|
+
const [open, setOpen] = React.useState(false);
|
|
79
|
+
const [keyDrafts, setKeyDrafts] = React.useState({});
|
|
80
|
+
const [orderDraft, setOrderDraft] = React.useState(null);
|
|
81
|
+
const [dragKey, setDragKey] = React.useState(null);
|
|
82
|
+
const [dropTarget, setDropTarget] = React.useState(null);
|
|
83
|
+
const [saving, setSaving] = React.useState(false);
|
|
84
|
+
const [failed, setFailed] = React.useState('');
|
|
85
|
+
const storedKey = (field) => {
|
|
86
|
+
const value = snapshot.value?.[field];
|
|
87
|
+
return typeof value === 'string' ? value : '';
|
|
88
|
+
};
|
|
89
|
+
const storedOrder = () => {
|
|
90
|
+
const raw = snapshot.value?.providerOrder;
|
|
91
|
+
if (Array.isArray(raw) && raw.length > 0)
|
|
92
|
+
return raw.filter((k) => PROVIDERS.some((p) => p.key === k));
|
|
93
|
+
return DEFAULT_ORDER;
|
|
94
|
+
};
|
|
95
|
+
const order = orderDraft ?? storedOrder();
|
|
96
|
+
const keyDirty = PROVIDERS.filter((p) => p.field in keyDrafts && normalizeKeys(keyDrafts[p.field]) !== normalizeKeys(storedKey(p.field)));
|
|
97
|
+
const orderDirty = orderDraft !== null && JSON.stringify(orderDraft) !== JSON.stringify(storedOrder());
|
|
98
|
+
const dirty = keyDirty.length > 0 || orderDirty;
|
|
99
|
+
const disabled = saving || snapshot.writable === false;
|
|
100
|
+
const save = async () => {
|
|
101
|
+
setSaving(true);
|
|
102
|
+
setFailed('');
|
|
103
|
+
const pending = keyDirty.map((p) => ({ field: p.field, value: normalizeKeys(keyDrafts[p.field]) }));
|
|
104
|
+
for (const { field, value } of pending) {
|
|
105
|
+
if (value === '')
|
|
106
|
+
await scope.unset(field);
|
|
107
|
+
else
|
|
108
|
+
await scope.set(field, value);
|
|
109
|
+
}
|
|
110
|
+
if (orderDirty && orderDraft !== null)
|
|
111
|
+
await scope.set('providerOrder', orderDraft);
|
|
112
|
+
// Writes swallow wire and revision failures and reload instead of throwing,
|
|
113
|
+
// so the Host's readback is the only authority on what actually landed.
|
|
114
|
+
const after = scope.getSnapshot();
|
|
115
|
+
const rejectedKeys = pending.filter(({ field, value }) => (after.value?.[field] ?? '') !== value);
|
|
116
|
+
const orderLanded = JSON.stringify(after.value?.providerOrder ?? DEFAULT_ORDER) === JSON.stringify(orderDraft ?? storedOrder());
|
|
117
|
+
setKeyDrafts(Object.fromEntries(rejectedKeys.map(({ field, value }) => [field, value])));
|
|
118
|
+
if (!orderLanded)
|
|
119
|
+
setOrderDraft(null);
|
|
120
|
+
if (rejectedKeys.length > 0 || !orderLanded)
|
|
121
|
+
setFailed('部分字段未保存成功,请重试。');
|
|
122
|
+
setSaving(false);
|
|
123
|
+
};
|
|
124
|
+
const reorder = (from, to) => {
|
|
125
|
+
if (from === to)
|
|
126
|
+
return;
|
|
127
|
+
const next = [...order];
|
|
128
|
+
const [moved] = next.splice(from, 1);
|
|
129
|
+
next.splice(to, 0, moved);
|
|
130
|
+
setOrderDraft(next);
|
|
131
|
+
};
|
|
132
|
+
const indexOfKey = (key) => order.indexOf(key);
|
|
133
|
+
const cardBody = () => {
|
|
134
|
+
if (snapshot.status === 'loading')
|
|
135
|
+
return [text('正在读取设置…')];
|
|
136
|
+
if (snapshot.status === 'unavailable')
|
|
137
|
+
return [text('该连接不同步设置,无法在此配置。')];
|
|
138
|
+
const children = [];
|
|
139
|
+
children.push(React.createElement('div', { style: { fontSize: 13, color: 'var(--dsw-alias-label-tertiary)' } }, '拖动每一行调整调用顺序:排在前面的引擎优先调用,失败则 fallback 到下一个。每个引擎可填多个 Key(每行一个),同一引擎内也会按顺序轮换。未填 Key 的引擎不进入调用链。'));
|
|
140
|
+
// One row per provider; the row's vertical position IS providerOrder, so the
|
|
141
|
+
// key inputs double as the order controls — no separate list to render.
|
|
142
|
+
children.push(React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8 } }, ...order.map((key, index) => {
|
|
143
|
+
const provider = byKey(key);
|
|
144
|
+
const keyCount = parseKeys(keyDrafts[provider.field] ?? storedKey(provider.field)).length;
|
|
145
|
+
const hasKey = keyCount > 0;
|
|
146
|
+
const isDragging = dragKey === key;
|
|
147
|
+
const isDropTarget = dropTarget === key;
|
|
148
|
+
return React.createElement('div', {
|
|
149
|
+
key,
|
|
150
|
+
draggable: !disabled,
|
|
151
|
+
onDragStart: (e) => { setDragKey(key); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', key); },
|
|
152
|
+
onDragEnd: () => { setDragKey(null); setDropTarget(null); },
|
|
153
|
+
onDragOver: (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; if (dragKey && dragKey !== key)
|
|
154
|
+
setDropTarget(key); },
|
|
155
|
+
onDrop: (e) => {
|
|
156
|
+
e.preventDefault();
|
|
157
|
+
if (dragKey && dragKey !== key)
|
|
158
|
+
reorder(indexOfKey(dragKey), index);
|
|
159
|
+
setDragKey(null);
|
|
160
|
+
setDropTarget(null);
|
|
161
|
+
},
|
|
162
|
+
style: {
|
|
163
|
+
display: 'flex', alignItems: 'center', gap: 8, fontSize: 13,
|
|
164
|
+
borderRadius: 8, padding: '8px 10px',
|
|
165
|
+
border: isDropTarget ? '1px dashed var(--dsw-alias-brand-primary)' : '1px solid var(--dsw-alias-border-l2)',
|
|
166
|
+
background: isDragging ? 'var(--dsw-alias-bg-layer-2)' : 'var(--dsw-alias-bg-layer-3)',
|
|
167
|
+
opacity: isDragging ? 0.5 : 1,
|
|
168
|
+
cursor: disabled ? 'default' : 'grab',
|
|
169
|
+
},
|
|
170
|
+
}, React.createElement('span', {
|
|
171
|
+
style: { flex: 'none', color: 'var(--dsw-alias-label-tertiary)', fontSize: 13, userSelect: 'none' },
|
|
172
|
+
}, '⋮⋮'), React.createElement('span', {
|
|
173
|
+
style: {
|
|
174
|
+
whiteSpace: 'nowrap', borderRadius: 999, padding: '1px 8px', fontSize: 11, fontWeight: 500, lineHeight: '17px', flex: 'none',
|
|
175
|
+
...(hasKey
|
|
176
|
+
? { background: 'var(--dsw-alias-bg-module-platform)', color: 'var(--dsw-alias-label-secondary)' }
|
|
177
|
+
: { color: 'var(--dsw-alias-label-tertiary)' }),
|
|
178
|
+
},
|
|
179
|
+
}, hasKey ? `#${index + 1}` : '—'), React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 4, flex: 1, minWidth: 0 } }, React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 } }, React.createElement('span', { style: { color: hasKey ? 'var(--dsw-alias-label-primary)' : 'var(--dsw-alias-label-tertiary)', fontWeight: 500 } }, provider.label), React.createElement('a', {
|
|
180
|
+
href: provider.signup,
|
|
181
|
+
target: '_blank',
|
|
182
|
+
rel: 'noreferrer',
|
|
183
|
+
onClick: (e) => e.stopPropagation(),
|
|
184
|
+
style: { fontSize: 12, color: 'var(--dsw-alias-brand-primary)', textDecoration: 'none', flex: 'none' },
|
|
185
|
+
}, '获取 API Key ↗')), React.createElement('textarea', {
|
|
186
|
+
rows: 2,
|
|
187
|
+
autoComplete: 'off',
|
|
188
|
+
spellCheck: false,
|
|
189
|
+
value: keyDrafts[provider.field] ?? storedKey(provider.field),
|
|
190
|
+
disabled,
|
|
191
|
+
placeholder: '每行一个 API Key,支持多 Key 轮换',
|
|
192
|
+
onChange: (event) => setKeyDrafts({ ...keyDrafts, [provider.field]: event.target.value }),
|
|
193
|
+
style: { ...inputStyle, height: 'auto', minHeight: 30, resize: 'vertical', padding: '6px 12px', fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', lineHeight: '20px' },
|
|
194
|
+
}), React.createElement('div', { style: { fontSize: 11, color: 'var(--dsw-alias-label-tertiary)' } }, keyCount > 0 ? `${keyCount} 个 Key · 按顺序轮换` : '未配置')));
|
|
195
|
+
})));
|
|
196
|
+
children.push(React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, justifyContent: 'flex-end', borderTop: '1px solid var(--dsw-alias-border-l2)', paddingTop: 12 } }, failed === '' ? null : React.createElement('span', {
|
|
197
|
+
style: { flex: 1, minWidth: 0, fontSize: 12, color: 'var(--dsw-alias-label-error)' },
|
|
198
|
+
}, failed), React.createElement('button', {
|
|
199
|
+
type: 'button',
|
|
200
|
+
disabled: disabled || !dirty,
|
|
201
|
+
onClick: () => { setKeyDrafts({}); setOrderDraft(null); setFailed(''); },
|
|
202
|
+
style: { ...btnOutlineStyle, ...(disabled || !dirty ? { opacity: 0.4, cursor: 'default' } : {}) },
|
|
203
|
+
}, '放弃'), React.createElement('button', {
|
|
204
|
+
type: 'button',
|
|
205
|
+
disabled: disabled || !dirty,
|
|
206
|
+
onClick: save,
|
|
207
|
+
style: { ...btnPrimaryStyle, ...(disabled || !dirty ? { opacity: 0.4, cursor: 'default' } : {}) },
|
|
208
|
+
}, saving ? '保存中…' : '保存')));
|
|
209
|
+
return children;
|
|
210
|
+
};
|
|
211
|
+
return React.createElement('li', {
|
|
212
|
+
style: {
|
|
213
|
+
listStyle: 'none',
|
|
214
|
+
borderRadius: 12,
|
|
215
|
+
border: '1px solid var(--dsw-alias-border-l2)',
|
|
216
|
+
background: open ? 'var(--dsw-alias-bg-layer-2)' : 'var(--dsw-alias-bg-layer-3)',
|
|
217
|
+
transition: 'border-color .16s, background .16s',
|
|
218
|
+
},
|
|
219
|
+
}, React.createElement('button', {
|
|
220
|
+
type: 'button',
|
|
221
|
+
onClick: () => setOpen(!open),
|
|
222
|
+
style: {
|
|
223
|
+
appearance: 'none', width: '100%', font: 'inherit', textAlign: 'left', cursor: 'pointer',
|
|
224
|
+
background: 'none', border: '0', borderRadius: 12, color: 'inherit',
|
|
225
|
+
display: 'flex', alignItems: 'center', gap: 12, padding: '14px 16px',
|
|
226
|
+
},
|
|
227
|
+
}, React.createElement('div', { style: { display: 'flex', flexDirection: 'column', flex: 1, gap: 2, minWidth: 0 } }, React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8 } }, React.createElement('span', { style: { fontSize: 15, fontWeight: 600, color: 'var(--dsw-alias-label-primary)' } }, 'Web Search Free'), dirty ? React.createElement('span', {
|
|
228
|
+
style: { whiteSpace: 'nowrap', background: 'var(--dsw-alias-bg-module-platform)', color: 'var(--dsw-alias-label-secondary)', borderRadius: 999, padding: '1px 8px', fontSize: 11, fontWeight: 500, lineHeight: '17px' },
|
|
229
|
+
}, '未保存') : null), React.createElement('div', { style: { fontSize: 13, color: 'var(--dsw-alias-label-tertiary)' } }, '免费多引擎 Web Search(拖动排序 · 按 providerOrder 顺序 fallback)')), React.createElement('span', {
|
|
230
|
+
style: { color: 'var(--dsw-alias-label-tertiary)', display: 'inline-flex', flex: 'none', transition: 'transform .16s', transform: open ? 'rotate(180deg)' : 'none' },
|
|
231
|
+
}, React.createElement('svg', {
|
|
232
|
+
width: 14, height: 14, viewBox: '0 0 14 14', fill: 'none', 'aria-hidden': true,
|
|
233
|
+
}, React.createElement('path', {
|
|
234
|
+
d: 'M3.5 5.5L7 9l3.5-3.5',
|
|
235
|
+
stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round', strokeLinejoin: 'round',
|
|
236
|
+
})))), open ? React.createElement('div', {
|
|
237
|
+
style: { display: 'flex', flexDirection: 'column', gap: 16, margin: '0 16px 4px', borderTop: '1px solid var(--dsw-alias-border-l2)', paddingTop: 12 },
|
|
238
|
+
}, ...cardBody()) : null);
|
|
239
|
+
}
|
|
240
|
+
const inputStyle = {
|
|
241
|
+
height: 34,
|
|
242
|
+
padding: '0 12px',
|
|
243
|
+
font: 'inherit',
|
|
244
|
+
fontSize: 13,
|
|
245
|
+
borderRadius: 8,
|
|
246
|
+
border: '1px solid var(--dsw-alias-border-l2)',
|
|
247
|
+
background: 'var(--dsw-alias-bg-layer-3)',
|
|
248
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
249
|
+
};
|
|
250
|
+
const btnOutlineStyle = {
|
|
251
|
+
font: 'inherit', fontSize: 13, padding: '5px 14px', borderRadius: 8, cursor: 'pointer',
|
|
252
|
+
border: '1px solid var(--dsw-alias-border-l2)', background: 'none',
|
|
253
|
+
color: 'var(--dsw-alias-label-secondary)',
|
|
254
|
+
};
|
|
255
|
+
const btnPrimaryStyle = {
|
|
256
|
+
font: 'inherit', fontSize: 13, padding: '5px 14px', borderRadius: 8, cursor: 'pointer',
|
|
257
|
+
border: '1px solid transparent', background: 'var(--dsw-alias-label-primary)',
|
|
258
|
+
color: 'var(--dsw-alias-bg-layer-3)',
|
|
259
|
+
};
|
|
260
|
+
function text(value) {
|
|
261
|
+
return React.createElement('span', { style: { fontSize: 13, color: 'var(--dsw-alias-label-tertiary)' } }, value);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
return module.exports;
|
|
266
|
+
}
|
|
267
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Context, Schema } from 'cordis';
|
|
2
|
+
export declare const name = "web-search-free";
|
|
3
|
+
export declare const inject: string[];
|
|
4
|
+
/**
|
|
5
|
+
* Settings namespace this plugin owns. The browser card in `./client` is keyed
|
|
6
|
+
* on this string: the Plugins settings tab dispatches `settings.plugin.item`
|
|
7
|
+
* per namespace the Host serves, so the two halves must spell it identically.
|
|
8
|
+
*/
|
|
9
|
+
export declare const SETTINGS_NAMESPACE = "web-search-free";
|
|
10
|
+
export interface Config {
|
|
11
|
+
jinaApiKey?: string;
|
|
12
|
+
exaApiKey?: string;
|
|
13
|
+
tavilyApiKey?: string;
|
|
14
|
+
firecrawlApiKey?: string;
|
|
15
|
+
braveApiKey?: string;
|
|
16
|
+
providerOrder: ('jina' | 'exa' | 'tavily' | 'firecrawl' | 'brave')[];
|
|
17
|
+
}
|
|
18
|
+
export declare const Config: Schema<Config>;
|
|
19
|
+
declare module 'cordis' {
|
|
20
|
+
interface Context {
|
|
21
|
+
web: any;
|
|
22
|
+
logger: any;
|
|
23
|
+
settings: any;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export declare function apply(ctx: Context, config: Config): void;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.Config = exports.SETTINGS_NAMESPACE = exports.inject = exports.name = void 0;
|
|
4
|
+
exports.apply = apply;
|
|
5
|
+
const cordis_1 = require("cordis");
|
|
6
|
+
const providers_1 = require("./providers");
|
|
7
|
+
exports.name = 'web-search-free';
|
|
8
|
+
exports.inject = ['web'];
|
|
9
|
+
/**
|
|
10
|
+
* Settings namespace this plugin owns. The browser card in `./client` is keyed
|
|
11
|
+
* on this string: the Plugins settings tab dispatches `settings.plugin.item`
|
|
12
|
+
* per namespace the Host serves, so the two halves must spell it identically.
|
|
13
|
+
*/
|
|
14
|
+
exports.SETTINGS_NAMESPACE = 'web-search-free';
|
|
15
|
+
exports.Config = cordis_1.Schema.object({
|
|
16
|
+
jinaApiKey: cordis_1.Schema.string().description('API key(s) for Jina AI. One key per line for multi-key rotation.'),
|
|
17
|
+
exaApiKey: cordis_1.Schema.string().description('API key(s) for Exa (Metaphor). One key per line for multi-key rotation.'),
|
|
18
|
+
tavilyApiKey: cordis_1.Schema.string().description('API key(s) for Tavily. One key per line for multi-key rotation.'),
|
|
19
|
+
firecrawlApiKey: cordis_1.Schema.string().description('API key(s) for Firecrawl. One key per line for multi-key rotation.'),
|
|
20
|
+
braveApiKey: cordis_1.Schema.string().description('API key(s) for Brave Search. One key per line for multi-key rotation.'),
|
|
21
|
+
providerOrder: cordis_1.Schema.array(cordis_1.Schema.union(['jina', 'exa', 'tavily', 'firecrawl', 'brave']))
|
|
22
|
+
.default(['jina', 'exa', 'tavily', 'firecrawl', 'brave'])
|
|
23
|
+
.description('定义 Provider 的调用顺序。排在前面的服务会优先执行,如果请求失败(或额度用尽),会自动按照该顺序 fallback 到下一个可用服务。')
|
|
24
|
+
});
|
|
25
|
+
/** Short, non-leaking token for log lines so a failing key is identifiable without printing it. */
|
|
26
|
+
function maskKey(key) {
|
|
27
|
+
if (!key)
|
|
28
|
+
return '***';
|
|
29
|
+
if (key.length <= 8)
|
|
30
|
+
return '***';
|
|
31
|
+
return `${key.slice(0, 4)}…${key.slice(-3)}`;
|
|
32
|
+
}
|
|
33
|
+
function apply(ctx, config) {
|
|
34
|
+
const logger = ctx.logger?.('web-search-free') || console;
|
|
35
|
+
// Register the settings namespace so the user layer (written by the Plugins
|
|
36
|
+
// settings card) exists at all: a namespace the Host does not serve is never
|
|
37
|
+
// dispatched to a card. Each call projects the section fresh, so a key saved
|
|
38
|
+
// in the UI reaches the next search without a restart.
|
|
39
|
+
let resolved = () => config;
|
|
40
|
+
ctx.inject(['settings'], (sctx) => {
|
|
41
|
+
const scope = sctx.settings.register(exports.SETTINGS_NAMESPACE, exports.Config, { base: config });
|
|
42
|
+
resolved = () => scope.get();
|
|
43
|
+
sctx.effect(() => () => {
|
|
44
|
+
resolved = () => config;
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
const getActiveProviders = () => {
|
|
48
|
+
const current = resolved();
|
|
49
|
+
const activeProviders = [];
|
|
50
|
+
const orderedNames = Array.from(new Set([
|
|
51
|
+
...(current.providerOrder || []),
|
|
52
|
+
...Object.keys(providers_1.availableProviders)
|
|
53
|
+
]));
|
|
54
|
+
for (const name of orderedNames) {
|
|
55
|
+
const provider = providers_1.availableProviders[name];
|
|
56
|
+
if (!provider)
|
|
57
|
+
continue;
|
|
58
|
+
const configKey = `${name}ApiKey`;
|
|
59
|
+
const raw = current[configKey];
|
|
60
|
+
if (typeof raw === 'string' && raw.trim() !== '') {
|
|
61
|
+
// A key field may hold several keys, one per line. Empty lines and
|
|
62
|
+
// surrounding whitespace are stripped; rotation tries them in order.
|
|
63
|
+
const keys = raw.split(/\r?\n/).map(s => s.trim()).filter(Boolean);
|
|
64
|
+
if (keys.length > 0)
|
|
65
|
+
activeProviders.push({ provider, keys });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return activeProviders;
|
|
69
|
+
};
|
|
70
|
+
ctx.web?.registerSearchProvider({
|
|
71
|
+
id: 'web-search-free',
|
|
72
|
+
available: () => getActiveProviders().length > 0,
|
|
73
|
+
async search(request, signal) {
|
|
74
|
+
const activeProviders = getActiveProviders();
|
|
75
|
+
if (activeProviders.length === 0) {
|
|
76
|
+
throw new Error('No web search providers configured. Please set at least one API key in config.');
|
|
77
|
+
}
|
|
78
|
+
let lastError = null;
|
|
79
|
+
for (const { provider, keys } of activeProviders) {
|
|
80
|
+
for (const key of keys) {
|
|
81
|
+
try {
|
|
82
|
+
const result = await provider.search(request.query, key, signal);
|
|
83
|
+
if (typeof result === 'string') {
|
|
84
|
+
return { content: result, sources: [], truncated: false };
|
|
85
|
+
}
|
|
86
|
+
return { content: result.content || '', sources: result.sources || [], truncated: false };
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
lastError = err;
|
|
90
|
+
if (logger && logger.warn) {
|
|
91
|
+
logger.warn(`Provider ${provider.name} (key ${maskKey(key)}) search failed: ${err.message}. Trying next key/provider...`);
|
|
92
|
+
}
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
throw new Error(`All configured search providers failed. Last error: ${lastError?.message}`);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
ctx.web?.registerFetchProvider({
|
|
101
|
+
id: 'web-search-free',
|
|
102
|
+
available: () => getActiveProviders().length > 0,
|
|
103
|
+
async fetch(request, signal) {
|
|
104
|
+
const activeProviders = getActiveProviders();
|
|
105
|
+
if (activeProviders.length === 0) {
|
|
106
|
+
throw new Error('No web fetch providers configured. Please set at least one API key in config.');
|
|
107
|
+
}
|
|
108
|
+
let lastError = null;
|
|
109
|
+
for (const { provider, keys } of activeProviders) {
|
|
110
|
+
for (const key of keys) {
|
|
111
|
+
try {
|
|
112
|
+
const resultStr = await provider.fetch(request.url, key, signal);
|
|
113
|
+
return { url: request.url, statusCode: 200, body: { kind: 'text', content: resultStr }, truncated: false };
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
lastError = err;
|
|
117
|
+
if (logger && logger.warn) {
|
|
118
|
+
logger.warn(`Provider ${provider.name} (key ${maskKey(key)}) fetch failed: ${err.message}. Trying next key/provider...`);
|
|
119
|
+
}
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
throw new Error(`All configured fetch providers failed. Last error: ${lastError?.message}`);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.braveProvider = void 0;
|
|
4
|
+
exports.braveProvider = {
|
|
5
|
+
name: 'brave',
|
|
6
|
+
async search(query, apiKey, signal) {
|
|
7
|
+
const res = await fetch(`https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}`, {
|
|
8
|
+
method: 'GET',
|
|
9
|
+
headers: {
|
|
10
|
+
'Accept': 'application/json',
|
|
11
|
+
'X-Subscription-Token': apiKey
|
|
12
|
+
},
|
|
13
|
+
signal,
|
|
14
|
+
});
|
|
15
|
+
if (!res.ok)
|
|
16
|
+
throw new Error(`Brave search failed: ${res.status} ${res.statusText}`);
|
|
17
|
+
const data = await res.json();
|
|
18
|
+
if (data.web && data.web.results && data.web.results.length > 0) {
|
|
19
|
+
return {
|
|
20
|
+
content: '',
|
|
21
|
+
sources: data.web.results.map((r) => ({
|
|
22
|
+
url: r.url,
|
|
23
|
+
title: r.title,
|
|
24
|
+
snippet: r.description
|
|
25
|
+
}))
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
return 'No results found.';
|
|
29
|
+
},
|
|
30
|
+
async fetch(url, apiKey, signal) {
|
|
31
|
+
// Brave Search API is only for search queries, not content fetching/scraping.
|
|
32
|
+
throw new Error('Brave Search does not support direct URL fetching/scraping.');
|
|
33
|
+
}
|
|
34
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.exaProvider = void 0;
|
|
4
|
+
exports.exaProvider = {
|
|
5
|
+
name: 'exa',
|
|
6
|
+
async search(query, apiKey, signal) {
|
|
7
|
+
const res = await fetch('https://api.exa.ai/search', {
|
|
8
|
+
method: 'POST',
|
|
9
|
+
headers: {
|
|
10
|
+
'Content-Type': 'application/json',
|
|
11
|
+
'x-api-key': apiKey
|
|
12
|
+
},
|
|
13
|
+
body: JSON.stringify({
|
|
14
|
+
query: query,
|
|
15
|
+
contents: {
|
|
16
|
+
text: true
|
|
17
|
+
}
|
|
18
|
+
}),
|
|
19
|
+
signal,
|
|
20
|
+
});
|
|
21
|
+
if (!res.ok)
|
|
22
|
+
throw new Error(`Exa search failed: ${res.status} ${res.statusText}`);
|
|
23
|
+
const data = await res.json();
|
|
24
|
+
if (data.results && data.results.length > 0) {
|
|
25
|
+
return {
|
|
26
|
+
content: '',
|
|
27
|
+
sources: data.results.map((r) => ({
|
|
28
|
+
url: r.url,
|
|
29
|
+
title: r.title,
|
|
30
|
+
snippet: r.text || r.url
|
|
31
|
+
}))
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
return 'No results found.';
|
|
35
|
+
},
|
|
36
|
+
async fetch(url, apiKey, signal) {
|
|
37
|
+
const res = await fetch('https://api.exa.ai/contents', {
|
|
38
|
+
method: 'POST',
|
|
39
|
+
headers: {
|
|
40
|
+
'Content-Type': 'application/json',
|
|
41
|
+
'x-api-key': apiKey
|
|
42
|
+
},
|
|
43
|
+
body: JSON.stringify({
|
|
44
|
+
urls: [url],
|
|
45
|
+
text: true
|
|
46
|
+
}),
|
|
47
|
+
signal,
|
|
48
|
+
});
|
|
49
|
+
if (!res.ok)
|
|
50
|
+
throw new Error(`Exa fetch failed: ${res.status} ${res.statusText}`);
|
|
51
|
+
const data = await res.json();
|
|
52
|
+
if (data.results && data.results.length > 0) {
|
|
53
|
+
return data.results[0].text || 'No content found.';
|
|
54
|
+
}
|
|
55
|
+
return 'No results found.';
|
|
56
|
+
}
|
|
57
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.firecrawlProvider = void 0;
|
|
4
|
+
exports.firecrawlProvider = {
|
|
5
|
+
name: 'firecrawl',
|
|
6
|
+
async search(query, apiKey, signal) {
|
|
7
|
+
const res = await fetch('https://api.firecrawl.dev/v1/search', {
|
|
8
|
+
method: 'POST',
|
|
9
|
+
headers: {
|
|
10
|
+
'Content-Type': 'application/json',
|
|
11
|
+
'Authorization': `Bearer ${apiKey}`
|
|
12
|
+
},
|
|
13
|
+
body: JSON.stringify({
|
|
14
|
+
query: query
|
|
15
|
+
}),
|
|
16
|
+
signal,
|
|
17
|
+
});
|
|
18
|
+
if (!res.ok)
|
|
19
|
+
throw new Error(`Firecrawl search failed: ${res.status} ${res.statusText}`);
|
|
20
|
+
const data = await res.json();
|
|
21
|
+
if (data.success && data.data) {
|
|
22
|
+
return {
|
|
23
|
+
content: '', // Let sources speak for themselves
|
|
24
|
+
sources: data.data.map((r) => ({
|
|
25
|
+
url: r.url,
|
|
26
|
+
title: r.title,
|
|
27
|
+
snippet: r.markdown || r.description
|
|
28
|
+
}))
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
return 'No results found.';
|
|
32
|
+
},
|
|
33
|
+
async fetch(url, apiKey, signal) {
|
|
34
|
+
const res = await fetch('https://api.firecrawl.dev/v1/scrape', {
|
|
35
|
+
method: 'POST',
|
|
36
|
+
headers: {
|
|
37
|
+
'Content-Type': 'application/json',
|
|
38
|
+
'Authorization': `Bearer ${apiKey}`
|
|
39
|
+
},
|
|
40
|
+
body: JSON.stringify({
|
|
41
|
+
url: url,
|
|
42
|
+
formats: ['markdown']
|
|
43
|
+
}),
|
|
44
|
+
signal,
|
|
45
|
+
});
|
|
46
|
+
if (!res.ok)
|
|
47
|
+
throw new Error(`Firecrawl fetch failed: ${res.status} ${res.statusText}`);
|
|
48
|
+
const data = await res.json();
|
|
49
|
+
if (data.success && data.data) {
|
|
50
|
+
return data.data.markdown || 'No markdown content available.';
|
|
51
|
+
}
|
|
52
|
+
return 'Failed to fetch content.';
|
|
53
|
+
}
|
|
54
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.availableProviders = void 0;
|
|
4
|
+
const tavily_1 = require("./tavily");
|
|
5
|
+
const firecrawl_1 = require("./firecrawl");
|
|
6
|
+
const jina_1 = require("./jina");
|
|
7
|
+
const exa_1 = require("./exa");
|
|
8
|
+
const brave_1 = require("./brave");
|
|
9
|
+
// 注册所有可用的 Providers
|
|
10
|
+
exports.availableProviders = {
|
|
11
|
+
tavily: tavily_1.tavilyProvider,
|
|
12
|
+
firecrawl: firecrawl_1.firecrawlProvider,
|
|
13
|
+
jina: jina_1.jinaProvider,
|
|
14
|
+
exa: exa_1.exaProvider,
|
|
15
|
+
brave: brave_1.braveProvider,
|
|
16
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.jinaProvider = void 0;
|
|
4
|
+
exports.jinaProvider = {
|
|
5
|
+
name: 'jina',
|
|
6
|
+
async search(query, apiKey, signal) {
|
|
7
|
+
const res = await fetch(`https://s.jina.ai/${encodeURIComponent(query)}`, {
|
|
8
|
+
method: 'GET',
|
|
9
|
+
headers: {
|
|
10
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
11
|
+
// Jina API returns ready-to-use markdown by default
|
|
12
|
+
'Accept': 'text/plain'
|
|
13
|
+
},
|
|
14
|
+
signal,
|
|
15
|
+
});
|
|
16
|
+
if (!res.ok)
|
|
17
|
+
throw new Error(`Jina search failed: ${res.status} ${res.statusText}`);
|
|
18
|
+
return await res.text();
|
|
19
|
+
},
|
|
20
|
+
async fetch(url, apiKey, signal) {
|
|
21
|
+
const res = await fetch(`https://r.jina.ai/${encodeURIComponent(url)}`, {
|
|
22
|
+
method: 'GET',
|
|
23
|
+
headers: {
|
|
24
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
25
|
+
'Accept': 'text/plain'
|
|
26
|
+
},
|
|
27
|
+
signal,
|
|
28
|
+
});
|
|
29
|
+
if (!res.ok)
|
|
30
|
+
throw new Error(`Jina fetch failed: ${res.status} ${res.statusText}`);
|
|
31
|
+
return await res.text();
|
|
32
|
+
}
|
|
33
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.tavilyProvider = void 0;
|
|
4
|
+
exports.tavilyProvider = {
|
|
5
|
+
name: 'tavily',
|
|
6
|
+
async search(query, apiKey, signal) {
|
|
7
|
+
const res = await fetch('https://api.tavily.com/search', {
|
|
8
|
+
method: 'POST',
|
|
9
|
+
headers: { 'Content-Type': 'application/json' },
|
|
10
|
+
body: JSON.stringify({
|
|
11
|
+
api_key: apiKey,
|
|
12
|
+
query: query,
|
|
13
|
+
search_depth: 'basic',
|
|
14
|
+
include_answer: true,
|
|
15
|
+
}),
|
|
16
|
+
signal,
|
|
17
|
+
});
|
|
18
|
+
if (!res.ok)
|
|
19
|
+
throw new Error(`Tavily search failed: ${res.status} ${res.statusText}`);
|
|
20
|
+
const data = await res.json();
|
|
21
|
+
return {
|
|
22
|
+
content: data.answer || '',
|
|
23
|
+
sources: data.results?.map((r) => ({
|
|
24
|
+
url: r.url,
|
|
25
|
+
title: r.title,
|
|
26
|
+
snippet: r.content
|
|
27
|
+
})) || []
|
|
28
|
+
};
|
|
29
|
+
},
|
|
30
|
+
async fetch(url, apiKey, signal) {
|
|
31
|
+
const res = await fetch('https://api.tavily.com/extract', {
|
|
32
|
+
method: 'POST',
|
|
33
|
+
headers: { 'Content-Type': 'application/json' },
|
|
34
|
+
body: JSON.stringify({
|
|
35
|
+
api_key: apiKey,
|
|
36
|
+
urls: [url],
|
|
37
|
+
}),
|
|
38
|
+
signal,
|
|
39
|
+
});
|
|
40
|
+
if (!res.ok)
|
|
41
|
+
throw new Error(`Tavily extract failed: ${res.status} ${res.statusText}`);
|
|
42
|
+
const data = await res.json();
|
|
43
|
+
if (data.results && data.results.length > 0) {
|
|
44
|
+
return data.results[0].raw_content || 'No content found.';
|
|
45
|
+
}
|
|
46
|
+
return 'No results found.';
|
|
47
|
+
}
|
|
48
|
+
};
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface SearchSource {
|
|
2
|
+
url: string;
|
|
3
|
+
title: string;
|
|
4
|
+
snippet?: string;
|
|
5
|
+
}
|
|
6
|
+
export interface SearchResult {
|
|
7
|
+
content?: string;
|
|
8
|
+
sources?: SearchSource[];
|
|
9
|
+
}
|
|
10
|
+
export interface WebSearchProvider {
|
|
11
|
+
name: string;
|
|
12
|
+
search(query: string, apiKey: string, signal?: AbortSignal): Promise<SearchResult | string>;
|
|
13
|
+
fetch(url: string, apiKey: string, signal?: AbortSignal): Promise<string>;
|
|
14
|
+
}
|
package/dist/types.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-web-search-free",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Free web search plugin for dsh supporting firecrawl and tavily",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"dsh-plugin",
|
|
7
|
+
"web-search"
|
|
8
|
+
],
|
|
9
|
+
"dsh": {
|
|
10
|
+
"name": "web-search-free",
|
|
11
|
+
"client": {
|
|
12
|
+
"platform": "web",
|
|
13
|
+
"inject": [
|
|
14
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
15
|
+
"@deepseek-ai/dsh-client-connection",
|
|
16
|
+
"@deepseek-ai/dsh-api-remotes",
|
|
17
|
+
"@deepseek-ai/dsh-client-ui-settings",
|
|
18
|
+
"@deepseek-ai/dsh-client-ui-settings-plugins"
|
|
19
|
+
]
|
|
20
|
+
},
|
|
21
|
+
"bundle": {
|
|
22
|
+
"patch": "./cordis.patch.yml"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist",
|
|
27
|
+
"cordis.patch.yml"
|
|
28
|
+
],
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"default": "./dist/index.js",
|
|
32
|
+
"types": "./dist/index.d.ts"
|
|
33
|
+
},
|
|
34
|
+
"./client": {
|
|
35
|
+
"default": "./dist/client.js",
|
|
36
|
+
"types": "./dist/client.d.ts"
|
|
37
|
+
},
|
|
38
|
+
"./package.json": "./package.json"
|
|
39
|
+
},
|
|
40
|
+
"koishi": {
|
|
41
|
+
"description": "免费 Web Search 与内容抓取 (支持多引擎轮询 fallback)"
|
|
42
|
+
},
|
|
43
|
+
"main": "dist/index.js",
|
|
44
|
+
"types": "dist/index.d.ts",
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "tsc && node wrap-client.js"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"@deepseek-ai/dsh-client-ui-settings-plugins": "^0.0.1-rc.3"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@types/node": "^18.0.0",
|
|
53
|
+
"@types/react": "^19.2.18",
|
|
54
|
+
"cordis": "^3.0.0",
|
|
55
|
+
"typescript": "^5.0.0"
|
|
56
|
+
},
|
|
57
|
+
"peerDependencies": {
|
|
58
|
+
"cordis": "^3.0.0"
|
|
59
|
+
}
|
|
60
|
+
}
|