21 KiB
AGENTS
面向在本仓库工作的 AI 代理。约定之外的部分按代码里的既有写法照做。
项目性质
Electron 桌面应用:多源文献检索 + 本地书库 + 内置阅读器(PDF/EPUB/MOBI/AZW)+ 批注笔记 + AI 助手。
Windows x64 免安装版,用户数据在程序同级 data/。
语言与风格
- 界面文案、提交信息、注释一律中文;标识符英文。
- 默认不写注释。只在原因不显然时写:隐藏约束、易踩的坑、绕过某个具体 bug 的原因。不要复述代码在做什么。
- 不用 emoji。散文里避免破折号。
- 回复简洁,不主动扩展用户没要求的事。
硬性约束
安全边界不可绕过
- 渲染层永远不能传任意路径读盘。所有文件访问必须过
main.js的resolveReadable(),它只放行书库中真实登记的条目。 - 分段读取会话(
src/reader/range-sessions.js)与 sender 绑定,句柄不透明,webContents 销毁即回收。新增 IPC 时必须校验 sender。 - 所有
ipcMain.handle走wrap(),同步抛出也要变成{ ok:false },否则渲染层的await无 catch,界面会永远卡在加载中。 - 不要把密钥、token 写进日志或错误信息。AI Key 经
safeStorage加密后落盘。
依赖
- 前端第三方库全部 vendored 在
src/ui/vendor/,版本在package.json中锁死(不用^)。不要引入新的运行时依赖,除非用户明确要求。 src/ui/vendor/pdf.worker.range.mjs是手工 patch 过的 PDF.js worker,不要用上游文件覆盖。见下文。
PDF 分段读取(易踩坑)
超大 PDF 不整文件读入内存,走 reader:rangeOpen/rangeRead/rangeClose。reader:bytes 直接拒绝 PDF。
worker 选择策略在 pdf-adapter.mjs:
- ≤256 MB:用官方 worker(
pdf.worker.min.mjs),即使是 range 模式。 -
256 MB:用稀疏 worker(
pdf.worker.range.mjs)。
稀疏 worker 的基础缓冲区是空的(new Uint8Array(0)),因此任何对 stream.bytes.buffer 直接建视图的代码都会抛 RangeError。已知踩过的坑:preEvaluateFont 里的 ToUnicode 哈希,错误表现是字体静默降级成不可见的 ErrorFont(文本项数量正常,但画布上没有墨迹)。必须用 stream.getByteRange(start, end)。
改动这个 worker 后,用真实 PDF 对比三条路径的渲染结果(文本项数 + 非白像素数):官方 worker 全量数据 / 官方 worker range / 稀疏 worker range。三者应一致。
PDF 渲染画质
- 底栏「画质」档位(1/2/3)经
renderOpts下发为opts.renderQuality,持久化在reader.pdfRenderQuality。 - 生效倍率是
max(devicePixelRatio, renderQuality),HiDPI 屏上不叠乘。所以断言时不能写「backing 翻倍」,只能断言backing / CSS 宽度 == 期望倍率;开发机 dpr 常见 1.75,写死 2 会假失败。 - 画布尺寸必须过
clampCanvasSize():Chromium 单边上限 16384px、面积上限 268435456px。超限时浏览器静默给出不可用画布,整页空白且不报错。大幅面图纸(2000pt 以上)在 scale 5 下必然踩中。 - 钳制后 backing 与 CSS 盒子的比例不再等于名义倍率,
pdfPage.render的transform必须用fit.scaleX/fit.scaleY(两个方向分别算,Math.floor的损失不同),否则画面错位或只画出一角。 - 倍率变化必须走
epoch++整篇重建,只改新渲染的页会让同屏出现清晰度不一致。 - 集成测试里量画质前要先把视口滚回目标页:离屏页会被回收成空白占位,量到的是占位画布。画布尺寸在重建时立即变大,墨迹要等重绘完才落上去,断言墨迹必须轮询而不是只等比例。
TXT 与 Markdown
text-adapter.mjs把 txt/md 转成内存 EPUB 再交给epub-adapter渲染,定位器kind为txt/md,结构与 epub 同源(chapter+offset)。新增重排格式时记得同步REFLOW_KINDS。- 格式白名单有六处,改一处不够:
main.js的READABLE_EXT、library/store.js与library/local-import.js的BOOK_EXT、main.js里选文件对话框的extensions,以及渲染层的两份READABLE_RE(ui/views/library.js、ui/reader/shell.mjs)。单测有正则锁死并逐项比对渲染层与READABLE_EXT是否一致。 - 渲染层那两份漏掉格式不会报错:IPC 照样能打开,但卡片上的「阅读」按钮和封面点击入口静默消失,阅读器的「从书库打开」列表里也少掉这些书。用户看到的现象是"内置阅读器打不开 txt"。集成测试里直接调
reader.open是查不出来的,必须从书库界面点「阅读」。 - 书库卡片的「阅读」取的是第一个可阅读文件。同一条书目既有 txt 又有 pdf 时,加进白名单后打开的文件会从 pdf 变成 txt,断言里不要写死 PDF 画布。
- Markdown 走 markdown-it(
html: false)+ DOMPurify 双保险。裸 HTML 会被转义成文本,所以净化断言必须按 DOM 查(querySelectorAll('script')、on*属性、a[href]协议),用innerHTML匹配onerror会把转义后的字面量误判成漏网。 - 编码探测支持 UTF-8 / UTF-16LE / UTF-16BE(含 BOM)与 GB18030。带 BOM 的 UTF-16LE 是记事本另存的默认之一,探测错会整本乱码且用户无从修正。
AI 助手
- 上下文范围:
selection | page | document | page-image | region-image。 - 只有
selection需要选中文本;page和document不依赖选区。 document无论多短都强制弹确认框,并提示可能超出模型上下文限制。- 正文完整发送,不做本地截断。模型窗口够不够由接口自己判断,超限时把报错转成中文提示(
isContextOverflow/overflowHint)。曾经按MAX_CHARS = 12000挖空中间,实测 40 页文档只发出 8 页,而界面仍显示全文字数,属于静默数据丢失,已移除。clipContext保留给有明确预算上限的场合显式调用。 - 界面展示的字数必须等于真正外发的字数。任何"先按上限裁剪再发送"的改动都要同步改
aiCost与确认框文案,否则用户会把答非所问归因于模型。 - 图像只用 JPEG 单一编码路径,1600px / 目标 400 KB。不要加格式回退(PNG 等),维护成本高于收益;视觉模型按像素图块计费,格式不影响费用。
- 三种协议:Anthropic
/messages、OpenAI Responses/responses、兼容 Chat/chat/completions。图像负载格式各不相同,改动时三条都要验。
AI 多轮会话
- 会话存在
reader-ai-sessions/,一个会话一个 JSON,外加可重建的index.json。不进store.js,避免整本阅读进度被聊天记录带着反复重写。 ai:run里顺序是硬约束:先historyFor()再appendUser()。顺序反了,当前这轮提问会出现在自己的历史里,模型收到两遍同样的问题。- 同一会话禁止并发生成(
ai:run里按sessionId查aiRuns)。两轮同时写一个文件,后完成的那轮会把前一轮的消息覆盖掉。 - 消息正文存的是用户看见的那句提问(
aiTurnTitle),整篇正文只在contextRef里留 scope / 字数 / 哈希。把正文当消息存会让重开后的气泡变成十几万字原文,还会被LIMITS.question截成一段无意义的残句。 - 失败和取消都要落盘,并且要把已经流出来的残片一起存(
streamed)。只写空串的话,界面上明明显示着半截回答,一重开就消失。 - 历史只发文本:
stream()只取role与text,图像一律不重发。历史里内联 base64 会让每轮费用随轮数线性上涨。 - 落盘失败不能把已经拿到的回答变成请求失败,
settleAiAssistant吞掉异常。 - 书籍删除、孤立对账都要带上会话(
aiSessions.forgetMany/orphanReport),删完调collectAiImages()。内容寻址的图片没有引用者就永远不会被回收。 - 集成测试里断言历史时不能按固定下标取消息:本轮提问固定在末尾,中间是历史。原来写死
messages[1]的断言在多轮上线后会取到上一轮,表现为"图像尺寸无效"这种完全无关的报错。
笔记独立窗口
src/reader/note-window.js是单窗口多标签:一个笔记窗口,一条笔记一个标签,已开则切到该标签。同一条笔记两处编辑时reader:updateNote是整条覆盖、无版本校验,后保存者会把画布内容整块吃掉,所以「一条笔记只能有一个编辑器」是数据安全约束,不是体验优化。openTab必须先tabOf()查重。- 阅读器内的笔记模态保留,因此「模态 + 独立窗口」仍可能撞车。靠列表按钮避开:已开窗时「编辑」变成「在窗口中编辑」并转为聚焦窗口,不再开模态。
- 笔记消失时必须关掉对应标签(不是销毁整个窗口):
reader:removeNote调closeFor,library:removeMany与reader:purgeOrphans调closeForEntries。留着标签,它下一次保存会把已删的笔记整条写回去。最后一个标签关掉后窗口才自行退场。 - 三处窗口广播(
notifyNotesChanged、applyWindowIcons、notifyUiThemeChanged)都要带noteWindow.all(),漏一处笔记窗口就收不到笔记变更或主题切换。单测有正则锁死。 notes:getOne要校验noteWindow.ownsNote(event.sender, noteId):笔记窗口只能读自己已开标签的那几条,否则这个通道就是遍历全部笔记的后门。开窗目标也必须过findNote()用listNotes()重新对账。notes:tabsChanged(setTabs)只能收窄标签集,即只允许openNotes.delete,绝不能set。允许渲染层往里加 ID 等于让它自己扩权:谎报持有某条笔记后notes:getOne立刻放行,授权集合就形同虚设。新增标签只能走open(),那条路径过findNote()对账。- 关窗拦截三件套必须配套:
close里preventDefault()+closePending+ 看门狗,渲染层处理完调notes:shutdownReady,用户取消时必须调notes:cancelClose。少了取消回报,closePending一直为真会让之后每次点关闭都被当成「正在处理」静默忽略,而看门狗仍会在十秒后把带未保存内容的窗口直接销毁。 - 笔记没有自动保存。切换标签只留在内存,LRU 回收前要把未保存内容序列化进
pendingContent,否则回收即丢改动。 - 脏判定必须比对序列化后的内容(
contentKey与挂载后取的baselineKey),不能用pointerdown/keydown之类的交互事件:只点选不改字也会被判脏,每个标签关闭时都弹一次无谓的确认。基线要在编辑器ready()之后取(画布有 version 1→2 归一化,拿磁盘原值当基线会让刚打开就显示已修改),保存成功后基线要跟着前移。 - 笔记窗口不纳入
isReaderSender,不获得 AI 会话等阅读器权限。 - PDF 底版草稿按 sender 隔离(
resolveDrafts/readDraft),草稿不能跨窗口交接,笔记窗口必须自己 stage。 - 编辑区吃满整窗要逐层
min-height: 0,缺一层 flex 子项就被内容顶高、画布溢出窗口。 - 笔记本下拉框要
max-width+min-width: 0。只给max-width不够:select的 min-content 以最长选项为准,长书名/长笔记本名照样把整行顶宽(实测取消限宽后从 260px 涨到 553px)。 - 标题栏只放品牌名「笔记」,不要副标题。
note.html用的是style.css,那里的.titlebar-left不是 flex(只有reader.css才是),把brand-sub放成.brand的同级会掉到下一行,把左侧块顶成 46px 而标题栏只有 44px。要加副标题只能像index.html那样塞进.brand内部。 note.html不加载reader.css,所以标签条样式必须在note-window.css里自带一份,也不能用只在reader.css里定义的变量(如--hover-bg-soft),否则静默失效。- 集成测试四个坑:窗口刚建好时
getURL()还是空串,找窗口必须轮询;断言窗口数量只能数笔记窗口,用总窗口数当基线会被阅读器窗口的开关搅乱;笔记页同时有多张卡片,找按钮必须限定在.note-card[data-note-id=...]内,全局找「编辑」会命中别的卡片;多标签后表单是每标签一份,查询要限定在当前激活的.note-tab-view内,或直接按[data-note-id]定位,否则量到的是别的标签。 - 删除笔记要走真实 IPC(
reader:removeNote),它内部已经调了closeFor。测试里再手工补一次closeFor等于在验证自己造的假路径,还会因为重复处理而看到「标签没关掉」的假失败。
书库卡片封面
- 封面比例来源不一(内置生成 400x500,书源常见 0.65~0.75,还有方图和横图)。卡片盒子固定
aspect-ratio: 3/4,用background-size: cover会按各自比例裁掉不同的边,观感就是「预览大小不一致」,但量盒子是量不出问题的(每张都一样宽高),必须看截图或比对可见的封面内容。 - 现在用
contain完整显示封面,留白由::before里同一张图放大模糊后垫底。注意::before会盖在父元素自己的背景之上,所以清晰的那层必须单独画在::after里,只调z-index是压不住父元素背景的;照这个顺序:::before(模糊,z-index 0)→::after(清晰,z-index 1)→.card-cover > *(角标文字,z-index 2)。两个伪元素都要pointer-events: none,否则挡掉封面的阅读点击。 - 多选复选框不要加
padding+ 背景色块。13px 的原生复选框套一圈衬底后看起来像加粗了边框,浅色封面上的可见性用filter: drop-shadow(...)解决。
笔记表单的选择器边界
.note-edit-form 里嵌着画布工具栏(.canvas-note-root)与富文本工具栏(.ql-toolbar),两者都有 <select>。因此 .note-edit-form select { ... } 这类后代选择器会一路灌进工具栏,必须显式 :not(.canvas-note-root select):not(.ql-toolbar select)。
踩过的坑:表单控件的 margin-top: 5px 落到工具栏的粗细/纸张下拉上,分组高度变成 28 与 33 两种,工具栏从一行变两行(42px → 78px)。表现像是「工具栏没横排、需要 flex-wrap 调整」,改 .canvas-note-tool-group 的 wrap/shrink 只能把 78px 压到 46px,剩下的 4px 差和分组错位依然在,因为根因不在布局属性而在这条越界的样式。
诊断方法:量到子项全是 28px 而父分组是 33px 时,不要继续猜 flex 属性,直接遍历 document.styleSheets 找出 el.matches(rule.selectorText) 的全部规则,越界的那条会立刻现形。
测试
npm test # Node 单测
npx electron src/_test/electron/<name>.integration.js # Electron 集成
集成套件:ai-scope、reader-features、library-notes、annotation、download、cover、startup。
要求:
- 完成任务前跑单测 + 相关集成套件。
- 单测里有对源码的正则断言(锁死关键约定)。改了被断言的代码要同步更新断言,不要为了让测试过而弱化断言。
- 集成测试断言"真正离开进程的内容"(真实本地 HTTP 服务收到的 body、真实渲染出的像素),不要 stub 渲染层。
- 不要为了测试往生产代码里加
window.__test之类的全局钩子,通过真实 UI 断言。 library-notes偶发失败,重跑确认再判断。
构建
npm run build # Windows,输出 dist/PeopleLib-windows-x64/
npm run build:mac # macOS arm64,输出 .app 与 .dmg,只能在 macOS 上跑
npm run build:linux -- --arch x64 # Linux,输出 tar.gz,任意平台可构建
npm run release -- --platform linux --arch x64 # 发布件 + 校验和
Windows:
- 输出目录固定,不带版本号。改名会导致
data/被遗留在旧目录。 - 构建保留
data/,但会清空其余内容。构建前必须退出该目录下运行中的PeopleLib.exe,否则清理到一半失败,目录处于不完整状态。 - 涉及
data/的操作前后做逐文件哈希比对,确认书库、笔记、批注未被改动。
macOS(build-mac.js,易踩坑):
- 不能交叉构建。DMG 需要
hdiutil,且 Apple Silicon 内核直接拒绝执行未签名二进制;改名与改 plist 会让 Electron 原始签名失效,必须用 macOS 的codesign重签(ad-hoc--sign -即可)。 - 解压官方 zip 必须用
ditto。Electron Framework.framework内有符号链接,用 Node 或unzip解压会展开成副本,签名随即失效。 - 重打包后
Info.plist里的ElectronAsarIntegrity必须删除,否则启动即报完整性错误。 - helper 的 plist 没有
CFBundleExecutable,靠 bundle 名推断可执行文件名。重命名 helper 后必须显式补上该字段,否则渲染进程起不来,界面一片空白。 - 签名严格由内向外:嵌套可执行文件 → helper → framework → 外层
.app。带版本的 framework 签Versions/A;Squirrel.framework的Resources/ShipIt是独立可执行文件,要单独签。 - 数据目录走
~/Library/Application Support/PeopleLib,不要沿用 Windows 的便携布局:.app在 DMG 里只读,且升级覆盖会删掉用户书库。 .icns由npm run icons:icns生成,纯 Node 实现(icns 自 10.7 起内嵌 PNG),不依赖 macOS 的iconutil。窗口图标在非 Windows 平台用 PNG,.ico只有 Windows 认。
Linux(build-linux.js):
- 直接从官方 zip 的条目转写进 tar,不落地中间目录。可执行位存在 zip 的 external attributes 里,先解到 NTFS 再打包会全部丢掉,产物解压后主程序和
chrome-sandbox都不可执行。因此这个脚本在 Windows 上也能构建。 - tar 头是手写的。路径超过 100 字节要走 PAX 扩展头:ustar 的
prefix只能在斜杠处切分,undici与 vendor 里的深层路径切不出合法组合。
发布件(build-release.js):
- 发布件的唯一出口,不要手工压缩构建目录上传。Windows 便携版的
data/就在程序同级,手工压缩会把用户书库连同笔记打进公开发布件;脚本按前缀排除并在打包后回读压缩包确认。 - 打完包一定回读产物再签校验和:Linux 要确认可执行位还在,Windows 要确认没有
data/与_test。只算哈希不看内容,等于把「构建脚本改坏了」这类问题一路放到用户手上。
持续集成
.github/workflows/build.yml:validate(单测 + 全部集成套件,xvfb-run起 X server)→package(四目标矩阵)→release(仅v*标签)。- 仓库
.npmrc指向 npmmirror,GitHub runner 在境外拉不动,工作流用npm_config_registry与ELECTRON_MIRROR覆盖回官方源。新增构建步骤时别把这两个环境变量漏掉。 - 新增集成套件后要同步加进工作流的套件列表,单测里有断言按
src/_test/electron/的实际文件逐个核对,漏加会直接失败。 - 标签名必须等于
v+package.json的version,且标签要指向被构建的那个提交。 - 下载完整日志要仓库管理员权限,所以失败详情都转成
::error注解。改这几步时别把注解去掉,否则没有管理员权限的人只能看到「exit code 1」。 - runner 上
chrome-sandbox拿不到 root:root 4755,集成测试必须带--no-sandbox。这个开关只属于 CI,不要带进构建产物。 - runner 自带字体极少,工作流装了 Liberation 与 Noto CJK。界面文案是中文,缺 CJK 字体会渲染成豆腐块,版式测量也跟着偏。
Linux 上首次跑测试暴露过三类只在该平台成立的问题,新写代码时留意:
mtimeMs在 ext4/APFS 带亚毫秒小数,Date.now()只到整毫秒。直接相减,刚落盘的文件年龄是负数,「超过宽限期就回收」的逻辑永远不触发。比较前先Math.floor。- 集成夹具要走网络时,代理只能读
HTTPS_PROXY,不能写死本机端口,否则 CI 上直接 ECONNREFUSED。 - 版式类断言的容差不要写死字符数,按实际渲染出的每行字数算。字体集不同,同一行的字数就不同,写死的数字换个平台就假失败。
仓库
两个远端,用途不同:
origin(私有):完整源码,日常开发推这里。github(公开):源码与 CI。GitHub Actions 必须 checkout 到源码才能构建,所以公开仓不再只放 README。历史与本地无共同祖先,推送前先核对两边的差异范围。
推公开仓前必须确认:没有本地配置、账号凭据、data/ 内容或诊断产物混进去。公开仓一旦推出去,删提交也留在别人的克隆里。
其他:
- 根目录
dist/已 gitignore,规则写作/dist/,必须保留前导斜杠:不加会连icons/dist/一起忽略,新克隆的仓库缺图标,构建直接失败。诊断产物(*-diagnostic.png、probe*.json)也已忽略,不要提交。 - 未跟踪文件视为用户资产,不要删除或覆盖。清理前先看
git status --porcelain。 - 提交前
git diff --cached检查是否混入密钥。