linanbuan/dsh-session-retry ↗★ 0

@local/dsh-session-retry

会话重试 · Session retry — 助手消息动作条上的「重新回答」按钮:先把这一轮的旧回答从模型上下文里移除,再把该轮的用户提问重新提交给同一个会话。 A DSH plugin that adds an "answer again" action to every assistant turn strip, dropping the previous answer from the model context first. 支持移除当前轮次旧回答并重新提交提问,适合需要无污染重试对话的用户。

パッケージ
@local/dsh-session-retry
互換性
未検証
Cordis ピア範囲
^4.0.1
バージョン
1.0.0
ライセンス
MIT
最終更新
2026/10/11

インストール

$npx -p @deepseek-ai/dsh dsh plugin --profile web add github:linanbuan/dsh-session-retry

ドキュメント

README 全文を読む ↗

重新回答 · dsh-session-retry

中文 | English

在助手消息的动作条上加一个 重新回答 按钮:把该轮回答对应的用户提问重新提交给 同一个会话,并先让这一轮的旧回答退出模型上下文 —— 重答时模型看不到自己上一次 在这一轮里答了什么(日志与界面上的历史消息不动)。

按钮落在复制与分支之间(conversation.chat.assistant-actions,order: 20),样子与原生动作条按钮一致:方形图标按钮、悬停出深色提示气泡、与那条动作条一起悬停/聚焦显隐(data-clock=end 那一轮尺寸跟着原生图标走)。图标与提示气泡都是插件自带的:插件实践要求第三方插件的浏览器半边不 require Harness 客户端包,所以这里只 require('react')。

  • 行 id:session-retry
  • 位置:助手消息动作条(conversation.chat.assistant-actions)
  • 依赖:无(宿主半边注入 connection 与 agents,浏览器半边只依赖 react)

它做什么

  • 宿主半边(index.js)在共享的 /api 通道上注册两条路由:

    POST /api/session-retry/context      { sessionId }                 (只读)
      → { ok, sessionId, latestTurn, cached, anchors: [
            { messageId, turn, text, promptCount, omittedParts, promptSeq, anchorSeq } ] }
    
    POST /api/session-retry/regenerate   { sessionId, messageId }      (登记改写)
      → { ok, turn, promptSeq, runs, shadowed, kept, eventSeqs, deferred: true }
    

    前者一遍扫过整条会话日志,把「动作条挂在哪条助手消息上」与「该轮的用户提问」配对,结果按会话缓存 1.5 秒。后者只认活会话:从 sessions.get(id) 拿会话、用 snapshotEvents() 现场重算锚点(与 /context 同一套 retryTargets 规则),再把该轮的可见面节点替换成空节点 —— 但不在点按钮的当场写,而是登记一份影子计划,等下一次请求真的打开一个步骤时补写(原因见下一节)。

  • 浏览器半边(client.js)按会话注册一个动作条条目:取一次上面的表,每条动作条拿自己的 messageId 查表 —— 命中就可点,没命中就置灰并说明原因。点击后先 POST /regenerate,成功才用 session.prompt([{ type: 'text', text }], 'queue') 提交提问(与 ui-conversation 的 conversation.send 走同一条路径)—— 两步都成了才提示「已重新回答(旧回答已退出上下文…)」;改写没成就只转述原因、不提交,否则等于在旧回答还在上下文时把提问问第二遍。

旧回答是怎么消失的

改写的是可见面(surface),不是日志:会话记录、界面上的历史消息、标题与统计都原样保留,只有送给模型的消息列表少掉这一段 —— 模型能看到什么由 dsh-session 的 surface 折叠与 deriveMessages() 决定。

用法是 surface 自己的替换通道:追加一条 system/message 事件,带 surfaceOp: { op: 'replace', startSeq, endSeq } 与 sourceEventSeqs(被遮蔽节点必须列全,应用会严格校验)。载体必须是空 system 节点:应用自己的规则是「空 system/developer 节点保留可见面位置但不产生线缆消息」(dsh-session 的 deriveEventMessage),而空 user/message 会作为一条空 user 消息真的发出去。

