dsh-code-explainer
把 DeepSeek Harness(DSH)里 agent 实际执行的代码,逐行翻译成不懂编程的人也能看懂的中文。
解释直接写在工具卡代码块的每一行末尾,灰色注释样式,不额外占用版面:
const r = await tools.ops_bash({ // 调用命令行工具,把结果存起来,await(等待异步完成)表示等这步跑完再继续
command: "Get-ChildItem -LiteralPath 'D:\\project'", // 列出文件夹里有哪些文件,command(命令,必填)其余参数可选
description: "List directory contents", // 给这次操作加一句说明,这是可选参数,按需给
timeoutMs: 60000 // 最多等 60 秒,timeoutMs(超时毫秒)防止命令卡死
});
console.log(JSON.stringify(r, null, 2)); // 把结果打印出来,console.log(控制台打印)最直接查看结果
空行、纯括号、纯注释行不会硬加说明;太长的注释会随代码一起换行,不会盖住原代码。
工具跑完的输出区也解释,但按输出类型区别对待:
输入(灰 //) command: "Get-ChildItem -LiteralPath 'D:\\project'" // 列出文件夹里有哪些文件
输出(蓝 →) const r = await tools.ops_bash({ → 调用命令行工具,把结果存起来
console.log(JSON.stringify(r, null, 2)); → 把结果打印出来
- 输出是代码(读到的源码、脚本片段、diff):逐行加行尾说明,用蓝色
→,和输入区分。 - 输出是普通文本(目录清单、日志、纯数据):块首加一句「这段输出是……」,只给关键行加说明(报错、退出码、找不到文件等),不逐行硬凑。
行尾注释承担两层信息
代码块下方不再有教学面板,所有说明都写在行尾同一句注释里:
- 前半句说清「这行在做什么」,必要时用半句补「为什么这么写 / 这是什么通用写法」。
- 同一种写法反复出现时,只在第一次出现的那行解释;后面标「(同上)」,避免整页重复。
- 术语第一次出现给中文对照(如
await(等待异步完成)),后续不再解释。 - 注释是灰色伪元素,在行尾,不进入
textContent;复制和选中拿到的仍是原始代码。 - 长行允许注释换到下一行,但代码本身保持原样,不会被挤掉。
- 顶部「注释:开/关」总开关保留,关掉后回到原始纯代码视图。
await 第一次出现 // 调用命令行工具,把结果存起来,await(等待异步完成)表示等这步跑完再继续
同写法第二次出现 // 等第二步做完(同上)
长什么样
- 注释挂在每一行的行尾(CSS
::after),颜色比代码淡,和语法高亮并存。 - 输入用灰色
//,输出用蓝色→,一眼能分清哪条说的是「要执行的代码」、哪条说的是「执行结果」。 - 代码块下方没有额外面板;「为什么这么写」已经并进对应行的行尾注释。
- 代码块右上角多一个「注释:开 / 注释:关」开关,点一下可以在所有代码块和输出区隐藏/显示注释,状态记在浏览器
localStorage。 - 复制按钮复制到的仍然是原始代码:注释不在 DOM 文本里,不会被复制;选中代码时也不会带上注释。
- 不再有工具卡下方的独立说明卡,避免同一件事出现两套解释。
解释什么
| 对象 | 例子 |
|---|---|
| 命令行 | Get-ChildItem → 列出文件夹里有哪些文件;Remove-Item -Recurse → 删除指定的文件或文件夹;npm install → 安装项目需要的第三方代码包 |
| 工具调用 | await tools.ops_bash({...}) → 调用命令行工具,把结果存起来 |
| 参数行 | command: → 要执行的命令;description: → 给这次操作加一句说明;timeoutMs: 60000 → 最多等 60 秒 |
| 常见语句 | console.log(...) → 把结果打印出来;return → 把结果交回去;if (...) → 先判断一个条件;for (...) → 重复处理每一项 |
| 赋值/等待 | const x = await ... → 等这一步做完,把结果存起来 |
| 模块/API | await import("node:fs") → 动态加载文件系统模块,返回模块对象,node: 前缀表示内置模块;fs.readFileSync("a.txt","utf8") → 读取文件内容,utf8 是编码选项;path.join(...) → 拼路径;process.env.X → 读环境变量 |
规则覆盖不到的复杂行,交给模型补一句(见下)。
语言范围(三档)
判定只有一个入口 detectDialect(code, declared):代码块标签优先,标签缺席时按内容特征兜底,两者都给不出把握时不逐行猜。
| 档位 | 语言 | 怎么讲 |
|---|---|---|
| Tier 1 · 逐行 | 命令行(bash / sh / cmd)、JavaScript / TypeScript、PowerShell | 本地规则保证每行都能出话,不依赖模型 |
| Tier 2 · 高频行 | Python、Java | 常见写法(def / import / print,public / class / System.out 等)有规则,其余不硬凑 |
| Tier 3 · 只讲结构 | JSON / YAML / XML / HTML / Markdown,以及其他语言 | 讲这块是什么、在描述什么;认不出来的行明说「这行我讲不了」,不拿通用话充数 |
档位按「工具卡里实际出现多少行」排,不按语言流行度排:run_code 与 ops_bash 占了绝大多数行。
Tier 2 各有一张薄规则表,命中一句就停,命中不了交回通用规则引擎(if / for / return 这类跨语言动作),再没有就留空:
- Python:
__main__/def/class/from … import/import/self./print(f"…")/print/ f-string /for … in/return - Java:
main入口 /@Override/import java…/System.out/ 方法签名 /class/interface/static/ 可见性修饰符 /TODO/FIXME(注释里的也算)
安装
要求:DSH >= 0.2.0,Node >= 20。
dsh plugin --profile desktop add "link:<本仓库目录的绝对路径>"
装完刷新 DSH 页面(Ctrl+R)或重启宿主。卸载:
dsh plugin --profile desktop remove dsh-code-explainer
package.json声明了dsh.bundle.patch,宿主会把cordis.patch.yml挂到 profile 层;不需要改 DSH 安装目录。
配置
编辑 cordis.patch.yml,或安装时由 profile 传入:
- insert:
- id: dsh-code-explainer
name: './index.js'
config:
llm: auto
# provider: deepseek-official
# model: deepseek-chat
| 键 | 默认 | 含义 |
|---|---|---|
enabled |
true |
总开关;false 时插件完全不工作 |
llm |
auto |
auto 规则判不出的行自动请模型补;off 只用本地规则,不调用模型 |
provider / model |
空 | 显式指定补充用的模型;留空优先复用当前会话最近一次请求的模型路由,再兜底到第一个可用模型 |
maxInputChars |
8000 |
发给模型的输入上限 |
maxOutputTokens |
900 |
模型输出上限 |
timeoutMs |
25000 |
单次模型调用超时 |
concurrency |
2 |
模型补充的并发上限 |
页面里的「注释:开/关」是给人用的可见开关,状态存在浏览器本地;enabled 是插件级开关,管的是要不要启动。
工作方式
浏览器半身(client.js)
- 在
[data-chat-call-id]里的.md-code-block上找到输入代码块,取出每行文本(pre > code > span.line)。没有.line的纯文本块(没有语言标注、或高亮包还没加载)按行补出span.line再挂注释:行与行之间留一个\n文本节点,pre.textContent因此逐字不变。 POST /line-notes拿逐行说明;对每个.line只写data-dce-note属性。- 说明由 CSS 伪元素渲染:
content: " // " attr(data-dce-note)。伪元素不进入textContent,所以复制按钮(读pre.textContent)拿到的仍是原代码,选中也不会带注释。长行按可见宽度加data-dce-long="1",注释换到下一行。 - 输出区(
[class*="ioText"])先分类:代码 → 去掉1:行号后按行说明(蓝色→);JSON/日志/清单/普通文本 → 输出块上方插一行.dce-io-head总述,关键行写data-dce-onote。按行拆分时只用<span class="dce-ol">保留原文字与换行,说明仍走伪元素。- 总述插在 ioSection 外侧:作为
ioCard的子节点、紧挨在输出那个ioSection前面。宿主的ioSection是display:grid; grid-template-columns:max-content 1fr的「标签 + 正文」两格,往里多塞一个子节点,自动排布就会把ioText挤到第二行第一列,正文被甩到下面、上面留一片空白。test/output-card.mjs就是守这条结构的回归测试。
- 总述插在 ioSection 外侧:作为
MutationObserver观察会话 DOM;React 重渲染后自动补回属性。每 3 秒还会兜底扫一次。- 开关按钮插在代码块自己的标题栏里(
[data-code-block-banner]),不额外加卡片。 - 工具卡还在运行时(
data-state="running")只取规则结果,等状态结束再请模型补,避免半截代码反复调用模型。
宿主半身(index.js)
POST /line-notes收到代码文本后,先用本地规则逐行给动作说明(零成本、零等待),再用enrichLineNotes把通用写法、术语对照压进同一句行尾注释。- 语言判定不只看标签:工具卡的代码块大多没有
lang,所以标签缺席时按内容特征判方言:逐行看像不像 PowerShell 语句($var =赋值、Get-ChildItem形式的动词-名词 Cmdlet、| Cmdlet管道、$env:/$_/$ErrorActionPreference这类自动变量),也逐行看像不像 JS(const/import/console./=>/ 收尾括号)。PowerShell 语句至少 2 行、且比 JS 语句多(只有一行的块,只要那行是 PowerShell 也算),才把整块当 shell 讲;否则退回 JS 规则。判定前先摘掉记录里带出来的提示符(PS C:\work>、PS>),Cmdlet 不分大小写,$btn = $("#id")这种 jQuery 不算 PowerShell 赋值,||不算管道。行数对峙比打分更稳:一段 JS 里夹一句 PowerShell 字符串时,打分版会整块误判成 shell。
- 语言判定不只看标签:工具卡的代码块大多没有
- 规则给不出动作说明的行,用
ctx.llm.stream()请模型按行号补一句;结果按内容哈希缓存,同一段代码不重复请求。 - 模型输出是
{"notes":{"行号":"说明"}};解析失败、超时、没有可用模型时保留规则结果,不阻塞页面。 - AI 补出的行直接进入同一个
notes数组,前端不再需要额外字段。补充结果写入~/.dsh/dsh-code-explainer/line-notes.jsonl(最多读回最近 800 条)。
本地 HTTP 接口
前缀 /dsh-code-explainer/api,只接受回环地址。
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /health |
存活检查 |
| GET | /state |
开关、模型模式、缓存条数、模型是否可用 |
| POST | /line-notes |
{code, lang, ai} → 逐行说明 notes(已含为什么);ai:false 只给规则结果,模型补充异步进行 |
| GET | /line-notes?hash=… |
取补充后的最新结果(前端在 aiStatus=running 时轮询) |
隐私、成本与降级
- 凭据:
sk-...、ghp_...、github_pat_...、Bearer ...、api_key/token/password=...在展示、落盘、发给模型前都替换成***。 - 成本:规则优先;只有规则判不出的行才请求模型,且按代码内容哈希缓存,重复代码不重复请求;
llm: off可完全关闭模型调用。 - 降级:模型不可用、超时、返回为空或 JSON 解析失败时,页面保留规则说明,不会影响工具执行,也不会改用户当前的模型选择。
- 不写会话:辅助调用不向会话日志追加事件。
验证到什么程度
已经做过:
node --check index.js / rules.js / client.js / test/smoke.mjs通过。node test/smoke.mjs通过:逐行规则、具体 API 识别(import()/require()、fs、path、process)、同写法去重、未识别标注、凭据掩码、HTTP、模型补充、哈希缓存、运行中不请模型、无模型降级、落盘脱敏。node test/output-card.mjs通过:用最小 DOM 桩复刻宿主的 ioCard > ioSection >(ioLabel + ioText)结构,断言总述挂在外侧、ioSection 始终只有两个子节点、拆行不改原文。把旧实现塞回 ioSection 里跑,这条测试立刻红。node test/plain-block.mjs通过:无语言标注、语言标签不认识(dsh-ui这类第三方 fence)的纯文本块也逐行补出span.line并挂上注释,pre.textContent逐字不变,已有span.line的块行节点不被重建,二次扫描不套娃,宿主重写文本后下一次扫描能接回来;card[data-state="running"]里先不补行,落定后再补。把annotateBlock早退那行放回去,这条测试立刻红。node test/teach.mjs通过:教学层能补进动作说明为空的行、已有动作说明的行逐字不变、泛泛写法不硬填、同一写法跨分支只讲一次、注释行不补。它还钉着TEACH_HINTS的表级不变量(每条要么为空、要么达到MIN_TEACH_HINT)。node test/powershell.mjs通过:无标签的 PowerShell 代码按 shell 规则解释($ErrorActionPreference赋值、Start-Sleep、New-Object、Get-ChildItem | Where-Object、Out-File都给具体说明),JS 的赋值/减法/按位或不会被误判。- 代码块 DOM 契约(
.md-code-block/[data-code-block-banner]/pre > code > span.line/ 复制读pre.textContent)来自 DSH0.2.1-rc.5前端产物的实现。 - DSH 0.2.1-rc.5 实测(插件 0.4.0,
run_code执行const fs = await import("node:fs"); console.log(fs.readdirSync("D:\\project\\demo").slice(0, 3));):- 该行行尾注释为「动态加载 文件系统模块 fs,返回模块对象,后面用 fs 调用;node: 前缀表示 Node 内置模块」,不再只讲
await/const; - 同一段里第二次
import("node:fs")标成「(同上)」;try {行标「试着做下面的操作,try/catch(先试,出错就接住)」; - 代码块下方直接是输出区,没有「为什么这么写」按钮或面板;
- 长注释换到下一行,代码本身没有被挤掉;
- 点复制按钮后剪贴板得到的是完整原始代码,不含任何注释;
- 截图仅用于本地验证,不随仓库发布。
- 该行行尾注释为「动态加载 文件系统模块 fs,返回模块对象,后面用 fs 调用;node: 前缀表示 Node 内置模块」,不再只讲
- 修正过:
ops_bash的输入 JSON 与输出共用同一 DOM 结构,已按「输入/输出」标签过滤;面板时代代码块高度被压缩的问题随面板删除一并消失。 - 修正过(0.4.5):宿主的代码块渲染器
iw()里lineNumbers默认false,而唯一调用点iU()从不传它,所以「没有语言标注 / 语言包还没加载」的块渲染成<pre class="plain"><code>整段文本</code></pre>——里面一个span.line都没有。annotateBlock早先在这里直接早退,结果是这类块零注释、连「注释:开/关」按钮都不出现(#10)。现在拿不到.line且code里没有元素子节点时,按行补出宿主自己的span.line(\n文本节点夹在中间),再走同一条data-dce-note路径;有元素子节点的块(别的渲染器接了手)一根手指都不动。流式渲染中的块先不补——宿主会反复重写那段纯文本,补出来的行下一次渲染就没了。会话采样(40 个最大会话、994 个 fence、剔除dsh-ui等第三方 fence 后 642 块)里 478 块(74.5%)属于这一类,改动前它们一行注释都没有。 - 修正过(0.4.4):0.4.3 的判定顺序留了两个尾巴——空说明行讲过的写法,后面的说明行又会追加一遍(同一写法讲两次);空说明分支没有「值得解释」的门禁,注释行和 JSDoc 也被补上教学说明。现在
needsNote从宿主半身搬进rules.js共用(单一来源),空分支先过这道门禁;说明行遇到空行已经讲过的写法不再追加。另外修掉一处旧账:没有具体解释可追加的写法(fs/process/path)过去也会被记账,导致同写法第二次出现时被错标「(同上)」。 复核口径——仓库自身 7 个源文件 2450 个非空行:新增说明 15 行;既有说明有 69 行发生变化,其中 57 行是去掉错标的「(同上)」、9 行是不再重复追加已讲过的教学半句、3 行是少了追加后不再被截断(露出更完整的原说明);动作说明本身(explainCodeLines的输出)一处未改。会话样本 876 行:新增 5 行、去掉重复半句 2 行。 - 修正过(0.4.3):教学层的「为什么」原本只能追加在已有动作说明的行后面——
enrichLineNotes先判断「说明为空就整行返回空串」,空说明行的教学命中被直接丢掉(foo(async () => {})就是这种形状)。现在空说明行只要教学说明够具体(长度 ≥ 8,fs/process/path这类没有具体解释的写法不算)就改用教学说明当行尾注释,同一写法只讲一次;已有动作说明的行保持原样。实测同一批会话样本 876 行,有说明的行 11.30% → 11.87%(这条「逐字不变」说得太满,0.4.4 修掉了两个尾巴)。 - 修正过(0.4.2):工具卡的代码块几乎都没有
lang标签,PowerShell 语句一律按 JS 规则处理、一句都判不出(#4 采样 1630 行里具体说明只有 5.6%)。现在标签缺席时按内容判方言。实测同一批样本(25 个最大的会话记录、15 个含 PowerShell 特征的代码块、378 行),PowerShell 行有说明的比例从 6.6%(25 行)升到 20.6%(78 行)。 - 修正过(0.4.1):输出区的总述原本插进
ioSection内部,等于给两列 grid 加了第三个子节点,正文被挤到下一行、上面留一大片空白。现在改成插在ioSection外侧,并补了test/output-card.mjs。
没有验证(离线测试不能替代):
- 假
ctx.llm只验证协议形状,不代表真实模型可用;真实模型对具体 API 的解释效果仍要在 DSH 里实测。 - 宿主升级后的代码块 DOM 是否保持同样结构,未验证。
已知边界
- 依赖宿主代码块的 DOM 契约(
span.line、data-code-block-banner);DSH 前端大改时需要同步改选择器。 - 纯文本块的补行是一次性改写:宿主把文本节点换成了行 span,如果它在落定之后又原地改写这段文本(React 会往已经摘下来的那个文本节点上写),补出来的行会停在旧文本上,要等整块重挂才恢复。所以流式期间先不补行。
- 输出区版式依赖宿主
ioSection是「两列 grid」这一事实:插件只往它外面加节点,绝不插进去;宿主若改成 flex,test/output-card.mjs需要同步。 - 内容判方言是启发式:PowerShell 与其它脚本长得像时会漏判(仍按 JS 讲),继续补特征时要注意别把 JS 误判成 shell。
- 多行字符串里的行也会被当作独立代码行判断,可能出现不准确的注释。
- 长行注释是否换行,按代码块可见宽度和字符数估算(约 7.2px/字符);不同字号或等比缩放时可能提前/延后换行。
- 一行里有多个表达式嵌套时只讲外层:如
fs.readFileSync(path.join(__dirname, "a.json"), "utf8"),可能把path.join里的字符串误当成文件路径和编码选项。 require("node:fs").readdirSync(".")这类链式调用只讲加载模块,不讲后面的方法。- 条件表达式、可选链、模板字符串、Promise 链、数组
map/filter链目前多半只给一句通用说明,尚未逐个成分拆解。 - 规则和模型都没覆盖的调用会标「未识别:xxx」,只标出调用名,不解释参数。
- 同一写法去重按单个代码块统计;输入和输出两个代码块会各自解释一次。
- 只处理会话里工具卡内的代码块,不处理 markdown 正文里的代码块。
- 「注释:开/关」是浏览器本地状态,不跟随账号同步。
- 注释的通俗程度取决于规则覆盖和模型本身;提示词禁止术语和复述代码,但不能保证每条都完美。
开发与测试
node --check index.js
node --check rules.js
node --check client.js
node test/smoke.mjs
node test/output-card.mjs
node test/plain-block.mjs
node test/powershell.mjs
node test/teach.mjs
冒烟测试使用临时目录(DSH_CODE_EXPLAINER_DIR),不会写入真实的 ~/.dsh 数据。
License
MIT © 2026 xiaoluoli45
本项目以 MIT 协议开源,可自由使用、修改和分发,保留版权与许可声明即可。完整条款见 LICENSE。
No comments yet. Be the first to write one.