Skip to content

dsh-plugin-message-retry

Verified

dsh-plugin-message-retry · v0.1.0 · MIT · Web UI

Hover any user message in the DeepSeek Harness Web conversation and re-send it from that exact point: the session returns to the state before the message, then the message is sent again

Install

dsh plugin add dsh-plugin-message-retry

Confirm the layer applied with dsh --profile default --dump-config — see the install guide.

Source

Tags

Creators

Readme

dsh-plugin-message-retry

给 DSH Web 对话里的每条用户消息加一个「重试」按钮:鼠标悬浮到用户语句上时出现,点击即可重新发送。

用法

把鼠标移到任意一条用户消息上,消息右侧的操作行(复制、分支旁边)会出现一个重试按钮:

操作 行为
点击 从此处重新发送:把会话回退到这条消息之前(最后一个已结束的轮次)的状态,再把这句话发一遍。这句话之后的所有轮次都会被丢弃,模型拿到与当时完全一致的上文重跑,浏览器自动跳转到回退出的新会话。效果就是「回到这句话还没发过的状态,重新发一次」。
Shift + 点击 不改历史,只把这句话作为新一轮追加发送到当前会话。偶尔确实想多要一轮回答时才用。

按钮状态用颜色反馈:进行中变暗禁用,成功显示主题色,失败显示错误色并把具体原因写进原生 tooltip。

第一条消息的处理:它之前没有任何已结束的轮次,但同样不会退化成追加。 因为 fork 的 atSeq 必须指向一个真实存在的事件(Host 校验 events[boundary]?.seq !== boundary),最小的一次 fork 也仍然包含会话的第一个事件 —— 「第一条消息之前」这个状态用 fork 表达不出来。所以此时会新建一个空白会话 (ctx.sessions.create,它按定义就是空的),把这句话作为那个会话的第一条发出去。

安装

本包是一个标准的 DSH bundle,用官方插件管理方式安装,不在 profile 里手工插行。

包结构

dsh-plugin-message-retry/
├── package.json          dsh.bundle.patch + dsh.client(dual-face)
├── cordis.patch.yml      bundle 的 patch 层:insert 自己那一行
└── lib/
    ├── index.js          Host 半边
    └── client.js         浏览器半边

package.json 里两个声明各司其职:

"dsh": {
  "bundle": { "patch": "./cordis.patch.yml" },
  "client": { "platform": "web", "external": [] }
}

cordis.patch.yml 把自己作为 loader 行插入:

- insert:
    - id: message-retry
      name: "dsh-plugin-message-retry"

bundle 自插入是官方惯用法 —— dsh-web-app 就是这么给自己插 web-runtime 行的 (- id: web-runtime / name: '@deepseek-ai/dsh-web-app')。同一个包里同时声明 dsh.client,于是这一行既是 Host 半边,也是浏览器 roster 条目:client-modules 扫描 loader 行、读该包 manifest 的 dsh.client,据此提供 /plugins/<id>/client.js 并组合 window.__DSH_BOOT__。

注意 name 必须是裸包名,由 profile 的 node_modules 解析。写成文件路径会同时 踩两个坑:Node ESM 不能 import 目录(ERR_UNSUPPORTED_DIR_IMPORT),而且会让 client-modules 的包查找走偏。

安装

三种方式任选其一,都会跑 pnpm 把包装进 ~/.dsh/profiles/<name>/node_modules/、写进 profile 的 package.json 依赖与 dsh.profile.bundles,随后 Plugins 页就会列出它, 带启用开关和 Uninstall。装完刷新页面(客户端 bundle 需要一次刷新)。

1. 从 npm 安装(推荐)

app 内的 Plugins 页签 → Add plugin → 输入包名:

dsh-plugin-message-retry

其他 profile 用 CLI:

dsh plugin --profile web add dsh-plugin-message-retry

2. 直接从 GitHub 安装