写这条事件的时机有硬约束:system/message 属于 V4 的「步骤事件」,校验要求它的 turn/step 必须等于当前打开的那一对(dsh-session-format-v3-to-v4 的 STEP_EVENT_TYPES + requireStep)。点按钮时会话是空闲的 —— 上一轮已经 turn/end、下一轮还没 turn/start,没有任何打开的步骤 —— 此刻带着上一轮的 turn/step 写进去,日志当场就坏:

SessionFormatError: system/message does not match an open turn and step
→ stored log is corrupt ...

所以 /regenerate 只登记影子计划(deferred: true,可见面当场不动),等客户端提交的提问真的打开一个步骤时,在 agent/request 钩子里用当时打开的 turn/step 补写。这个位置既合法(步骤确实开着),又足够早 —— dsh-agent-loop 的 step() 先 step/start,然后 prepareRequest→agent/request(hook),最后才 buildRequest→deriveMessages(),所以用户这一次请求就已经看不见旧回答了。会话在补写前被关掉也没事:计划带 TTL,过期即弃,日志里什么都没写过。

影子范围怎么算:每个可见面节点取一个「内容区间终点」—— 追加型节点是自己本身就是新内容,终点是它自己的 seq;替换型节点继承被替换段的位置(压缩摘要就是这样占住早期位置的),终点是被替换段的末端。终点早于本轮提问的节点属于「提问之前的记忆」(例如提问前产生的压缩摘要),保留;终点不早于提问的节点都是这一轮的产物,影掉。要影的节点在可见面里未必连续,所以按可见面下标切成连续段,一段一次 append(每段留一个空节点占位)。这段判定是纯函数 shadowPlan(),真实日志与合成事件都能直接喂进去测。

判定规则(与界面严格对齐)

动作条由 ui-chat 的轮次尾部渲染:每轮一条,挂在该轮最后一个产出文本的步骤里最后一条 assistant/message 上(closing = finalized.findLast(hasText))。本插件的锚点就是这条消息,所以:

  • 该轮没有产出文本的步骤(例如中途被打断)→ 界面本来就不渲染动作条,自然也没有按钮;
  • 该轮的提问 = turn/start 之后、锚点之前的人工消息(source.kind === 'user')。注入的上下文(time-context、skill-catalog、插件通知)与压缩检查点都不是提问;替换操作(surfaceOp 为对象,压缩复写旧消息)也不是追加,不参与判定。

一轮里不止一句提问:用户在模型回答途中发话(steering / 排队)时,那句话会被接进同一个轮次。此时按顺序把该轮全部提问合并成一段重发 —— 只挑其中一句会丢掉另一句,合并则既不丢内容也只提交一次。提示语会如实写明「合并 N 条提问」。

刻意做出的取舍

  • 影掉而不是删除:宿主没有截断历史或回滚轮次的公开接口,已经写进日志的事件也删不掉,所以这是「让模型看不见」。想要真正另起一段历史,用动作条上的分支按钮(另开会话)。
  • 重答更早的轮次 = 回退到那一轮重来:影子范围从该轮提问起一直覆盖到可见面末尾,所以那一轮之后的轮次也会退出上下文(否则模型会带着「后面已经发生的事」去答前面那轮)。
  • 只服务活会话:/regenerate 要拿到内存里的会话对象才能改写;会话不在应用里打开时返回 NOT_LIVE。会话正在回答时返回 RUNNING —— 边答边改可见面属于压缩那种受控场景,这里不做(提示语会直接说「先停止再重新回答」)。
  • 非文字内容不重发:只重发文字块;原消息里的图片、附件只计数,并在提示语里说明「N 个非文字内容没有重发」。
  • 一次请求答完整条会话:动作条是每轮一条,一场长会话有几十条同时挂在页面上;逐条询问就是几十趟日志回放。所以客户端按会话取一次表,同一提交里挂载的动作条共用一趟请求(后挂载的动作条会补一趟,保证看到的是它挂载之后的判定)。

