dsh-tinyfish-websearch 0.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/INSTALL.md +93 -0
- package/LICENSE +21 -0
- package/README.md +82 -0
- package/client.js +277 -0
- package/cordis.patch.yml +18 -0
- package/index.js +224 -0
- package/package.json +55 -0
package/INSTALL.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# dsh-tinyfish-websearch 安装指南(接收方)
|
|
2
|
+
|
|
3
|
+
把这个插件装进你的 DeepSeek Harness(DSH),默认 web 搜索就会替换为 **TinyFish**:
|
|
4
|
+
免费搜索(30 次/分钟),模型无关、不消耗 LLM 额度,还带一个设置页可以随时改 API Key。
|
|
5
|
+
|
|
6
|
+
> 本指南面向使用者。插件原理、配置项等完整说明见同目录的 [README.md](./README.md)。
|
|
7
|
+
|
|
8
|
+
## 前提
|
|
9
|
+
|
|
10
|
+
- 已安装 DSH,并已有 `web` profile(跑过 `dsh web`)。
|
|
11
|
+
- 准备一个 TinyFish API Key:到 [tinyfish.ai](https://www.tinyfish.ai) 注册,**免费、不用绑卡**(注册后即可获取 key)。
|
|
12
|
+
|
|
13
|
+
## 步骤 1:安装插件
|
|
14
|
+
|
|
15
|
+
任选一种来源,在你的终端执行(不需要进任何目录):
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
# npm 发布后
|
|
19
|
+
dsh plugin --profile web add dsh-tinyfish-websearch
|
|
20
|
+
|
|
21
|
+
# 或 GitHub 托管后
|
|
22
|
+
dsh plugin --profile web add github:lijq3370/dsh-tinyfish-websearch
|
|
23
|
+
|
|
24
|
+
# 或拿到的是插件文件夹(本地路径)
|
|
25
|
+
dsh plugin --profile web add /绝对/路径/tinyfish-websearch
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
装完**不需要手动改任何配置文件**:插件会作为 bundle 自动进入 profile,并自动完成"注册 TinyFish provider + 把默认搜索切到 TinyFish"。
|
|
29
|
+
|
|
30
|
+
## 步骤 2:重启 dsh web
|
|
31
|
+
|
|
32
|
+
在你启动 dsh 的终端里 Ctrl+C 停掉,重新运行:
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
dsh web
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
重启后设置页侧边栏会出现 **「TinyFish 搜索」** 入口。
|
|
39
|
+
|
|
40
|
+
## 步骤 3:配置 API Key
|
|
41
|
+
|
|
42
|
+
任选一种(推荐第一种):
|
|
43
|
+
|
|
44
|
+
**方式 A:设置页填写(推荐)**
|
|
45
|
+
打开 **设置 → TinyFish 搜索** → 在输入框粘贴 API Key → 保存。下次搜索立即生效,无需再重启。
|
|
46
|
+
|
|
47
|
+
**方式 B:环境变量**
|
|
48
|
+
在启动 `dsh web` 的环境里:
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
export TINYFISH_API_KEY=你的key
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**方式 C:写入 `$DSH_HOME/.env`**
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
echo 'TINYFISH_API_KEY=你的key' >> ~/.dsh/.env
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## 步骤 4:验证
|
|
61
|
+
|
|
62
|
+
在任意会话里让 agent 搜一个话题(例如"DeepSeek Harness")。能返回带来源链接的结果即成功。
|
|
63
|
+
|
|
64
|
+
没配 key 时会看到明确报错:`TinyFish search has no API key; set it in Settings → TinyFish 搜索 or export TINYFISH_API_KEY`——按步骤 3 配置即可。
|
|
65
|
+
|
|
66
|
+
## 常见问题
|
|
67
|
+
|
|
68
|
+
| 现象 | 处理 |
|
|
69
|
+
| --- | --- |
|
|
70
|
+
| 设置页没有「TinyFish 搜索」入口 | 没有重启过 `dsh web`,重启即可 |
|
|
71
|
+
| 搜索报 "has no API key" | 还没配置 key,见步骤 3 |
|
|
72
|
+
| 搜索报 HTTP 429 | 超过免费档限速(30 次/分钟),稍等再试 |
|
|
73
|
+
| 想换回 DSH 默认搜索 | 见下方"切回默认搜索" |
|
|
74
|
+
|
|
75
|
+
## 切回默认搜索
|
|
76
|
+
|
|
77
|
+
在你的 `~/.dsh/profiles/web/cordis.patch.yml` 末尾加:
|
|
78
|
+
|
|
79
|
+
```yaml
|
|
80
|
+
- id: web
|
|
81
|
+
config:
|
|
82
|
+
searchProvider: deepseek-official
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
重启 `dsh web` 即恢复 DSH 默认搜索(open-code-go / zen 原生搜索)。
|
|
86
|
+
|
|
87
|
+
## 卸载
|
|
88
|
+
|
|
89
|
+
```sh
|
|
90
|
+
dsh plugin --profile web remove dsh-tinyfish-websearch
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
会同时从依赖和 bundle 层移除。卸载后默认搜索自动回到 `deepseek-official`(如果之前没覆盖过的话),重启 `dsh web` 生效。
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 lijq3370
|
|
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,82 @@
|
|
|
1
|
+
# dsh-tinyfish-websearch
|
|
2
|
+
|
|
3
|
+
DeepSeek Harness **bundle 插件**:注册一个 **TinyFish** 驱动的 `web_search` provider 进 `ctx.web` 能力缝,并**把 DSH 默认搜索替换为 TinyFish**(默认引擎是 base bundle 的 `deepseek-official`,本插件的补丁层将其覆盖为 `tinyfish`)。模型无关,不消耗 LLM 额度;TinyFish 搜索免费,不消耗 credits。带一个**设置页表单**,可在界面里填/改/删 API Key。
|
|
4
|
+
|
|
5
|
+
纯 ESM、零构建、**零 npm 运行时依赖**(provider 按 `ctx.web` 契约结构化实现,任何安装位置都能解析,不依赖模块兜底层)。
|
|
6
|
+
|
|
7
|
+
## 安装(接收方:一条命令)
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
# 本地目录(开发/内部分发)
|
|
11
|
+
pnpm dsh plugin --profile web add ./tinyfish-websearch
|
|
12
|
+
|
|
13
|
+
# npm 发布后
|
|
14
|
+
pnpm dsh plugin --profile web add dsh-tinyfish-websearch
|
|
15
|
+
|
|
16
|
+
# GitHub 托管后
|
|
17
|
+
pnpm dsh plugin --profile web add github:lijq3370/dsh-tinyfish-websearch
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`dsh plugin` 只是 pnpm 转发器,等价于在 `$DSH_HOME/profiles/web` 里执行 `pnpm add <源>`;装完后自动完成两件事:
|
|
21
|
+
|
|
22
|
+
1. 该包声明了 `dsh.bundle.patch`,自动加入 profile 的 bundle 层(`dsh.profile.bundles`);
|
|
23
|
+
2. bundle 补丁层(包内 `cordis.patch.yml`)自动插入 `tinyfish-websearch` 行,并把 `web` 行的 `searchProvider` 覆盖为 `tinyfish`。
|
|
24
|
+
|
|
25
|
+
**无需手动改 cordis.patch.yml。** 重启 `dsh web` 后:
|
|
26
|
+
|
|
27
|
+
- 设置页侧边栏出现 **「TinyFish 搜索」** 入口,界面填/改/删 API Key;
|
|
28
|
+
- `web_search` 工具走 TinyFish(默认搜索已被替换)。
|
|
29
|
+
|
|
30
|
+
## 切换回其他搜索引擎
|
|
31
|
+
|
|
32
|
+
在你的 `$DSH_HOME/profiles/web/cordis.patch.yml`(应用顺序晚于 bundle 层,优先)加一行即可覆盖:
|
|
33
|
+
|
|
34
|
+
```yaml
|
|
35
|
+
- id: web
|
|
36
|
+
config:
|
|
37
|
+
searchProvider: deepseek-official # DSH 默认(zen/go 搜索),或 tavily / exa 等已注册 provider
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## API Key(界面配置)
|
|
41
|
+
|
|
42
|
+
打开 **设置 → TinyFish 搜索**:密码框输入 key → 保存(写入 `$DSH_HOME/.credentials.yaml`,下次搜索立即生效,无需重启);已配置时显示来源并可清除。
|
|
43
|
+
|
|
44
|
+
解析优先级(每次搜索惰性解析):
|
|
45
|
+
|
|
46
|
+
1. 插件行的 `config.apiKey` 字面量(密钥落盘到配置)
|
|
47
|
+
2. 凭据 seam `TINYFISH_API_KEY`:进程环境变量 → `$DSH_HOME/.credentials.yaml` → `$DSH_HOME/.env`
|
|
48
|
+
|
|
49
|
+
注意:key 来自环境变量/.env 等只读层时,界面会提示不可修改(写会被凭据 seam 拒绝),需改环境变量。
|
|
50
|
+
|
|
51
|
+
## 配置
|
|
52
|
+
|
|
53
|
+
| 键 | 默认 | 含义 |
|
|
54
|
+
| --- | --- | --- |
|
|
55
|
+
| `apiKey` | 凭据 seam `TINYFISH_API_KEY` | TinyFish API key(字面量优先于界面/凭据) |
|
|
56
|
+
| `baseURL` | `https://api.search.tinyfish.ai` | Search API 地址(GET 根路径即操作) |
|
|
57
|
+
| `maxResults` | `5` | 结果数(1–20);请求自带上限时以请求为准 |
|
|
58
|
+
|
|
59
|
+
## 工作原理
|
|
60
|
+
|
|
61
|
+
- **补丁层**(`cordis.patch.yml`,随 bundle 生效):插入 `tinyfish-websearch` 行 + 覆盖 `web.searchProvider: tinyfish`。注意补丁按 id 整行替换 config,因此 `web` 行只 restate `searchProvider`;base bundle 当前也只设这一个键。
|
|
62
|
+
- **宿主半**(`index.js`):`apply(ctx, config)` 调用 `ctx.web.registerSearchProvider(new TinyFishSearchProvider({...}))`,与随包的 `web-search-perplexity` / `web-search-deepseek` 同一注册点;key 经 `ctx.credentials` 惰性解析。
|
|
63
|
+
- **浏览器半**(`client.js`):`settings.section` 槽位贡献「TinyFish 搜索」表单,经 `api.credentials.set/unset/describe`(RPC)读写凭据。
|
|
64
|
+
- `GET {baseURL}?query=…`,`X-API-Key` 鉴权;凭据请求拒绝跟随重定向(`redirect: 'error'`)。响应映射:`results[]` → `sources[]`(url/title/snippet/date → publishedAt),`maxResults` 上限由能力缝在回程强制截断。
|
|
65
|
+
|
|
66
|
+
## 手动验证
|
|
67
|
+
|
|
68
|
+
```sh
|
|
69
|
+
cd ~/.dsh/profiles/web/node_modules/dsh-tinyfish-websearch
|
|
70
|
+
TINYFISH_API_KEY=tf-… node --input-type=module -e "
|
|
71
|
+
const m = await import('./index.js');
|
|
72
|
+
const p = new m.TinyFishSearchProvider({ apiKey: process.env.TINYFISH_API_KEY, baseURL: m.TINYFISH_DEFAULT_BASE_URL, maxResults: 3 });
|
|
73
|
+
console.log('available:', p.available());
|
|
74
|
+
console.log(await p.search({ query: 'DeepSeek Harness' }));
|
|
75
|
+
"
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## 限制
|
|
79
|
+
|
|
80
|
+
- TinyFish 免费档限速 30 req/min(按 key 计);搜索请求不消耗 credits,但账号需能访问 Search API(注册即有)。
|
|
81
|
+
- 错误为带 `code` 属性的普通 `Error`(`WEB_PROVIDER_ERROR` / `WEB_ABORTED` / `WEB_PROVIDER_CREDENTIAL_MISSING`),非 `HarnessError` 类型;错误文本仍会透传模型,仅结构化错误元数据不完整。
|
|
82
|
+
- 浏览器半由 `dsh.client` 声明自动发现;插件集变更(新增 `dsh.client`)需要重启才被 client-modules 扫描到。
|
package/client.js
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-tinyfish-websearch — browser half.
|
|
3
|
+
*
|
|
4
|
+
* A classic script served at /plugins/dsh-tinyfish-websearch/client.js and
|
|
5
|
+
* executed by the client module system (the package.json `dsh.client`
|
|
6
|
+
* declaration makes client-modules pick the package up automatically). It
|
|
7
|
+
* registers a factory through window.__ModuleLoader__.load; the factory's
|
|
8
|
+
* exports become the cordis loader entry, so `apply(ctx)` runs as a
|
|
9
|
+
* browser-side plugin.
|
|
10
|
+
*
|
|
11
|
+
* Contribution: one `settings.section` entry — 「TinyFish 搜索」— with a form
|
|
12
|
+
* that writes the TinyFish API key through the credentials RPC
|
|
13
|
+
* (`ctx.remote.credentials.set`), which lands in `$DSH_HOME/.credentials.yaml`.
|
|
14
|
+
* The host half resolves the key per search through the credentials seam,
|
|
15
|
+
* so a key saved here takes effect on the next search without a restart.
|
|
16
|
+
*/
|
|
17
|
+
window.__ModuleLoader__.load({
|
|
18
|
+
id: 'dsh-tinyfish-websearch',
|
|
19
|
+
factory: function (require) {
|
|
20
|
+
'use strict';
|
|
21
|
+
var module = { exports: {} };
|
|
22
|
+
var exports = module.exports;
|
|
23
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
|
24
|
+
|
|
25
|
+
var React = require('react');
|
|
26
|
+
|
|
27
|
+
var NS = 'settings.tinyfish';
|
|
28
|
+
var inject = ['slots', 'locale', 'remote', 'remote.credentials'];
|
|
29
|
+
var REF = 'TINYFISH_API_KEY';
|
|
30
|
+
|
|
31
|
+
var zh = {
|
|
32
|
+
nav: 'TinyFish 搜索',
|
|
33
|
+
title: 'TinyFish 搜索设置',
|
|
34
|
+
loading: '加载中…',
|
|
35
|
+
notConfigured: '尚未配置 API Key。',
|
|
36
|
+
configuredFrom: '已配置(来源:{source})。',
|
|
37
|
+
readOnly: '当前 Key 由只读层提供(环境变量 / .env),界面不可修改;请改环境变量后重启。',
|
|
38
|
+
keyLabel: 'API Key',
|
|
39
|
+
keyPlaceholder: 'TinyFish API Key',
|
|
40
|
+
save: '保存',
|
|
41
|
+
clear: '清除',
|
|
42
|
+
saved: '已保存,下次搜索立即生效。',
|
|
43
|
+
cleared: '已清除。',
|
|
44
|
+
retry: '重试',
|
|
45
|
+
hint: 'web_search 工具当前使用 TinyFish(searchProvider: tinyfish)。结果数等参数在 ~/.dsh/profiles/web/cordis.patch.yml 的 tinyfish-websearch 行配置。',
|
|
46
|
+
};
|
|
47
|
+
var en = {
|
|
48
|
+
nav: 'TinyFish Search',
|
|
49
|
+
title: 'TinyFish search settings',
|
|
50
|
+
loading: 'Loading…',
|
|
51
|
+
notConfigured: 'No API key configured yet.',
|
|
52
|
+
configuredFrom: 'Configured (source: {source}).',
|
|
53
|
+
readOnly: 'The key comes from a read-only layer (environment / .env); edit it there and restart.',
|
|
54
|
+
keyLabel: 'API Key',
|
|
55
|
+
keyPlaceholder: 'TinyFish API Key',
|
|
56
|
+
save: 'Save',
|
|
57
|
+
clear: 'Clear',
|
|
58
|
+
saved: 'Saved; the next search picks it up immediately.',
|
|
59
|
+
cleared: 'Cleared.',
|
|
60
|
+
retry: 'Retry',
|
|
61
|
+
hint: 'The web_search tool currently uses TinyFish (searchProvider: tinyfish). Result counts etc. live in the tinyfish-websearch row of ~/.dsh/profiles/web/cordis.patch.yml.',
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
var styles = {
|
|
65
|
+
wrap: { maxWidth: 560, display: 'flex', flexDirection: 'column', gap: 14, padding: '8px 0' },
|
|
66
|
+
title: { fontSize: 16, fontWeight: 600, margin: 0 },
|
|
67
|
+
hint: { color: 'var(--dsw-alias-label-tertiary)', fontSize: 13, lineHeight: 1.6, margin: 0 },
|
|
68
|
+
error: { color: 'var(--dsw-alias-state-error-primary)', fontSize: 13, lineHeight: 1.6, margin: 0 },
|
|
69
|
+
ok: { color: 'var(--dsw-alias-state-success-primary, #22c55e)', fontSize: 13, lineHeight: 1.6, margin: 0 },
|
|
70
|
+
row: { display: 'flex', alignItems: 'center', gap: 8 },
|
|
71
|
+
input: {
|
|
72
|
+
flex: 1, minWidth: 0, boxSizing: 'border-box', height: 32,
|
|
73
|
+
padding: '0 10px', font: 'inherit', fontSize: 14, lineHeight: 22,
|
|
74
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
75
|
+
background: 'var(--dsw-alias-bg-layer-1)', border: '1px solid var(--dsw-alias-border-l2)',
|
|
76
|
+
borderRadius: 8,
|
|
77
|
+
},
|
|
78
|
+
button: {
|
|
79
|
+
border: '1px solid var(--dsw-alias-border-l2)', color: 'var(--dsw-alias-label-primary)',
|
|
80
|
+
font: 'inherit', fontSize: 13, cursor: 'pointer', background: 'transparent', borderRadius: 6, padding: '5px 12px',
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** One settings entry rendering the key form over the credentials RPC. */
|
|
85
|
+
function SettingsPanel(props) {
|
|
86
|
+
var describeCredential = props.describeCredential;
|
|
87
|
+
var storeCredential = props.storeCredential;
|
|
88
|
+
var removeCredential = props.removeCredential;
|
|
89
|
+
var t = typeof props.t === 'function' ? props.t : function (k) { return zh[k] || k; };
|
|
90
|
+
|
|
91
|
+
var viewPair = React.useState(null);
|
|
92
|
+
var view = viewPair[0];
|
|
93
|
+
var setView = viewPair[1];
|
|
94
|
+
|
|
95
|
+
var statusPair = React.useState('loading');
|
|
96
|
+
var status = statusPair[0];
|
|
97
|
+
var setStatus = statusPair[1];
|
|
98
|
+
|
|
99
|
+
var errorPair = React.useState(null);
|
|
100
|
+
var error = errorPair[0];
|
|
101
|
+
var setError = errorPair[1];
|
|
102
|
+
|
|
103
|
+
var valuePair = React.useState('');
|
|
104
|
+
var value = valuePair[0];
|
|
105
|
+
var setValue = valuePair[1];
|
|
106
|
+
|
|
107
|
+
var busyPair = React.useState(false);
|
|
108
|
+
var busy = busyPair[0];
|
|
109
|
+
var setBusy = busyPair[1];
|
|
110
|
+
|
|
111
|
+
var noticePair = React.useState(null);
|
|
112
|
+
var notice = noticePair[0];
|
|
113
|
+
var setNotice = noticePair[1];
|
|
114
|
+
|
|
115
|
+
var refresh = React.useCallback(function () {
|
|
116
|
+
if (typeof describeCredential !== 'function') {
|
|
117
|
+
setStatus('ready');
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
describeCredential(REF).then(function (res) {
|
|
121
|
+
if (res && res.ok) {
|
|
122
|
+
setView(res.value ? res.value[REF] : null);
|
|
123
|
+
setStatus('ready');
|
|
124
|
+
setError(null);
|
|
125
|
+
} else {
|
|
126
|
+
setStatus('failure');
|
|
127
|
+
setError((res && res.error && res.error.message) || 'Failed to query credential status');
|
|
128
|
+
}
|
|
129
|
+
}).catch(function (e) {
|
|
130
|
+
setStatus('failure');
|
|
131
|
+
setError(String((e && e.message) || e));
|
|
132
|
+
});
|
|
133
|
+
}, [describeCredential, setView, setStatus, setError]);
|
|
134
|
+
|
|
135
|
+
React.useEffect(function () {
|
|
136
|
+
refresh();
|
|
137
|
+
}, [refresh]);
|
|
138
|
+
|
|
139
|
+
var save = function () {
|
|
140
|
+
if (value.length === 0 || busy || typeof storeCredential !== 'function') return;
|
|
141
|
+
setBusy(true);
|
|
142
|
+
storeCredential(REF, value).then(function (res) {
|
|
143
|
+
if (res && res.ok) {
|
|
144
|
+
setNotice({ kind: 'ok', text: t('saved') });
|
|
145
|
+
setValue('');
|
|
146
|
+
refresh();
|
|
147
|
+
} else {
|
|
148
|
+
setNotice({ kind: 'err', text: (res && res.error && res.error.message) || 'Failed to save credential' });
|
|
149
|
+
}
|
|
150
|
+
}).catch(function (e) {
|
|
151
|
+
setNotice({ kind: 'err', text: String((e && e.message) || e) });
|
|
152
|
+
}).finally(function () {
|
|
153
|
+
setBusy(false);
|
|
154
|
+
});
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
var clear = function () {
|
|
158
|
+
if (busy || typeof removeCredential !== 'function') return;
|
|
159
|
+
setBusy(true);
|
|
160
|
+
removeCredential(REF).then(function (res) {
|
|
161
|
+
if (res && res.ok) {
|
|
162
|
+
setNotice({ kind: 'ok', text: t('cleared') });
|
|
163
|
+
refresh();
|
|
164
|
+
} else {
|
|
165
|
+
setNotice({ kind: 'err', text: (res && res.error && res.error.message) || 'Failed to clear credential' });
|
|
166
|
+
}
|
|
167
|
+
}).catch(function (e) {
|
|
168
|
+
setNotice({ kind: 'err', text: String((e && e.message) || e) });
|
|
169
|
+
}).finally(function () {
|
|
170
|
+
setBusy(false);
|
|
171
|
+
});
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
if (status === 'loading') {
|
|
175
|
+
return React.createElement('div', { style: styles.wrap },
|
|
176
|
+
React.createElement('p', { style: styles.hint }, t('loading')));
|
|
177
|
+
}
|
|
178
|
+
if (status === 'failure') {
|
|
179
|
+
return React.createElement('div', { style: styles.wrap },
|
|
180
|
+
React.createElement('p', { style: styles.error }, error),
|
|
181
|
+
React.createElement('button', { style: styles.button, onClick: refresh }, t('retry')));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
var configured = !!(view && view.configured);
|
|
185
|
+
var writable = !view || view.writable !== false;
|
|
186
|
+
var stateLine = configured
|
|
187
|
+
? t('configuredFrom').replace('{source}', view.source || '?')
|
|
188
|
+
: t('notConfigured');
|
|
189
|
+
|
|
190
|
+
return React.createElement('div', { style: styles.wrap },
|
|
191
|
+
React.createElement('h2', { style: styles.title }, t('title')),
|
|
192
|
+
React.createElement('p', { style: styles.hint }, stateLine),
|
|
193
|
+
!writable
|
|
194
|
+
? React.createElement('p', { style: styles.error }, t('readOnly'))
|
|
195
|
+
: React.createElement('div', { style: styles.row },
|
|
196
|
+
React.createElement('input', {
|
|
197
|
+
type: 'password',
|
|
198
|
+
placeholder: t('keyPlaceholder'),
|
|
199
|
+
value: value,
|
|
200
|
+
onChange: function (e) { setValue(e.target.value); },
|
|
201
|
+
style: styles.input,
|
|
202
|
+
autoComplete: 'off',
|
|
203
|
+
}),
|
|
204
|
+
React.createElement('button', {
|
|
205
|
+
style: styles.button,
|
|
206
|
+
onClick: save,
|
|
207
|
+
disabled: busy || value.length === 0,
|
|
208
|
+
}, t('save')),
|
|
209
|
+
configured
|
|
210
|
+
? React.createElement('button', { style: styles.button, onClick: clear, disabled: busy }, t('clear'))
|
|
211
|
+
: null,
|
|
212
|
+
),
|
|
213
|
+
notice
|
|
214
|
+
? React.createElement('p', { style: notice.kind === 'ok' ? styles.ok : styles.error }, notice.text)
|
|
215
|
+
: null,
|
|
216
|
+
React.createElement('p', { style: styles.hint }, t('hint')));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Browser plugin body: register the dictionaries and the settings entry.
|
|
221
|
+
* @param ctx - the browser-side cordis context.
|
|
222
|
+
*/
|
|
223
|
+
function apply(ctx) {
|
|
224
|
+
ctx.effect(function () {
|
|
225
|
+
ctx.locale.register(NS, { zh: zh, en: en });
|
|
226
|
+
}, 'tinyfish-websearch: dictionaries');
|
|
227
|
+
var t = ctx.locale.bind(NS);
|
|
228
|
+
|
|
229
|
+
var getRemote = function () {
|
|
230
|
+
return ctx.get('remote.credentials') || (ctx.remote && ctx.remote.credentials);
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
var describeCredential = function (ref) {
|
|
234
|
+
var remote = getRemote();
|
|
235
|
+
if (!remote) return Promise.resolve({ ok: false, error: { message: 'remote.credentials is unavailable' } });
|
|
236
|
+
return remote.describe([ref]);
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
var storeCredential = function (ref, val) {
|
|
240
|
+
var remote = getRemote();
|
|
241
|
+
if (!remote) return Promise.resolve({ ok: false, error: { message: 'remote.credentials is unavailable' } });
|
|
242
|
+
return remote.set(ref, val);
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
var removeCredential = function (ref) {
|
|
246
|
+
var remote = getRemote();
|
|
247
|
+
if (!remote) return Promise.resolve({ ok: false, error: { message: 'remote.credentials is unavailable' } });
|
|
248
|
+
return remote.unset(ref);
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
var injected = function () {
|
|
252
|
+
return {
|
|
253
|
+
describeCredential: describeCredential,
|
|
254
|
+
storeCredential: storeCredential,
|
|
255
|
+
removeCredential: removeCredential,
|
|
256
|
+
t: t,
|
|
257
|
+
};
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
ctx.slots.inject('settings.section', function () {
|
|
261
|
+
return ctx.slots.register({
|
|
262
|
+
name: 'settings.section',
|
|
263
|
+
id: 'tinyfish',
|
|
264
|
+
order: 50,
|
|
265
|
+
label: function () { return t('nav'); },
|
|
266
|
+
locale: NS,
|
|
267
|
+
inject: injected,
|
|
268
|
+
}, SettingsPanel);
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
exports.NS = NS;
|
|
273
|
+
exports.apply = apply;
|
|
274
|
+
exports.inject = inject;
|
|
275
|
+
return module.exports;
|
|
276
|
+
},
|
|
277
|
+
});
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# dsh-tinyfish-websearch bundle patch: installing this package registers the
|
|
2
|
+
# TinyFish search provider AND makes it the active search provider (replacing
|
|
3
|
+
# the DSH default, which the base bundle sets to deepseek-official).
|
|
4
|
+
#
|
|
5
|
+
# A patch replaces the targeted row's whole `config`, so the `web` row below
|
|
6
|
+
# restates every key it owns (searchProvider). The base bundle inserts the
|
|
7
|
+
# `web` row with `searchProvider: deepseek-official`; this layer overrides it.
|
|
8
|
+
# A deployment that wants a different provider overrides searchProvider in its
|
|
9
|
+
# own cordis.patch.yml (applied after bundle layers).
|
|
10
|
+
- insert:
|
|
11
|
+
- id: tinyfish-websearch
|
|
12
|
+
name: dsh-tinyfish-websearch
|
|
13
|
+
config:
|
|
14
|
+
maxResults: 5
|
|
15
|
+
|
|
16
|
+
- id: web
|
|
17
|
+
config:
|
|
18
|
+
searchProvider: tinyfish
|
package/index.js
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-tinyfish-websearch — host half.
|
|
3
|
+
*
|
|
4
|
+
* Registers a TinyFish-backed WebSearchProvider into the `ctx.web` seam, the
|
|
5
|
+
* same seam the shipped `web-search-perplexity` / `web-search-deepseek`
|
|
6
|
+
* providers and the `dsh-tavily-websearch` plugin use. The browser tool
|
|
7
|
+
* (`dsh-tool-web`) keeps working unchanged: the active provider is pinned by
|
|
8
|
+
* the `web` row's `searchProvider` config, which the profile patch sets to
|
|
9
|
+
* `tinyfish`.
|
|
10
|
+
*
|
|
11
|
+
* Zero runtime imports and no build step: the provider implements the seam's
|
|
12
|
+
* contract structurally (id / available() / search(request, signal)) and
|
|
13
|
+
* errors are plain `Error`s carrying a `code` property (`WEB_PROVIDER_ERROR`
|
|
14
|
+
* / `WEB_ABORTED` / `WEB_PROVIDER_CREDENTIAL_MISSING`). Thrown messages still
|
|
15
|
+
* surface to the model; the `HarnessError`-typed structured metadata the
|
|
16
|
+
* shipped providers emit is the only thing not reproduced. This keeps the
|
|
17
|
+
* package fully portable — it resolves from any install location without
|
|
18
|
+
* depending on the profile module fallback.
|
|
19
|
+
*
|
|
20
|
+
* The API key resolves lazily per search through the credentials seam
|
|
21
|
+
* (`ctx.credentials`), which reads process env → `$DSH_HOME/.credentials.yaml`
|
|
22
|
+
* → `.env`. The browser half (./client.js) provides a Settings → 「TinyFish 搜索」
|
|
23
|
+
* form that writes the key through `credentials.set`, so a key saved in the
|
|
24
|
+
* GUI is picked up by the next search without a restart.
|
|
25
|
+
*
|
|
26
|
+
* Wire format (https://docs.tinyfish.ai/search-api/reference): one GET on the
|
|
27
|
+
* base URL with `query` as a query parameter, `X-API-Key` bearer-style header
|
|
28
|
+
* auth. Search requests consume no credits (free tier: 30 req/min).
|
|
29
|
+
*/
|
|
30
|
+
/** Cordis plugin name used by loader diagnostics. */
|
|
31
|
+
export const name = 'dsh-tinyfish-websearch'
|
|
32
|
+
|
|
33
|
+
/** The web seam and the credentials seam this plugin reads. */
|
|
34
|
+
export const inject = ['web', 'credentials']
|
|
35
|
+
|
|
36
|
+
/** Stable provider id; the `web` row's `searchProvider` must name it. */
|
|
37
|
+
export const TINYFISH_PROVIDER_ID = 'tinyfish'
|
|
38
|
+
|
|
39
|
+
/** Default TinyFish Search endpoint; the operation is a GET on the root. */
|
|
40
|
+
export const TINYFISH_DEFAULT_BASE_URL = 'https://api.search.tinyfish.ai'
|
|
41
|
+
|
|
42
|
+
/** Default result count; the seam truncates to the request's bound regardless. */
|
|
43
|
+
export const TINYFISH_DEFAULT_MAX_RESULTS = 5
|
|
44
|
+
|
|
45
|
+
/** Attribution header sent on every request. */
|
|
46
|
+
const USER_AGENT = 'dsh-tinyfish-websearch/0.1.0'
|
|
47
|
+
|
|
48
|
+
/** Config defaults; the row config overrides them wholesale. */
|
|
49
|
+
const CONFIG_DEFAULTS = Object.freeze({
|
|
50
|
+
baseURL: TINYFISH_DEFAULT_BASE_URL,
|
|
51
|
+
maxResults: TINYFISH_DEFAULT_MAX_RESULTS,
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Validate and normalize the row config. Unknown shapes fail loud at load
|
|
56
|
+
* (never a silent skip).
|
|
57
|
+
* @param raw - the cordis.yml `config` for this row.
|
|
58
|
+
* @returns the normalized config.
|
|
59
|
+
*/
|
|
60
|
+
export function resolveConfig(raw) {
|
|
61
|
+
const cfg = { ...CONFIG_DEFAULTS, ...(raw ?? {}) }
|
|
62
|
+
if (cfg.apiKey !== undefined && typeof cfg.apiKey !== 'string') {
|
|
63
|
+
throw new Error('tinyfish-websearch: config.apiKey must be a string')
|
|
64
|
+
}
|
|
65
|
+
if (typeof cfg.baseURL !== 'string' || cfg.baseURL.length === 0 || !URL.canParse(cfg.baseURL)) {
|
|
66
|
+
throw new Error(`tinyfish-websearch: config.baseURL must be a valid URL, got "${String(cfg.baseURL)}"`)
|
|
67
|
+
}
|
|
68
|
+
if (!Number.isInteger(cfg.maxResults) || cfg.maxResults < 1 || cfg.maxResults > 20) {
|
|
69
|
+
throw new Error('tinyfish-websearch: config.maxResults must be an integer in 1..20')
|
|
70
|
+
}
|
|
71
|
+
return cfg
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Map a TinyFish search response to the seam's normalized result shape.
|
|
76
|
+
* TinyFish returns no generated answer, so `content` is always omitted; each
|
|
77
|
+
* `results[]` entry becomes a citeable source (URL always, title/snippet/date
|
|
78
|
+
* when present).
|
|
79
|
+
*
|
|
80
|
+
* @param payload - the parsed TinyFish response body.
|
|
81
|
+
* @returns the normalized result.
|
|
82
|
+
*/
|
|
83
|
+
export function mapTinyFishResponse(payload) {
|
|
84
|
+
const results = Array.isArray(payload?.results) ? payload.results : []
|
|
85
|
+
const sources = []
|
|
86
|
+
for (const item of results) {
|
|
87
|
+
if (item === null || typeof item !== 'object') continue
|
|
88
|
+
const url = typeof item.url === 'string' ? item.url : ''
|
|
89
|
+
if (url.length === 0) continue
|
|
90
|
+
const source = { url }
|
|
91
|
+
if (typeof item.title === 'string' && item.title.length > 0) source.title = item.title
|
|
92
|
+
if (typeof item.snippet === 'string' && item.snippet.length > 0) source.snippet = item.snippet
|
|
93
|
+
if (typeof item.date === 'string' && item.date.length > 0) source.publishedAt = item.date
|
|
94
|
+
sources.push(source)
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
sources,
|
|
98
|
+
truncated: false,
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Build a plain provider error carrying the seam's machine-routable code. */
|
|
103
|
+
function webError(message, code, cause) {
|
|
104
|
+
const error = new Error(message)
|
|
105
|
+
error.code = code
|
|
106
|
+
if (cause !== undefined) error.cause = cause
|
|
107
|
+
return error
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** True for a fetch/`AbortSignal` abort, surfaced as `WEB_ABORTED`. */
|
|
111
|
+
function isAbortError(error) {
|
|
112
|
+
return error instanceof DOMException && error.name === 'AbortError'
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The TinyFish-backed search provider; HTTP redirects fail as `WEB_PROVIDER_ERROR`. */
|
|
116
|
+
export class TinyFishSearchProvider {
|
|
117
|
+
id = TINYFISH_PROVIDER_ID
|
|
118
|
+
|
|
119
|
+
constructor(options) {
|
|
120
|
+
this.options = options
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Cheap local usability check; must not make network calls. */
|
|
124
|
+
available() {
|
|
125
|
+
const { apiKey, resolveApiKey, baseURL, maxResults } = this.options
|
|
126
|
+
const hasKey = (typeof apiKey === 'string' && apiKey.length > 0)
|
|
127
|
+
|| typeof resolveApiKey === 'function'
|
|
128
|
+
return hasKey
|
|
129
|
+
&& URL.canParse(baseURL)
|
|
130
|
+
&& Number.isInteger(maxResults) && maxResults > 0
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Resolve one operation's key: a literal row config wins, then the
|
|
135
|
+
* credentials seam (env → $DSH_HOME/.credentials.yaml → .env). Resolved per
|
|
136
|
+
* search so a key saved in the Settings GUI takes effect without a restart.
|
|
137
|
+
* @returns the resolved key.
|
|
138
|
+
* @throws the missing-credential error when no layer supplies one.
|
|
139
|
+
*/
|
|
140
|
+
async #resolveApiKey() {
|
|
141
|
+
const literal = this.options.apiKey
|
|
142
|
+
if (typeof literal === 'string' && literal.length > 0) return literal
|
|
143
|
+
if (typeof this.options.resolveApiKey === 'function') {
|
|
144
|
+
const resolved = await this.options.resolveApiKey()
|
|
145
|
+
if (typeof resolved === 'string' && resolved.length > 0) return resolved
|
|
146
|
+
}
|
|
147
|
+
throw webError(
|
|
148
|
+
'TinyFish search has no API key; set it in Settings → TinyFish 搜索 or export TINYFISH_API_KEY',
|
|
149
|
+
'WEB_PROVIDER_CREDENTIAL_MISSING',
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Run one TinyFish search; honor `signal` for cancellation. */
|
|
154
|
+
async search(request, signal) {
|
|
155
|
+
const apiKey = await this.#resolveApiKey()
|
|
156
|
+
const url = new URL(this.options.baseURL)
|
|
157
|
+
url.searchParams.set('query', request.query)
|
|
158
|
+
let response
|
|
159
|
+
try {
|
|
160
|
+
response = await fetch(url, {
|
|
161
|
+
method: 'GET',
|
|
162
|
+
redirect: 'error',
|
|
163
|
+
headers: {
|
|
164
|
+
'x-api-key': apiKey,
|
|
165
|
+
'accept': 'application/json',
|
|
166
|
+
'user-agent': USER_AGENT,
|
|
167
|
+
},
|
|
168
|
+
...(signal !== undefined ? { signal } : {}),
|
|
169
|
+
})
|
|
170
|
+
} catch (error) {
|
|
171
|
+
if (isAbortError(error)) {
|
|
172
|
+
throw webError('TinyFish search aborted', 'WEB_ABORTED', error)
|
|
173
|
+
}
|
|
174
|
+
throw webError(`TinyFish search request failed: ${String(error)}`, 'WEB_PROVIDER_ERROR', error)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (!response.ok) {
|
|
178
|
+
const status = response.status
|
|
179
|
+
let message = `TinyFish API error (HTTP ${status})`
|
|
180
|
+
try {
|
|
181
|
+
const parsed = await response.json()
|
|
182
|
+
const detail = parsed?.detail?.error ?? parsed?.error?.message ?? parsed?.detail ?? parsed?.message
|
|
183
|
+
if (typeof detail === 'string' && detail.length > 0) message = detail
|
|
184
|
+
} catch (error) {
|
|
185
|
+
// An abort fired mid-body must surface as WEB_ABORTED, not be swallowed
|
|
186
|
+
// into a generic HTTP-error message — cancellation is not a provider
|
|
187
|
+
// error (the seam's cancellation contract).
|
|
188
|
+
if (isAbortError(error)) {
|
|
189
|
+
throw webError('TinyFish search aborted', 'WEB_ABORTED', error)
|
|
190
|
+
}
|
|
191
|
+
// Otherwise: the HTTP status is already captured in `message` above; a
|
|
192
|
+
// malformed/non-JSON error body can only cost a richer message.
|
|
193
|
+
}
|
|
194
|
+
throw webError(message, 'WEB_PROVIDER_ERROR')
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
const payload = await response.json()
|
|
199
|
+
return mapTinyFishResponse(payload)
|
|
200
|
+
} catch (error) {
|
|
201
|
+
if (isAbortError(error)) {
|
|
202
|
+
throw webError('TinyFish search aborted', 'WEB_ABORTED', error)
|
|
203
|
+
}
|
|
204
|
+
throw webError(`TinyFish returned an unprocessable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', error)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Plugin body: register the TinyFish provider with `ctx.web`.
|
|
211
|
+
* @param ctx - cordis context with the injected `web` and `credentials` services.
|
|
212
|
+
* @param rawConfig - the cordis.yml row config.
|
|
213
|
+
*/
|
|
214
|
+
export function apply(ctx, rawConfig) {
|
|
215
|
+
const config = resolveConfig(rawConfig)
|
|
216
|
+
ctx.web.registerSearchProvider(new TinyFishSearchProvider({
|
|
217
|
+
// Literal row config wins; otherwise the credentials seam resolves
|
|
218
|
+
// `TINYFISH_API_KEY` (env → .credentials.yaml → .env) per search.
|
|
219
|
+
apiKey: config.apiKey ?? '',
|
|
220
|
+
resolveApiKey: () => ctx.credentials.resolve('TINYFISH_API_KEY').then((cred) => cred?.value),
|
|
221
|
+
baseURL: config.baseURL,
|
|
222
|
+
maxResults: config.maxResults,
|
|
223
|
+
}))
|
|
224
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-tinyfish-websearch",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "dsh bundle plugin: replaces the default web_search provider with TinyFish (ctx.web seam), with a Settings → TinyFish 搜索 key form",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"files": [
|
|
8
|
+
"index.js",
|
|
9
|
+
"client.js",
|
|
10
|
+
"cordis.patch.yml",
|
|
11
|
+
"README.md",
|
|
12
|
+
"INSTALL.md"
|
|
13
|
+
],
|
|
14
|
+
"exports": {
|
|
15
|
+
".": "./index.js",
|
|
16
|
+
"./client": "./client.js",
|
|
17
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
18
|
+
"./package.json": "./package.json"
|
|
19
|
+
},
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/lijq3370/dsh-tinyfish-websearch.git"
|
|
23
|
+
},
|
|
24
|
+
"homepage": "https://github.com/lijq3370/dsh-tinyfish-websearch",
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/lijq3370/dsh-tinyfish-websearch/issues"
|
|
27
|
+
},
|
|
28
|
+
"author": {
|
|
29
|
+
"name": "lijq3370",
|
|
30
|
+
"url": "https://github.com/lijq3370"
|
|
31
|
+
},
|
|
32
|
+
"keywords": [
|
|
33
|
+
"dsh",
|
|
34
|
+
"deepseek-harness",
|
|
35
|
+
"plugin",
|
|
36
|
+
"web-search",
|
|
37
|
+
"tinyfish",
|
|
38
|
+
"search"
|
|
39
|
+
],
|
|
40
|
+
"license": "MIT",
|
|
41
|
+
"dsh": {
|
|
42
|
+
"bundle": {
|
|
43
|
+
"patch": "./cordis.patch.yml"
|
|
44
|
+
},
|
|
45
|
+
"client": {
|
|
46
|
+
"inject": [
|
|
47
|
+
"@deepseek-ai/dsh-api-remotes",
|
|
48
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
49
|
+
"@deepseek-ai/dsh-client-ui-settings",
|
|
50
|
+
"@deepseek-ai/dsh-client-locale"
|
|
51
|
+
],
|
|
52
|
+
"platform": "web"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|