dsh-chat-git
把每个对话变成一条 git 检查点链的 DeepSeek Harness 插件。
每轮对话结束自动提交一次工作区,随时回到任意一轮的代码状态,或者只改对话不碰代码 —— 两条线互不干扰,而且每一步都能找回。
DSH Desktop 插件市场
本插件通过 GitHub 仓库安装,安装后重启 DSH Desktop 即可加载:
dsh plugin --profile web add github:NOOB-P/dsh-chat-git
插件市场元信息位于 package.json 的 dsh 字段,profile bundle manifest 为
cordis.patch.yml。仓库使用 MIT 协议,并通过 GitHub 的 dsh-plugin Topic
参与社区插件索引。
权限与数据访问
- 在当前会话工作目录中读取 Git 状态、提交历史和变更,并按设置执行
git init、git add、git commit 以及用户确认的工作区还原。
- 将会话与检查点映射写入
$DSH_HOME/chat-git.json;不会把 Git 操作扩展到当前工作目录之外。
- 在 DSH Web 界面注入回退按钮、历史面板和设置项,通过本地
/chat-git/* 路由与宿主通信。
- 启用提交标题总结时,会把本轮提示词和暂存变更摘要交给当前 DSH 模型生成提交标题;关闭该设置即可不调用模型总结。
- 插件没有独立的远程服务,也没有运行时 npm 依赖;Git 是可选的系统级依赖。
目录
这是什么
dsh-chat-git 是 DeepSeek Harness 的一个 profile bundle 插件。它把「对话」和「工作区代码」这两件本来各自漂移的事情绑成一条可回溯的链:
- 对话开始时,检查该会话的工作目录是不是一个 git 仓库,不是就
git init;
- 每轮对话结束(agent 交付完成)时,
git add -A -- . + git commit,提交信息形如 Ai-coding:修复设置页开关无法启用;
- 提交描述默认由模型总结该轮的提示词与改动文件生成,而不是把冗长的原始提示词塞进标题;
- 回合图标行里多一个回退按钮:一下待确认、一下确认,同时回滚代码与对话;
- 标签栏里多一个 历史 标签页,左边管对话(打开新对话 / 回退 / 编辑并发送),右边管工作区 Git(查看提交、还原到任意提交),两栏互不读写。
整个插件零 npm 依赖、无构建步骤,lib/ 下的文件就是源码。
功能特性
自动检查点
| 能力 | 说明 |
|---|
自动 git init | 工作目录不是仓库根时自动初始化,不会把提交打到上层仓库去 |
| 每轮自动提交 | 默认每轮一次,可在设置里放宽到每 2 / 3 … 10 轮 |
| 模型生成标题 | 读提示词 + git diff --cached 摘要,输出一句 ≤20 字标题 |
| 失败必留检查点 | 模型不可用 / 超时 / 报错一律回退成提示词本身,绝不丢提交 |
回合回退
回合图标行(复制按钮旁边)的回退按钮:点一下进入待确认,再点一下才真的回滚。回滚走 sessions.fork,所以原始会话不会被销毁,随时可以找回。
历史标签页(左右两栏)
| 左栏 · 对话 | 右栏 · 工作区 Git |
|---|
| 按时间倒序列出每一轮(最新在最上) | 直接读仓库:分支 / HEAD / 是否干净 |
| 从这里打开新对话(fork) —— 新开一条线,原会话保留 | 列出最近 100 条提交(含非本插件写的) |
| 回退到这里(还原) —— 新开一条线,原会话归档 | 每条提交可还原到这里 |
| 编辑并发送 —— 弹窗选「当前对话 / 新建对话」,把该轮请求(文字 + 图片)放进输入框 | 标出当前位置并弹出确认对话框 |
每张卡片都带年月日 + 时分秒的时间戳(2026-09-13 14:05:32)—— 只写 14:05 答不出「这是今天、昨天还是上周」,而两个回合落在同一分钟里是常事。
带图片的那一轮会额外标一行 含 N 张图片 —— 只写数量、不铺缩略图:历史列表是列表,不是图库;句柄本身由唯一需要搬动它们的那一步去重读。
左栏的最上面就是当前所在的那一轮,它带一个 当前位置 标签,列表上方另有一行「当前位置:第 N 轮。」—— 列表一旦滚动,「最上面那行」就不再是答案。
两栏是两条独立的路由、两次独立的读取:仓库坏掉不会带走对话列表,反之亦然。
编辑并发送(预填输入区,不自动发送)
点历史卡片上的编辑并发送,先弹出对话框选去向(当前对话 / 新建对话 / 取消):
- 取回该轮完整的原始请求(不是卡片上被截断的展示文本),文字与图片一起;
- 重建对话,两条去向的差别只有一个 —— 你正站着的这条对话还留不留下:
- 当前对话(还原):本窗口变成重建后的那条线(同样在「上一轮」处切开),原对话随即归档(可从归档恢复);
- 新建对话(fork):在它旁边另开一条只到「上一轮」的会话并切换过去,原对话保持原样,两条线都能继续,新线带可区分的标题(
… (1))。
- 不论哪条去向,请求都被放进新会话的输入框,光标在那里等你 —— 想改就改,想换模型就用 shell 自己的模型选择器,不想发就删掉。
它不会替你发送。 之前的行为是截断后立刻把提示词排队,模型在用户还没看清之前就开始回答了;现在这一步停在输入区,发送键本身就是确认。
只有 当前对话 和 回退到这里(还原) 会归档原会话;新建对话 是纯粹的分叉。读取请求与移动会话都发生在选完去向之后,所以它们的失败报在这个对话框里,就报在做出选择的地方。
工作区 Git 还原(含当前位置)
- 点还原到这里会先弹出确认对话框,写明:要还原的提交 id 与主题、
HEAD 不动、未跟踪文件不会被清理、以及 当前位置:… → 还原后变成 …;
- 确认前不发任何请求,
取消 是真正的一等出口;
- 还原后,工作区当前所在的那一行会带上
当前位置 标签,按钮变成不可点的 已在此位置;列表上方另有一行「当前位置:xxxxxxx。」;
- 还原只动工作区:不改对话、不移动 HEAD、不丢检查点。
设置页
| 设置项 | 说明 |
|---|
| 自动检查点 | 总开关;未安装 git 时无法开启 |
| 自动保存间隔 | 每轮 / 每 2 轮 / … / 每 10 轮 |
| 历史标签页 | 纯展示偏好,关掉只是撤下这个标签页,功能不受影响 |
| 总结模型 | 关闭 / 使用当前模型 / 指定模型(provider + model) |
| 检测 | 执行 git --version |
| 下载 | 跳转 Git 官方下载页 |
安装
前置要求
- Node.js ≥ 22(插件本身零依赖,这是宿主的要求);
- DeepSeek Harness,并有一个可用的 profile(下面以
web 为例);
- git(可选但强烈建议;未安装时插件会检测到并给出下载入口,只是无法提交检查点)。
方式一:从 GitHub 直接安装(推荐)
dsh plugin --profile web add github:NOOB-P/dsh-chat-git
pnpm 会把这个 GitHub 仓库拉进 profile 的依赖里,dsh 随后按已安装状态重建层列表 —— 不必先手动 git clone。
想改代码、随时 git pull 的话,先克隆再按本地路径安装:
git clone https://github.com/NOOB-P/dsh-chat-git.git
cd dsh-chat-git
dsh plugin --profile web add link:.
Windows 上用正斜杠或反斜杠都可以(在克隆出来的目录里执行):
dsh plugin --profile web add link:.
方式二:下载 release 里的 tarball 安装
从 Releases 下载 dsh-chat-git-<版本>.tgz,然后:
dsh plugin --profile web add ./dsh-chat-git-0.6.0.tgz
或者一条命令直接从 release 资产安装(无需先下载):
dsh plugin --profile web add https://github.com/NOOB-P/dsh-chat-git/releases/download/v0.6.0/dsh-chat-git-0.6.0.tgz
也可以自己从源码打包再装:
npm pack # 产出 dsh-chat-git-0.6.0.tgz
dsh plugin --profile web add ./dsh-chat-git-0.6.0.tgz
dsh plugin --profile <name> ... 是一条 pnpm 转发器:它在 profile 目录里执行 pnpm,然后按已安装状态重建 dsh.profile.bundles 层列表 —— 任何能解析到「声明了 dsh.bundle 的包」的依赖都会自动加入层栈,本包正是这种情况,所以不必手改 profile 的 package.json。
更新
按当初的安装方式二选一:
# 装的是 github: 或 release tarball
dsh plugin --profile web add github:NOOB-P/dsh-chat-git
# 装的是本地克隆(link:)
git pull
dsh plugin --profile web add link:. # 重新链接
卸载
dsh plugin --profile web remove dsh-chat-git
装完 / 更新后需要重启 dsh web 才会加载。
使用
回合图标行的回退按钮
每轮 agent 回复下方的图标行里(紧挨复制按钮之后)多出一个回退图标:
- 第一次点击 —— 进入待确认状态,按钮文案变化,此时不会发生任何事;
- 第二次点击 —— 在该轮的结束序列处分叉出新会话并切换过去,原会话归档(可从归档恢复);
- 移动鼠标或点别处即取消。
历史标签页
标签栏里(对话 / 轨迹 旁边)点 历史,得到左右两栏:
- 左栏按时间倒序列出该会话的每一轮(最新一轮在最上面,带
当前位置 标签、带年月日时分秒时间戳,带图片的轮次另标 含 N 张图片),每张卡片三个动作:从这里打开新对话(fork) / 回退到这里(还原) / 编辑并发送;
- 右栏展示工作区仓库的分支、HEAD、是否干净,以及最近 100 条提交(含不是本插件写的提交),每条可以
还原到这里。
正在进行中、还没有结束序列的那一轮无法分叉,按钮置灰并说明原因 —— 猜一个边界会产出错误的 fork。
编辑并发送
- 在左栏点某张卡片的 编辑并发送,在弹窗里选 当前对话 或 新建对话(
取消 是真正的一等出口,点它不读请求、不建会话、不归档);
- 会话切到重建后的新线,该轮的完整请求(文字 + 图片)已经在输入框里;
- 改好之后按 shell 自己的发送键。
结果:该轮及其之后的所有对话不在新线上(实现上是在「上一轮」处分叉出新会话),而这一轮的请求被交还给你。选 新建对话 时原会话不被归档,每一轮都还在、随时切得回去,新线另外带一个可区分的标题(… (1)),所以两条线在侧栏里不会读成同一个对话;选 当前对话 时原会话归档,本窗口就是重建后的那条线(标题沿用不变 —— 它替换了自己的来源,而不是并排新增一条)。第 1 轮没有上一轮,两种去向都会在同一工作区新建一个会话 —— 这才是「这一轮之前什么都不留」的诚实表达。
图片是重新读进来的:新线是在「这一轮之前」切开的,那张图的持久引用并不在新会话的日志里,所以字节是通过原会话自己的授权读回来、再注册成新会话的草稿图片。部署没挂 conversation 服务时,图片会被放弃,文字照常送达。
工作区 Git 栏
- 点某条提交的 还原到这里;
- 在确认对话框里核对提交主题、当前位置与还原后位置;
- 点 确认还原。
工作区被 git checkout <sha> -- . 覆盖,并清掉该提交中不存在的新增路径;HEAD 不动,提交历史与对话都不变。未跟踪文件不属于任何检查点,删掉就找不回来,因此不会被清理。
设置项
设置 → 仓库增强。
提交信息格式
每个检查点的提交主题由固定的 Ai-coding: 前缀(全角冒号)加一段简短描述组成:
Ai-coding:修复设置页开关无法启用
- 描述优先由模型生成:把该轮的提示词与
git diff --cached 摘要交给 ctx.llm,要求输出一句不超过 20 字、与提示词同语言的标题。返回结果会被清洗(去掉引号、标题: 之类的标签、被回显的 Ai-coding: 前缀、结尾句号),并截到 40 字。
- 整行(含前缀)不超过 72 字符,超出部分截断并以
… 结尾;
- 模型不可用、超时、报错或返回内容不可用时,自动回退为提示词本身(空白折叠成单个空格),绝不会因此丢掉检查点;
- 该轮没有记录到提示词时(会话在重启后恢复、或该轮没有用户消息),再回退为
Ai-coding:第 N 轮对话。
- 设置里的 总结模型 有三态:关闭(描述直接取提示词,零模型调用)、使用当前模型(该对话自身的模型,读不到时退回默认模型)、指定模型(显式选一个 provider + model)。
- 指定模型的选择器由 host 从实时模型注册表(
llm.listProviders() + llm.listModels())填充,因此不会列出本部署无法调用的路由;没有已注册模型的服务商不会出现在列表里,而已存储但注册表不再列出的路由仍保持可选 —— 否则打开一次设置页就会让用户的配置变得不可达。
架构
组成
| 半边 | 入口 | 职责 |
|---|
| Host | lib/index.js(exports ".") | 会话事件钩子、git 命令、/chat-git 路由、设置持久化 |
| Client | lib/client.js(exports "./client") | 回退按钮、历史标签页、设置页 |
.
├── lib/
│ ├── index.js # host 半边:事件钩子 + 路由 + 状态
│ ├── client.js # client 半边:四个客户端席位
│ ├── git.js # git 封装(全部走 argv 数组)
│ ├── store.js # $DSH_HOME/chat-git.json 的读写与校验
│ ├── summarize.js # 模型生成提交标题(含清洗与回退)
│ └── timeline.js # 会话日志折叠成「轮次」与「整轮请求」(文字 + 图片句柄)
├── test/
│ ├── harness.mjs # host 侧:真实 git + 真实 loopback HTTP
│ └── client.mjs # client 侧:React 替身 + 假 host
├── cordis.patch.yml
└── package.json
为什么零依赖、无构建
lib/ 下的文件就是源码,没有构建步骤 —— 客户端半边直接写在 window.__ModuleLoader__.load({ id, factory }) 包装格式里(这正是 client-modules 期望被服务的文件形状),因此不需要 TypeScript、tsdown 或任何 npm 依赖。
Host 半边只 import node: 内置模块和同目录兄弟模块:profile 安装会把包软链过去,Node 会从真实项目路径向上解析裸模块名,那里并不存在依赖树,所以保持零依赖是刻意的。
四个客户端席位
| Slot | 类型 | 用途 |
|---|
conversation.chat.assistant-actions | list | 回退按钮。shell 把它渲染成该回合图标行的 extraActions,即紧跟在复制按钮之后 |
conversation.view | list | 标签栏里的历史标签页,左右两栏:左栏是对话(每一轮带「从这里打开新对话(fork)」「回退到这里(还原)」「编辑并发送」,带图片的轮次另标 含 N 张图片),右栏是工作区 git(提交列表,每条带「还原到这里」并带 当前位置 标记,还原前弹出确认对话框) |
settings.section | list | 设置页(自动检查点开关 / 自动保存间隔 / 历史标签页开关 / 总结模型三态 / 检测 / 下载) |
conversation.input.dock | list | 无渲染的转交席位:编辑并发送 把该轮的请求寄存在新会话 id 下,这个席位在该会话的输入区挂载时把它取走,写进草稿。它什么都不画,只占一个空节点 |
四个席位都是纯增量的 list:不与他人争抢任何 cell,因此本插件不会遮蔽、也不会顶掉任何既有 UI。
历史视图用 conversation.view 而不是自绘的浮层,是因为标签栏是从 slot 本身投影出来的:一个组件无法隐藏自己那一页,唯一能撤下标签的办法是退出注册表。因此设置里的开关先把偏好写进 host,再镜像到本地 store,由 store 的订阅者注册或注销这一席 —— 标签与开关不可能各说各话。这也是为什么开关状态必须持久化到磁盘:conversation.view 的注册发生在启动期,而不是标签被点开的那一刻。
Host 路由
全部 loopback-only、POST、JSON 信封 { ok, value } / { ok, error }。
路由只接受 sessionId,从不接受路径,因此无法被指向任意目录。
| 路由 | 作用 |
|---|
POST /chat-git/state | 全部偏好(自动检查点 / 自动保存间隔 / 历史标签页)的状态、git 探测结果、该会话的检查点列表 |
POST /chat-git/detect | 执行 git --version |
POST /chat-git/set-enabled | 切换自动检查点;git 不可用时拒绝开启 |
POST /chat-git/set-history | 切换历史标签页;纯展示偏好,不做任何能力检查 |
POST /chat-git/set-interval | 设置自动保存间隔(1–10 的整数);越界、非整数一律拒绝,不会存成"永不提交" |
POST /chat-git/set-summary | 局部更新总结偏好(mode / provider / model);无路由的 custom 被拒绝 |
POST /chat-git/timeline | 折叠该会话的日志,返回按顺序的每一轮(含 fork 边界与对应检查点);提示词按展示需要截断 |
POST /chat-git/turn-prompt | 取某一轮完整的原始请求(不截断):{ turn, text, images },images 只带 { attachmentId } 句柄,字节由浏览器半边经原会话授权自行读回 |
POST /chat-git/repo | 直接读该会话工作区的仓库:root / 分支 / HEAD / 当前位置(position + positionKnown)/ 是否干净 / 最近 100 条提交(与轮次无关) |
POST /chat-git/models | 实时模型目录,供设置页的总结模型选择器使用(带 60 秒缓存与每服务商 4 秒上限) |
POST /chat-git/revert | 该回合图标按钮用:git checkout <sha> -- .、清理之后新增的路径,并丢掉该轮之后的检查点 |
POST /chat-git/restore | 右栏用:把工作区还原到仓库里任意一个提交,只动工作区,不动对话、不动 HEAD、不丢检查点 |
POST /chat-git/inherit | 回退分叉后,把剩余检查点交给新会话 |
状态文件
写在 $DSH_HOME/chat-git.json(默认 ~/.dsh/chat-git.json),内容为偏好与
sessionId -> { cwd, position, commits } 映射:
{
"version": 1,
"enabled": true,
"history": true,
"interval": 1,
"summary": { "mode": "current", "provider": "", "model": "" },
"sessions": { "<sessionId>": { "cwd": "...", "position": "", "commits": [] } }
}
放在磁盘上而非内存里,因此 harness 重启后历史对话的回退按钮依然可用。任何读写失败都降级为仅内存,不会把插件拖垮。
position 是「工作区当前站在哪个提交上」。它必须记在这里而不是从 git 读回来:还原刻意不动 HEAD,所以还原之后 git 已经答不出这个问题了。旧状态文件没有这个字段,读作「不知道」,路由会把它解析成 HEAD 并同时给出 positionKnown: false,让右栏可以说清楚自己显示的是记录还是推断。
0.2 的布尔 summarize 字段会被读取并迁移为对应的 summary.mode(false → off,true → current),不会因为升级而悄悄把用户关掉的功能重新打开。
设计边界与取舍
这些是刻意的工程决定,不是未完成项:
- 回退用 fork,不销毁会话。 DSH 没有「截断会话」API,只有
sessions.fork({ sessionId, atSeq })。所以「后面的对话全部删除」实现为:在该轮结束序列处分叉,得到只含前 N 轮的新会话并切换过去。原始日志保留,用户不会因为一次误点永久丢失记录。
git checkout 语义是「还原内容 + 清掉新增」,HEAD 不动。 单条 git checkout <sha> -- . 不会删除该提交中不存在的路径,后面几轮新建的文件会残留、让回溯看起来只做了一半。所以在此之后还会 git rm 掉 sha..HEAD 之间新增的路径。只动 HEAD 里存在的路径,因此每一步都还能从提交历史里找回。HEAD 保持不动,使「轮次 → 检查点」映射继续可读。
- 未跟踪文件不碰。 它们不属于任何检查点,删掉就找不回来了。回退后它们会作为未跟踪文件留在原地。
- 已有仓库会被沿用,不会被重新 init。 只有当会话工作目录本身就是一个仓库根时才会沿用;仅仅位于某个上层仓库内部时,会在工作目录里
git init 出自己的仓库,避免把提交打到用户没在这个对话里打开过的祖先仓库上。这个隔离由两件事共同保证:嵌套仓库本身,以及 git add -A -- . 中显式的 pathspec —— 自 Git 2.0 起,不带 pathspec 的 git add -A 会暂存整个工作树而与当前目录无关,一旦工作区落在更大的仓库内就会把无关改动一起卷进来。
- 按钮落在回合图标行,而不是用户消息那一行。 用户消息的复制按钮由 shell 的
UserMessageNodeView 内联渲染,内部不渲染任何 slot;conversation.chat.node 下只声明了 tool.call.toolview / assistant-actions / turnTail / commandview 四个子席,没有任何用户消息操作位。且 UserStyleBubble / MessageIconActions 是 dsh-client-ui-chat 的模块私有符号,exports 只暴露 4 个值;所有公开 client 模块(ui-chat 4 个、ui-conversation 13 个、ui-session 3 个)都不提供可复用的气泡组件,dsh-client-ui-primitives 在本机安装树里甚至不存在。因此「复制旁边」只能落在回合图标行 —— 那里恰好是 MessageIconActions 的 extraActions,渲染顺序为 [时间, 复制按钮, extraActions, 分支按钮, …],即紧邻复制按钮。
- 按钮靠
messageId 反查轮次。 assistant-actions 只派发 messageId,而检查点按轮次编号存放,回退还需要分叉边界。轮次号与回合结束序列都从 Chat snapshot 的 turn-tail 节点上读回(其已声明的载荷同时携带三者),选择器返回 "turn:seq" 字符串而非对象,以保证取值按值比较稳定、不引发多余渲染。
/chat-git/state 的空 sessionId 是合法的全局读。 设置页没有会话,只读开关、git 探测与状态文件路径。曾经把它当作缺参拒绝,结果设置页永远拿不到 state、开关一直禁用并显示 sessionId is required —— 现由 host 与 client 两侧的测试共同看住。
- git 走
ctx.subprocess 的 argv 数组,不是 shell 字符串:没有引号规则要处理、提交信息与路径没有注入面,并且与工具调用享有同等的子进程生命周期与输出上限。
- AI 总结在读不到模型时必须是「少一个功能」而不是「丢一个检查点」。 所以
ctx.get('llm') 与 agentDefaultModel.currentSelection() 都是可选读,整条路径返回空字符串即回退到提示词;llm 调用另有 8 秒上限,且 GenerateOptions 里不声明 —— 本插件的调用不是 harness 的 功能,冒用它会让遥测把用量记到别处。标题消息以 标注作者。
开发
运行测试
npm test # 两套一起跑
npm run test:host # 203 项:真实 git、假 ctx/假模型、日志折叠(含压缩检查点跳过)、整段请求(文字 + 图片句柄)读取、`set-model` 已下线、当前位置记账、间隔节流、工作区兜底、真实 loopback HTTP
npm run test:client # 218 项:席位注册(含无渲染的转交席位)、消息→轮次反查、回退流程、三态选择器、倒序两栏历史与当前轮标记、时间戳格式(年月日 + 时分秒、同一分钟可区分、无时间不显示 1970)、图片数量标记、还原确认对话框与当前位置标记、编辑并发送三选一对话框(当前对话归档 / 新建对话保留 / 取消)、重建会话 + 预填输入区 + 图片回读 + 只取一次、间隔选择、失败重试
test/harness.mjs 用真实 git 子进程在一个临时工作区里跑完整链路,并通过真实 HTTP 服务器驱动路由处理器,因此 loopback 守卫、JSON 信封都真正被覆盖。
test/client.mjs 用一个刻意很小的 React 替身(函数组件递归展开、useState 跨渲染保持)在 Node 里驱动客户端半边,不需要浏览器。
Windows 沙箱注意:受限模式下 Node 无法用 stdio: 'pipe' 捕获子进程输出(spawn 直接 EPERM),所以测试桩把子进程输出重定向到普通文件描述符再读取。
打包
npm pack --cache .npm-cache # Windows 沙箱下需要自定义 cache 目录
产出 dsh-chat-git-<version>.tgz,内含 lib/、test/、cordis.patch.yml、README.md 与 package.json。
代码约定
- 注释用英文,并且解释为什么,而不是复述代码在做什么;
- 所有面向用户的文案是中文;
- 提交前
npm test 必须全绿。
常见问题
为什么还原之后 HEAD 没动?
因为 HEAD 是「轮次 → 检查点」映射的锚点。如果还原顺手移动了 HEAD,后面几轮的检查点就会从历史上消失,右栏也就再也列不出它们了。所以还原只覆盖工作区内容并清掉新增路径,HEAD 保持不动。
代价是 git 自己答不出「工作区现在站在哪儿」,这正是插件把 position 记在状态文件里、并在列表上标出 当前位置 的原因。
为什么未跟踪文件没被删掉?
未跟踪文件不属于任何检查点 —— 它们从未被提交过,所以「回到某个提交的状态」并不能推出它们该不该存在。删掉就找不回来了,因此插件选择不碰它们,只还原 HEAD 里存在的路径。
为什么按钮在回合图标行,而不是用户消息旁边?
因为用户消息那一行没有任何 slot。shell 的 UserMessageNodeView 内联渲染复制按钮,不派发子席;相关的气泡组件是 dsh-client-ui-chat 的模块私有符号。回合图标行里的 extraActions 是唯一可以插入动作、并且渲染顺序紧邻复制按钮的位置。详见设计边界第 5 条。
回退之后原来的对话去哪了?
归档了,不是删除了。回退到这里(还原) 会新建一条只到该轮为止的会话并把原会话归档,可以从归档里恢复;从这里打开新对话(fork) 则原样保留两条线。两者在 DSH 里都是同一个 sessions.fork 原语,区别只在原会话的去向。编辑并发送 的弹窗把这件事变成了一道选择题:选 当前对话 就是归档(本窗口变成重建后的线),选 新建对话 就是保留。
一定要装 git 吗?
不装也能用,但没有任何检查点功能:插件检测不到 git 时会把自动检查点开关锁住,并在设置页给出官方下载入口。git 是唯一的外部依赖,而且是进程级的,不是 npm 依赖。
harness 重启后历史还能回退吗?
能。sessionId -> { cwd, position, commits } 映射写在 $DSH_HOME/chat-git.json 里,不在内存里。只要磁盘可写(写失败会降级为仅内存,不会报错),重启后旧对话的回退按钮依然可用。
但 /chat-git/timeline 只对已加载的会话有效:会话没加载时如实返回 session-unavailable,而不是猜一个边界。
「编辑并发送」为什么不用卡片上显示的提示词?
因为卡片上那段是展示用的截断文本(截到 300 字)。用它交回去等于悄悄让用户对着一个自己从没写过的问题继续。所以插件单开了一条 /chat-git/turn-prompt 返回完整原文(连图片一起),输入区用原文填充。
为什么点完「编辑并发送」不会自动发出去?
因为「按下这个按钮」表达的是「我想改这一轮」,不是「照原样再问一遍」。旧行为会在切换会话的同时排队发送,模型在用户还没看清之前就开始回答;现在它只把请求放进新会话的输入区,改不改、发不发都由用户决定,模型选择器也回到 shell 自己那一个。
图片也一起带过去了吗?
带了。新线是在选中那一轮之前切开的,所以那张图的持久引用不在新会话日志里,它授权不了自己的字节 —— 转交时用原会话的 readAttachment 把字节读成浏览器文件,再铸成新会话的草稿图片。部署没挂 conversation 服务时图片会被放弃,文字照常送达。
会自动提交别人的仓库吗?
不会。只有当会话工作目录本身就是仓库根时才沿用已有仓库;仅仅位于某个上层仓库内部时,会在工作目录里 git init 出自己的仓库。同时所有 git 命令都带显式 pathspec,不会波及工作区之外。
更新日志
0.6.0
- 「编辑并重新发送」改为「编辑并发送」,不再自动发送:点下去只重建会话并把该轮请求放进新会话的输入区,改不改、发不发由你决定(旧行为会在切换会话的同时排队发送,模型在用户还没看清之前就开始回答)。发送键回到 shell 自己那一个,模型选择也回到 shell 自己的选择器。
- 图片一起带过去:
/chat-git/turn-prompt 现在返回 { turn, text, images },images 只带 { attachmentId } 句柄;浏览器半边用原会话的 readAttachment 把字节读回来,再铸成新会话的草稿图片,与文字一起写进输入区。新线是在「这一轮之前」切开的,所以那些引用不在它的日志里,只有原会话能授权这些字节。
- 新增无渲染的转交席位
conversation.input.dock:新会话的输入区此刻还不存在(sessions.open 是请求不是同步切换),请求因此按 session id 寄存,由该席位在它自己那一轮的输入区挂载时取走 —— 取走即删除,中途切走再切回不会覆盖用户已经打好的内容。
- 删掉
POST /chat-git/set-model:它是为了让重建的新线跑在选定模型上而写的全局默认模型,一个历史卡片上的动作会改掉整个部署的模型。既然不再自动发送,这个时机消失了,路由与对话框里的模型下拉框一并移除;host 测试改为断言这条路由已经不存在,而不是留着没人调用的全局写入口。
- 第 1 轮重建的会话留在原工作区分组里:以前只传
cwd,而 cwd 只决定运行目录、不进分组(host 侧只有 { workspaceId } 会走 attachSession),所以点第 1 轮的 编辑并发送 会在侧栏多出一个「未分组」条目,即使那个目录就是工作区本身。现在先按源会话反查它所属的 workspaceId 再创建,新会话与原会话同组;只有拿不到工作区服务时才退回 { cwd }(两者在协议上互斥)。
- 历史时间戳显示年月日 + 时分秒(
2026-09-13 14:05:32):只写 14:05 答不出「这是今天、昨天还是上周」,而两个回合落在同一分钟里是常事。
- 「编辑并发送」改为先弹窗选去向:点下去不再直接动作,而是弹出 当前对话 / 新建对话 / 取消 三个按钮 —— 当前对话(还原) 在本窗口里回退到上一轮并从这一轮重新开始(原对话随即归档,可从归档恢复),新建对话(fork) 另开一条只到上一轮的会话并切换过去(原对话保持原样,两条线都能继续),取消 什么都不做。两者的唯一区别就是你正站着的这条对话还留不留下,而这件事在动作发生之后无法从界面上读出来,所以它必须在发生之前被问一次。读取整段请求与会话移动都发生在选完去向之后,失败因此报在这个对话框里。
- 卡片按钮改名:
从这里 fork → 从这里打开新对话(fork),回退到这里 → 回退到这里(还原)。原来的写法把英文动词丢给用户猜,而「回退」在 DSH 里到底是删掉还是归档,只有括号里那个词说得清。
- 「编辑并发送」不再归档原会话,且给新线一个可区分的标题:以前切换过去之后会把原会话归档,而 fork 出来的新线继承同一个标题 —— 两件事叠在一起,侧栏里刚点过的那条对话看起来就像被「回退到了上一轮」,用户回头找不到自己原来的对话。现在 新建对话 是纯粹的分叉:原会话每一轮都在、原样留在侧栏,新线通过
increaseTitle 拿到 … (1) 这样的标题;当前对话 与 回退到这里(还原) 是仅有的两个会归档的动作。
- 卡片记住这一轮带过几张图片:时间线现在返回
imageCount,带图片的卡片多一行 含 N 张图片。只写数量、不铺缩略图 —— 历史列表是列表而不是图库;句柄本身由唯一需要搬动它们的那一步(编辑并发送)去原会话重读。
0.5.0
- 历史左栏改为倒序:最新一轮排在最上面,与右栏的
git log 方向一致;每一项仍带着它在时间线里的原始下标,所以「编辑并重新发送」需要的「上一轮」边界不受影响。
- 当前轮带
当前位置 标签:最新一轮的卡片带药丸标签,列表上方另有一行「当前位置:第 N 轮。」。
- 修复压缩检查点被当成用户提示词:上下文压缩会用一条
user/message(source.plugin === 'compact')替换被压缩的片段,折叠时间线时它曾被当成该轮的提示词显示在卡片上,并会被「编辑并重新发送」原样发给模型。现在按来源标记跳过,卡片与编辑器都只认真实输入。
- 测试 421 项(host 205 / client 216)。
0.4.9
- 工作区 Git 还原改为弹窗确认:行上的按钮只打开对话框(此时不发请求),对话框写明提交主题、HEAD 不动、未跟踪文件不清理,以及
当前位置 → 还原后位置;取消 成为一等出口。
- 新增「当前位置」标签:当前所在提交的那一行带
当前位置 药丸标签、按钮变为不可点的 已在此位置,列表上方另有一行「当前位置:…」。位置记在 store 的 position 字段里,/chat-git/repo 同时返回 positionKnown。
- 「编辑并重新发送」支持重选模型:对话框内新增扁平化模型下拉框,默认选中当前路由;新增
POST /chat-git/set-model 在会话重建之前写入默认选择,并对照实时模型目录校验。
0.4.8
- 历史卡片新增 编辑并重新发送:载入该轮完整原始提示词,确认后删掉该轮及其之后的所有对话并重发一次,原会话归档可恢复。
- 新增
POST /chat-git/turn-prompt,返回不截断的整段提示词。
更早版本的变更见提交历史。
许可证
MIT © NOOB-P