dsh-plugin-market
English | 简体中文
为 DeepSeek Harness (DSH) 桌面版补上第三方插件的发现能力:从 GitHub topic dsh-plugin
拉取插件列表,支持浏览、预览 README(可选 DeepL 中文翻译与本地关键词提取)、一键装入当前
profile,并负责 GitHub 加速代理在客户端的配置读取。
本插件以独立 bundle 形式装入 DSH profile,同时提供宿主半边(Node.js)与客户端半边(浏览器)。
目录
问题背景
DSH 官方桌面版自带的插件管理器只覆盖「已经知道名字」的插件:填入一个包名或规格,它负责 安装与卸载。它缺少的是发现 —— 用户无从知道存在哪些第三方插件、它们各自做什么。
DSH 官方没有插件市场。社区实际使用的「插件库」是 GitHub topic
dsh-plugin:仓库打上该 topic 即可被搜到。本插件
就是建立在该 topic 之上的浏览与安装界面。
补充说明两点:
- 本插件不替代官方插件管理器。装、卸、启停等写操作仍与官方 profile 语义一致(改写
dsh.profile.bundles),本插件只是把入口放到了同一个页面里。 - 发现完全依赖 GitHub 公开 API,不经过任何中间服务。
环境要求
| 项 | 要求 |
|---|---|
| Node.js | ^22.19.0 || >=24 |
| DeepSeek Harness | 官方桌面版(使用 desktop profile;插件在该 profile 下开发与验证) |
| pnpm | 无需单独安装,使用官方桌面版随包的 pnpm |
| DeepL API Key | 可选,仅 README 机器翻译需要;不配置时其余功能不受影响 |
安装
# 用官方桌面版随包的 pnpm 装进 profile
pnpm add "file:<插件目录绝对路径>"
# 或者直接从 GitHub 装
pnpm add "github:VCPr0j3k7/dsh-plugin-market"
装完必须登记 bundle(关键步骤)
pnpm add 只写 profile 的 dependencies,不会把包登记进 bundle 层栈。DSH 只在启动时
按 dsh.profile.bundles 装配插件树,因此还需要手动把包名加进 profile package.json 的
dsh.profile.bundles 数组:
// ~/.dsh/profiles/desktop/package.json
{
"dependencies": {
"dsh-plugin-market": "file:/path/to/dsh-plugin-market"
},
"dsh": {
"profile": {
"bundles": [
// …官方 bundle…
"dsh-plugin-market"
]
}
}
}
漏掉这一步的表现是:依赖装好了、node_modules 里也有这个包,但界面上一片空白、日志里也
没有任何报错 —— 因为它从未被装载。
替代做法是改用官方 CLI 安装,它会在 pnpm 执行完毕后运行一次 reconcilePlugins(),把声明了
dsh.bundle.patch 的依赖自动追加进 dsh.profile.bundles:
dsh plugin --profile desktop add github:VCPr0j3k7/dsh-plugin-market
无论用哪种方式,安装后都需要重启 harness:插件在启动时装载,运行中的实例不会热更新。
卸载
pnpm remove dsh-plugin-market
若 dsh.profile.bundles 中仍有残留条目,手动删除它,然后重启 harness。
工作原理
本插件由两个半边组成,两半都是独立装载的。
宿主半边(index.js)是一个 Cordis 插件,导出 name / inject / apply(ctx)。它在
ctx.webServer 上只注册一条 kind: 'prefix' 的 HTTP 路由 /dsh-plugin-market/api,
前缀内部的派发由 host/router.mjs 完成。这样做的好处是注册点只有一处,卸载时由框架自动
清理,不会在 webServer 里留下半张路由表。
约定所有接口统一返回 { ok, data | error },业务失败也回 HTTP 200(ok: false);HTTP 状态码
只用于表达「路由不存在」这类传输层事实。
客户端半边(client.js)通过 window.__ModuleLoader__.load({ id, factory }) 装载,注册到
三个官方槽位:
| 槽位 | 内容 |
|---|---|
sidebar.panellist |
侧边栏入口「插件市场」 |
main |
插件获取页本体 |
shell.overlay |
README 全文预览抽屉 |
客户端不写死宿主基址。页面来源(origin)有三种可能 —— 宿主自己的 HTTP 地址、外壳的自定义
scheme、以及不透明来源(location.origin === "null")—— 因此第一次请求时按 ["", "http://dsh.internal"]
顺序逐条探测,用 /info 返回的 data.plugin 是否等于本插件模块 id 来判定哪条真的通。
仅凭「拿到 200」不够:SPA 的兜底路由会把未知路径也回成 200 + HTML。选定的基址会打印到浏览器
控制台。
安装进度走事件队列:宿主半边维护一个 seq 单调递增的环形队列(最多 400 条),客户端每
300ms 轮询 GET /events?since=<seq> 拉增量。选择轮询而不是 SSE,是因为外壳对自定义 scheme
的转发是否会缓冲响应体无法确认;轮询没有这个未知数,代价只是 300ms 的粒度。
取消安装由 AbortController 实现:POST /market/install 为每个仓库登记一个 controller,
POST /market/cancel 触发 abort(),下载循环随之中断。
重启宿主做不到。 插件本身就是宿主加载的一部分,没有让宿主重启自身的办法,而尝试杀进程
会连带丢掉用户的会话。因此 POST /restart-host 如实拒绝并返回「请手动退出并重新打开」的
提示;界面上的「立即重启」按钮据此显示失败原因。
路由表
前缀:/dsh-plugin-market/api。路径均相对于该前缀。
通用路由(host/router.mjs,各插件共用):
| 方法 | 路径 | 作用 |
|---|---|---|
| GET | /info |
宿主状态与插件 id(客户端据此判定基址是否可用) |
| GET | /config |
读共享配置 |
| POST | /config |
深合并写回共享配置 |
| POST | /open-external |
用系统默认程序打开 http/https 地址 |
| POST | /pick-directory |
选择目录(优先官方 directoryPicker) |
| GET | /restart-pending |
profile 是否有改动尚未生效 |
| POST | /restart-host |
如实拒绝(插件无法重启宿主) |
| GET | /events |
按 ?since= 拉取事件增量 |
本插件路由(index.js):
| 方法 | 路径 | 作用 |
|---|---|---|
| POST | /market/list |
列出 topic 下的仓库(带 1 小时磁盘缓存) |
| POST | /market/details |
取单个仓库的 README / 译文 / 关键词 |
| POST | /market/install |
下载并装入当前 profile(支持取消) |
| POST | /market/cancel |
取消指定仓库正在进行的下载 |
| GET | /market/translate-status |
翻译功能的当前状态(是否开启、是否有密钥) |
客户端半边只声明本页实际用到的路由,不留无人调用的死路由。test/check.mjs 会从 client.js
提取它调用的全部路由,逐条确认宿主路由表里有实现 —— 漏一条的表现通常是「某个按钮永远没
反应」,排查成本很高,因此纳入自检。
README 预览与翻译
展开一张卡片时会按顺序做四件事:
- 找作者自带的中文 README:列出仓库根目录,按常见文件名(
README.zh-CN.md、README_zh.md、README.zh.md…)匹配;命中的话直接使用,比机器翻译准确,也省掉一次 翻译配额。正文走raw.githubusercontent.com,不消耗 API 配额。 - 抓 README 正文:用 GitHub 的 readme 端点(自动处理
.rst、docs/下的文件、大小写 差异),而不是猜README.md的路径。 - 提取中文关键词:完全本地的词典匹配,不消耗任何配额,翻译关闭时照常工作。
- 翻译(可选):仅当正文基本不含中文时调用 DeepL。判据是 CJK 字符占比低于 8% 且中文 绝对数少于 30。
界面上的 README 抽屉提供三种视图:
| 视图 | 内容 | 上限 |
|---|---|---|
| 完整原文 | 原始 Markdown,由内置渲染器渲染 | 40 万字符(超出则截断并如实标注) |
| 中文译文 | DeepL 译文,纯文本 | 6000 字符(README_LIMIT,受翻译配额约束) |
| Markdown 源码 | 原始 Markdown 原文 | 同「完整原文」 |
译文另按仓库缓存在磁盘上,重复打开不再计费。详情结果在进程内按仓库名缓存(最多 60 条)。
README 渲染器是自写的子集实现:支持围栏代码块、ATX 标题、水平线、引用、有序/无序列表
(含嵌套)、表格、段落,以及行内代码、粗体、斜体、删除线、链接、图片;另有一个 HTML 子集
渲染器处理 README 里常见的内联排版(<table> 导航、<details> 折叠块、居中 logo 等)。
输出的是 React 元素树而不是 HTML 字符串:README 来自任意第三方仓库,属于不可信输入,走
dangerouslySetInnerHTML 等于把 XSS 装进门。HTML 子集渲染器采用标签与属性白名单,名单外的
标签剥掉但保留文字,名单外的属性直接丢弃,因此即使 README 里塞了 <script> 或 onclick=,
也只会变成一段无害的文字。
GitHub 加速代理
直连 GitHub 在部分网络环境下又慢又容易失败,瓶颈通常是 TLS 握手与首字节延迟,而不是带宽。 本插件支持把对 GitHub 的请求改写成走用户自建的反向代理。
三条设计约束:
- 默认关闭,地址必须由用户显式填写。 关闭时所有请求走官方地址,行为与没有这个功能时 完全一致。
- 只改地址,不改语义。 路径前缀与上游域名一一对应,令牌、Accept 头、请求方法都原样 透传,因此开不开代理拿到的响应体是同一份。
- 自己跟随 302。 GitHub 会把下载类请求重定向到另一个域名(
api.github.com→codeload.github.com,release 则跳到release-assets/objects)。若交给 fetch 自动 跟随,它会直接连那个域名,等于绕开了代理。因此这里手动跟随,把 Location 里的 GitHub 域名重新改写成代理地址再请求。
路径前缀与上游域名的映射:
| 前缀 | 上游域名 |
|---|---|
/api |
api.github.com |
/raw |
raw.githubusercontent.com |
/codeload |
codeload.github.com |
/gh |
github.com |
/objects |
objects.githubusercontent.com |
/release-assets |
release-assets.githubusercontent.com |
这张表必须与服务端反向代理的 location 配置一一对应。 对不上不会报错,只会静默 404。
头像(avatars.githubusercontent.com)与 README 里的配图不在代理范围内:它们是渲染器直接
拉取的装饰性资源,代理它们不会增加功能,反而扩大了暴露面。
设置页的「测试连通性」会打两个探针 —— 代理根路径与一条真实的 raw 请求 —— 两个都通过才算
通过。只看根路径是不够的:它由反向代理直接应答,上游通不通是另一回事(resolver 未配置、
上游证书过期、proxy_pass 缺少 rewrite,都会让根路径正常而正文 502)。
注意:自建反向代理可能是明文 HTTP。配置了 githubToken 时 Authorization 头会经它明文发出,
开启前请确认链路可信。
配置项
本插件与同族的桌面设置插件共用一份配置:<DSH_HOME>/dsh-extras.json(默认 ~/.dsh/dsh-extras.json)。
固定使用同一路径是为了避免「用户改了一处、另一处不生效」这类在界面上看不出来的问题;用文件
而不是插件间调用,则是因为文件没有「另一个插件尚未启动」的时序问题。
| 字段 | 默认值 | 说明 |
|---|---|---|
appName |
"DeepSeek Harness" |
显示名(官方桌面版自己管理窗口标题) |
githubProxy.enabled |
false |
是否启用 GitHub 加速代理 |
githubProxy.baseUrl |
"" |
反向代理基地址;留空时即使开关打开也回落到直连 |
githubToken |
"" |
GitHub API 令牌;留空按匿名请求发送(60 次/小时),填写后走认证额度 |
translate.enabled |
true |
是否对英文 README 调用机器翻译 |
translate.endpoint |
https://api-free.deepl.com/v2/translate |
DeepL 端点;Free 版密钥以 :fx 结尾,必须走 api-free 主机 |
translate.apiKey |
"" |
DeepL API Key;不内置任何密钥 |
translate.targetLang |
"ZH" |
目标语言代码(DeepL 使用大写) |
translate.characterCount |
0 |
本地累计的已翻译字符数,仅用于显示大致用量 |
translate.maxCharacters |
4000 |
单次翻译的字符上限 |
首次运行且配置文件不存在时,会尝试从 DSH-Desktop 外壳的配置(%APPDATA%/DSH-Desktop/config.json)
迁移一次 translate 与 githubProxy,使用户此前填过的密钥与地址继续生效。
配置不做进程内缓存:四个插件是四个独立的模块实例,各自缓存会互相看不到对方的写入。文件只有 几百字节,每次重读的成本可以忽略,换来的是「改完立刻生效」。
启用条件
插件要被装载,必须同时满足两条:
- 包出现在 profile 的
dependencies中(node_modules里能解析到); - 包名出现在 profile
package.json的dsh.profile.bundles数组中。
第二条尤其容易被忽略,见安装一节。
排障提示:官方桌面版的「插件库」开关会重写 dsh.profile.bundles(内部调用 sanitizeProfile(...)
清理非官方 bundle)。若插件在正常运行一段时间后突然失效,首先检查 bundles 中该条目是否仍然
存在。
测试
npm test
# 等价于 node test/check.mjs
test/check.mjs 是纯逻辑自检(34 项),不依赖 DSH 运行时,也不需要把插件装进 profile。
覆盖:
package.json的dsh.bundle.patch/dsh.client/exports['./client']声明及其指向的 文件是否真实存在;- 宿主半边能被 import,且导出
name/inject/apply; apply()不抛错,路由注册在预期的前缀上、使用prefix匹配,并交给ctx.effect收尾;- 路由对齐:从
client.js提取客户端调用的全部路由,逐条确认宿主路由表里有实现;另以 真实请求验证GET /info回 200 且data.plugin等于本插件 id,未知路由回 404,前缀之外 的请求不命中; - 共享配置的默认值包含
githubToken字段。
自检会把 DSH_HOME 指向临时目录,不会读写用户真实的 ~/.dsh。
端到端验证:安装并重启桌面版后,侧边栏出现「插件市场」入口,页面能拉到仓库列表;点「README」 从右侧滑出预览;点「安装」在应用内完成下载并在结束后提示需要重启。
已知限制
- 装完必须重启宿主才会生效。 插件在启动时装配,运行中的实例不会热更新;
POST /restart-host只能如实拒绝,不会真的重启。 - GitHub API 有速率限制。 未认证时搜索接口约 10 次/分钟、其余接口 60 次/小时。列表结果 缓存 1 小时(磁盘),详情在进程内按仓库缓存最多 60 条;展开详情每次最多发起两次 API 请求。
- 译文只覆盖前 6000 字符(
README_LIMIT)。这是 DeepL 按字符计费带来的硬约束,界面会 如实标注覆盖范围。想看全文请切到「完整原文」。 - 全文预览上限 40 万字符(
README_MARKDOWN_LIMIT),超出部分截断并在界面上标注。 - 内置 Markdown 渲染器是子集实现。 脚注、定义列表、数学公式、任务列表勾选框等不支持, 遇到时按原文显示。白名单之外的 HTML 标签会被剥掉(保留文字)。
- README 里的相对图片路径依赖 GitHub raw 地址。若仓库使用 Git LFS 或私有资源,图片仍可能 无法显示。
- 代理只覆盖上表列出的 6 个 GitHub 域名,头像与 README 配图不走代理。
- 宿主半边在模块加载期读
process.argv来定位 profile 目录与随包 pnpm(布局见host/env.mjs)。脱离官方宿主进程单独运行时,这些位置取不到,安装相关功能不可用。 host/yaml-libs.mjs等通用工具当前在本包内没有调用点,作为宿主层工具保留。
No comments yet. Be the first to write one.