工程约束

  • 浏览器半边只依赖 react:dsh.client.inject 只声明 @deepseek-ai/dsh-client-ui-chat(插槽的属主),immediately: true 让前端在启动时就预取这个包。图标(RefreshIcon,几何照抄原生刷新图标)与提示气泡(纯 CSS 显隐,不 portal、不写 document.body)都在 client.js 里自带。
  • 宿主半边注入 connection 与 agents:agents 是 agent/request 钩子(补写那一步)必需的;sessionQuery 与 sessions 都不注入,缺失时按不可用降级回答 —— 没挂 sessionQuery 的组合里 /context 答 UNAVAILABLE,没挂 sessions 的组合里 /regenerate 也答 UNAVAILABLE,都不会把插件拖垮。
  • 空节点写在打开的步骤里:补写只发生在 agent/request(步骤已打开、消息还没装配),所以空节点的 turn/step 一定等于当时打开的那一对,日志不会被写坏;没有打开的步骤时(例如钩子被别的组合提前调用)就什么也不写,计划留到下一步。

安装

本插件是一个标准 DSH bundle:package.json 里声明了 dsh.bundle.patch 与 dsh.client,装进任何 profile 都走同一条路。先装好 DSH 桌面端(deepseek.com/download),然后下面任选一种。

方式一 · 在 DSH 桌面端里装(推荐)

  1. 侧栏点 插件 → 添加插件(Add plugin);
  2. 在输入框里填下面任意一种 spec:
    • 本仓库地址:https://github.com/linanbuan/dsh-session-retry
    • 本仓库的压缩包链接(GitHub 页面的 Source code (zip))
    • 已经克隆到本机的目录绝对路径
    • 包名(若将来发布到 npm)
  3. 点 安装:Harness 先读出这个 spec 指向什么(包名、简介、版本),确认后开始装;
  4. 装完点 立即启用。

整个安装由 Harness 自己的插件管理器完成:把这个包写进当前 profile 的 dsh.profile.bundles,并挂上宿主半边。桌面端装插件不需要命令行,也不需要手改 profile 文件。

方式二 · 让 Agent 自己装

把仓库地址或本机目录直接交给任意会话里的 Agent,它会调用 plugin_manager 工具(action: install_bundle)走同一条路。这是官方给第三方插件推荐的安装方式。

方式三 · 命令行

装了 dsh CLI 的机器上:

dsh plugin --profile 
 add 

`` 与方式一相同(包名、Git 地址、压缩包链接或本地路径);参数以 dsh plugin --help 的实际输出为准。

方式四 · 手工装进某个 profile(排障用,不推荐)

profile 目录默认在 ~/.dsh/profiles/ 。把包放进去,并在 profile 自己的 package.json 里登记两处:

"dependencies": { "@local/dsh-session-retry": "link:" },
"dsh": { "profile": { "bundles": [ "…", "@local/dsh-session-retry" ] } }

手改 profile 文件要过权限审批,写坏会让 Harness 起不来,所以正常安装请走前面三种。

一个坑:在 WSL 里 ln -s 出来的链接是 Linux 符号链接,Windows 侧跑的 Electron 解析不了它,插件页会报 dsh: cannot resolve profile bundle "@local/dsh-session-retry"。要真链接就让 Windows 侧的 pnpm 去建(方式一/二),否则就用目录副本。

装上之后

激活与改完代码之后怎么让它生效是两件事:

  1. 侧栏 插件 → 找到 @local/dsh-session-retry;
  2. 点那一行的开关 停用再启用(一次热挂载);
  3. 组件状态从「已关闭」变「运行中」=宿主半边挂在启动图里了;
  4. 浏览器半边会跟着 /plugins/events 的 graph 帧重新拉取 client.js,不用刷新页面。

但宿主半边改了代码必须重启应用。 cordis 的插件加载器用裸 import() 加载宿主模块,Node 的 ESM 缓存按文件 URL 命中,停用再启用只会重跑缓存里那份旧模块的 apply() —— 新注册的路由不会出现。表现是:按钮是新版本,点下去却提示「宿主那半还是旧版本(404),重启应用后再试」(客户端把这条 404 专门翻译成能照着做的一句话)。所以:

停用 / 卸载

- id: session-retry
  disabled: true

或者直接点插件页那一行的开关;彻底移除用插件页 → 这个包 → 卸载。

