Files
peoplelib/AGENTS.md
T
lofyer d406a0a508
构建与发布 / 单测与集成测试 (push) Waiting to run
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
构建与发布 / 发布 GitHub Release (push) Blocked by required conditions
docs: 记录 CI 与 Linux 平台差异踩坑
2026-08-05 20:28:26 +08:00

200 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)` 的全部规则,越界的那条会立刻现形。
## 测试
```bash
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` 偶发失败,重跑确认再判断。
## 构建
```bash
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` 指向 npmmirrorGitHub 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` 检查是否混入密钥。