dsh-custom-search
A DeepSeek Harness plugin that replaces the built-in DeepSeek web search with a
search engine you choose — SearXNG, Tavily, Exa, Brave, Serper, Bing, or Google
CSE. The model-facing web_search tool keeps its name and schema, so prompts,
agent presets, and tool lists are unchanged.
用你自己的搜索源替换 DSH 内置的「仅官方 DeepSeek」搜索。web_search 工具名与参数保持不变,
换的只是背后的检索通道——所以模型的提示词、工具表、Agent 预设都不用动。
第三方社区插件。 本项目与 DeepSeek 官方无隶属关系,也不属于官方插件市场。
下文「官方搜索」「官方提供方」一律指 DSH 自带的 @deepseek-ai/dsh-web-search-deepseek。
它替换的到底是什么
DSH 把「联网搜索」拆成了三层,本插件只接管最底下那层:
| 层 | 包 | 职责 | 本插件是否改动 |
|---|
| 工具层 | @deepseek-ai/dsh-tool-web | 向模型暴露 web_search / web_fetch | 不改 |
| 服务层 | @deepseek-ai/dsh-web(ctx.web) | 选后端、取消、错误码、maxResults 截断 | 只改一个配置值 |
| 提供方层 | @deepseek-ai/dsh-web-search-deepseek | 真正发请求、返回结果 | 替换掉 |
服务层通过 registerSearchProvider() 收集提供方,并按配置里 pin 的 id 选一个执行。
官方提供方注册的 id 是 deepseek-official,本插件注册 custom-search——
所以「换搜索引擎」在 DSH 里就是改一个字符串,不需要替换任何官方文件。
这也意味着:官方搜索的一个查询要花一整轮模型调用(DeepSeek 没有独立的检索接口),
而接上 SearXNG / Tavily 这类真正的检索 API 后,搜索不再消耗会话模型的 token。
安装
桌面端面板(桌面端只有这一条路)
侧栏「插件」→「+ 添加插件」→ 填 github:Anixuil/dsh-custom-search → 安装 → 立即启用 → 重启 Harness。
在桌面端「+ 添加插件」里填入 github:Anixuil/dsh-custom-search,被识别为「Git 仓库」来源
安装完成:pnpm add github:Anixuil/dsh-custom-search 成功,点「立即启用」
左:面板把 github:Anixuil/dsh-custom-search 识别为「Git 仓库」来源,底层走 pnpm add 拉取。
右:安装完成,落点是 profile 目录 ~/.dsh/profiles/desktop,点「立即启用」。
面板那一栏填的是安装源(npm 包名 / GitHub 源 / 本地路径),不是插件名。
直接填 dsh-custom-search 会提示「未找到相关插件」——这个名字在 npm 上还没发布。
装完重启 Harness 才生效(bundle 类插件不会热加载)。桌面端用「重启 Harness」或退出重开。
改成从本地目录装(自己改代码时用这个)
同一栏填本仓库的绝对路径,例如 D:\code\self\all\dsh-custom-search。
pnpm 把它记成 link: 而不是复制,所以此后改 index.js / client.js
只要重启 Harness 就生效,不必卸载重装。改了代码要反复看效果时用这个,稳定了再换回 github:。
命令行安装(需要单独装 CLI,桌面端不自带)
dsh plugin --profile desktop add github:Anixuil/dsh-custom-search#main
DeepSeek Harness Desktop 的应用包没有声明 bin,也没有随附 dsh 可执行文件;
dsh plugin 来自单独安装的 CLI。只有桌面端的话,用上面面板那一栏,效果相同。
desktop 是桌面端的 profile 名(数据目录 ~/.dsh/profiles/desktop)。
用 CLI 或其他 profile 时换成实际名称,例如
dsh plugin --profile web add github:Anixuil/dsh-custom-search#main。
本插件没有构建步骤,github: 源可以直装。若 pnpm 仍拦下构建脚本,
在 $DSH_HOME/profiles/desktop/pnpm-workspace.yaml 的 allowBuilds 里放行 dsh-custom-search。
升级:必须卸载再装
插件管理器不提供升级——不列注册表版本,也没有更新按钮。而且 pnpm-lock.yaml 会把
git 源解析出的 commit 钉死(resolution: {commit, type: git}),所以后台也不会自己挪。
面板里:卸载(要确认)→ 「+ 添加插件」填同一个 github: 地址 → 安装 → 立即启用 → 重启。
命令行同理:
dsh plugin --profile desktop remove dsh-custom-search
dsh plugin --profile desktop add github:Anixuil/dsh-custom-search#main
github: 源拉的是默认分支的当前提交,所以重装即拿到最新代码。
要省掉反复重装,就按上面说的换成本地目录安装——之后只需重启。
从 ≤0.1.0 升上来的注意:旧版的 patch 写法会让面板卸载永久失败(报
bundle-in-use),卸载那一步走不通。照「卸载」一节末尾的手工步骤摘掉旧版,再装 0.2.0。
兼容性
provider 契约(ctx.web.registerSearchProvider 与返回结构)是照着
@deepseek-ai/dsh-* 0.2.0-rc.2(DeepSeek Harness Desktop 0.2.0-rc.2)的源码实现的,
并对该版本做过离线契约验证。
DSH 仍在 developer preview:apply(ctx) 规范稳定,但 ctx.web 这类服务在预览版之间
仍可能调整。升级 Harness 后若搜索报 WEB_PROVIDER_* 错误,先对照
@deepseek-ai/dsh-web 的 registerSearchProvider 签名和 WebRuntime.Config 字段。
依赖:为什么 peerDependencies 是必需的
宿主半侧会 import 五个 Harness 自己的包(schemastery——Config 需要它;
dsh-credentials / dsh-launch-environment——解析 key;dsh-web——错误码;
dsh-typert-protocol——连通性探针)。这些包一个都不在插件的 node_modules 里、也不该在:
- 重复安装一份
@deepseek-ai/cordis / Typert,会让 instanceof 判定的对象身份对不上,
探针服务在宿主眼里就不是它认识的那个 Service;
- 从 npm 拉这些内部包也拉不到与当前运行时同版本的副本。
DSH 的解法是 dsh-app-boot 给 profile 装的一层模块解析拦截(profile-resolution-bootstrap.js):
来自 profile 或 linked 目录的模块,其 @deepseek-ai/* 导入会被路由到当前安装的那一份。
但它只对 package.json 的 peerDependencies 里声明过的名字生效——linked 安装(
link:D:\...)走 routeLinked,逐个祖先目录读 package.json 的 peerDependencies 才决定拦截。
没声明就是解析不到,而失败方式极其安静:插件照常加载、搜索照常能用,
只有那个 import 背后的功能凭空消失。
这正是 0.3.x 的真实故障:@deepseek-ai/dsh-typert-protocol 没声明,installProbe
的 await import() 抛 ERR_MODULE_NOT_FOUND,探针静默退出,界面只显示
「宿主没有提供探针」——看起来像"部署不支持",实际是清单少了一行。
dev/validate.mjs 现在会拿源码里的 @deepseek-ai/* 导入和 peerDependencies 对账,
两边任一侧多一条少一条都直接失败。
范围写成 * 是刻意的:dsh-app-boot 会拿 @deepseek-ai/dsh-* 的 peer 去比运行版本,
填具体范围等于每次 Harness 升级都要用户去开一次 exact-version 豁免。
@deepseek-ai/schemastery 不在该检查范围内,但一并写 * 保持一致。
这份清单是重启即生效,不用重装。 readPeerNames() 是每次解析时现读磁盘上的
package.json(readFileSync,无缓存),而"这是 linked 目录"这个事实来自 profile 的
link: 依赖,本来就在——所以本地目录安装改完 package.json 重启一次即可。
只有 github: 源安装才需要卸载重装(它拉的是仓库快照,本地改什么都不影响它)。
声明和导入对上了还不够:声明的那一份得真的在安装包里,否则路由过去同样
ERR_MODULE_NOT_FOUND。这个只有当前安装包能回答,所以有 dev/peer-closure.py
——它把安装包的依赖闭包从 app.asar 里按宿主的走法重算一遍(0.2.0-rc.2 上是 513 个包),
再逐个核对本插件的 peer:
python dev/peer-closure.py # 全部 5 个 peer 都能被当前安装包供给 ✓
python dev/peer-closure.py --all # 顺带列出作用域里所有 @deepseek-ai/*
(node_modules/ 里那份 @deepseek-ai/{cosmokit,schemastery} 只是离线跑 dev/ 测试用的,
.gitignore 忽略、files 白名单排除。声明了 peer 之后,运行时会优先走上面的拦截、取安装包
那一份,所以不会出现"插件自己带了一份旧 schemastery"这种漂移。)
配置
在插件页改(推荐)。侧栏「插件」,三个入口,进去是同一张表单:
- 点开
dsh-custom-search 组合包 —— 配置表单就在包详情页里。这是最稳的一个入口,
因为它和这一行在同一个页面上。
- 「官方」区块里的「自定义搜索引擎」卡片 —— 点开是一个独立配置页。
- 或者点开组合包 → 组件行
custom-search 右侧的配置按钮,进行详情页。
表单里 12 个字段全在,引擎还能用一排胶囊一键切换。点保存即写入 profile 的 patch 层并由
Loader 应用,不用重启。
装完要重启一次 Harness,卡片才会出现。浏览器半侧是前端启动时加载的模块:
安装是在前端启动之后才落地的,热重载不会把它补进来。
API Key 不进设置文件:它经凭证服务写入,引用名默认取该引擎约定的环境变量名
(Brave 是 BRAVE_API_KEY),落点是 ~/.dsh/.credentials.yaml 的 refs: 段——和模型 provider 的
VOLCENGINE_API_KEY 并排。填一次就持久保存,profile patch 里只留引用名,key 不会进版本库。
测一下配好没有
表单最下面有个测试连接按钮,点一下就知道"我配的到底能不能用"。它分两层:
- 本地检查(不联网,立即出结果):引擎 id 是否有效、这个引擎要不要 key、key 到底配上了没有
(问凭证服务,不是看输入框)、
endpoint 是否是合法的 http(s) 地址、兜底链里有没有拼错的引擎、
数值有没有越界。这些正是真实请求只会回一个含糊厂商错误的地方。
- 真实请求:用已保存的配置走一遍 agent 实际使用的 provider,真跑一次搜索。会消耗 1 次搜索配额。
结果按 ✓ / ✗ / ! 逐条列出,真实请求那一段是明细,不是一句"成功/失败":
✓ Brave Search 返回 3 条结果,用时 312 ms。
密钥来源:凭据服务(BRAVE_API_KEY)
GET https://api.search.brave.com/res/v1/web/search
HTTP 200 · 298 ms
1. 第一条结果的标题
2. …
明细里的每一项都对应一个只有它才能回答的问题:
| 明细 | 它能回答的问题 |
|---|
| 方法与端点(已去掉查询串) | 我真的在打这个厂商、这个路径吗?自建 SearXNG 打的是不是我的实例? |
HTTP 200 / 401 / 403 / 429 / 5xx | key 无效(401/403)、触发限流(429)、还是厂商挂了(5xx)? |
| 往返耗时 | 是超时设置太紧,还是网络真的慢? |
| 密钥来源 | 配置里的字面密钥 / 凭据服务 / 启动环境 / 进程环境变量 / 哪里都没找到——"没配 key"和"配了但这次没解析到"是两个完全不同的故障,修法也不同 |
| 每一跳各一条 | 兜底链生效时,主引擎那一跳为什么被跳过(返回 0 条,还是报错) |
兜底链里每个被尝试过的引擎都会留一条记录,所以"回退成功了"不会掩盖"主引擎其实是坏的"。
表单里有未保存的改动时会另行提示——本地检查看的是你的输入,真实请求用的是已保存的配置。
按钮下面那行说"只剩本地检查"时,它会点名原因。 页面能看见自己的四种失败,所以四种各说各的:
$mount 不存在(这个 Harness 构建不对)、挂载被 Gateway 拒绝(把 Gateway 的错误原文带出来)、
挂载成功但读不到命名空间(列出 ctx.remote 上实际有什么)、挂载了但没有这个方法。
这句含糊话以前是一句包打天下,而且把责任推给宿主——宿主恰恰是页面看不见的那一方。
宿主侧的原因另有去处:宿主进程控制台会打 [dsh-custom-search] 连接探针未安装:… 并点名缺哪个包。
这是有意的降级:探针永远不影响搜索本身,最坏情况是少一个按钮。
没有界面时:直接改 profile 的 patch 层
$DSH_HOME/profiles/desktop/cordis.patch.yml 在所有 bundle 层之后应用,优先级高于插件自带的默认值:
- id: custom-search
name: dsh-custom-search
config:
engine: brave
- 只写要改的字段。没写的字段回落到
Config schema 的默认值(每个字段都有默认值)。
- 手改后重启 Harness。
- 同一个 id 已存在于 profile patch 时改那一条,不要重复添加。
为什么配置页要插件自带? DSH 的插件页只渲染插件自己注册了配置页的插件——
官方那四个配置页(shell 执行器、agent loop、子智能体、DeepSeek 搜索提供方)
各来自一个伴生包,设置里的「插件」分区只是只读清单。
本插件把这一半放在 client.js,三个入口全注册:
| 槽位 | 寻址字段 | 渲染成 |
|---|
plugins.bundle.config | key = 包名(keyed 槽) | 包详情页里的配置表单 |
plugins.item | id(list 槽) | 插件页上一张能直接点开的卡片,带独立页面 |
plugins.row.config | key = <包名>#<行 id>(keyed 槽) | 行详情里的配置表单 |
三个都得注册,少一个就少一个入口。 插件页对每个槽都是"没注册就整块不渲染",
而不是报错:包详情页的配置区由 ledger.bundles.has(包名) 控制,为假时整个 section
直接从树里拿掉。0.3.1 之前只注册了后两个,所以从组合包点进去看到的是一个白板。
三者都只在宿主在服务 custom-search 这个设置命名空间时才挂载
(configForms.whileServed),所以没装/没启用的部署上看不到任何痕迹。
没有它们,这一行就只有启停开关——这正是本插件 0.1.0 的状态。
字段
| 字段 | 默认 | 说明 |
|---|
engine | searxng | 主搜索引擎,可选 searxng / tavily / exa / brave / serper / bing / google |
fallback | 空 | 逗号分隔的备用引擎,按顺序兜底(主引擎报错或返回空结果时启用) |
endpoint | 空 | 主引擎的接口基址。留空则用该引擎的官方地址;自建 SearXNG 填 http://127.0.0.1:8080 |
endpoints | 空 | 按引擎分别指定基址的 JSON,例如 {"tavily":"https://gw.internal/tavily"},优先级高于 endpoint |
apiKey | 空 | API key 字面量。标记为 secret,不会回显、不进模型上下文 |
apiKeyEnv | 空 | 凭证引用名。留空则用该引擎约定的环境变量(见下表) |
googleCx | 空 | 仅 Google CSE 需要:Programmable Search 的搜索引擎 ID |
searchDepth | basic | 仅 Tavily:basic / advanced(advanced 消耗更多额度) |
maxResults | 8 | 单次搜索最多返回多少条来源,上限 20 |
timeoutMs | 20000 | 单次搜索超时(毫秒) |
language | zh-CN | 语言/地区提示,各引擎会映射成自己的参数。Brave 例外:它的 search_lang 只认 50 个固定标签(中文须写 zh-hans / zh-hant,日语是 jp、挪威语是 nb),本插件会自动换算;换算不出来的语言会省略该参数并在连接报告里说明,而不是发出去等 422 |
safeSearch | moderate | off / moderate / strict,引擎不支持时忽略 |
各引擎怎么准备
| 引擎 | 是否要 key | 约定的环境变量 | 备注 |
|---|
searxng | 否 | — | 自建或公共实例,必须在实例的 settings.yml 里开启 json 输出格式,否则接口返回 403 |
tavily | 是 | TAVILY_API_KEY | 为 AI 检索设计,按次计费 |
exa | 是 | EXA_API_KEY | 语义检索,可直接拿到正文片段 |
brave | 是 | BRAVE_API_KEY | 独立索引,免费额度较宽裕 |
serper | 是 | SERPER_API_KEY | Google 结果的低延迟代理 |
bing | 是 | BING_API_KEY | Azure 认知服务 Web Search |
google | 是 | GOOGLE_API_KEY | 还需填 googleCx |
key 的解析顺序:apiKey 字面量 → 凭证服务(设置页写入的) → 启动环境 → 进程环境变量。
apiKey / apiKeyEnv 只作用于当前选中的主引擎。fallback 里的引擎是别的厂商,
各自从自己的环境变量或凭证服务取 key——例如 engine: searxng + fallback: tavily
只需要额外准备 TAVILY_API_KEY。
反过来说:apiKeyEnv 留空才是常态。留空时引用名跟着主引擎走,
换引擎会自动跟着换;一旦显式填了引用名,它就压过引擎约定名(engine: tavily +
apiKeyEnv: BRAVE_API_KEY 就会拿着 Brave 的引用名去解 Tavily 的 key)。所以不要在配置里
硬写引擎专属的引用名,除非你确实要把 key 存在别的名字下。
常见组合
全本地、不花一分钱(推荐先跑通这个)
engine: searxng
endpoint: http://127.0.0.1:8080
要更稳,主引擎挂了自动兜底
engine: searxng
endpoint: http://127.0.0.1:8080
fallback: tavily
安装后的实际改动
cordis.patch.yml 里有两类条目,区别很关键:
- insert: # ① 新行:本组合包「拥有」的行
- id: custom-search
name: dsh-custom-search
config: { ... }
# ② 覆盖行:改的是别的组合包已经声明的行(dsh-base 声明了它们)
- id: web
name: '@deepseek-ai/dsh-web'
config:
searchProvider: custom-search
fetchProvider: http # 必须保留:patch 是整行替换,不是深合并
- id: web-search-deepseek
name: '@deepseek-ai/dsh-web-search-deepseek'
disabled: true
为什么这两行不能放进 insert:。 insert: 的语义是「插入新行」——
loader 以空列表为基准套用所有层,所以 insert: 里的行会被直接追加进树。
而 web / web-search-deepseek 这两行由 @deepseek-ai/dsh-base 声明,id 已经被占用;
再 insert: 一次只会多出同名副本,不会覆盖原来那行。
更隐蔽的后果在卸载:插件管理器把「insert: 里的行」记为本组合包的组件
(bundleRows),只要其中任何一行还挂着,就拒绝卸载并报
bundle-in-use(「其他配置仍在使用这个组合包的组件,请先停用它们」)。
web 那行的正本属于 dsh-base,永远不会下线——于是本组合包会永远卸不掉。
写成顶层覆盖就没有这个问题:覆盖行不被算作本组合包的组件,卸载时随层一起失效。
两个容易踩的点:
- patch 是整行替换。
web 那一行必须把 fetchProvider 一起写回去,
否则 web_fetch 会失去后端。
- 关掉官方提供方是为了避免歧义。服务层在没 pin id 时会要求「恰好一个可用提供方」;
两个都在就会报
WEB_PROVIDER_AMBIGUOUS。想让官方搜索和本插件并存,
把 disabled: true 改成 false,并确保 searchProvider 始终有值。
回到官方搜索
最省事:在插件页把 dsh-custom-search 这个组合包整个关掉。它的 patch 层随之失效,
那两条覆盖一起消失——web 的 searchProvider 回落、web-search-deepseek 不再被 disabled,
官方搜索自动回来。
想保留插件、只临时切回官方搜索:在 profile 的 cordis.patch.yml 里加两条覆盖:
- id: web
name: '@deepseek-ai/dsh-web'
config:
searchProvider: deepseek-official
fetchProvider: http # 整行替换,必须复述,否则 web_fetch 失去后端
- id: web-search-deepseek
name: '@deepseek-ai/dsh-web-search-deepseek'
disabled: false # 必须放开,否则 pin 的提供方本身是关闭的
卸载
面板里点组合包卡片上的卸载(要确认)。用 CLI 的话:
dsh plugin --profile desktop remove dsh-custom-search
两条路都会连组合包一起摘掉,它携带的两条覆盖随之消失,不需要手工清理。
要清理的是你自己写进 profile patch 的那些条目(见上面的「配置」)——组合包没了以后
id: custom-search 寻址不到任何一行,会被静默跳过;留在文件里只是垃圾。
如果面板报「其他配置仍在使用这个组合包的组件」
这条报错对应宿主的 bundle-in-use:管理器认为该组合包还有行挂在运行时里。
0.2.0 之前的版本一定会撞上它——那时 web / web-search-deepseek 被写在 insert: 里,
而它们的正本属于 dsh-base,永远不会下线,卸载检查因此永远过不去(见「安装后的实际改动」)。
0.2.0 起改成顶层覆盖,面板卸载正常。
已经被卡住的旧版:不用拆 profile,换掉那一份文件就够。 卸载检查读的是
已安装副本里的 cordis.patch.yml,把它换成 0.2.0 的,判据立刻从 3 行降到 1 行:
cd ~/.dsh/profiles/desktop/node_modules/dsh-custom-search
cp "D:\code\self\all\dsh-custom-search\cordis.patch.yml" .
# 然后回面板点卸载 —— 它会自己跑 pnpm remove 并整理 package.json / lockfile
想先确认再动手,用 dev/bundle-rows.mjs 对着这份文件跑一次:它照宿主的语义算出
「贡献行」,凡不是本包的行都会被标出来 —— 那正是 bundle-in-use 的判据。
如果报的是另一个码 —— reasonStopProfile(「这个 profile 没有启用 HMR,
正在使用的包要停止后用 dsh plugin 卸载」)—— 说明该组合包是本次启动时加载的。
先重启 Harness 让 startedBundles 复位,再卸载。
两条路都不通时的兜底(桌面端没有 dsh CLI,只能改文件):
# 1) 停掉 Harness
# 2) 从依赖里删掉这一行
# ~/.dsh/profiles/desktop/package.json 的 dependencies.dsh-custom-search
# 3) 删掉 profile patch 里 id: custom-search 那个条目(若有)
# ~/.dsh/profiles/desktop/cordis.patch.yml
# 4) 删目录,并删掉 dsh.profile.bundles 里的 dsh-custom-search
rm -rf ~/.dsh/profiles/desktop/node_modules/dsh-custom-search
# 5) 重启 Harness —— 官方搜索自动回来
安全说明
- 插件以 Harness 进程的权限运行,安装前请自行审阅源码——这是所有 DSH 插件的通用前提。
- 携带 API key 的请求使用
redirect: 'error',重定向会在发出前被拒绝,
避免凭证被转发到未核实的主机。免 key 的 SearXNG 允许跟随重定向(常见的 http→https)。
- 返回的 URL 只接受绝对
http(s) 地址,其他一律丢弃,不会出现在给模型的结果里。
apiKey 标记为 secret:设置页不回显,也不会进入模型上下文。
- 搜索结果是外部不可信内容,工具层会照常标注为 untrusted。
已知限制
- 「测试连接」的探测层可能缺席。本地检查(字段合法性、凭证状态,不联网)永远可用;
「真实请求」要经 Remote 调用到宿主,靠 Typert 的源模式发现,宿主没挂载那套包时只剩本地检查。
两条路都不影响搜索本身——最坏情况是少一个按钮。面板会说明是哪一种缺席(见上文
「测一下配好没有」),清单层面的原因见「依赖:为什么
peerDependencies 是必需的」。
- 探针走的是 Typert 的源模式兜底(手写 descriptor + 原型标记),因为本包没有构建步骤、
产不出
dsh-typert-generator 的那两份文件。这是 DSH 的内部机制,大版本升级后可能要跟着调;
调不动时的表现是面板点名说明,而不是搜索坏掉。
- 明细里的端点是去掉查询串的
scheme://host/path:Google CSE 把 key= 拼在 URL 上,
时限报错也会把整条 URL 写进消息,所以报告生成前统一脱敏(engines.js 的
publicEndpoint / redactUrls,smoke.mjs 有对应断言)。
- 换引擎不会迁移密钥。
apiKeyEnv 留空时引用名跟着主引擎走(brave → BRAVE_API_KEY),
所以从 Brave 换到 Tavily 要在表单里另填一个 key;旧的那个仍留在凭证服务里。
engine 在 schema 里是 union,表单上是文本框 + 一排胶囊。填了 union 之外的值保存会失败,
表单会保留草稿让你改(SettingsForm 的既定行为)。
- 仍可以把密钥明文写进 profile patch 的
apiKey 字段(只在本机),但推荐走表单/凭证服务。
ctx.web 的搜索请求只有 query 和 maxResults 两个参数,所以没有时间范围、域名过滤、
站点限定这类 provider 级控制(这是服务层的既定边界,不是本插件的取舍)。
search() 是每次查询一次请求。fallback 里多个引擎串行尝试,最坏情况耗时是各超时之和。
available() 必须是同步、离线的——服务层在选中 custom-search 时就是这么调它的——所以它只能
回答「有没有声明一个凭证引用」,回答不了「引用背后有没有值」:那个答案只有异步的凭证服务有。
声明了引用就算可用,判的是「有没有解析器」而不是「解析器当前有没有值」;官方 DeepSeek
搜索提供方用的也是这个判据(它检查自己的 resolver 闭包是否存在)。代价是缺 key 会推迟到
搜索时才报错——报的是引擎名与引用名,比在请求发出前被拦下更有用。process.env 里有值
不是必要条件,因为 key 的正常落点在凭证服务;未知引擎与不合理的 timeoutMs 仍判为不可用,
SearXNG 实例挂了同样体现为搜索报错。
目录结构
dsh-custom-search/
├── index.js 宿主半侧:Config schema、provider 注册、凭证解析、兜底链、连通性探针
├── client.js 浏览器半侧:插件页的配置表单(三个入口)、「测试连接」按钮与缺席诊断
├── engines.js 引擎适配表:每个引擎的 build(怎么发请求)+ parse(怎么读响应)+ 脱敏
├── cordis.patch.yml 组合包 patch:1 行 insert(本插件的行)+ 2 条对既有行的覆盖
├── package.json dsh.bundle.patch + dsh.client 声明、peerDependencies、icon、locale、files 白名单
├── locale/ 面板显示用的中英文标题与描述
├── icon.svg
├── docs/ README 用的安装截图
└── dev/ 离线验证套件(不随插件分发,files 白名单已排除)
├── smoke.mjs 20 项:provider 契约、可用判据、归一化、去重、兜底、取消、错误码、Brave 语言映射与参数省略、探针报告与脱敏
├── validate.mjs 57 项:清单、patch 方言、行归属(insert / 覆盖)、patch 与 Config 对齐、peerDependencies 对账、Brave 语言枚举、可用判据(引用即解析器)、浏览器半侧的契约与渲染、探针两端一致性与缺席文案
├── degraded-probe.mjs 子进程跑的降级用例:没有 Typert 包时探针缺席但 provider 照常
├── stubs/ 只在 dev 里顶替 @deepseek-ai/dsh-typert-protocol(含注册用的 resolve hook)
├── bundle-rows.mjs 对着任一份 patch 复现卸载判据(bundle-in-use),非本包的行会标出来
├── peer-closure.py 用安装包的依赖闭包核对 peerDependencies 是否真的可供给
└── asar.py 从 DSH 安装包里抽源码/依赖的解析脚本
开发
装成本地目录,之后改代码只需重启 Harness,不必反复卸载重装:
面板「+ 添加插件」填本仓库的绝对路径,或
dsh plugin --profile desktop add D:\code\self\all\dsh-custom-search
本地目录是 link: 而不是复制,所以 index.js(宿主半侧)改完重启生效,
client.js(浏览器半侧)改完重启后刷新页面即可。改稳定了再换回 github: 地址。
新增一个搜索引擎 = 在 engines.js 的 ENGINES 里加一个条目,声明 build 和 parse,
再到 index.js 的 Config.engine union 里加上这个 id,
最后在 client.js 的 ENGINES 里补一行(浏览器半侧够不到宿主半侧的源码,这张表是刻意重复的,
validate.mjs 会盯着两边不许漂移)。
验证
dev/ 下的套件直接 import 上级目录的真实代码,没有副本需要同步:
cd dev
node smoke.mjs # 起本地 mock server,用 stub ctx.web 驱动真实插件模块(含探针报告与脱敏)
node validate.mjs # 清单、patch 方言、两端声明一致性,以及浏览器半侧的契约与渲染
node bundle-rows.mjs <某份 cordis.patch.yml> # 复现宿主的卸载判据,定位「卸不掉」
python peer-closure.py # 安装包到底供不供得起本插件声明的每个 peer(读 app.asar)
degraded-probe.mjs 不用手动跑——smoke.mjs 会用子进程拉它起来(必须另起进程,
因为 resolve hook 是按进程注册的)。
validate.mjs 里那条 Brave 语言断言值得单说:language 是本插件唯一一个发给外部服务、
却被对方用封闭枚举校验的参数,而 Brave 对不在枚举里的值直接回 422 —— 整个搜索失败,
不只是丢一个提示。默认值 zh-CN 恰好是最容易踩雷的形状(Brave 没有裸 zh)。
所以断言的不是「每个映射对不对」这一件事,而是更硬的不变量:braveSearchLang() 的返回值
要么是 undefined,要么必须是枚举成员,用 40 个越界探针(含 id-ID、xx-YY、ja、
no、pt、空串、null、对象)去撞。想验证这条护栏真的会咬,就把 braveSearchLang
改回 language.split('-')[0] —— 两个套件都会当场失败。
validate.mjs 里另一条宿主机侧的不变量是可用判据。服务层选中 custom-search 时会同步调
一次 available()(dsh-web 的 resolveProvider 里就是 if (!provider.available())),
而那一问只能回答「有没有声明一个凭证引用」——凭证服务是异步的,且到那时已经没有第二个提供方
可退。原实现在这条判据上多加了一句:引擎约定的引用名还必须在 process.env 里有值。于是
把 key 存在凭证服务里这个最常见、也正是本插件设置页自己写出来的部署判成了不可用:
configured web provider "custom-search" is registered but unavailable,请求一行都没发出去。
所以断言两条不变量:每个需 key 引擎都必须声明约定的引用名(枚举 ENGINES 逐个查,将来加引擎
自动纳入),以及 available() 的实现里不得出现 process.env(只看代码——注释里讨论这个坑
不算)。参照系是官方的 dsh-web-search-deepseek:它的 available() 检查的是自己的 resolver
闭包存不存在,而不是去解出值。想看这条护栏会不会咬,就把 available() 改回要求
process.env 有值 —— 两个套件都会当场失败(validate 报「不得以进程环境为前提」,
smoke 报「未配置任何东西时也应自认可用」)。
validate.mjs 里浏览器半侧那段最值得一提:它真的把 client.js 跑了两遍——先在 vm 里
用 stub 的 require 物化工厂,拿一份假 ctx 调 apply(ctx),检查它到底注册了什么;再拿一份
假 React(会真建元素树、真展开函数组件)把配置页组件渲染一遍。
第一遍断言注册的形状:三个槽位各自的寻址字段(plugins.bundle.config 用包名、
plugins.item 用 id、plugins.row.config 用 <包名>#<行 id>)、卡片 id 不与官方占用的
四个撞车、三个入口共用同一份表单、页面组件不自带 React hook、注入的 hooks.<key> 与组件
读取的 props.use<Key>() 拼写一致。这些全都只会静默失败——写错一个字符,插件行就只剩
启停开关,界面上没有任何报错。
第二遍渲染才是抓真 bug 的那一遍:组件抛错时槽运行时会把它从那一格退役,渲染成空白,
不留任何信息。所以"能不能渲染"必须真的渲染才知道。0.3.1 修的那个 bug(connectionPanel
的签名漏了 state,函数体里 state?.writable 于是成了未声明标识符——可选链只护 null,
不护未声明变量)正是靠这一遍抓出来的。渲染这遍还覆盖了三条分支:带探针、不带探针、
以及探针已出报告(检查行 + 来源列表 + 逐次尝试明细)。
最后那条分支的断言值一提:validate.mjs 从 client.js 里读出真实的中文模板当渲染时的
t,再把渲染结果与模板对起来。用恒等函数当 t 的话占位符根本不会被填充,明细渲染成
key: {ref} 这种半成品也照样"渲染成功"——那样的断言等于没写。同时它先把函数组件展开一遍
拿到文本(textOf),再渲染一遍(stageReport 可重复调用),所以同一份报告会被走两次,
次数多算一次就会当场露馅。
探针缺席的理由也照同一套断言。 那四种成因各有各的句子,脚本逐个渲染、逐个比对模板,
再断言四句话互不相同——所以"用一句含糊话糊过去"是过不了的。它还把
describeProbeAbsence 会报的成因与 ABSENCE_KEY_OF 的句子双向对账:报了没句子的成因而
渲染出裸键、或写了句子却没人用,两种都当场失败。
探针那几项是同类问题的另一面。 测试按钮要经 Remote 调用才能碰到宿主,而这个包没有构建步骤,
@deepseek-ai/dsh-typert-generator 本该产出的那两份声明只能手写;写错命名空间不会报错,
只会让面板安静地退回"只有本地检查"。所以校验脚本把两半对起来看:命名空间与包名、descriptor 的
id / service / namespace / method、参数与结果的 codec 是否满足下面那三套校验器、宿主方法的参数名
与 descriptor 的 wire(源模式是从函数源码文本里解析参数名的,
所以不能有默认值、解构或 rest),以及原型标记键是否与 Gateway 读取的那个常量逐字相同。
探针的 codec 值得单独说,因为它让我猜错过两轮。 $mount 看起来是一道闸门,其实它后面还站着
两道,三套校验器对「一个 strict codec 该长什么样」的要求并不一致:
| 校验器 | 要求 |
|---|
Gateway requireStrictInputs | 只看 codec.mode !== 'strict' |
Typert registry validateCodec | 另要求 typeSymbol 非空、create 是函数 |
Typert loader requireStrictCodec | 同上,且 decode / encode 若写了必须是函数 |
只写裸 { mode: 'strict' } 能过第一道、卡在第二道,而它报出来的是
Cannot read properties of undefined (reading 'length') —— validateNonempty 去读
typeSymbol.length,一个字都不提是哪个字段,所以看起来像个内部错误。validate.mjs 的
assertStrictCodec 就是把这套要求就地转录成断言,下次 DSH 收紧规则能在离线套件里当场发现,
而不是又靠用户截图。
顺带说明 create() 产出的 schema 是原样放行:宿主给 SRC 来源的参数合成的是
codec.mode === 'src-json',而 decode 只在 strict 分支才取用 codec —— 这条路上工厂根本不会被
调用,它只是为了让三道校验器都满意。
smoke.mjs 补的是另一半,而且两条路都跑真代码:进程内 register() 一个 resolve hook
(dev/stubs/hooks.mjs),只把它一个 specifier 顶替掉,于是 installProbe 真的挂载起来,
探针出报告那一串(HTTP 状态、脱敏端点、往返耗时、每跳成败、密钥来源与引用名)才有东西可断言;
另一半"压根没有 Typert 包"的降级场景交给子进程 degraded-probe.mjs,它不带这个 hook,
所以 import() 失败得和真实部署一样。断言的是:apply 照常注册 provider 并返回 disposer,
console.warn 恰好报一次且原因里点名缺失的包——探针缺席不牵连搜索。
顶替品做成 node:module 的 hook 而不是往 node_modules/ 里塞个假包,是因为后者会真的遮蔽
真包(哪天本机装上了 Typert,测试会继续跑在假货上却毫无提示)。hook 只重定向一个 specifier,
@deepseek-ai/schemastery 走的还是真包。
行归属那几项同理:脚本会照 applyEntryPatches 的语义把各层合成一遍,断言
insert: 里只有本包自己的行、合成后 web / web-search-deepseek 各只有一份。
把覆盖行误写进 insert: 时,运行期只表现为「卸载按钮报错」,这里会当场失败。
bundle-rows.mjs 是这条断言的现场版:同一套语义,但可以指向任意一份 patch ——
包括 profile 里那份已安装的副本,所以线上被卡住时也能直接查,不必先复现安装。
这些脚本需要 js-yaml、@deepseek-ai/schemastery、@deepseek-ai/cosmokit
(已放在本目录的 node_modules/,被 .gitignore 忽略)。
@deepseek-ai/* 那几个是 DSH 自带的 vendor 包,可从本机安装里抽:
python dev/asar.py list "node_modules/@deepseek-ai/schemastery"
python dev/asar.py extract "node_modules/@deepseek-ai/schemastery" node_modules
asar.py 默认指向 %LOCALAPPDATA%\Programs\DeepSeek Harness\resources\app.asar;
换机器时改脚本顶部的 ASAR 常量即可。
许可证
MIT