DSH HUB
HomePlugin StorePlugin PacksCommunityRankingsResourcesPublish Guide
Plugin source
Back to catalog

NOOB-P /

NOOB-P/dsh-chat-git

Verified

deepseek harness仓库增强插件,主要用于管理对话,每轮对话的代码

★ 1 Stars0 Forks0 IssuesN/A Community rating0 Confirmed installs
View on GitHub
READMESource: master@a69e0041

dsh-chat-git

version license node dependencies tests

把每个对话变成一条 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 是可选的系统级依赖。

目录

  • 这是什么
  • 功能特性
  • 安装
  • 使用
  • 提交信息格式
  • 架构
  • Host 路由
  • 状态文件
  • 设计边界与取舍
  • 开发
  • 常见问题
  • 更新日志
  • 许可证

这是什么

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 轮。」—— 列表一旦滚动,「最上面那行」就不再是答案。

两栏是两条独立的路由、两次独立的读取:仓库坏掉不会带走对话列表,反之亦然。

编辑并发送(预填输入区,不自动发送)

点历史卡片上的编辑并发送,先弹出对话框选去向(当前对话 / 新建对话 / 取消):

  1. 取回该轮完整的原始请求(不是卡片上被截断的展示文本),文字与图片一起;
  2. 重建对话,两条去向的差别只有一个 —— 你正站着的这条对话还留不留下:
    • 当前对话(还原):本窗口变成重建后的那条线(同样在「上一轮」处切开),原对话随即归档(可从归档恢复);
    • 新建对话(fork):在它旁边另开一条只到「上一轮」的会话并切换过去,原对话保持原样,两条线都能继续,新线带可区分的标题(… (1))。
  3. 不论哪条去向,请求都被放进新会话的输入框,光标在那里等你 —— 想改就改,想换模型就用 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 回复下方的图标行里(紧挨复制按钮之后)多出一个回退图标:

  1. 第一次点击 —— 进入待确认状态,按钮文案变化,此时不会发生任何事;
  2. 第二次点击 —— 在该轮的结束序列处分叉出新会话并切换过去,原会话归档(可从归档恢复);
  3. 移动鼠标或点别处即取消。

历史标签页

标签栏里(对话 / 轨迹 旁边)点 历史,得到左右两栏:

  • 左栏按时间倒序列出该会话的每一轮(最新一轮在最上面,带 当前位置 标签、带年月日时分秒时间戳,带图片的轮次另标 含 N 张图片),每张卡片三个动作:从这里打开新对话(fork) / 回退到这里(还原) / 编辑并发送;
  • 右栏展示工作区仓库的分支、HEAD、是否干净,以及最近 100 条提交(含不是本插件写的提交),每条可以 还原到这里。

正在进行中、还没有结束序列的那一轮无法分叉,按钮置灰并说明原因 —— 猜一个边界会产出错误的 fork。

编辑并发送

  1. 在左栏点某张卡片的 编辑并发送,在弹窗里选 当前对话 或 新建对话(取消 是真正的一等出口,点它不读请求、不建会话、不归档);
  2. 会话切到重建后的新线,该轮的完整请求(文字 + 图片)已经在输入框里;
  3. 改好之后按 shell 自己的发送键。

结果:该轮及其之后的所有对话不在新线上(实现上是在「上一轮」处分叉出新会话),而这一轮的请求被交还给你。选 新建对话 时原会话不被归档,每一轮都还在、随时切得回去,新线另外带一个可区分的标题(… (1)),所以两条线在侧栏里不会读成同一个对话;选 当前对话 时原会话归档,本窗口就是重建后的那条线(标题沿用不变 —— 它替换了自己的来源,而不是并排新增一条)。第 1 轮没有上一轮,两种去向都会在同一工作区新建一个会话 —— 这才是「这一轮之前什么都不留」的诚实表达。

图片是重新读进来的:新线是在「这一轮之前」切开的,那张图的持久引用并不在新会话的日志里,所以字节是通过原会话自己的授权读回来、再注册成新会话的草稿图片。部署没挂 conversation 服务时,图片会被放弃,文字照常送达。

工作区 Git 栏

  1. 点某条提交的 还原到这里;
  2. 在确认对话框里核对提交主题、当前位置与还原后位置;
  3. 点 确认还原。

工作区被 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),不会因为升级而悄悄把用户关掉的功能重新打开。


设计边界与取舍

这些是刻意的工程决定,不是未完成项:

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

—/ 5

No ratings yet

Verified DSH bundle

Commit a69e004105d9

Community comments

No comments yet. Be the first to write one.

DSH HUB

A community index for DSH plugins. Not an official GitHub or DeepSeek AI product.

CommunityResourcesAPIAbout