# 重新回答 · dsh-session-retry [中文](README.md) | [English](README.en.md) > 在助手消息的动作条上加一个 **重新回答** 按钮:把该轮回答对应的用户提问重新提交给 > **同一个会话**,并**先让这一轮的旧回答退出模型上下文** —— 重答时模型看不到自己上一次 > 在这一轮里答了什么(日志与界面上的历史消息不动)。 按钮落在复制与分支之间(`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](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](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](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](https://www.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 的机器上: ```bash dsh plugin --profile add ``` `` 与方式一相同(包名、Git 地址、压缩包链接或本地路径);参数以 `dsh plugin --help` 的实际输出为准。 ### 方式四 · 手工装进某个 profile(排障用,不推荐) profile 目录默认在 `~/.dsh/profiles/`。把包放进去,并在 profile 自己的 `package.json` 里登记两处: ```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 专门翻译成能照着做的一句话)。所以: - 只改 [client.js](client.js):热挂载即可,页面不用刷新; - 改了 [index.js](index.js) 或 [package.json](package.json):**重启应用**。 ### 停用 / 卸载 ```yaml - 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/...` 形式或 `<占位符>`)。 ## 测试 ```bash 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](LICENSE) © 2026 linanbuan