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里不声明purpose—— 本插件的调用不是 harness 的session-title功能,冒用它会让遥测把用量记到别处。标题消息以source: { kind: 'plugin', plugin: 'chat-git' }标注作者。 - 标题调用发生在暂存之后、提交之前。 先把
git add -A -- .做掉,标题调用才能看到这一轮真正改了哪些文件 —— 这是「总结这一轮做了什么」而非「复述请求」的关键。代价是每轮结束会等一次模型调用(上限 8 秒);换来的是提交必定早于客户端渲染该轮的回退按钮,两者不会赛跑。 - 偏好校验放在 store,不放在路由。
setSummary自己拒绝未知的mode,以及缺少 provider 或 model 的custom—— 这样每个写入方都被覆盖,而路由只是转发。另一面是部分补丁会合并:只发{ mode: 'custom' }会保留已存好的路由,因此「切模式」和「选路由」可以分两步走,不会互相清空。 - 选择器只提供可用的东西,但保留已成事实的配置。 没有已注册模型的服务商不出现在列表里(选它必然被 host 拒绝,等于设陷阱);而已存储、注册表却不再列出的 provider / model 仍然可选 —— 否则打开一次设置页就会让用户的配置变得不可达。模型目录读取带 60 秒缓存,且每个服务商各自 4 秒上限,卡住的服务商只会退化成空模型列表,不会拖住整个设置页。
- 历史标签页的数据来自会话日志,不来自客户端已渲染的内容。
Session.snapshotEvents()的日志里turn/start/user/message/turn/end齐备,而turn/end事件自身的seq就是 fork 边界。这样读是权威的:不取决于聊天视图当前渲染了哪几轮,所以早已滚出窗口、甚至本次进程重启前发生的那一轮,同样拿得到可用的边界;代价是读取只对已加载的会话有效,未加载时如实报session-unavailable,而不是猜一个边界。 - fork 与回退在 DSH 里是同一个原语,区别在原会话的去向。 没有「截断会话」API,两者都只能
sessions.fork({ atSeq })出一个到该轮为止的新会话,并在切换过去之前把剩余检查点inherit给新会话。区别是回退会额外把原会话归档(可恢复),让列表反映你实际走上的那条线;fork则原样保留,两条线都能继续。 - 没有结束序列的一轮不允许分叉。 仍在进行中的轮次(或日志被截断)拿不到
turn/end,此时按钮置灰并说明原因 —— 猜一个边界会产出错误的 fork,比不给按钮更糟。 - 对话与仓库分成两栏、两条路由,互不读写。 左栏只发
/chat-git/timeline、只做sessions.fork+ 归档;右栏只发/chat-git/repo与/chat-git/restore。这不只是排版:两侧的失败因此彼此隔离(仓库坏掉不会带走对话列表),而且restore刻意不复用/chat-git/revert—— 后者会顺手丢掉该轮之后的检查点,那是「连对话一起回退」才需要的语义,右栏只动工作区,所以那一步必须不发生。代价是右栏能还原仓库里的任意提交(包括不是本插件创建的提交),因此 sha 在进入 argv 之前先按对象名形状校验,再经rev-parse --verify <sha>^{commit}解析成完整提交 id:既不做路径拼接,也不可能把--upload-pack=…这类字符串当选项传下去。 - 自动保存间隔只节流 git,不节流对话。 每轮对话都由 harness 自己的会话日志逐轮记录,所以把间隔设成 2 或 3 只会让工作区的改动攒着,到下一个整数倍轮次一次性提交 —— 对话历史不会因此变稀疏,中间的改动也不会丢,它们就留在工作区里(右栏如实显示为未提交改动)。取值在 store 里校验为 1–10 的整数:0 或越界值会被拒绝而不是存下来,否则「每轮提交」的开关还亮着,实际上却再也不会提交。
- 工作区根目录可以从活着的会话头解析。 只信 store 里记的
cwd是一个真实的 bug:插件加载时就已经打开的会话(或 harness 重启后从磁盘恢复的会话)在某一轮结束前没有记录,于是右栏对眼前明明存在的仓库回一句「没有记录到工作区」。因此 store 是快路径、会话的header.cwd是权威兜底,解析结果顺手记下来。cwd仍然不来自请求,所以路由依旧无法被指向任意目录。 - 提交行按
git log的样子排版:图槽 + id + 主题 + 作者 · 时间。 右栏列的是仓库的真实历史(含非本插件写的提交),所以作者必须显示。图槽只用 CSS 画一条竖线加一个圆点 —— 这份数据是线性历史,画真正的泳道会暗示并不存在的分支结构。 - 失败要说清楚是谁失败,并且要能重试。 右栏第一次上线时的报错是「读取仓库失败」,可真正的原因既不是仓库也不是 git:
/chat-git/repo这条路由根本没挂上(宿主进程还是旧构建),网页服务器自己回的是{ error: 'not found' },而客户端只读了error.message—— 字符串没有.message,于是拿到undefined、落回那句泛泛的兜底文案,把唯一的线索丢了。现在错误信封先归一化成{ code, message },右栏显示宿主原话并给出重试按钮;同时不再在报错时同时显示「没有可读取的仓库」——把路由问题说成仓库问题,正好是误导用户的那个诊断。 - 「编辑并发送」需要整段请求,所以它有自己的路由。
/chat-git/timeline是给卡片用的,每段提示词都被截到 300 字;把这段展示用的截断文本交给新会话,等于悄悄让用户对着一个自己从没写过的问题继续。因此turnPrompt()与buildTimeline()分开:前者返回折叠出来的原文与图片句柄,后者只负责截断。这个读取发生在会话被移动之前,所以「读不到」报在用户按下按钮的地方,而且什么都不用回滚。 - 重建仍是一次 fork,只是边界落在「上一轮」。 选第 N 轮时,截断点必须是第 N-1 轮的结束序列 —— 第 N 轮及其之后才是要消失的部分。第 1 轮没有上一轮,
fork在「没有边界」时会保留整段对话,所以那一档改为在同一工作区sessions.create()出一个全新会话,这才是「这一轮之前什么都不留」的诚实表达。 - 它不再替你发送。 旧行为是「截断 → 立刻排队发送 → 归档原会话」,模型在用户还没看清新会话长什么样之前就开始回答了;而且发送失败时对话已经搬走,只能事后报错。现在顺序是「重建 → 把请求寄存到新会话 id → 切换」,发送键留给用户,
会话已重建但发送失败这个状态根本不存在了。它也不归档原会话:归档加上「分叉出来的新线继承同一个标题」,会让侧栏里刚点过的那条对话看起来像被回退到了上一轮 —— 而这是分叉,原对话每一轮都该还在。 - 转交靠
conversation.input.dock上的一个无渲染席位。 新会话的输入区属于另一个插件(ui-conversation),而且它此刻还不存在 ——sessions.open是请求,不是同步切换。所以请求按 session id 寄存在插件自己的Map里,由注册在输入区 slot 上的ComposerSeeder在它自己那一轮的输入区挂载时取走,并只通过 shell 公布的inputActions.setDraft/addImages写入 —— 绝不去碰另一个插件的内部状态。取走即删除,否则用户中途切走再切回来,第二次挂载会把人家刚写的内容覆盖掉。 cwd只来自宿主自己知道的两个来源。 见第 18 条:store 的记账与会话头的header.cwd,二者都没有就如实拒绝(session-unknown),绝不从请求体里取路径,否则这条路由就成了任意目录读取器。- 还原的确认从「按钮点两下」改成对话框。 一次
git checkout会覆盖整个工作区,而确认它需要的是一句话(「你正在离开哪个提交、要去哪个提交、什么会被留下」),列表行里放不下这句话 —— 按钮变文字只是让人再点一次同一个按钮,等于把确认的内容省掉了。所以行上的按钮只负责打开对话框(此时不发任何请求),对话框里写明提交主题、HEAD 不动、未跟踪文件不清理,以及当前位置 → 还原后位置,确认才发/chat-git/restore。副作用是「取消」变成了真正的一等出口。 - 「当前位置」是记在 store 里的,不是从 git 读的。 还原刻意不动 HEAD(第 2 条),所以还原之后
git rev-parse HEAD答的已经不是「工作区站在哪儿」。位置因此记在sessionId -> position:每次提交把它移到新提交,每次还原把它移到被还原的提交。同时返回positionKnown,把「宿主有记录」和「宿主没有记录、这里只是 HEAD」分开 —— 右栏对这两种情况用不同措辞,而不是把推断说成事实。落到界面上就是三处:行首的当前位置标签、列表上方的「当前位置:…」一行、以及该行按钮变成不可点的已在此位置(还原到自己已经站着的地方是个只会报成功的空操作)。 - 历史左栏倒序,但序号是原始序号。 左栏与右栏(
git log)方向保持一致:最新一轮在最上面。倒序只是渲染顺序 —— 每一项都带着它在时间线里的真实下标,因为「编辑并发送」需要的分叉边界属于上一轮(第 N-1 轮),而不是列表里的下一行。把倒序做进数据层会让那个边界指向错误的一轮。 - 压缩检查点不是用户提示词。 上下文压缩会把一段历史替换成一条合成的
user/message,它同样落在turn/start与turn/end之间。若不区分,卡片会显示「This is an automatically generated checkpoint …」这类前言,而「编辑并发送」会把它当成用户请求交回去。因此按来源标记(source.kind === 'plugin' && source.plugin === 'compact')跳过,而不是按文案匹配:标记是协议,文案随时会变。标记写死在插件里而不是 import,是因为本插件零依赖 —— 标记若变,代价只是退回旧行为,绝不会崩。 - 本插件不再写「默认模型」。 旧版本为了让重建的新线跑在选定的模型上,先调
POST /chat-git/set-model写入部署的默认选择 —— 那是一个全局副作用:一个历史卡片上的动作会改掉整个部署的模型,而模型选择器本身对这件事毫不知情。既然现在不再自动发送,这个时机就消失了:请求落进输入区,用户按 shell 自己的模型选择器选好再发,插件没有理由替他做这个决定。因此这条路由被整个删掉,对应的 host 测试改成断言它已经不存在 —— 留着一个没人调用的全局写入口,比删掉更危险。 - 图片按原会话的授权读回来。 新线是在「选中的那一轮之前」切开的,那张图的持久引用并不在新会话的日志里,所以它无法授权自己的字节。转交时用原会话的
readAttachment把字节读成浏览器文件,再经conversation.createDraftImages铸成新会话的草稿图片 id(addImages只认这种 id)。读不回来就放弃图片、保留文字:一个少了图片的请求仍然比一个根本交不过去的请求有用。
开发
运行测试
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 是唯一的外部依赖,而且是进程级的,不是 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 张图片。只写数量、不铺缩略图 —— 历史列表是列表而不是图库;句柄本身由唯一需要搬动它们的那一步(编辑并发送)去原会话重读。 - 测试 421 项(host 203 / client 218)。
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
No comments yet. Be the first to write one.