dsh-web-search-firecrawl 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/LICENSE +21 -0
- package/README.md +44 -0
- package/cordis.patch.yml +11 -0
- package/lib/client.js +346 -0
- package/lib/client.js.map +1 -0
- package/lib/dsh-web-search-firecrawl.js +333 -0
- package/lib/types/accounts.d.ts +21 -0
- package/lib/types/client/accounts-controller.d.ts +12 -0
- package/lib/types/client/index.d.ts +5 -0
- package/lib/types/dsh-web-search-firecrawl.d.ts +42 -0
- package/lib/types/provider.d.ts +116 -0
- package/package.json +83 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 aa2246740
|
|
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,44 @@
|
|
|
1
|
+
# dsh-web-search-firecrawl
|
|
2
|
+
|
|
3
|
+
```sh
|
|
4
|
+
dsh plugin --profile web add github:aa2246740/dsh-firecrawl
|
|
5
|
+
```
|
|
6
|
+
|
|
7
|
+
PATH 上要有官方 `dsh`(没有就用 `npx @deepseek-ai/dsh`)和 **pnpm**。`dsh plugin add` 会在 `$DSH_HOME/profiles/web` 里跑 pnpm。仓库已提交编译好的 `lib/`,git 安装不用 `prepare`,也不用改 profile 的 `allowBuilds`。
|
|
8
|
+
|
|
9
|
+
然后重启这个 Host,再刷新页面。`dsh plugin add` 只写 profile,不会热挂正在跑的进程。
|
|
10
|
+
|
|
11
|
+
给 DeepSeek Harness 的 `web_search` 换 Firecrawl,并带多账号池。包名仍是 `dsh-web-search-firecrawl`。仓库是 `dsh-firecrawl`。
|
|
12
|
+
|
|
13
|
+
在 `ctx.web` 上注册 `id: firecrawl` 的 `WebSearchProvider`,走 Firecrawl `/v1/search`。调度默认 `round-robin`,也可 `least-loaded`。`429` 或 `5xx` 切到下一个健康账号,默认冷却 60 秒。不可重试的 `4xx` 立刻失败并点名那个账号。
|
|
14
|
+
|
|
15
|
+
这个 bundle 会把官方 `web.searchProvider` 设成 `firecrawl`,并保留 `fetchProvider: http`。设置在 Settings → Plugins → Plugin configuration → **Firecrawl search**。
|
|
16
|
+
|
|
17
|
+
面向官方 DeepSeek Harness **0.1.5-rc.3**。
|
|
18
|
+
|
|
19
|
+
## 其它装法
|
|
20
|
+
|
|
21
|
+
本地目录或 tarball:
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
dsh plugin --profile web add ./dsh-firecrawl
|
|
25
|
+
dsh plugin --profile web add ./dsh-web-search-firecrawl-0.1.1.tgz
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`dsh.bundle` 是开机捕获的。不要再往 profile 的 `cordis.patch.yml` 手写同一条 insert,会重复挂载。
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
dsh plugin --profile web remove dsh-web-search-firecrawl
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## 使用
|
|
35
|
+
|
|
36
|
+
1. 打开 WebUI:Settings → Plugins → Plugin configuration → **Firecrawl search**。
|
|
37
|
+
2. 加至少一个 Firecrawl API key([firecrawl.dev](https://firecrawl.dev) 的 `fc-...`),点 **保存**。密钥不会回显。改名时 key 留空表示保留原 key。
|
|
38
|
+
3. 用会暴露 `web_search` 的新会话。
|
|
39
|
+
|
|
40
|
+
激活时会把旧的按账号密钥迁进按账号 ID 索引的 secret dictionary。
|
|
41
|
+
|
|
42
|
+
## 许可
|
|
43
|
+
|
|
44
|
+
MIT。Firecrawl 服务用你自己的账号,遵守它的条款。不要把 API key 或 DSH home/profile 提交进这个仓库。
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Official bundle layer. `dsh plugin add` applies this into the profile.
|
|
2
|
+
# Rows resolve by package name so the installed copy is what boots.
|
|
3
|
+
# Official `id: web` already pins searchProvider + fetchProvider; a patch
|
|
4
|
+
# replaces that whole config, so both keys are restated.
|
|
5
|
+
- id: web
|
|
6
|
+
config:
|
|
7
|
+
searchProvider: firecrawl
|
|
8
|
+
fetchProvider: http
|
|
9
|
+
- insert:
|
|
10
|
+
- id: dsh-web-search-firecrawl
|
|
11
|
+
name: dsh-web-search-firecrawl
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "dsh-web-search-firecrawl",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
|
+
let react = require("react");
|
|
8
|
+
let react_jsx_runtime = require("react/jsx-runtime");
|
|
9
|
+
//#region src/accounts.ts
|
|
10
|
+
/** Wire-safe metadata projection; never repeat any credential in an array write. */
|
|
11
|
+
function metadataOnly(accounts) {
|
|
12
|
+
return accounts.map(({ id, label }) => ({
|
|
13
|
+
id,
|
|
14
|
+
label
|
|
15
|
+
}));
|
|
16
|
+
}
|
|
17
|
+
//#endregion
|
|
18
|
+
//#region src/client/accounts-controller.ts
|
|
19
|
+
/** All edits use the namespace's revision fence and never read credential values. */
|
|
20
|
+
var AccountsController = class {
|
|
21
|
+
scope;
|
|
22
|
+
constructor(scope) {
|
|
23
|
+
this.scope = scope;
|
|
24
|
+
}
|
|
25
|
+
snapshot() {
|
|
26
|
+
const snapshot = this.scope.getSnapshot();
|
|
27
|
+
if (snapshot.status !== "ready" || !snapshot.writable) throw new Error("Settings are not writable");
|
|
28
|
+
return {
|
|
29
|
+
accounts: metadataOnly(snapshot.value?.accounts ?? []),
|
|
30
|
+
revision: snapshot.revision
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
add(id) {
|
|
34
|
+
const { accounts, revision } = this.snapshot();
|
|
35
|
+
return this.scope.mutate([{
|
|
36
|
+
op: "set",
|
|
37
|
+
path: ["accounts"],
|
|
38
|
+
value: [...accounts, {
|
|
39
|
+
id,
|
|
40
|
+
label: ""
|
|
41
|
+
}]
|
|
42
|
+
}], revision);
|
|
43
|
+
}
|
|
44
|
+
save(id, label, key) {
|
|
45
|
+
const { accounts, revision } = this.snapshot();
|
|
46
|
+
if (!accounts.some((account) => account.id === id)) throw new Error("Account was removed");
|
|
47
|
+
const ops = [{
|
|
48
|
+
op: "set",
|
|
49
|
+
path: ["accounts"],
|
|
50
|
+
value: accounts.map((account) => account.id === id ? {
|
|
51
|
+
id,
|
|
52
|
+
label
|
|
53
|
+
} : account)
|
|
54
|
+
}];
|
|
55
|
+
if (key.trim()) ops.push({
|
|
56
|
+
op: "set",
|
|
57
|
+
path: ["apiKeys", id],
|
|
58
|
+
value: key.trim()
|
|
59
|
+
});
|
|
60
|
+
return this.scope.mutate(ops, revision);
|
|
61
|
+
}
|
|
62
|
+
remove(id) {
|
|
63
|
+
const { accounts, revision } = this.snapshot();
|
|
64
|
+
return this.scope.mutate([{
|
|
65
|
+
op: "set",
|
|
66
|
+
path: ["accounts"],
|
|
67
|
+
value: accounts.filter((account) => account.id !== id)
|
|
68
|
+
}, {
|
|
69
|
+
op: "unset",
|
|
70
|
+
path: ["apiKeys", id]
|
|
71
|
+
}], revision);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
//#endregion
|
|
75
|
+
//#region src/client/index.tsx
|
|
76
|
+
const NAMESPACE = "dsh-web-search-firecrawl";
|
|
77
|
+
const name = "dsh-web-search-firecrawl-client";
|
|
78
|
+
const inject = [
|
|
79
|
+
"slots",
|
|
80
|
+
"settingsScope",
|
|
81
|
+
"remote",
|
|
82
|
+
"remote.settings"
|
|
83
|
+
];
|
|
84
|
+
function apply(ctx) {
|
|
85
|
+
const scope = ctx.settingsScope.bind({ namespace: NAMESPACE });
|
|
86
|
+
const accounts = new AccountsController(scope);
|
|
87
|
+
ctx.slots.inject("settings.plugin.item", () => ctx.slots.register({
|
|
88
|
+
name: "settings.plugin.item",
|
|
89
|
+
key: NAMESPACE
|
|
90
|
+
}, () => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FirecrawlCard, {
|
|
91
|
+
scope,
|
|
92
|
+
accounts,
|
|
93
|
+
ctx
|
|
94
|
+
})));
|
|
95
|
+
}
|
|
96
|
+
const inputStyle = {
|
|
97
|
+
padding: "6px 8px",
|
|
98
|
+
border: "1px solid #8886",
|
|
99
|
+
borderRadius: 6,
|
|
100
|
+
background: "transparent",
|
|
101
|
+
color: "inherit"
|
|
102
|
+
};
|
|
103
|
+
const buttonStyle = {
|
|
104
|
+
...inputStyle,
|
|
105
|
+
cursor: "pointer"
|
|
106
|
+
};
|
|
107
|
+
function AccountRow({ account, configured, busy, save, remove }) {
|
|
108
|
+
const [label, setLabel] = (0, react.useState)(account.label);
|
|
109
|
+
const [key, setKey] = (0, react.useState)("");
|
|
110
|
+
(0, react.useEffect)(() => {
|
|
111
|
+
setLabel(account.label);
|
|
112
|
+
}, [account.label]);
|
|
113
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("fieldset", {
|
|
114
|
+
style: {
|
|
115
|
+
border: "1px solid #8884",
|
|
116
|
+
borderRadius: 6,
|
|
117
|
+
padding: 10,
|
|
118
|
+
marginTop: 10
|
|
119
|
+
},
|
|
120
|
+
disabled: busy,
|
|
121
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("legend", { children: account.label || "新账号" }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
122
|
+
style: {
|
|
123
|
+
display: "flex",
|
|
124
|
+
gap: 8,
|
|
125
|
+
flexWrap: "wrap",
|
|
126
|
+
alignItems: "end"
|
|
127
|
+
},
|
|
128
|
+
children: [
|
|
129
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|
|
130
|
+
style: {
|
|
131
|
+
display: "grid",
|
|
132
|
+
gap: 4
|
|
133
|
+
},
|
|
134
|
+
children: ["名称", /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
135
|
+
"aria-label": "账号名称",
|
|
136
|
+
style: inputStyle,
|
|
137
|
+
value: label,
|
|
138
|
+
onChange: (event) => setLabel(event.target.value)
|
|
139
|
+
})]
|
|
140
|
+
}),
|
|
141
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|
|
142
|
+
style: {
|
|
143
|
+
display: "grid",
|
|
144
|
+
gap: 4
|
|
145
|
+
},
|
|
146
|
+
children: [
|
|
147
|
+
"API key · ",
|
|
148
|
+
configured === void 0 ? "状态待确认" : configured ? "已配置" : "未配置",
|
|
149
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
150
|
+
"aria-label": "Firecrawl API key",
|
|
151
|
+
style: inputStyle,
|
|
152
|
+
type: "password",
|
|
153
|
+
autoComplete: "off",
|
|
154
|
+
value: key,
|
|
155
|
+
placeholder: configured ? "留空保留已有 key" : "fc-...",
|
|
156
|
+
onChange: (event) => setKey(event.target.value)
|
|
157
|
+
})
|
|
158
|
+
]
|
|
159
|
+
}),
|
|
160
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
161
|
+
style: buttonStyle,
|
|
162
|
+
onClick: () => {
|
|
163
|
+
save(label, key).then(() => setKey("")).catch(() => {});
|
|
164
|
+
},
|
|
165
|
+
children: "保存"
|
|
166
|
+
}),
|
|
167
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
168
|
+
style: buttonStyle,
|
|
169
|
+
onClick: remove,
|
|
170
|
+
children: "移除账号"
|
|
171
|
+
})
|
|
172
|
+
]
|
|
173
|
+
})]
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
function FirecrawlCard({ scope, accounts: controller, ctx }) {
|
|
177
|
+
const snapshot = (0, react.useSyncExternalStore)(scope.subscribe.bind(scope), scope.getSnapshot.bind(scope));
|
|
178
|
+
const [open, setOpen] = (0, react.useState)(false);
|
|
179
|
+
const [busy, setBusy] = (0, react.useState)(false);
|
|
180
|
+
const [error, setError] = (0, react.useState)("");
|
|
181
|
+
const [configured, setConfigured] = (0, react.useState)();
|
|
182
|
+
(0, react.useEffect)(() => {
|
|
183
|
+
let current = true;
|
|
184
|
+
setConfigured(void 0);
|
|
185
|
+
ctx.remote.settings.describe().then((result) => {
|
|
186
|
+
if (!current || !result.ok) return;
|
|
187
|
+
const ns = result.value.namespaces.find((item) => item.ns === NAMESPACE);
|
|
188
|
+
if (ns) setConfigured(Object.fromEntries(ns.secrets.filter((secret) => secret.path[0] === "apiKeys").map((secret) => [secret.path[1], secret.set])));
|
|
189
|
+
}).catch(() => {});
|
|
190
|
+
return () => {
|
|
191
|
+
current = false;
|
|
192
|
+
};
|
|
193
|
+
}, [snapshot.revision, ctx]);
|
|
194
|
+
if (snapshot.status !== "ready") return null;
|
|
195
|
+
const section = snapshot.value ?? {};
|
|
196
|
+
async function run(action) {
|
|
197
|
+
setBusy(true);
|
|
198
|
+
setError("");
|
|
199
|
+
try {
|
|
200
|
+
await action();
|
|
201
|
+
} catch (error) {
|
|
202
|
+
setError("保存失败,未确认写入。请重新打开设置后重试。");
|
|
203
|
+
throw error;
|
|
204
|
+
} finally {
|
|
205
|
+
setBusy(false);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
function act(action) {
|
|
209
|
+
run(action).catch(() => {});
|
|
210
|
+
}
|
|
211
|
+
const disabled = busy || !snapshot.writable;
|
|
212
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
|
|
213
|
+
style: {
|
|
214
|
+
listStyle: "none",
|
|
215
|
+
border: "1px solid #8884",
|
|
216
|
+
borderRadius: 8,
|
|
217
|
+
margin: "8px 0"
|
|
218
|
+
},
|
|
219
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
220
|
+
style: {
|
|
221
|
+
...buttonStyle,
|
|
222
|
+
border: 0,
|
|
223
|
+
width: "100%",
|
|
224
|
+
textAlign: "left",
|
|
225
|
+
padding: 14
|
|
226
|
+
},
|
|
227
|
+
"aria-expanded": open,
|
|
228
|
+
onClick: () => setOpen(!open),
|
|
229
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: "Firecrawl search" }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
230
|
+
style: {
|
|
231
|
+
opacity: .7,
|
|
232
|
+
fontSize: 12
|
|
233
|
+
},
|
|
234
|
+
children: ["搜索账号与负载均衡 · ", open ? "收起" : "展开"]
|
|
235
|
+
})]
|
|
236
|
+
}), open && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
237
|
+
style: { padding: "0 14px 14px" },
|
|
238
|
+
children: [
|
|
239
|
+
(section.accounts ?? []).map((account) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AccountRow, {
|
|
240
|
+
account,
|
|
241
|
+
configured: configured === void 0 ? void 0 : configured[account.id] === true,
|
|
242
|
+
busy: disabled,
|
|
243
|
+
save: (label, key) => run(() => controller.save(account.id, label, key)),
|
|
244
|
+
remove: () => act(() => controller.remove(account.id))
|
|
245
|
+
}, account.id)),
|
|
246
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
247
|
+
style: {
|
|
248
|
+
...buttonStyle,
|
|
249
|
+
marginTop: 10
|
|
250
|
+
},
|
|
251
|
+
disabled,
|
|
252
|
+
onClick: () => act(() => controller.add(crypto.randomUUID())),
|
|
253
|
+
children: "添加账号"
|
|
254
|
+
}),
|
|
255
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
256
|
+
style: {
|
|
257
|
+
fontSize: 12,
|
|
258
|
+
opacity: .75
|
|
259
|
+
},
|
|
260
|
+
children: "Key 只写入、不回显。填写后点“保存”;只改名称不会清除已有 key。"
|
|
261
|
+
}),
|
|
262
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("fieldset", {
|
|
263
|
+
disabled,
|
|
264
|
+
style: {
|
|
265
|
+
border: 0,
|
|
266
|
+
padding: 0,
|
|
267
|
+
display: "flex",
|
|
268
|
+
gap: 12,
|
|
269
|
+
flexWrap: "wrap"
|
|
270
|
+
},
|
|
271
|
+
children: [
|
|
272
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", { children: ["策略 ", /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
|
|
273
|
+
"aria-label": "账号策略",
|
|
274
|
+
style: inputStyle,
|
|
275
|
+
value: section.strategy ?? "round-robin",
|
|
276
|
+
onChange: (event) => act(() => scope.set("strategy", event.target.value)),
|
|
277
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
|
|
278
|
+
value: "round-robin",
|
|
279
|
+
children: "轮询"
|
|
280
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
|
|
281
|
+
value: "least-loaded",
|
|
282
|
+
children: "最少使用"
|
|
283
|
+
})]
|
|
284
|
+
})] }),
|
|
285
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", { children: ["结果数 ", /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
286
|
+
"aria-label": "搜索结果数",
|
|
287
|
+
style: {
|
|
288
|
+
...inputStyle,
|
|
289
|
+
width: 65
|
|
290
|
+
},
|
|
291
|
+
type: "number",
|
|
292
|
+
min: 1,
|
|
293
|
+
defaultValue: section.limit ?? 5,
|
|
294
|
+
onBlur: (event) => {
|
|
295
|
+
const value = Number(event.target.value);
|
|
296
|
+
if (Number.isInteger(value) && value > 0) act(() => scope.set("limit", value));
|
|
297
|
+
}
|
|
298
|
+
})] }),
|
|
299
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", { children: ["冷却毫秒 ", /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
300
|
+
"aria-label": "冷却毫秒",
|
|
301
|
+
style: {
|
|
302
|
+
...inputStyle,
|
|
303
|
+
width: 90
|
|
304
|
+
},
|
|
305
|
+
type: "number",
|
|
306
|
+
min: 0,
|
|
307
|
+
defaultValue: section.cooldownMs ?? 6e4,
|
|
308
|
+
onBlur: (event) => {
|
|
309
|
+
const value = Number(event.target.value);
|
|
310
|
+
if (Number.isInteger(value) && value >= 0) act(() => scope.set("cooldownMs", value));
|
|
311
|
+
}
|
|
312
|
+
})] })
|
|
313
|
+
]
|
|
314
|
+
}),
|
|
315
|
+
error && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
316
|
+
role: "alert",
|
|
317
|
+
children: error
|
|
318
|
+
}),
|
|
319
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
|
|
320
|
+
style: {
|
|
321
|
+
fontSize: 12,
|
|
322
|
+
opacity: .75
|
|
323
|
+
},
|
|
324
|
+
children: [
|
|
325
|
+
"这个 bundle 会把 ",
|
|
326
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", { children: "web.searchProvider" }),
|
|
327
|
+
" 设为 ",
|
|
328
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", { children: "firecrawl" }),
|
|
329
|
+
"。加好账号后,用会暴露 ",
|
|
330
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", { children: "web_search" }),
|
|
331
|
+
" 的新会话。"
|
|
332
|
+
]
|
|
333
|
+
})
|
|
334
|
+
]
|
|
335
|
+
})]
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
//#endregion
|
|
339
|
+
exports.apply = apply;
|
|
340
|
+
exports.inject = inject;
|
|
341
|
+
exports.name = name;
|
|
342
|
+
return module.exports;
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
//# sourceMappingURL=client.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client.js","names":["useState","useSyncExternalStore"],"sources":["../src/accounts.ts","../src/client/accounts-controller.ts","../src/client/index.tsx"],"sourcesContent":["import type { FirecrawlAccount } from './provider.js'\n\n/** Legacy secrets are accepted only for migration; new writes keep them separate. */\nexport interface AccountMetadata {\n id: string\n label: string\n apiKey?: string\n}\n\nexport interface AccountSettings {\n accounts?: AccountMetadata[]\n apiKeys?: Record<string, string>\n}\n\n/** Storage boundary: an unfinished account is not a usable credential. */\nexport function resolvedAccounts(config: AccountSettings): FirecrawlAccount[] {\n return (config.accounts ?? []).flatMap(account => {\n const apiKey = config.apiKeys?.[account.id] ?? account.apiKey\n return typeof apiKey === 'string' && apiKey.trim().length > 0\n ? [{ id: account.id, label: account.label, apiKey }]\n : []\n })\n}\n\n/** One atomic owner-side migration, never reconstructed from a redacted view. */\nexport function legacyAccountMigration(config: AccountSettings): AccountSettings | undefined {\n if (!(config.accounts ?? []).some(account => account.apiKey !== undefined)) return undefined\n const apiKeys = { ...config.apiKeys }\n const accounts = (config.accounts ?? []).map(({ id, label, apiKey }) => {\n if (!Object.hasOwn(apiKeys, id) && typeof apiKey === 'string' && apiKey.trim()) apiKeys[id] = apiKey\n return { id, label }\n })\n return { accounts, apiKeys }\n}\n\n/** Wire-safe metadata projection; never repeat any credential in an array write. */\nexport function metadataOnly(accounts: AccountMetadata[]): { id: string; label: string }[] {\n return accounts.map(({ id, label }) => ({ id, label }))\n}\n","import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client'\nimport type { SettingsPathOpView } from '@deepseek-ai/dsh-settings/types'\nimport { metadataOnly, type AccountSettings } from '../accounts.js'\n\n/** All edits use the namespace's revision fence and never read credential values. */\nexport class AccountsController {\n constructor(private readonly scope: SettingsScope<AccountSettings>) {}\n\n private snapshot() {\n const snapshot = this.scope.getSnapshot()\n if (snapshot.status !== 'ready' || !snapshot.writable) throw new Error('Settings are not writable')\n return { accounts: metadataOnly(snapshot.value?.accounts ?? []), revision: snapshot.revision }\n }\n\n add(id: string): Promise<void> {\n const { accounts, revision } = this.snapshot()\n return this.scope.mutate([{ op: 'set', path: ['accounts'], value: [...accounts, { id, label: '' }] }], revision)\n }\n\n save(id: string, label: string, key: string): Promise<void> {\n const { accounts, revision } = this.snapshot()\n if (!accounts.some(account => account.id === id)) throw new Error('Account was removed')\n const ops: SettingsPathOpView[] = [{ op: 'set', path: ['accounts'], value: accounts.map(account => account.id === id ? { id, label } : account) }]\n if (key.trim()) ops.push({ op: 'set', path: ['apiKeys', id], value: key.trim() })\n return this.scope.mutate(ops, revision)\n }\n\n remove(id: string): Promise<void> {\n const { accounts, revision } = this.snapshot()\n return this.scope.mutate([\n { op: 'set', path: ['accounts'], value: accounts.filter(account => account.id !== id) },\n { op: 'unset', path: ['apiKeys', id] },\n ], revision)\n }\n}\n","import { useEffect, useState, useSyncExternalStore, type ReactNode } from 'react'\nimport type { Context } from '@deepseek-ai/cordis'\nimport type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client'\nimport type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'\nimport type {} from '@deepseek-ai/dsh-client-ui-slots'\nimport type {} from '@deepseek-ai/dsh-client-ui-renderer/client'\nimport type { SettingsDescribeValue } from '@deepseek-ai/dsh-settings/types'\nimport type {} from '@deepseek-ai/dsh-api-remotes/client'\nimport type { AccountSettings, AccountMetadata } from '../accounts.js'\nimport { AccountsController } from './accounts-controller.js'\n\nconst NAMESPACE = 'dsh-web-search-firecrawl'\ninterface Section extends AccountSettings {\n limit?: number\n strategy?: 'round-robin' | 'least-loaded'\n cooldownMs?: number\n}\nexport const name = 'dsh-web-search-firecrawl-client'\nexport const inject = ['slots', 'settingsScope', 'remote', 'remote.settings']\n\nexport function apply(ctx: Context): void {\n const scope = ctx.settingsScope.bind<Section>({ namespace: NAMESPACE })\n const accounts = new AccountsController(scope)\n ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({ name: 'settings.plugin.item', key: NAMESPACE },\n () => <FirecrawlCard scope={scope} accounts={accounts} ctx={ctx} />))\n}\n\nconst inputStyle = { padding: '6px 8px', border: '1px solid #8886', borderRadius: 6, background: 'transparent', color: 'inherit' }\nconst buttonStyle = { ...inputStyle, cursor: 'pointer' }\n\nfunction AccountRow({ account, configured, busy, save, remove }: {\n account: AccountMetadata; configured: boolean | undefined; busy: boolean\n save: (label: string, key: string) => Promise<void>; remove: () => void\n}): ReactNode {\n const [label, setLabel] = useState(account.label)\n const [key, setKey] = useState('')\n useEffect(() => { setLabel(account.label) }, [account.label])\n return <fieldset style={{ border: '1px solid #8884', borderRadius: 6, padding: 10, marginTop: 10 }} disabled={busy}>\n <legend>{account.label || '新账号'}</legend>\n <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'end' }}>\n <label style={{ display: 'grid', gap: 4 }}>名称\n <input aria-label=\"账号名称\" style={inputStyle} value={label} onChange={event => setLabel(event.target.value)} />\n </label>\n <label style={{ display: 'grid', gap: 4 }}>API key · {configured === undefined ? '状态待确认' : configured ? '已配置' : '未配置'}\n <input aria-label=\"Firecrawl API key\" style={inputStyle} type=\"password\" autoComplete=\"off\" value={key}\n placeholder={configured ? '留空保留已有 key' : 'fc-...'} onChange={event => setKey(event.target.value)} />\n </label>\n <button style={buttonStyle} onClick={() => { void save(label, key).then(() => setKey('')).catch(() => {}) }}>保存</button>\n <button style={buttonStyle} onClick={remove}>移除账号</button>\n </div>\n </fieldset>\n}\n\nfunction FirecrawlCard({ scope, accounts: controller, ctx }: { scope: SettingsScope<Section>; accounts: AccountsController; ctx: Context }): ReactNode {\n const snapshot = useSyncExternalStore(scope.subscribe.bind(scope), scope.getSnapshot.bind(scope))\n const [open, setOpen] = useState(false)\n const [busy, setBusy] = useState(false)\n const [error, setError] = useState('')\n const [configured, setConfigured] = useState<Record<string, boolean> | undefined>()\n // The API reports configured-state metadata, never credential values.\n useEffect(() => {\n let current = true\n setConfigured(undefined)\n void ctx.remote.settings.describe().then((result: { ok: true; value: SettingsDescribeValue } | { ok: false }) => {\n if (!current || !result.ok) return\n const ns = result.value.namespaces.find(item => item.ns === NAMESPACE)\n if (ns) setConfigured(Object.fromEntries(ns.secrets.filter(secret => secret.path[0] === 'apiKeys').map(secret => [secret.path[1], secret.set])))\n }).catch(() => {})\n return () => { current = false }\n }, [snapshot.revision, ctx])\n\n if (snapshot.status !== 'ready') return null\n const section = snapshot.value ?? {}\n async function run(action: () => Promise<void>): Promise<void> {\n setBusy(true)\n setError('')\n try { await action() }\n catch (error) { setError('保存失败,未确认写入。请重新打开设置后重试。'); throw error }\n finally { setBusy(false) }\n }\n function act(action: () => Promise<void>): void { void run(action).catch(() => {}) }\n const disabled = busy || !snapshot.writable\n return <li style={{ listStyle: 'none', border: '1px solid #8884', borderRadius: 8, margin: '8px 0' }}>\n <button style={{ ...buttonStyle, border: 0, width: '100%', textAlign: 'left', padding: 14 }} aria-expanded={open} onClick={() => setOpen(!open)}>\n <strong>Firecrawl search</strong><div style={{ opacity: 0.7, fontSize: 12 }}>搜索账号与负载均衡 · {open ? '收起' : '展开'}</div>\n </button>\n {open && <div style={{ padding: '0 14px 14px' }}>\n {(section.accounts ?? []).map(account => <AccountRow key={account.id} account={account}\n configured={configured === undefined ? undefined : configured[account.id] === true} busy={disabled}\n save={(label, key) => run(() => controller.save(account.id, label, key))}\n remove={() => act(() => controller.remove(account.id))} />)}\n <button style={{ ...buttonStyle, marginTop: 10 }} disabled={disabled} onClick={() => act(() => controller.add(crypto.randomUUID()))}>添加账号</button>\n <p style={{ fontSize: 12, opacity: 0.75 }}>Key 只写入、不回显。填写后点“保存”;只改名称不会清除已有 key。</p>\n <fieldset disabled={disabled} style={{ border: 0, padding: 0, display: 'flex', gap: 12, flexWrap: 'wrap' }}>\n <label>策略 <select aria-label=\"账号策略\" style={inputStyle} value={section.strategy ?? 'round-robin'} onChange={event => act(() => scope.set('strategy', event.target.value))}>\n <option value=\"round-robin\">轮询</option><option value=\"least-loaded\">最少使用</option>\n </select></label>\n <label>结果数 <input aria-label=\"搜索结果数\" style={{ ...inputStyle, width: 65 }} type=\"number\" min={1} defaultValue={section.limit ?? 5}\n onBlur={event => { const value = Number(event.target.value); if (Number.isInteger(value) && value > 0) act(() => scope.set('limit', value)) }} /></label>\n <label>冷却毫秒 <input aria-label=\"冷却毫秒\" style={{ ...inputStyle, width: 90 }} type=\"number\" min={0} defaultValue={section.cooldownMs ?? 60000}\n onBlur={event => { const value = Number(event.target.value); if (Number.isInteger(value) && value >= 0) act(() => scope.set('cooldownMs', value)) }} /></label>\n </fieldset>\n {error && <p role=\"alert\">{error}</p>}\n <p style={{ fontSize: 12, opacity: 0.75 }}>这个 bundle 会把 <code>web.searchProvider</code> 设为 <code>firecrawl</code>。加好账号后,用会暴露 <code>web_search</code> 的新会话。</p>\n </div>}\n </li>\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import z from "@deepseek-ai/schemastery";
|
|
2
|
+
import { WebError } from "@deepseek-ai/dsh-web";
|
|
3
|
+
//#region lib/types/accounts.js
|
|
4
|
+
/** Storage boundary: an unfinished account is not a usable credential. */
|
|
5
|
+
function resolvedAccounts(config) {
|
|
6
|
+
return (config.accounts ?? []).flatMap((account) => {
|
|
7
|
+
const apiKey = config.apiKeys?.[account.id] ?? account.apiKey;
|
|
8
|
+
return typeof apiKey === "string" && apiKey.trim().length > 0 ? [{
|
|
9
|
+
id: account.id,
|
|
10
|
+
label: account.label,
|
|
11
|
+
apiKey
|
|
12
|
+
}] : [];
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
/** One atomic owner-side migration, never reconstructed from a redacted view. */
|
|
16
|
+
function legacyAccountMigration(config) {
|
|
17
|
+
if (!(config.accounts ?? []).some((account) => account.apiKey !== void 0)) return void 0;
|
|
18
|
+
const apiKeys = { ...config.apiKeys };
|
|
19
|
+
return {
|
|
20
|
+
accounts: (config.accounts ?? []).map(({ id, label, apiKey }) => {
|
|
21
|
+
if (!Object.hasOwn(apiKeys, id) && typeof apiKey === "string" && apiKey.trim()) apiKeys[id] = apiKey;
|
|
22
|
+
return {
|
|
23
|
+
id,
|
|
24
|
+
label
|
|
25
|
+
};
|
|
26
|
+
}),
|
|
27
|
+
apiKeys
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
//#endregion
|
|
31
|
+
//#region lib/types/provider.js
|
|
32
|
+
/**
|
|
33
|
+
* Firecrawl-backed `WebSearchProvider` with a multi-account load-balanced pool.
|
|
34
|
+
*
|
|
35
|
+
* Firecrawl (`https://firecrawl.dev`) exposes a simple REST `/v1/search`
|
|
36
|
+
* endpoint: POST `{ query, limit, ... }` with `Authorization: Bearer <key>`.
|
|
37
|
+
* Each API key is rate-limited independently, so a user with several keys can
|
|
38
|
+
* pool them. This provider spreads searches across the pool, fails a request
|
|
39
|
+
* over to another healthy account on a rate-limit (`429`) or transient server
|
|
40
|
+
* error (`5xx`), and cools an exhausted account down for a short window before
|
|
41
|
+
* it is eligible again. `available()` is cheap and makes no network calls.
|
|
42
|
+
*
|
|
43
|
+
* The wire format and native `fetch` client are provider-private and do not use
|
|
44
|
+
* `ctx.llm`.
|
|
45
|
+
*
|
|
46
|
+
* @module dsh-web-search-firecrawl/provider
|
|
47
|
+
*/
|
|
48
|
+
/** Stable id this provider registers under (`ctx.web.registerSearchProvider`). */
|
|
49
|
+
const FIRECRAWL_PROVIDER_ID = "firecrawl";
|
|
50
|
+
/** Default Firecrawl API base; `/v1/search` is the operation. */
|
|
51
|
+
const FIRECRAWL_DEFAULT_BASE_URL = "https://api.firecrawl.dev";
|
|
52
|
+
/** Default maximum search results per request. */
|
|
53
|
+
const FIRECRAWL_DEFAULT_LIMIT = 5;
|
|
54
|
+
/** Attribution header sent on every request. Bump with the package version. */
|
|
55
|
+
const USER_AGENT = "deepseek-harness/0.0.1";
|
|
56
|
+
/**
|
|
57
|
+
* Map one structured Firecrawl search item to a normalized source. Blank
|
|
58
|
+
* fields are omitted rather than set empty; a URL is required.
|
|
59
|
+
*/
|
|
60
|
+
function mapFirecrawlItem(item) {
|
|
61
|
+
if (item.url == null || item.url.length === 0) return void 0;
|
|
62
|
+
return {
|
|
63
|
+
url: item.url,
|
|
64
|
+
...item.title != null && item.title.length > 0 ? { title: item.title } : {},
|
|
65
|
+
...item.description != null && item.description.length > 0 ? { snippet: item.description } : {},
|
|
66
|
+
...item.publishedAt != null && item.publishedAt.length > 0 ? { publishedAt: item.publishedAt } : {}
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Map a Firecrawl `/v1/search` response to a normalized search result. `data[]`
|
|
71
|
+
* is deduped by URL (Firecrawl may surface the same page more than once across
|
|
72
|
+
* engines). The web service owns the final `maxResults` truncation, so
|
|
73
|
+
* `truncated` is always `false` here.
|
|
74
|
+
*
|
|
75
|
+
* @param response - the parsed Firecrawl response body.
|
|
76
|
+
* @returns the normalized result with deduped sources.
|
|
77
|
+
*/
|
|
78
|
+
function mapFirecrawlResponse(response) {
|
|
79
|
+
const seen = /* @__PURE__ */ new Set();
|
|
80
|
+
const sources = [];
|
|
81
|
+
for (const item of response.data ?? []) {
|
|
82
|
+
const source = mapFirecrawlItem(item);
|
|
83
|
+
if (source === void 0 || seen.has(source.url)) continue;
|
|
84
|
+
seen.add(source.url);
|
|
85
|
+
sources.push(source);
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
sources,
|
|
89
|
+
truncated: false
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/** True for a fetch/`AbortSignal` abort, surfaced as `WEB_ABORTED`. */
|
|
93
|
+
function isAbortError(error) {
|
|
94
|
+
return error instanceof DOMException && error.name === "AbortError";
|
|
95
|
+
}
|
|
96
|
+
/** True for a request limit that can be sent to Firecrawl (a positive whole number). */
|
|
97
|
+
function isPositiveInteger(value) {
|
|
98
|
+
return Number.isInteger(value) && value > 0;
|
|
99
|
+
}
|
|
100
|
+
/** Build the provider's stable cancellation error while retaining the caller's reason. */
|
|
101
|
+
function searchAborted(signal, fallback) {
|
|
102
|
+
return new WebError("Firecrawl search aborted", "WEB_ABORTED", { cause: signal?.aborted === true ? signal.reason : fallback });
|
|
103
|
+
}
|
|
104
|
+
/** Throw the provider's stable cancellation error when the caller already aborted. */
|
|
105
|
+
function throwIfSearchAborted(signal) {
|
|
106
|
+
if (signal?.aborted === true) throw searchAborted(signal);
|
|
107
|
+
}
|
|
108
|
+
/** Race a same-process asynchronous preflight against caller cancellation. */
|
|
109
|
+
function abortable(operation, signal) {
|
|
110
|
+
if (signal === void 0) return operation;
|
|
111
|
+
if (signal.aborted) return Promise.reject(searchAborted(signal));
|
|
112
|
+
return new Promise((resolve, reject) => {
|
|
113
|
+
const onAbort = () => {
|
|
114
|
+
reject(searchAborted(signal));
|
|
115
|
+
};
|
|
116
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
117
|
+
operation.then((value) => {
|
|
118
|
+
signal.removeEventListener("abort", onAbort);
|
|
119
|
+
resolve(value);
|
|
120
|
+
}, (error) => {
|
|
121
|
+
signal.removeEventListener("abort", onAbort);
|
|
122
|
+
reject(new Error(String(error).replace(/^Error: /u, ""), { cause: error }));
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
/** Add endpoint/account recovery instructions to a provider failure. */
|
|
127
|
+
function searchEndpointError(message, cause) {
|
|
128
|
+
return new WebError(`${message}\n\nThis came from the Firecrawl search provider. Check the accounts in Settings > Plugins > Plugin configuration > Firecrawl search: each saved account needs a valid Firecrawl API key, and rate-limited accounts are cooled down automatically before they are reused. Only the user should change or choose which account serves a search.`, "WEB_PROVIDER_ERROR", cause === void 0 ? void 0 : { cause });
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* The Firecrawl-backed search provider. Spreads work across a pool of accounts,
|
|
132
|
+
* failing a rate-limited or server-erroring account over to the next healthy
|
|
133
|
+
* one and cooling the exhausted account down for {@link FirecrawlSearchProviderOptions.cooldownMs}.
|
|
134
|
+
*/
|
|
135
|
+
var FirecrawlSearchProvider = class {
|
|
136
|
+
resolveOptions;
|
|
137
|
+
id = FIRECRAWL_PROVIDER_ID;
|
|
138
|
+
slots = [];
|
|
139
|
+
next = 0;
|
|
140
|
+
/**
|
|
141
|
+
* @param resolveOptions - the options for the NEXT operation, snapshotted
|
|
142
|
+
* once at each operation's entry so one search never mixes two settings
|
|
143
|
+
* sections. A thunk rather than a value because the settings section can
|
|
144
|
+
* change between searches, and re-registering the provider to carry a new
|
|
145
|
+
* account pool would make the seam's selection observable as a flicker.
|
|
146
|
+
*/
|
|
147
|
+
constructor(resolveOptions) {
|
|
148
|
+
this.resolveOptions = resolveOptions;
|
|
149
|
+
}
|
|
150
|
+
/** Cheap local usability check; makes no network calls. */
|
|
151
|
+
available() {
|
|
152
|
+
const options = this.resolveOptions();
|
|
153
|
+
return options.accounts.some((account) => account.apiKey.length > 0) && URL.canParse(options.baseURL) && isPositiveInteger(options.limit) && Number.isInteger(options.cooldownMs) && options.cooldownMs >= 0;
|
|
154
|
+
}
|
|
155
|
+
async search(request, signal) {
|
|
156
|
+
const options = this.resolveOptions();
|
|
157
|
+
throwIfSearchAborted(signal);
|
|
158
|
+
const slots = this.syncSlots(options);
|
|
159
|
+
if (slots.length === 0) throw new WebError("Firecrawl search has no accounts configured; add at least one Firecrawl API key through Settings > Plugins > Plugin configuration > Firecrawl search, or set the dsh-web-search-firecrawl config", "WEB_PROVIDER_CREDENTIAL_MISSING");
|
|
160
|
+
const endpoint = `${options.baseURL}/v1/search`;
|
|
161
|
+
const body = {
|
|
162
|
+
query: request.query,
|
|
163
|
+
...request.maxResults !== void 0 ? { limit: Math.min(request.maxResults, options.limit) } : { limit: options.limit }
|
|
164
|
+
};
|
|
165
|
+
const ordered = this.orderCandidates(slots, options.strategy);
|
|
166
|
+
const attempts = [];
|
|
167
|
+
let lastError;
|
|
168
|
+
for (const account of ordered) {
|
|
169
|
+
throwIfSearchAborted(signal);
|
|
170
|
+
let response;
|
|
171
|
+
try {
|
|
172
|
+
response = await abortable(fetch(endpoint, {
|
|
173
|
+
method: "POST",
|
|
174
|
+
redirect: "error",
|
|
175
|
+
headers: {
|
|
176
|
+
"authorization": `Bearer ${account.account.apiKey}`,
|
|
177
|
+
"content-type": "application/json",
|
|
178
|
+
"accept": "application/json",
|
|
179
|
+
"user-agent": USER_AGENT
|
|
180
|
+
},
|
|
181
|
+
body: JSON.stringify(body),
|
|
182
|
+
...signal !== void 0 ? { signal } : {}
|
|
183
|
+
}), signal);
|
|
184
|
+
} catch (error) {
|
|
185
|
+
if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
|
|
186
|
+
lastError = error;
|
|
187
|
+
account.lastStatus = 0;
|
|
188
|
+
account.uses += 1;
|
|
189
|
+
this.startCooldown(account, options.cooldownMs);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
account.lastStatus = response.status;
|
|
193
|
+
account.uses += 1;
|
|
194
|
+
if (response.ok) try {
|
|
195
|
+
return mapFirecrawlResponse(await response.json());
|
|
196
|
+
} catch (error) {
|
|
197
|
+
if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
|
|
198
|
+
throw searchEndpointError(error instanceof WebError ? error.message : `Firecrawl returned an unprocessable response body: ${String(error)}`, error);
|
|
199
|
+
}
|
|
200
|
+
attempts.push({
|
|
201
|
+
response,
|
|
202
|
+
account,
|
|
203
|
+
endpoint
|
|
204
|
+
});
|
|
205
|
+
if (response.status === 429 || response.status >= 500) {
|
|
206
|
+
this.startCooldown(account, options.cooldownMs);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
const message = await this.errorMessage(response, signal);
|
|
210
|
+
throw searchEndpointError(`Firecrawl API error (HTTP ${response.status}) for account "${this.label(account.account)}"${message.length > 0 ? `: ${message}` : ""}`);
|
|
211
|
+
}
|
|
212
|
+
if (attempts.length === 0) throw searchEndpointError(`Firecrawl search request failed: ${String(lastError ?? "no accounts could be reached")}`, lastError);
|
|
213
|
+
const last = attempts[attempts.length - 1];
|
|
214
|
+
const message = await this.errorMessage(last.response, signal);
|
|
215
|
+
throw searchEndpointError(`Firecrawl search failed after trying ${attempts.length} account(s); last attempt was HTTP ${last.response.status}${message.length > 0 ? `: ${message}` : ""}`);
|
|
216
|
+
}
|
|
217
|
+
/** Reconcile the persistent slot table with the latest snapshot's accounts. */
|
|
218
|
+
syncSlots(options) {
|
|
219
|
+
const byId = new Map(this.slots.map((slot) => [slot.account.id, slot]));
|
|
220
|
+
this.slots = options.accounts.map((account) => {
|
|
221
|
+
const existing = byId.get(account.id);
|
|
222
|
+
if (existing !== void 0) {
|
|
223
|
+
existing.account = account;
|
|
224
|
+
return existing;
|
|
225
|
+
}
|
|
226
|
+
return {
|
|
227
|
+
account,
|
|
228
|
+
uses: 0,
|
|
229
|
+
cooledUntil: 0,
|
|
230
|
+
lastStatus: 0
|
|
231
|
+
};
|
|
232
|
+
});
|
|
233
|
+
return this.slots;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Order candidate accounts for this operation: healthy ones first (in
|
|
237
|
+
* strategy order), then cooling-down ones, so a small exhausted pool still
|
|
238
|
+
* degrades gracefully.
|
|
239
|
+
*/
|
|
240
|
+
orderCandidates(slots, strategy) {
|
|
241
|
+
const now = Date.now();
|
|
242
|
+
const healthy = slots.filter((slot) => slot.cooledUntil <= now);
|
|
243
|
+
const cooling = slots.filter((slot) => slot.cooledUntil > now);
|
|
244
|
+
if (strategy === "least-loaded") {
|
|
245
|
+
healthy.sort((a, b) => a.uses - b.uses);
|
|
246
|
+
cooling.sort((a, b) => a.uses - b.uses);
|
|
247
|
+
} else if (healthy.length > 0) {
|
|
248
|
+
this.next = this.next % healthy.length;
|
|
249
|
+
const rotated = healthy.slice(this.next).concat(healthy.slice(0, this.next));
|
|
250
|
+
this.next = (this.next + 1) % healthy.length;
|
|
251
|
+
return rotated.concat(cooling);
|
|
252
|
+
}
|
|
253
|
+
return healthy.concat(cooling);
|
|
254
|
+
}
|
|
255
|
+
/** Put one account in cooldown (or extend an existing cooldown) for `cooldownMs`. */
|
|
256
|
+
startCooldown(slot, cooldownMs) {
|
|
257
|
+
const until = Date.now() + cooldownMs;
|
|
258
|
+
slot.cooledUntil = Math.max(slot.cooledUntil, until);
|
|
259
|
+
}
|
|
260
|
+
label(account) {
|
|
261
|
+
return account.label !== void 0 && account.label.length > 0 ? account.label : account.id;
|
|
262
|
+
}
|
|
263
|
+
/** Extract a human-readable message from a non-ok error body, honoring abort. */
|
|
264
|
+
async errorMessage(response, signal) {
|
|
265
|
+
try {
|
|
266
|
+
const parsed = await response.json();
|
|
267
|
+
return parsed.error ?? parsed.message ?? "";
|
|
268
|
+
} catch (error) {
|
|
269
|
+
if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
|
|
270
|
+
return "";
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
//#endregion
|
|
275
|
+
//#region lib/types/dsh-web-search-firecrawl.js
|
|
276
|
+
/**
|
|
277
|
+
* Firecrawl-backed search provider for the DeepSeek Harness web capability
|
|
278
|
+
* seam (`ctx.web`). Unlike the bundled DeepSeek search, which only accepts the
|
|
279
|
+
* DeepSeek API key, this plugin lets the user bring their own Firecrawl
|
|
280
|
+
* account(s) and load-balances across a pool of Firecrawl API keys.
|
|
281
|
+
*
|
|
282
|
+
* Two halves, one package:
|
|
283
|
+
* - Host half (this file): registers the {@link FirecrawlSearchProvider} on
|
|
284
|
+
* `ctx.web` and a `settings` section so accounts are manageable.
|
|
285
|
+
* - Browser half (`src/client/`): a `settings.plugin.item` card that adds,
|
|
286
|
+
* edits, and removes accounts and tunes pool behavior.
|
|
287
|
+
*
|
|
288
|
+
* @module dsh-web-search-firecrawl
|
|
289
|
+
*/
|
|
290
|
+
/** Cordis plugin name used by loader diagnostics. */
|
|
291
|
+
const name = "dsh-web-search-firecrawl";
|
|
292
|
+
/** The web seam this provider registers into. */
|
|
293
|
+
const inject = ["web"];
|
|
294
|
+
/** Default cooldown for a rate-limited account (one minute). */
|
|
295
|
+
const DEFAULT_COOLDOWN_MS = 6e4;
|
|
296
|
+
/** Settings namespace carrying this provider's accounts and pool options. */
|
|
297
|
+
const FIRECRAWL_SETTINGS_NAMESPACE = "dsh-web-search-firecrawl";
|
|
298
|
+
const Config = z.object({
|
|
299
|
+
accounts: z.array(z.object({
|
|
300
|
+
id: z.string(),
|
|
301
|
+
label: z.string(),
|
|
302
|
+
apiKey: z.string().role("secret")
|
|
303
|
+
})),
|
|
304
|
+
apiKeys: z.dict(z.string().role("secret")),
|
|
305
|
+
baseURL: z.string(),
|
|
306
|
+
limit: z.number().step(1).min(1),
|
|
307
|
+
strategy: z.union(["round-robin", "least-loaded"]),
|
|
308
|
+
cooldownMs: z.number().step(1).min(0)
|
|
309
|
+
});
|
|
310
|
+
/** Project one resolved section into the options the provider serves next. */
|
|
311
|
+
function resolveOptions(config) {
|
|
312
|
+
return {
|
|
313
|
+
accounts: resolvedAccounts(config),
|
|
314
|
+
baseURL: config.baseURL ?? "https://api.firecrawl.dev",
|
|
315
|
+
limit: config.limit ?? 5,
|
|
316
|
+
strategy: config.strategy ?? "round-robin",
|
|
317
|
+
cooldownMs: config.cooldownMs ?? DEFAULT_COOLDOWN_MS
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
/** Register the Firecrawl search provider with `ctx.web` and its settings section. */
|
|
321
|
+
function apply(ctx, config) {
|
|
322
|
+
console.log("[my-plugins/dsh-web-search-firecrawl] loaded");
|
|
323
|
+
let current = () => config;
|
|
324
|
+
ctx.inject(["settings"], async (settingsCtx) => {
|
|
325
|
+
const scope = settingsCtx.settings.register(FIRECRAWL_SETTINGS_NAMESPACE, Config, { base: config });
|
|
326
|
+
const migration = legacyAccountMigration(scope.get());
|
|
327
|
+
if (migration !== void 0) await scope.update(migration);
|
|
328
|
+
current = () => scope.get();
|
|
329
|
+
});
|
|
330
|
+
ctx.web.registerSearchProvider(new FirecrawlSearchProvider(() => resolveOptions(current())));
|
|
331
|
+
}
|
|
332
|
+
//#endregion
|
|
333
|
+
export { Config, FIRECRAWL_DEFAULT_BASE_URL, FIRECRAWL_DEFAULT_LIMIT, FIRECRAWL_PROVIDER_ID, FIRECRAWL_SETTINGS_NAMESPACE, FirecrawlSearchProvider, apply, inject, name };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { FirecrawlAccount } from './provider.js';
|
|
2
|
+
/** Legacy secrets are accepted only for migration; new writes keep them separate. */
|
|
3
|
+
export interface AccountMetadata {
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
apiKey?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface AccountSettings {
|
|
9
|
+
accounts?: AccountMetadata[];
|
|
10
|
+
apiKeys?: Record<string, string>;
|
|
11
|
+
}
|
|
12
|
+
/** Storage boundary: an unfinished account is not a usable credential. */
|
|
13
|
+
export declare function resolvedAccounts(config: AccountSettings): FirecrawlAccount[];
|
|
14
|
+
/** One atomic owner-side migration, never reconstructed from a redacted view. */
|
|
15
|
+
export declare function legacyAccountMigration(config: AccountSettings): AccountSettings | undefined;
|
|
16
|
+
/** Wire-safe metadata projection; never repeat any credential in an array write. */
|
|
17
|
+
export declare function metadataOnly(accounts: AccountMetadata[]): {
|
|
18
|
+
id: string;
|
|
19
|
+
label: string;
|
|
20
|
+
}[];
|
|
21
|
+
//# sourceMappingURL=accounts.d.ts.map
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client';
|
|
2
|
+
import { type AccountSettings } from '../accounts.js';
|
|
3
|
+
/** All edits use the namespace's revision fence and never read credential values. */
|
|
4
|
+
export declare class AccountsController {
|
|
5
|
+
private readonly scope;
|
|
6
|
+
constructor(scope: SettingsScope<AccountSettings>);
|
|
7
|
+
private snapshot;
|
|
8
|
+
add(id: string): Promise<void>;
|
|
9
|
+
save(id: string, label: string, key: string): Promise<void>;
|
|
10
|
+
remove(id: string): Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
//# sourceMappingURL=accounts-controller.d.ts.map
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Firecrawl-backed search provider for the DeepSeek Harness web capability
|
|
3
|
+
* seam (`ctx.web`). Unlike the bundled DeepSeek search, which only accepts the
|
|
4
|
+
* DeepSeek API key, this plugin lets the user bring their own Firecrawl
|
|
5
|
+
* account(s) and load-balances across a pool of Firecrawl API keys.
|
|
6
|
+
*
|
|
7
|
+
* Two halves, one package:
|
|
8
|
+
* - Host half (this file): registers the {@link FirecrawlSearchProvider} on
|
|
9
|
+
* `ctx.web` and a `settings` section so accounts are manageable.
|
|
10
|
+
* - Browser half (`src/client/`): a `settings.plugin.item` card that adds,
|
|
11
|
+
* edits, and removes accounts and tunes pool behavior.
|
|
12
|
+
*
|
|
13
|
+
* @module dsh-web-search-firecrawl
|
|
14
|
+
*/
|
|
15
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
16
|
+
import z from '@deepseek-ai/schemastery';
|
|
17
|
+
import { type AccountSettings } from './accounts.js';
|
|
18
|
+
import { type FirecrawlPoolStrategy } from './provider.js';
|
|
19
|
+
export { FIRECRAWL_DEFAULT_BASE_URL, FIRECRAWL_DEFAULT_LIMIT, FIRECRAWL_PROVIDER_ID, FirecrawlSearchProvider, } from './provider.js';
|
|
20
|
+
export type { FirecrawlAccount, FirecrawlPoolStrategy, FirecrawlSearchProviderOptions, } from './provider.ts';
|
|
21
|
+
/** Cordis plugin name used by loader diagnostics. */
|
|
22
|
+
export declare const name = "dsh-web-search-firecrawl";
|
|
23
|
+
/** The web seam this provider registers into. */
|
|
24
|
+
export declare const inject: string[];
|
|
25
|
+
/** Settings namespace carrying this provider's accounts and pool options. */
|
|
26
|
+
export declare const FIRECRAWL_SETTINGS_NAMESPACE = "dsh-web-search-firecrawl";
|
|
27
|
+
/** Plugin config (all optional — `apply` fills defaults). */
|
|
28
|
+
export interface Config extends AccountSettings {
|
|
29
|
+
/** Load-balanced pool of Firecrawl accounts (each is one API key). */
|
|
30
|
+
/** Endpoint base; `/v1/search` is appended. Defaults to the public API. */
|
|
31
|
+
baseURL?: string;
|
|
32
|
+
/** Maximum search results requested per search. Defaults to 5. */
|
|
33
|
+
limit?: number;
|
|
34
|
+
/** Pool scheduling: round-robin (default) or least-loaded. */
|
|
35
|
+
strategy?: FirecrawlPoolStrategy;
|
|
36
|
+
/** How long a rate-limited account stays cooled down, in ms. */
|
|
37
|
+
cooldownMs?: number;
|
|
38
|
+
}
|
|
39
|
+
export declare const Config: z<Config>;
|
|
40
|
+
/** Register the Firecrawl search provider with `ctx.web` and its settings section. */
|
|
41
|
+
export declare function apply(ctx: Context, config: Config): void;
|
|
42
|
+
//# sourceMappingURL=dsh-web-search-firecrawl.d.ts.map
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Firecrawl-backed `WebSearchProvider` with a multi-account load-balanced pool.
|
|
3
|
+
*
|
|
4
|
+
* Firecrawl (`https://firecrawl.dev`) exposes a simple REST `/v1/search`
|
|
5
|
+
* endpoint: POST `{ query, limit, ... }` with `Authorization: Bearer <key>`.
|
|
6
|
+
* Each API key is rate-limited independently, so a user with several keys can
|
|
7
|
+
* pool them. This provider spreads searches across the pool, fails a request
|
|
8
|
+
* over to another healthy account on a rate-limit (`429`) or transient server
|
|
9
|
+
* error (`5xx`), and cools an exhausted account down for a short window before
|
|
10
|
+
* it is eligible again. `available()` is cheap and makes no network calls.
|
|
11
|
+
*
|
|
12
|
+
* The wire format and native `fetch` client are provider-private and do not use
|
|
13
|
+
* `ctx.llm`.
|
|
14
|
+
*
|
|
15
|
+
* @module dsh-web-search-firecrawl/provider
|
|
16
|
+
*/
|
|
17
|
+
import type { WebSearchProvider, WebSearchRequest, WebSearchResult, WebSearchSource } from '@deepseek-ai/dsh-web';
|
|
18
|
+
/** Stable id this provider registers under (`ctx.web.registerSearchProvider`). */
|
|
19
|
+
export declare const FIRECRAWL_PROVIDER_ID = "firecrawl";
|
|
20
|
+
/** Default Firecrawl API base; `/v1/search` is the operation. */
|
|
21
|
+
export declare const FIRECRAWL_DEFAULT_BASE_URL = "https://api.firecrawl.dev";
|
|
22
|
+
/** Default maximum search results per request. */
|
|
23
|
+
export declare const FIRECRAWL_DEFAULT_LIMIT = 5;
|
|
24
|
+
/** Default pool scheduling strategy: strict round-robin across healthy accounts. */
|
|
25
|
+
export type FirecrawlPoolStrategy = 'round-robin' | 'least-loaded';
|
|
26
|
+
/**
|
|
27
|
+
* One Firecrawl account. A pool needs at least one account to serve a search;
|
|
28
|
+
* each account is an independent API key with its own rate limit.
|
|
29
|
+
*/
|
|
30
|
+
export interface FirecrawlAccount {
|
|
31
|
+
/** Stable, user-assigned id (used as the credential's display label fallback). */
|
|
32
|
+
id: string;
|
|
33
|
+
/** Optional human-readable label shown in the settings card. */
|
|
34
|
+
label?: string;
|
|
35
|
+
/** The Firecrawl API key (kept secret; never logged or echoed). */
|
|
36
|
+
apiKey: string;
|
|
37
|
+
}
|
|
38
|
+
/** Resolved provider options (the plugin's `apply` supplies defaults). */
|
|
39
|
+
export interface FirecrawlSearchProviderOptions {
|
|
40
|
+
/** The load-balanced pool of accounts. */
|
|
41
|
+
accounts: readonly FirecrawlAccount[];
|
|
42
|
+
/** Endpoint base; `/v1/search` is appended. */
|
|
43
|
+
baseURL: string;
|
|
44
|
+
/** Maximum results requested per search; defaulted before this point. */
|
|
45
|
+
limit: number;
|
|
46
|
+
/** How the next account is chosen: round-robin or least-recently-used. */
|
|
47
|
+
strategy: FirecrawlPoolStrategy;
|
|
48
|
+
/** How long an exhausted (rate-limited) account stays cooled down, in ms. */
|
|
49
|
+
cooldownMs: number;
|
|
50
|
+
}
|
|
51
|
+
/** A Firecrawl `/v1/search` result item (`data[]`). */
|
|
52
|
+
interface FirecrawlSearchItem {
|
|
53
|
+
url?: string;
|
|
54
|
+
title?: string | null;
|
|
55
|
+
description?: string | null;
|
|
56
|
+
/** Search engines may surface a recency/publish string; mapped to `publishedAt`. */
|
|
57
|
+
publishedAt?: string | null;
|
|
58
|
+
}
|
|
59
|
+
/** A Firecrawl `/v1/search` response envelope (best-effort; fields vary by API version). */
|
|
60
|
+
interface FirecrawlSearchResponse {
|
|
61
|
+
success?: boolean;
|
|
62
|
+
data?: FirecrawlSearchItem[];
|
|
63
|
+
error?: string;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Map one structured Firecrawl search item to a normalized source. Blank
|
|
67
|
+
* fields are omitted rather than set empty; a URL is required.
|
|
68
|
+
*/
|
|
69
|
+
export declare function mapFirecrawlItem(item: FirecrawlSearchItem): WebSearchSource | undefined;
|
|
70
|
+
/**
|
|
71
|
+
* Map a Firecrawl `/v1/search` response to a normalized search result. `data[]`
|
|
72
|
+
* is deduped by URL (Firecrawl may surface the same page more than once across
|
|
73
|
+
* engines). The web service owns the final `maxResults` truncation, so
|
|
74
|
+
* `truncated` is always `false` here.
|
|
75
|
+
*
|
|
76
|
+
* @param response - the parsed Firecrawl response body.
|
|
77
|
+
* @returns the normalized result with deduped sources.
|
|
78
|
+
*/
|
|
79
|
+
export declare function mapFirecrawlResponse(response: FirecrawlSearchResponse): WebSearchResult;
|
|
80
|
+
/**
|
|
81
|
+
* The Firecrawl-backed search provider. Spreads work across a pool of accounts,
|
|
82
|
+
* failing a rate-limited or server-erroring account over to the next healthy
|
|
83
|
+
* one and cooling the exhausted account down for {@link FirecrawlSearchProviderOptions.cooldownMs}.
|
|
84
|
+
*/
|
|
85
|
+
export declare class FirecrawlSearchProvider implements WebSearchProvider {
|
|
86
|
+
private readonly resolveOptions;
|
|
87
|
+
readonly id = "firecrawl";
|
|
88
|
+
private slots;
|
|
89
|
+
private next;
|
|
90
|
+
/**
|
|
91
|
+
* @param resolveOptions - the options for the NEXT operation, snapshotted
|
|
92
|
+
* once at each operation's entry so one search never mixes two settings
|
|
93
|
+
* sections. A thunk rather than a value because the settings section can
|
|
94
|
+
* change between searches, and re-registering the provider to carry a new
|
|
95
|
+
* account pool would make the seam's selection observable as a flicker.
|
|
96
|
+
*/
|
|
97
|
+
constructor(resolveOptions: () => FirecrawlSearchProviderOptions);
|
|
98
|
+
/** Cheap local usability check; makes no network calls. */
|
|
99
|
+
available(): boolean;
|
|
100
|
+
search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>;
|
|
101
|
+
/** Reconcile the persistent slot table with the latest snapshot's accounts. */
|
|
102
|
+
private syncSlots;
|
|
103
|
+
/**
|
|
104
|
+
* Order candidate accounts for this operation: healthy ones first (in
|
|
105
|
+
* strategy order), then cooling-down ones, so a small exhausted pool still
|
|
106
|
+
* degrades gracefully.
|
|
107
|
+
*/
|
|
108
|
+
private orderCandidates;
|
|
109
|
+
/** Put one account in cooldown (or extend an existing cooldown) for `cooldownMs`. */
|
|
110
|
+
private startCooldown;
|
|
111
|
+
private label;
|
|
112
|
+
/** Extract a human-readable message from a non-ok error body, honoring abort. */
|
|
113
|
+
private errorMessage;
|
|
114
|
+
}
|
|
115
|
+
export {};
|
|
116
|
+
//# sourceMappingURL=provider.d.ts.map
|
package/package.json
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-web-search-firecrawl",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Firecrawl search provider and multi-account settings for DeepSeek Harness",
|
|
5
|
+
"repository": { "type": "git", "url": "https://github.com/aa2246740/dsh-firecrawl.git" },
|
|
6
|
+
"homepage": "https://github.com/aa2246740/dsh-firecrawl#readme",
|
|
7
|
+
"bugs": { "url": "https://github.com/aa2246740/dsh-firecrawl/issues" },
|
|
8
|
+
"keywords": [
|
|
9
|
+
"deepseek-harness",
|
|
10
|
+
"dsh",
|
|
11
|
+
"dsh-plugin",
|
|
12
|
+
"firecrawl",
|
|
13
|
+
"web-search"
|
|
14
|
+
],
|
|
15
|
+
"type": "module",
|
|
16
|
+
"main": "lib/dsh-web-search-firecrawl.js",
|
|
17
|
+
"types": "lib/types/dsh-web-search-firecrawl.d.ts",
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc -p tsconfig.json && tsdown",
|
|
20
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
21
|
+
"test": "pnpm build && node --test tests/*.mjs"
|
|
22
|
+
},
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"types": "./lib/types/dsh-web-search-firecrawl.d.ts",
|
|
26
|
+
"default": "./lib/dsh-web-search-firecrawl.js"
|
|
27
|
+
},
|
|
28
|
+
"./client": "./lib/client.js",
|
|
29
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
30
|
+
"./package.json": "./package.json"
|
|
31
|
+
},
|
|
32
|
+
"dsh": {
|
|
33
|
+
"bundle": {
|
|
34
|
+
"patch": "./cordis.patch.yml"
|
|
35
|
+
},
|
|
36
|
+
"client": {
|
|
37
|
+
"inject": [
|
|
38
|
+
"@deepseek-ai/dsh-client-ui-settings-plugins"
|
|
39
|
+
],
|
|
40
|
+
"platform": "web"
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"files": [
|
|
44
|
+
"lib/*.js",
|
|
45
|
+
"lib/*.js.map",
|
|
46
|
+
"lib/types/**/*.d.ts",
|
|
47
|
+
"cordis.patch.yml",
|
|
48
|
+
"README.md",
|
|
49
|
+
"LICENSE"
|
|
50
|
+
],
|
|
51
|
+
"engines": {
|
|
52
|
+
"node": "^22.19.0 || >=24.0.0"
|
|
53
|
+
},
|
|
54
|
+
"license": "MIT",
|
|
55
|
+
"peerDependencies": {
|
|
56
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
57
|
+
"@deepseek-ai/dsh-client-ui-settings": "^0.1.5-rc.3",
|
|
58
|
+
"@deepseek-ai/dsh-client-ui-settings-plugins": "^0.1.5-rc.3",
|
|
59
|
+
"@deepseek-ai/dsh-settings": "^0.1.5-rc.3",
|
|
60
|
+
"@deepseek-ai/dsh-web": "^0.1.5-rc.3",
|
|
61
|
+
"@deepseek-ai/schemastery": "^3.18.1",
|
|
62
|
+
"react": "^18.2.0",
|
|
63
|
+
"react-dom": "^18.2.0"
|
|
64
|
+
},
|
|
65
|
+
"devDependencies": {
|
|
66
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
67
|
+
"@deepseek-ai/dsh-api-remotes": "^0.1.5-rc.3",
|
|
68
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.5-rc.3",
|
|
69
|
+
"@deepseek-ai/dsh-client-ui-renderer": "^0.1.5-rc.3",
|
|
70
|
+
"@deepseek-ai/dsh-scope": "^0.1.5-rc.3",
|
|
71
|
+
"@deepseek-ai/dsh-invariants": "^0.1.5-rc.3",
|
|
72
|
+
"@deepseek-ai/dsh-client-ui-settings": "^0.1.5-rc.3",
|
|
73
|
+
"@deepseek-ai/dsh-client-ui-settings-plugins": "^0.1.5-rc.3",
|
|
74
|
+
"@deepseek-ai/dsh-settings": "^0.1.5-rc.3",
|
|
75
|
+
"@deepseek-ai/dsh-web": "^0.1.5-rc.3",
|
|
76
|
+
"@deepseek-ai/schemastery": "^3.18.1",
|
|
77
|
+
"@types/react": "~18.3.1",
|
|
78
|
+
"react": "^18.2.0",
|
|
79
|
+
"react-dom": "^18.2.0",
|
|
80
|
+
"tsdown": "^0.22.2",
|
|
81
|
+
"typescript": "^6.0.3"
|
|
82
|
+
}
|
|
83
|
+
}
|