@caihua/dsh-doc-review · DSH 文档批注台
在 DSH 里直接对方案文档划词批注,批注落盘成 sidecar 文件,Agent 用 doc_review_* 工具读批注改稿,改完回标状态。
30 秒装上
两个办法,任选一个。
办法一:让你 DSH 里的 AI 自己装(推荐)
把下面这句话整段发给你自己 DSH 会话里的 AI:
帮我装一个 DSH 插件:
github:caihua8669/dsh-doc-review。 装到桌面端正在用的那个 profile(不是 web),用桌面端自带的 CLI(resources/runtime/cli/bin/dsh.cmd)执行plugin --profile <profile名> add github:caihua8669/dsh-doc-review。 装完确认三件事:①命令退出码为 0;②该 profile 的package.json里dependencies与dsh.profile.bundles都出现了@caihua/dsh-doc-review;③GET http://127.0.0.1:19387/api/dsh-doc-review/ping有响应(说明宿主半边挂上了)。最后刷新页面,确认会话页顶部页签栏出现「批注」。
办法二:自己敲一行
# 桌面端自带的 CLI;profile 名换成你正在用的(桌面端通常是 desktop)
dsh plugin --profile desktop add github:caihua8669/dsh-doc-review
装完刷新页面,会话页顶部页签栏里会出现「批注」。
要求:DSH 桌面端(或任意 profile)+ Node 20 以上。不需要构建,仓库里带的就是能直接跑的 lib。
它解决什么
以前的改稿回环:把段落复制到聊天窗口 → 下一行写建议 → 发出去。批注台把这一步改成:在文档上划词 → 旁边写意见 → 点「交给 AI 改稿」,Agent 拿到的是带行号、带原文引用、带作者与状态的结构化批注,不再丢上下文。
对话划词菜单的「添加到对话」在输入框插入原生引用徽章。发送时记录来源会话和完整选文,引用中的结束标记按原文保留。插件通过 DSH 的 conversation.chat.node 消息槽位显示可展开、可收起的引用徽章,刷新后从历史消息恢复,无需重建或替换桌面应用。普通消息继续使用宿主渲染器;停用插件后引用仍保留为带来源的文本。这里支持展开原文,暂不定位到来源消息。
界面在哪
DSH 会话页顶部页签栏里的 「批注」(与「对话轨迹」「上下文」「白板看板」并列)。三栏:
- 左:按优先级列文档。标题条右侧两个图标按钮切换视图(目录树 ⇄ 平铺,本机记忆选择);
结果行上是「N 篇」计数(详情挂在悬浮)加「范围」下拉——展开后选三档,末项是「重扫并刷新」:
- 相关(默认):两层优先级,不扫文件夹全量。
- 第一层「本会话产出 / 编辑」:这个会话里 agent 真正 read / write / edit 过的文档,加上
deliverables/presented(交付给你的文件)与agent/inbox/spliced(子代理产出,子代理写的文档只在这里出现)。条目上标「产出 / 编辑 / 读过」,时间显示的是会话里最后一次动它的时间,并用它排序(比文件 mtime 更贴近「会话里最新那篇」)。 - 第二层「本项目其它会话碰过」:同一工作区里最近 15 个会话(可配
projectSessions)碰过的文档并集,去重后按文档改动时间倒序。标「本项目」,悬浮显示来自几个会话。这一层回答的是「这个项目里被工作过的文档」,不是C:\业务\其他那 5,494 篇全量。 - 有搜索词时改在项目文件夹里全量找(否则只搜得到已经碰过的那几篇),提示行会写「搜索:项目文件夹全量」。
- 第一层「本会话产出 / 编辑」:这个会话里 agent 真正 read / write / edit 过的文档,加上
- 工作区:会话 cwd 下的全部 md(想翻整个文件夹时用)。
- 全库:配置
roots(缺省C:\业务)下的全部 md,24,856 篇(深度不限,实测扫完 1.6 秒)。 - 两层都空时,自动放宽到「工作区」并在提示行标注原因,不会给你一个空列表。
- 层间不混排:本会话的永远在最前,层内才按时间/待处理排;结果行右侧另一个按钮切「按最近 ⇄ 按待处理优先」。
- 列表项显示相对时间(今天 16:20 / 昨天 09:05 / 08-29 14:00)、待处理条数与批注总数。底部输入框可直接粘绝对路径。
- 返回给界面的始终是「最新 400 篇」(搜索时是命中的最新 400 篇);批注数只挂在这 400 篇上算,所以「待处理优先」是在最近 400 篇里的排序。
- 缓存:全库/工作区扫描 5 分钟,本会话转写 20 秒(会话进行中一直在长),本项目聚合 10 分钟。真实数据实测:本工作区 205 个会话 / 140MB 转写,取最近 15 个会话解码 + 合并统计共 311ms。
- 会话碰过的文档即使不在
roots里(比如插件源码、~/.dsh/skills下的文件)也允许打开与写批注;没被碰过的域外路径仍然拒。 - 踩过的坑(2026-10-11):①
getConfig()里曾写死Math.min(disk.maxDocs, 5000),把配置的 60000 静默压成 5000;叠加maxDepth: 9,全库只扫到 7,960 篇(约三分之一)。现在钳位上限放到 200000 / 64 层。②一个会话目录里可能有多份转写(session.jsonl.zstd/session.v3.jsonl.zstd/session.v4.jsonl.zstd…),旧版文件按字母序排在前面;曾用files.find()取第一个 ⇒ 读到几个月前的旧转写,会话最近做的事全看不到(实测session-7c58dbd2:v3=9/23 旧稿 306 次引用 vs v4=今天 519 次;修好后该会话第一层从 3 篇变 15 篇)。现在会把该目录下所有转写合并(同一路径累加次数、取较晚的发生时间),第二层按会话取最新那一份以省时间。 - 回归断言覆盖上述两条(
test/scan-coverage.mjs对数量、烟测第 [15] 组对多转写)。
- 相关(默认):两层优先级,不扫文件夹全量。
- 中:Markdown 正文(标题/段落/列表/表格/引用/代码块/分割线/行内强调)。选中任意文字弹出批注框,
Ctrl+Enter保存。- 批注框默认贴在选区上方(下沿离选区留 10px),上方放不下(选区在视口顶部 170px 内)才落到选区下方;横向与选区左沿对齐并夹在视口内——任何情况下都不压住选中的文字。定位算法抽成纯函数
floatPlacement(rect, vw, vh),有 8 条断言。 - 弹框抢走焦点后浏览器原生选区会变淡,所以用 CSS Custom Highlight(
::highlight(dr-pending))把选区持续标黄,并在框内显示所选文字前 28 字,避免「改着改着忘了选的是哪段」。
- 批注框默认贴在选区上方(下沿离选区留 10px),上方放不下(选区在视口顶部 170px 内)才落到选区下方;横向与选区左沿对齐并夹在视口内——任何情况下都不压住选中的文字。定位算法抽成纯函数
- 建议模式(改稿默认走这条路):AI 不再直接改文件,而是先用
doc_change_propose登记改动提案(quote原文 →after新文 +note为什么),落<文档目录>/.dsh-review/<文件名>.proposals.json。登记时文件一个字都不动;用户在「批注」页的「待审改动」面板逐条 接受/拒绝(可填拒绝原因),接受那一刻才真正写文件,并按quote重新定位(文档后来被别人改过也能对上;原文彻底找不到就一个字都不写并说明是哪几条对不上)。受影响的段落在正文里会挂一条内联预览(红=原文、绿=拟改)+ 就地接受/拒绝按钮。 - 右:批注卡片(飞书式),底部是整篇级意见。
- 卡片:#序号 / 行号 / 状态 / 原文引用 / 意见,可 已处理·退回·删除;自己写的批注可「编辑」(记
x_updated_at)。 - 回复串:每条批注下可来回回复(回车发送);AI 的
doc_review_reply/doc_review_resolve回填也追加进同一串,不覆盖上一条。 - 双向联动:点卡片,正文滚到锚点并加深高亮;点正文里的高亮,右栏滚到对应卡片。锚点用 CSS Custom Highlight 精确标到划词原文(
dr-anchor/dr-anchor-active),不再整块染色。 - 已处理的批注折叠进「已解决 (n)」,点开才展开;激活一条已处理批注时自动展开。
- 正文右侧有一条位置轨(
dr-rail),每条批注一个点,点了直接跳过去。 - 快捷键按平台显示:Mac 是
⌘↩/⇧⌘M,其它平台是Ctrl+Enter/Ctrl+Shift+M(后者把焦点送回批注框)。
- 卡片:#序号 / 行号 / 状态 / 原文引用 / 意见,可 已处理·退回·删除;自己写的批注可「编辑」(记
- 会话批注:在正常对话界面里选中一段文字,选区旁边会出现一条小工具条(批注 / 复制 / 划词提示词 / ⋯),点「批注」写意见即可——不用占用对话输入框,也不用把整段贴进来。落盘在
~/.dsh/doc-review/session-notes/<sessionId>.json,doc_review_list之外的session_note_list/session_note_resolve负责给 agent 读与回标。- 保存即写进聊天输入框(攒批发送):保存后批注以
【批注 · 请逐条回应】…【批注结束】的块追加进当前会话的输入框草稿,并保持焦点。可以连着划好几段逐条保存,块会整体重算(不会攒出两个块),你写在块下方的问题原样保留,攒够了一次回车发出。依据:GitHub pending review 的官方理由就是"别让 AI 在你没写完之前就按单条开工"。 - 输入框是斜杠命令(
/…)时不动它,只落盘;批注被处理/删除后再同步会把块自动摘掉。 - 输入框走
ctx.get('conversation').input.for(sessions.scope(sessionId))(.state.getSnapshot().draft/.setDraft())。用ctx.get取而不声明依赖:服务拿不到就只落盘、不碰输入框,绝不让客户端条目挂掉(v0.5.0 那次 boot 崩溃的教训)。抽出的纯逻辑有 16 条断言(test/composer-block.mjs)。 - 为什么是「先出按钮再点开」而不是像文档那样直接弹框:对话里选文字经常是为了复制,直接抢弹框会烦。文档视口内仍是直接弹。
- 「批注」页左栏顶部有「本会话批注 (待处理数)」区,可逐条 已处理 / 退回 / 删除,或一键「交给 AI」把这一批发给当前会话。
- 浮层挂在
shell.overlay插槽(全局,独立于「批注」页签),所以选中文字时人在哪儿都行。 - 划词提示词:工具条上「复制」之后直接列出前 3 个提示词(默认「翻译」「解读」),其余收进「⋯ → 更多提示词」。默认点击后将「提示词 + 选中文字」插入当前会话输入框;可在管理弹窗为单条提示词开启自动发送,但仅当输入框原本为空时提交,有草稿时只插入并提示确认。模板里用
{{text}}指代选中文字,不写就附在末尾。 - 管理入口在工具条末位:提示词不超过 3 个时是一个「编辑提示词」图标,点一下直接打开弹窗;超过 3 个时才变成 ⋯ 菜单(放不下的提示词 + 底部「管理提示词…」)。弹窗里可增删改、上移下移、恢复默认;缺名称或内容的行保存时忽略。提示词存本机 localStorage(键
dsh-doc-review.prompts),最多 30 条。 - 工具条、菜单、弹窗、提示条按 DSH 官方 Menu / Modal / Button / Toast 规格写,颜色只用宿主
--dsw-*变量,深色主题自动跟随。
- 保存即写进聊天输入框(攒批发送):保存后批注以
工具条三个动作:
- 交给 AI 改稿 (n):把待处理批注组装成指令,直接往当前会话排队一条消息(
remote.session.prompt),切回对话页就能看它逐条改。 - 复制指令:同样的内容进剪贴板(remote 面不可用时自动降级为复制)。
- 刷新正文:重新读盘。
数据落在哪
跟着文档走,Obsidian 不索引点开头目录:
<文档目录>/.dsh-review/<文件名>.review.json 结构化真源(MRSF / Sidemark 形状)
<文档目录>/.dsh-review/<文件名>.review.md 人读 / 别的工具读的导出
.review.json 落盘格式对齐 MRSF v1.0(Markdown Review Sidecar Format,sidemark.org):
| 字段 | 含义 |
|---|---|
id / author / timestamp |
批注身份 |
text / resolved |
意见正文、是否已处理 |
line / end_line |
1 基源码行号 |
selected_text |
划词原文(权威锚点,任何情况下都不改写) |
selected_text_hash |
sha256:... |
anchored_text |
重锚后实际命中的文本;空串 = 已失锚 |
reply_to |
回复串:每条回复是独立条目,reply_to 指向父批注 id |
x_status / x_reply / x_resolved_at / x_updated_at / x_created_at / x_role |
本插件私有状态(MRSF 的 x_ 扩展位);x_role 区分回复来自用户还是 AI,x_reply 只留最后一条 AI 回复做兼容 |
为什么不自创格式:MRSF 已有 @mrsf/mcp、@mrsf/cli、VS Code 扩展等消费方,字段语义也写死了重锚规则(§7.4 / §8)。
锚点怎么保持有效
锚点打在 .md 源文本上,不是渲染后的 DOM 偏移:渲染时把源码行号写进每个块的 data-ls / data-le,划词时反查行区间。
文档被改过之后,打开文档那一刻按 MRSF §7.4 的重锚四步重新定位:精确匹配 → 多命中按旧行号就近消歧 → 空白归一化再匹配 → 都失败标「已失锚」但保留。孤儿批注绝不静默删除,也不做假锚定(行号保持原值,卡片上挂红色「已失锚」)。
Agent 侧工具
| 工具 | 用途 |
|---|---|
doc_review_list |
读批注。给 doc 读单篇;不给则巡检配置根目录下所有带批注的文档。返回行号区间 + 原文 + 意见 |
doc_review_resolve |
改完标记已处理,可回填一句「改了什么」(追加进该批注的回复串,用户和同事都能看到) |
doc_review_reopen |
改得不满意,退回待处理 |
doc_review_reply |
在某条批注的回复串里追加一条 AI 回复(解释做法、追问),不改状态 |
doc_change_propose |
建议模式:登记改动提案(quote → after + note),不动文件 |
doc_change_list |
查看待审提案;不给 doc 则巡检所有带提案的文档 |
doc_change_decide |
接受(此时才落盘,按原文重新定位)/ 拒绝(带原因)/ 退回待审 |
session_note_list |
读会话批注(对话消息上的划词意见) |
session_note_resolve |
会话批注 已处理 / 退回 / 删除 |
/doc-review 技能相关工具(规则沉淀 + 预审)
装插件时自动在 ~/.dsh/skills/doc-review/SKILL.md 落一份技能文件,skill-filesystem 自动发现,用户可用 /doc-review 调用。初始规则库 ~/.dsh/doc-review/rules.json 为空;所有规则都来自用户历次批注的提炼,插件不自带任何通用规则。
| 工具 | 用途 |
|---|---|
doc_review_history |
跨项目聚合所有历史批注,给技能做共性提炼 |
doc_review_mine_candidates |
从历史里挖反复出现 ≥3 次 / ≥2 篇文档的意见模式作为候选规则(manual 粒度,用户入库前可换成启发式) |
doc_review_rules_list |
列规则库(含 candidate/active/deprecated)+ 自动健康检查(拒绝率、修改占比、90 天未命中) |
doc_review_rules_add |
加规则。detect.kind:regex / line_length / para_lines / heading_level / manual |
doc_review_rules_update |
改规则(status 变更 / 字段修改) |
doc_review_rules_remove |
删规则(想保留历史痕迹改用 update 置 deprecated) |
doc_review_preview_apply |
读 active 启发式规则,扫一篇文档,把命中结果挂进 doc_change_propose 待审改动(用户在批注页逐条 ✓/✗,这些 ✓/✗ 决定规则的 accept/reject 统计) |
自我进化闭环:批注(手打反馈) → mine_candidates(提炼候选) → rules_add(固化成规则) → 打开新文档时 preview_apply 做预审 → 用户 ✓/✗(强化/弱化规则) → rules_list 健康检查自动降级坏规则 / 建议细化。
"交给 AI 改稿"的 prompt 现在额外带一句:处理完批注后请调 doc_review_mine_candidates,发现 ≥3 条同质就提醒用户固化为规则。没发现就不打扰。
规则库格式(人直接可读可改):
{
"version": 1,
"updatedAt": "...",
"_comment": "预审规则库。rules[] 为空表示还没沉淀过规则——按正常批注累积反馈,再用 /doc-review 技能或 doc_review_mine_candidates 工具提炼。",
"rules": [
{
"id": "r_xxx",
"trigger": "行长超过 80 字符",
"suggest": "拆短",
"detect": { "kind": "line_length", "max": 80 },
"note_tpl": "",
"status": "active",
"stats": { "hits": 12, "accept": 9, "reject": 2, "edit": 1 },
"sources": ["c_abc", "c_def"],
"createdAt": "...",
"lastReview": ""
}
]
}
宿主路由(回环限定)
GET /api/dsh-doc-review/state 配置、根目录、上次打开的文档
GET /api/dsh-doc-review/docs?ws=&q= 列文档(按待处理数、改动时间排序)
GET /api/dsh-doc-review/doc?path= 读正文 + 批注 + 指纹
POST /api/dsh-doc-review/save { path, comments } 整体覆盖写回
POST /api/dsh-doc-review/hello 浏览器半边加载心跳
GET /api/dsh-doc-review/ping 存活探针
GET /api/dsh-doc-review/props?path= 读该文档的改动提案
POST /api/dsh-doc-review/prop { path, action: accept|reject|reset, ids, reason } 处理提案
GET /api/dsh-doc-review/snotes?sessionId= 读会话批注
POST /api/dsh-doc-review/snote { sessionId, action: save|delete|sent|resolve|reopen, … } 写会话批注
全部 isLoopback 校验;读文档、写批注都限定在配置根目录内,或本会话碰过的文档。
配置
~/.dsh/doc-review/config.json(首次运行自动生成):
{ "roots": ["C:\\业务"], "maxDocs": 60000, "maxDepth": 20, "projectSessions": 15, "author": "your-name" }
author 缺省取本机登录用户名(os.userInfo().username)。根目录默认取环境变量 DSH_DOC_REVIEW_VAULT;没设且 C:\业务 存在就用它,否则退到用户主目录——所以换台机器第一次跑,记得改 roots 或设这个环境变量。
maxDocs 缺省 60000、maxDepth 缺省 20(都等于不设上限,全量收),配置值上限分别是 200000 / 64;projectSessions 是「相关」第二层聚合几个最近会话,缺省 15、上限 60(另有 25MB 转写字节预算兜底)。根目录下有超大目录时把它们加进源码里的 SKIP_DIR_NAMES / SKIP_REL_PREFIXES(当前已排除 node_modules、.git、.obsidian、系统/tools、系统/ppt-master、各类 _ 前缀归档目录)。
诊断文件:~/.dsh/doc-review/heartbeat.json(宿主半边挂载时间与 pid、路由与工具清单、浏览器半边加载时间/次数/UA)、state.json(上次打开的文档)。
安装 / 升级 / 卸载
# 装:git 方式(别人用这个)
dsh plugin --profile desktop add github:caihua8669/dsh-doc-review
# 装:本地源码目录方式(自己开发改代码用这个,改完立刻生效)
dsh plugin --profile desktop add "file:/绝对路径/dsh-doc-review"
# 升级到最新
dsh plugin --profile desktop add github:caihua8669/dsh-doc-review
# 卸载
dsh plugin --profile desktop remove "@caihua/dsh-doc-review"
装完核对两处:该 profile 的 package.json 里 dependencies 与 dsh.profile.bundles 是否都有 @caihua/dsh-doc-review;再探 GET /api/dsh-doc-review/ping。
- 装进 desktop profile(桌面端 GUI 用的是它,不是 web)。
- 宿主半边为什么不会自动重载:DSH 宿主按插件名缓存已加载的模块,
remove + add只会重新 apply、不会失效缓存——实测连「换掉 package.json 的入口文件名」都没用(心跳里的 version 一直是旧值)。所有已挂载的旧实例都这样,只能重启一次桌面端。 - 所以入口做成了薄壳:
lib/index.js只负责每次 apply 时用带缓存戳的动态 import 重新加载lib/impl.js(真正的实现)。这样在第一次重启之后,改宿主半边只要remove + add(或任何一次 profile 重挂)就会加载新代码,不用再重启桌面端。机制自证见test/hot-swap.mjs。 - 浏览器半边是每次请求从磁盘读的,页面刷新即可拿到新版;
patchReload: live下 bundle 重挂时客户端会立刻重新加载(心跳里会看到 clientHello 时间更新)。
自测
node test\host-smoke.mjs # 96 条断言:真路由 / 真工具 / 真落盘 / 相关分层 / 多转写 / 弹框落点 / 会话批注 / 全量覆盖
node test\hot-swap.mjs # 3 条断言:入口热替换机制自证
node test\client-helpers.mjs # 40 条断言:飞书式批注的纯逻辑(原文定位 / 偏移映射 / 卡片对齐 / 回复串兜底 / 快捷键文案)
node test\composer-block.mjs # 28 条断言:会话批注写进聊天输入框的攒批逻辑
node test\thread.mjs # 43 条断言:回复串 MRSF 往返 / 旧 x_reply 迁移 / doc_review_reply / 编辑语义
node test\selection-prompts.mjs # 38 条断言:划词提示词(模板填充 / 草稿持久化 / 自动发送 / 工具条宽度)
node test\verify-real-session.mjs <sessionId> # 拿真会话转写验抽取质量(只读)
node test\verify-related.mjs <sessionId> # 拿真会话+真项目数据看「相关」两层实际会列出什么(只读)
node test\scan-coverage.mjs # 插件扫描 vs 独立遍历的数量/深度对比(只读)
node test\verify-session-timeline.mjs <sessionId> # 按转写顺序列出该会话碰过的文档,并显示读了几份转写(只读)
96 条断言:假 ctx 挂真插件 → 真路由 + 真工具 + 真落盘;含 MRSF 字段校验、越界拦截、按最近产出排序、搜索过滤、「相关」分层(本会话永远在最前、项目层去重不重复计、搜索时改走项目文件夹全量)、多帧 zstd 转写解析与会话范围(域外路径按会话放行、空会话自动放宽)、同一会话多份转写必须全部读到(v3 旧稿陷阱)、全量覆盖回归(11 层深的文档必须被扫到、getConfig 不得把 maxDocs 钳回 5000)、划词弹框落点(上方优先、不压选区、横向夹紧)、建议模式(提案阶段不动文件 / 拒绝留原因 / 顶部插行后仍改对位置 / 原文对不上则一个字不写)、会话批注(存取 / 按会话读回 / 空值拒绝 / agent 侧读与回标)、建议模式(提案阶段不动文件 / 拒绝留原因 / 顶部插行后仍改对位置 / 原文对不上则一个字不写),以及直接从 client.js 抽源码跑的失锚重定位四案例(行号位移 / 空白归一化 / 孤儿保留 / 多命中就近消歧)。
另外宿主侧还有一条真机自检路径:插件的路由不走 GUI 鉴权,所以可以直接 curl:
Invoke-RestMethod http://127.0.0.1:19387/api/dsh-doc-review/docs?scope=session&sessionId=<id>(/ping /state 同理)。
烟测与真会话校验都通过 DSH_DOC_REVIEW_HOME / DSH_DOC_REVIEW_SESSIONS 把运行目录与会话根顶到临时目录,不会碰你真实的 ~/.dsh/doc-review/ 与 ~/.dsh/sessions/。
No comments yet. Be the first to write one.