dsh-web
zhu1090093659/dsh-webDeepSeek Harness(DSH)Web 插件聚合生态包 · 一切皆插件,创意工坊分发(dsh-market.com)
DeepSeek Harness 会话导出插件:在 dsh Web 会话「更多」菜单注入「导出 Markdown / 导出 PDF」,纯前端注入、零 npm 依赖。Export DSH sessions to Markdown or PDF from the web GUI.
在 dsh 会话行"更多"菜单里,于"归档会话"下方注入两项:
.md 下载。复用官方 dsh-session-log-export 包的 /api/session.export route(返回 ZIP,内含 canonical JSONL log + media/<attachmentId>.<ext> 图片),前端 fetch 拿 ZIP → 浏览器原生 DecompressionStream('deflate-raw') 解压 → 迷你 ZIP 解析器找根 session*.jsonl(PDF 再解 media/*)→ 按 V4 event type 转换 → .md 下载,或渲染打印专用 HTML 到隐藏 iframe srcdoc 并调 contentWindow.print()。
纯前端注入,零 npm 依赖,不读 zstd(官方已用 persistence read handle 把 log 解好放进 ZIP),不碰 persistence。PDF 走 window.print 而不是 jsPDF/pdfMake:零依赖、中文用系统字体、图片与 CSS 排版交给浏览器。
| 事件 / 内容 | 是否导出 |
|---|---|
user/message 且 source.kind === 'user'(真实用户输入,含图片) | ✅ |
agent/inbox/spliced 里 role=user 的插入消息 | ✅ 与上一条是同一个 message id,按 id 去重只出一次 |
runtime-context / time-context / skill-catalog / 工作区指令等系统注入 | ❌ 它们也走 user/message,按 source.kind 滤掉 |
assistant/message 的 text block | ✅ 但每轮只留最后一条 |
assistant/message 的 image block | ✅ |
| 同一轮里工具调用前后的过渡说明文本 | ❌ 被该轮最后一条覆盖 |
thinking / reasoning block(思考过程) | ❌ |
tool_use / tool_result / tool/call / tool/result | ❌ |
system/message / developer/message | ❌ |
assistant/attempt(半途草稿、流式分片) | ❌ |
子代理会话(includeDescendants=false) | ❌ |
| 未知/其它事件类型 | ❌ |
即 PDF 是一份干净的"我问了什么 / 最终答了什么"记录,不含模型内部过程。配对规则:以 turn/end 作为一轮的边界,把该轮的用户输入与最后一条助手可见回复排成一组;日志末尾再收尾 flush 一次,所以被打断 / 未正常结束的回合也不会丢内容。用户消息里的图片会内嵌到 PDF(缺图时输出 [图片未随会话导出] 占位)。
为什么必须"按 id 去重 + 按 source.kind 过滤"(来自真实会话日志的教训): 同一条用户输入在
agent/inbox/spliced与user/message里各记一次、共用同一个 message id, 不去重就会每句提问出现两遍;而runtime-context、time-context、skill-catalog、工作区指令 (如 CLAUDE.md,实测上万字符)也是user/message,不按source.kind === 'user'过滤就会冒充用户提问 混进 PDF。两者叠加曾是 PDF 里"重复提问 + 莫名其妙的成段内容"的直接原因。
Markdown 与 PDF 共用同一套"极简问答对"(同一份 jsonlToBlocks 输出),只有标题、角色、正文和图片占位:
# Session <id>
- Created: <createdAt>
- Workspace: <cwd>
## 👤 用户
<用户消息>
## 🤖 助手
<助手回复(保留 Markdown 代码块与列表)>
## 👤 用户
…(每轮如此)
不再包含工具调用 / 工具结果 / 系统提示词 / 思考过程 / 上下文注入 / 未知事件的 JSON dump。图片位置输出 [图片] 占位(Markdown 不做 base64 内嵌,避免文件膨胀)。
实测(同一会话 session-5494db1e):旧导出 146,662 字节 → 新导出 3,208 字节(约 2.2%)。
需要完整会话日志(含工具调用、系统提示词)时,用会话菜单里官方
dsh-session-log-export提供的导出项,它仍会给出完整的 ZIP/日志。
官方菜单行由 @deepseek-ai/dsh-client-ui-primitives 的 MenuItemButton 渲染(类名是 CSS module 哈希):
<div class="itemWrap">
<div class="separator" role="separator"></div>
<button type="button" role="menuitem" class="item">
<span class="itemIcon"><svg/></span>
<span class="itemLabel">归档会话</span>
<span class="shortcut">…</span>
</button>
</div>
注入做法:按文本 归档会话 / 取消归档 找到 [role="menuitem"],再取它的外层 div.itemWrap,克隆整个 itemWrap(不是只克隆 button——.itemWrap 是 position:relative 的整行单元,只克隆 button 会让两个 button 挤在同一个包裹层里),然后改 itemLabel 文字、替换 svg 图标、移除 shortcut、绑定 click,依次插到原行后面(Markdown 在前、PDF 在后)。
index.js — apply 注入,通过 webServer.tapIndex 把 HTML 注入 </head> 前inject.mjs — 导出 exportInjectHtml 字符串,含 CSS + JS IIFE(两个导出项 + ZIP 解析 + MD/PDF 两条转换链)cordis.patch.yml — 插件 bundle 声明package.json — 包配置test-inject.mjs — 回归测试(见下)npx @deepseek-ai/dsh plugin --profile web add D:\dsh-code\export-plugin
插件以目录链接安装(profile 的 dsh-export-plugin → link:D:/dsh-code/export-plugin),改 inject.mjs / index.js 后只需重启 dsh(npx @deepseek-ai/dsh web),不必重新 add;改 package.json 结构字段需重新 add。
inject.mjs的注入串在服务启动时随模块加载定格,重启前改动不会生效。
node test-inject.mjs
它做六件事:
\n / \. / \d;<script> 并编译,捕获语法错误并打印出错行;itemWrap、用 itemLabel 改文字、移除了 shortcut;user + assistant 三组问答对;思考过程 / 工具调用 / 工具结果 / system / developer 文本 / 三种系统注入(runtime-context、time-context、skill-catalog)都不得出现;同一条用户消息(spliced + user/message 共用 id)只出一次;HTML 已转义、图片按 mediaMap 内嵌或占位、生成的 PDF 文档能被正常解析;Markdown 渲染器同样断言问答结构、内容齐全且无角色外的泄漏;index.js 的 injectAll 取的是最后一个 head 结束标签。两个必踩的坑(都真实发生过)
- 转义坑:内联 JS 写在外层模板字面量里,里面的
\n、正则\.\d必须写成\\n、\\.、\\d,并且不要在内联 JS 里再用反引号模板。漏写会让注入后的脚本报Invalid or unexpected token,整个 IIFE 不执行 —— 表现为菜单项完全不出现。- 锚点劫持坑:
webServer.tapIndex是链式的,各插件都用正则找第一个 head 结束标签来插入自己的内容。如果我们注入的脚本文本(字符串里、注释里都算)出现</head>这类结构标签字样,排在后面的插件就会把它的脚本元素插进我们的脚本内部,它的闭合标签会提前终止我们的脚本,剩下的 JS 被浏览器当页面文本渲染到页面上(按钮消失 + 页面顶部出现一大段源码)。所以 PDF 渲染里的结构标签全部拆开写:'<sty' + 'le>'、'</he' + 'ad>'这样;index.js也改成锚定最后一个 head 结束标签。test-inject.mjs的第 3、6 项守住这两点。
itemWrap 注入两项;data-dsh-handled 防重,React 每次重建菜单后重新注入props.sessionId,降级到 URL / 输入框 fiber 扫描DecompressionStream 解 deflate(方法 8)/ store(方法 0);media/<attachmentId>.<ext> 用 lastIndexOf('.') 还原 IDuser/message(且 source.kind === 'user',并按 message id 去重)与 assistant/message 与 spliced 的 user 消息;block 层只取 text 与 image,其余(thinking/tool_use/tool_result/file/未知)全部丢弃;再以 turn/end 为边界排成"用户输入 → 该轮最后一条助手回复"的问答对(末尾收尾 flush,打断的回合不丢)。Markdown 与 PDF 共用这一份 blocks,只是渲染器不同(renderMarkdown / renderPdfHtml){type:'image', attachment:{attachmentId, mediaType,…}},与 ZIP 条目按 attachmentId 对应;mime 优先取 attachment.mediaType;base64 分块编码(≤0x8000)、同一图只编码一次max-width:100%),所有动态文本经 escapeHtml 转义依赖官方 dsh-session-log-export 包提供的 /api/session.export route(dsh 默认 bundle 自带)。
test-inject.mjstest-inject.mjs 第 3/6 项守住json 代码块;PDF 路径未知类型直接忽略(可接受,PDF 本就是精简版)