注意事项

  1. 本插件不是官方插件:与 DeepSeek AI 无任何关联,不提供服务承诺。它依赖 DSH 内部的 surface 折叠与步骤事件契约,这些不是稳定 API:Harness 升级后按钮可能置灰或改写失败,此时插件只转述原因、不提交任何东西,不会破坏会话日志。
  2. 它会改写当前会话的可见面:这是插件的全部目的,但它确实往会话里追加了事件(system/message + surfaceOp)。原始日志与界面历史不动,可随时用「分支」另开会话保留原样。请勿在需要严格留证的一次性会话里使用。
  3. 0.3.0 之前写过坏日志:早期版本在会话空闲时当场追写步骤事件,会被 V4 校验器拒绝(system/message does not match an open turn and step),受影响的会话日志需要单独修复;1.0.0 起改为「登记影子计划 + 在打开的步骤里补写」,不再有这个问题。若你手上是旧版本,请升级。
  4. 运行中的会话不能重答:返回 RUNNING,提示语会说「先停止再重新回答」。
  5. 非文字内容不重发:图片与附件只计数、不重新提交,提示语会写明数量。
  6. 安装后不会自动更新:官方插件管理器目前不支持自动更新,升级=先卸载再装新版;替换已安装的包之后宿主半边要重启应用。
  7. 兼容性:在 DSH 0.1.7-rc.2(Windows 桌面端)上开发与验证。宿主半边注入 connection 与 agents(后者供 agent/request 钩子用),sessionQuery / sessions 缺失时降级为 UNAVAILABLE 而不是崩溃。
  8. 隐私:插件不联网、无遥测。它在宿主侧读会话日志与内存中的会话对象,浏览器半边只拿到这一轮要用的锚点表(消息 id、轮次、提问文本)。
  9. 权限:插件自身不申请任何额外权限;安装时由 Harness 的插件管理器走它自己的审批流程。
  10. 本仓库的隐私:仓库内不含任何密钥、凭据、账号标识、会话日志或本机路径(文档里的路径一律是 ~/.dsh/... 形式或 ``)。

测试

node test/retry-targets.mjs      # 判定与影子范围的单元测试(合成事件,零依赖)
node test/client-contract.mjs    # 浏览器半边在替身环境里的契约测试(零依赖)

retry-targets 覆盖锚点判定与影子范围:一轮一会话、多轮取最大轮次、一轮多句提问合并计数、提问只有非文字内容时不给锚点、该轮没有产出文本的步骤时不给锚点、注入上下文与插件通知不算提问、锚点之后的提问不算本轮、空日志/畸形事件/缺 turn/end 安全降级、影子范围的边界与不连续切段。

client-contract 按宿主客户端的加载契约搭一个最小替身(window.__ModuleLoader__ + react + slots/locale/sessions 服务),驱动真模块走完「注册 → 挂载 → 取上下文 → 点击提交 → 提示语」的全过程。替身故意不提供任何 Harness 客户端包 —— 一旦 client.js 又去 require 一个,就在那里当场失败。它验证的是模块自己的逻辑与注册形状;「按钮在真实界面里的位置与样式」不在它能证明的范围内。

真机实证(用应用自己的 Session 实现与真实会话日志跑端到端,含「补写出来的日志能被 V4 校验器读回来」这条)不在本仓库内,属于本机验证材料。

结构

package.json              bundle 清单(dsh.bundle.patch + dsh.client)
cordis.patch.yml          一行 insert:装载宿主半边
index.js                  宿主半边:两条路由 + retryTargets/shadowPlan
client.js                 浏览器半边:动作条按钮(手写 lazy CJS,只用 react)
test/retry-targets.mjs    判定与影子范围单元测试
test/client-contract.mjs  浏览器半边契约测试
icon.svg                  插件图标

致谢与第三方许可

  • 提示气泡与刷新图标照抄 DSH 官方 @deepseek-ai/dsh-client-ui-primitives 的外观(13px 文字、深色底、圆角、下方 6px 的摆放,以及 IconRefreshOutlineRegular 的几何),只取外观与几何,不加载该包、不复制其代码:官方包的许可为 MIT,本条按 MIT 的要求标注来源。
  • 与界面判定对齐的规则(动作条挂在「该轮最后一个产出文本步骤的最后一条助手消息」上)来自 DSH 官方 ui-chat 的实现口径,属于接口兼容性的对齐,不含官方代码副本。
  • surface 替换通道(surfaceOp 的 replace 语义、空 system 节点不产生线缆消息)是 DSH 官方 dsh-session 自带契约,本插件只按契约写入,不包含其代码。
  • 本仓库没有引用或改编任何第三方插件的代码。

License

MIT © 2026 linanbuan