dsh-reading-workbench
Reading workbench for DSH: a cover-grid bookshelf and a paginated single/double-page reader for Markdown, TXT, HTML, EPUB, MOBI and PDF, with highlight notes anchored to text offsets and three agent tools for listing books, reading highlights and pulling text excerpts.
Install
dsh plugin --profile web add dsh-reading-workbench
Sandbox test
This plugin has not been tested yet. Submit a test →
README
reading-workbench 阅读工作台插件
把 DeepSeek Harness(DSH)变成阅读工作台:书架、分页阅读器、划线批注,并让 Agent 能直接读到你在读的正文。
功能
- 书架:封面网格;支持 Markdown / TXT / HTML / EPUB / MOBI / PDF,另可把网页书源抓取成本地章节书
- 分页阅读器:单页 / 双页,滚轮与键盘翻页,进度按「页码 + 百分比」双锚点保存
- 划线批注:文本偏移锚点,页面重排后仍能恢复;支持与对话联动
- 阅读设置:字号、行距、页宽、明暗主题;TXT 可切换「自动合并折行 / 保留原样」
- Agent 工具:
reading_books(书目)、reading_highlights(划线)、reading_excerpt(正文节选)
安装
dsh plugin --profile <profile> add dsh-reading-workbench
尚未发布到 npm 时,也可以直接用源码目录安装:在 profile 的 package.json 里以 link: 指向本目录,
并把包名加进 dsh.profile.bundles。Host 半代码改动需重启 DSH 才生效(Node ESM 模块缓存,
disable/enable 不重载)。
截图
待补。市场卡片优先展示作者策展的截图,且只接受 GitHub 图床地址。
开发状态
说明:下文出现的docs/...路径属于开发工作区(dsh-plugin/docs),不在本仓库内,
仅作为设计依据与实测记录的引用。
状态:三期完成并已通过真实例验收(2026-10-19);本轮新增滚轮翻页 + TXT 段落重排。
v1 的多格式支持(EPUB / MOBI / PDF)已在真实实例通过验收。
TXT 重排(2026-10-19 本轮):TXT 正文从「一个大 pre 原样保留源文件换行」改为
按段边界判据合并折行 + 每段一个 p + 首行缩进 2em + 段间距,并提供
「换行:自动合并 / 保留原样」设置项。判据、偏移契约与全库实测依据见
docs/txt-reflow-design.md;
独立对抗验收报告见 docs/diag-txt-linebreak/2026-10-19-reflow-verification.md。
全部自测套件全绿(21 套既有 + 本轮新增 txt-layout-selftest 193 项、
独立验收txt-reflow-check1817 项、真实浏览器几何txt-reflow-browser-check18 项);tsc 0 错误。
真实实例验收:三格式 + 封面 + 设置 + 划线 PATCH + 进度锚点全部通过(fiberPhase: active)。
本文档是该插件的唯一权威设计来源,随开发持续更新。
遵循工作区根目录agents.md规范;运行时契约以当天 inspect 为准,基准探查见docs/dsh-runtime-notes.md(构建实测经验在第 10、11 节)。
Host 半运行在 vm 沙箱里(2026-10-19 两次真实事故,必读)
插件 install 成功、产物正确、静态 import 全部可解析,但 DSH 里 fiberPhase 一直 failed、
全部 REST 路由 401/404。两轮排查抓到两个独立根因。
事故一:沙箱里没有 process / Buffer
apply 第一行的 process.env.DSH_HOME 直接抛 ReferenceError: process is not defined,
cordis 记 _error、state 转 FAILED,连 14 条路由一起消失;writeJson 里的
Buffer.byteLength 是第二颗地雷(首次响应才炸)。
修复:数据目录改由 config.dshHome / ctx.get('sandbox'|'storage'|'workspace') 提供并回落 ~/.dsh;
编解码统一 TextEncoder / TextDecoder;readBody 返回 Uint8Array。
事故二:ctx.tools 没写进 inject
修完事故一仍然 failed。这次用宿主自己的启动参数起副本、把 stderr 落盘,才看到真话:
reading-workbench: Error: cannot get property "tools" without inject
at …/lib/index.mjs:1612:12
cordis 里读未 inject 的服务是抛异常而不是返回 undefined——于是
if (ctx.tools === undefined) 这种"判空跳过"自己就会炸,且炸在 ctx.effect 回调里
会被记成整条 fiber FAILED,try/catch 兜不住。
修复:inject = ['webServer', 'tools'];服务读取移到 effect 之外并显式 try/catch,
取不到就只少三个工具,书架与阅读照常。
排查心法:list_plugins 只给一个 fiberPhase: "failed" 字符串,没有异常文本。
最快的路径永远是抓宿主 stderr,别靠本地猜。
防线:scripts/sandbox-contract-check.mjs(已接进 pnpm build / pnpm check)——
在不带 process 的 vm 沙箱 + 真实 cordis + 白名单 ctx Proxy 里跑 apply,断言 ACTIVE,
并带多条断言/反证(还原 process.env 必 FAILED;漏声明 tools 时只少工具、不拖垮宿主)。
完整事故复盘见 docs/m2-agent-tools-notes.md 第三、四节。
文本编码契约(2026-10-19 事故三,必读)
症状:导入一本 GBK 老 txt(蓝天航空公司的空姐.txt,1.05 MB)后满屏菱形问号
(�),夹杂零星拉丁/西里尔/希腊字母;同一本书打开还特别慢。
根因(两层,缺一不可):
1. 编码:File.text() / readFile(path,'utf8') 都是「UTF-8 宽松解码」,
GBK 字节被逐段解成 U+FFFD(�);恰好构成合法 UTF-8 序列的字节对被解成别的文字
(D5 B9 → 希伯来文等)。更糟的是导入时把已损坏的字符串写回书库副本
(library/<id>.txt),损坏被永久固化 —— 该副本 2.44 MB,含 681,143 个替换符。
2. 性能:txt 没有章节结构,旧实现把整本书当成一个排版单元塞进 CSS 多栏容器
(column-fill: auto + 固定高度),55 万字要一次排出 400~600 列。
现在立的规矩(改代码时必须守住):
1. 导入只收原始字节:客户端用 file.arrayBuffer() 走二进制通道
(/books/upload-binary,format=txt|md 也走它),Host 侧统一入口
store.importTextBookFromBytes(paths, meta, bytes)。
任何地方都不得先解码再上传/落盘。
2. 解码严格、二选一、失败即报错:shared/text-encoding.ts 的 decodeTextBytes
按 BOM → UTF-8(fatal) → GB18030(fatal) → 抛 TextDecodeError 判定,
绝不接受宽松解码产出的替换符。GB18030 是 GBK/GB2312 的超集,覆盖中文老书。
3. 两道损坏闸门:
- 落库前
inspectReplacementDamage(>1% 且 ≥20 个)拒绝导入(importBookFromContent); - 打开时同一判据命中就报错「本书导入时被错误解码损坏,请重新导入」
(store.readBookContent)—— 已损坏的副本无法修复,只能重导,
绝不把乱码当正常内容渲染给用户。
4. 副本恒为规范 UTF-8:library/<id>.<format> 只有这一种编码,读取端不再猜。
txt 虚拟单元(性能):client/txt-units.ts 的 splitTextUnits 把正文切成若干单元
(章节标记行优先,否则约 2 万字一处;整本无换行的长文按字符强制切),
阅读器一次只渲染一个单元。偏移契约:unit.text === text.slice(unit.start, unit.end),
且 start 是全书字符偏移 —— 因此划线锚点仍是全书偏移,旧划线无需迁移,
渲染时用 toUnitRange 换算成单元内偏移。实测同一本书:556,817 字 → 27 个单元,
最大单元 36,364 字,单次排版量降到整本的 6.5%。
配套自测:scripts/encoding-selftest.mjs(编码表驱动 + 损坏判据 + 切分偏移契约)、
scripts/host-upload-selftest.mjs 的 10~12 号用例(GBK 二进制导入 / 非法编码拒绝 /
旧 JSON 通道拦替换符)。已损坏的历史数据用 docs/diag-blue-sky-encoding/repair-damaged-import.mjs
重导(自动备份副本与 data.json)。
Agent 工具(M2,2026-10-16)
三个只读工具把书库接进 DSH 会话(在对话里问「我有哪些书」「那本小说讲了什么」时,Agent 可直接查):
工具 · 参数 · 说明
reading_books · — · 书架列表(书名/作者/格式/进度);render 输出人读友好的书单
reading_highlights · bookId? limit? · 划线批注(含笔记),按时间倒序;可跨书查
reading_excerpt · bookId offset? length? · 正文节选(单次上限 4000 字),章节书剥成纯文本、图片占位为 [图片]
关键实现决策:不 import @deepseek-ai/dsh-tools
首版直接 import { defineTool } from '@deepseek-ai/dsh-tools',结果在测试阶段就炸了:
dsh-tools 0.1.0-rc.6 的 ESM 依赖 @deepseek-ai/dsh-llm 的 CallId 导出,
而本机运行时的 dsh-llm 0.2.0-rc.2 已把它改名 ToolCallId —— 版本族错位,import 阶段即抛
SyntaxError: does not provide an export named 'CallId',会炸掉整个宿主启动。
复盘 defineTool 的源码后确认它只是「schema 编译 + 参数校验包装」的纯数据变换,
因此按同形态自实现(含参数校验、ToolArgsError、parametersToJsonSchema),
不引入任何 @deepseek-ai/* 运行时依赖,工具对象与官方形态逐字段对齐。
tools-selftest 验证:注册形态、schema 编译、execute 输出、render 文本、过滤/倒序/limit、HTML 剥离、越界与报错,共 26 项。
设计取舍:工具只读(不提供写入/删除)——划线由人类在 GUI 里做,Agent 只消费。
划线批注(M3,2026-10-16)
正文选中文字 → 浮动「划线」按钮 → 入库并即时高亮;工具栏「划线 N」打开笔记抽屉。
定位模型:文本偏移锚点(重排后仍可恢复)
页码/屏号会随字号与视口变化,文本不会,因此划线锚点记录「章节 + 单元内字符偏移 + 长度」:
章节书:mark:ch=2:off=17:len=9 (ch 为章节序;段间 : 分隔、段内 key=value) 文本类:mark:off=120:len=30
- 恢复时按偏移在单元文本里重建
<mark>包裹;找不到(内容改版)静默跳过,不报错——text始终随划线存库,笔记栏永远能展示原文。 - 分隔符刻意设计:段间
:、段内=。首版把 key/value 也用:连接(mark:ch:5:off:17),split 后 key 与 value 分家、全部字段解析失败——mark-selftest的往返断言抓住了它。 - 选区超长(>600 字符)拒绝并提示,防止把整章划成一条。
- 笔记随划线存库,
PATCH /highlight?id=更新(正文不可改,只允许 note);空
…(truncated)
Rendered from the README published on npm. Formatted only, not edited.
Versions
Published on npm::2,最后更新 2026-10-31