DSH 把 github:owner/repo 当作一等安装写法,不必先发布到 npm:

dsh plugin --profile web add github:susices/dsh-plugin-message-retry

app 内的 Add plugin 输入框同样接受这个写法。

3. 从本地目录安装(开发时用)

Plugins 页签 → Add plugin → 输入本包的本地目录路径,例如:

C:\Doc\Projects\Jailbreak\dsh-plugin-message-retry

桌面版 profile 由 Electron 应用独占管理,CLI 会按名字硬拦:

$ dsh plugin --profile desktop add ...
error: profile "desktop" is managed exclusively by the Electron application

所以桌面版一律走 app 内的 Plugins 页签,三种写法都填在同一个输入框里。

卸载

在 Plugins 页签点 Uninstall(CLI 管理的 profile 用 dsh plugin --profile <name> remove)。 对话记录不受任何影响。

设计说明

为什么不用 conversation.chat.node 插槽

conversation.chat.node 按 user 键注册看起来是「正规」做法,但它的契约写着 replaceRisk: "shadows-shipped-ui" —— 注册即替换官方 UserMessageNodeView。 那样就得自己重写气泡、附件栏、引用标签、复制/时间操作,并且会随 DSH 升级悄悄跑偏。

因此本插件改为增强 DOM:官方 Chat 已经把所需的一切作为稳定公开面暴露出来 ——

  • 每个 Chat 行都包在 [data-chat-flow-kind=<ChatNodeKind>] 里,所以用户消息可定位为 [data-chat-flow-kind="user"];
  • 用户行的操作行本来就通过官方 [data-actions-reveal=hover] 规则做悬浮淡入,按钮用 _actions / _action 类名。

插件只往这个已有的操作行里追加一个按钮,配色、间距、悬浮淡入全部继承官方样式表, 不替换、不覆盖任何官方 UI;卸载后 DOM 与原来完全一致。

为什么是纯客户端

两个操作都已经是文档化的 Client 服务,会代理到 Host:

  • 重发:ctx.sessions.binding(id).session.prompt([{type:'text',text}], 'queue')
  • 从此处重试:ctx.sessions.fork({ sessionId, atSeq })

所以不需要 Host 半边、不需要 typert Remote 描述符、也不需要动态插件的客户端授权审批。 lib/index.js 只是一个空 apply,存在的意义是让这个包出现在 Host Loader 里, 浏览器半边再通过 dsh.client 声明被发现。

两处容易踩的坑

  1. styles 不是包形式的全局量。 包形式的 client bundle 只拿到 require; styles.insert 是动态插件运行时的符号。官方各 bundle 都是自己 document.createElement("style") 并挂 data-plugin-css 做幂等保护的,本插件照做。
  2. atSeq 是闭区间。 在消息自己的 seq 上 fork 会把它连同整轮一起保留, 所以必须先回退一个事件;这也是「从此处重试」真正的语义所在。

fork 之后子会话只是被编目、尚未 retain,因此发送前要用 sessions.using(childId, ...) 打开它的历史。

渲染开销

对话流式输出时 DOM 会持续变动。MutationObserver 的批处理被合并到 「每动画帧最多一次全量扫描」,避免长对话里每批变更都做一次全文档查询。

测试

node test/smoke-client.mjs lib/client.js
node test/behaviour.mjs   lib/client.js
  • smoke-client.mjs:模拟 DSH 的 window.__ModuleLoader__ 加载协议 + 最小 DOM + 假 ctx, 断言样式表插入、会话探针注册、按钮挂进官方操作行。故意不提供 styles 符号, 以便任何对它的裸引用都会抛错 —— 这正是上面第 1 条坑的回归防护。
  • behaviour.mjs:用假 sessions 服务跑通两条路径,断言点击发到当前会话、 Shift+点击在消息前一个 seq 上 fork 并把文字发进子会话、以及重复文本按出现次序 解析到不同 seq(同一句话说过两次是很正常的情况)。