feat: 笔记独立窗口改为多标签,AI 多轮会话与 TXT/MD 阅读

笔记独立窗口从「一窗一条」改为单窗口多标签,与阅读器一致:
标签集在主进程侧为权威,notes:tabsChanged 只能收窄不能新增,
否则渲染层可以谎报持有某条笔记来越权读取。存活编辑器上限 3 并
LRU 回收,回收前序列化未保存内容。笔记没有自动保存,关标签与
关窗都做二次确认,取消关闭必须回报主进程复位 closePending,
否则窗口再也关不掉而看门狗仍会销毁未保存内容。

AI 助手支持多轮会话:会话独立落盘,先取历史再写提问,
历史只发文本不重发图像,失败与取消都保留已流出的残片。

新增 TXT/MD 内置阅读(转内存 EPUB 复用 epub 渲染管线),
补上渲染层遗漏的可阅读格式白名单:主进程本就放行 txt/md,
但渲染层另有两份白名单漏了,表现为卡片上没有「阅读」按钮。

书库卡片封面改用 contain 完整显示,留白由同图模糊层垫底,
修正不同比例封面被裁切程度不一导致的观感不一致;多选复选框
去掉衬底色块,恢复原生外观。

其余:PDF 画质档位与画布尺寸钳制、原子写入、笔记资源托管、
GitHub Pages 站点。
This commit is contained in:
lofyer
2026-08-04 16:19:06 +08:00
parent 522b0f74a5
commit 0fd7c59e08
68 changed files with 11721 additions and 301 deletions
+66 -1
View File
@@ -41,15 +41,80 @@ worker 选择策略在 `pdf-adapter.mjs`
改动这个 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` 无论多短都强制弹确认框,并提示可能超出模型上下文限制。
- 正文`ai-client.js``MAX_CHARS` 截断,保留首尾(结论常在末尾,只留开头会让模型答非所问)
- 正文**完整发送,不做本地截断**。模型窗口够不够由接口自己判断,超限时把报错转成中文提示(`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
+3 -2
View File
@@ -24,7 +24,7 @@ AI 助手,可把选中文本、当前页、全文或框选区域作为上下
- **多源检索**:12 个数据源统一的搜索、详情、下载流程
- **本地书库**:收藏条目、下载文件、封面缓存、阅读状态管理
- **内置阅读器**PDF、EPUB无 DRM 的 MOBI/KF7/KF8 阅读,支持进度、书签、选文和笔记
- **内置阅读器**PDF、EPUB无 DRM 的 MOBI/KF7/KF8 与 TXT/Markdown 阅读,支持进度、书签、选文和笔记
- **全局代理**:一处配置,对所有数据源与封面请求生效
- **镜像故障转移**:镜像失效自动切换,恢复后自动重新启用
- **Z-Library 登录**:凭据本地保存,会话过期自动重新登录
@@ -37,7 +37,8 @@ AI 助手,可把选中文本、当前页、全文或框选区域作为上下
| PDF | ✓ | ✓ | 支持页面批注、书签、选文和笔记 |
| EPUB | ✓ | ✓ | 支持目录、重排、书签、选文和笔记 |
| MOBI / AZW / AZW3 | ✓ | ✓ | 使用 Foliate 解析无 DRM 的 MOBI、KF7 与 KF8 内容 |
| TXT / DJVU / FB2 / CBZ / CBR | ✓ | — | 可入库、整理并调用系统关联应用打开 |
| TXT / MD | ✓ | ✓ | 自动识别编码,Markdown 渲染标题、列表、代码块与表格 |
| DJVU / FB2 / CBZ / CBR | ✓ | — | 可入库、整理并调用系统关联应用打开 |
DRM 保护的 MOBI/AZW/AZW3、KFX、Topaz 以及损坏或不兼容的文件不会尝试绕过保护,可改用系统关联应用打开。
+352 -11
View File
@@ -108,9 +108,12 @@ const readerStore = require('./src/reader/store');
const annotations = require('./src/reader/annotations');
const noteAssets = require('./src/reader/note-assets');
const readerWindow = require('./src/reader/window');
const noteWindow = require('./src/reader/note-window');
const rangeSessions = require('./src/reader/range-sessions');
const aiConfig = require('./src/reader/ai-config');
const aiClient = require('./src/reader/ai-client');
const aiSessions = require('./src/reader/ai-sessions');
const aiImages = require('./src/reader/ai-images');
const { normalizeVisualContexts } = require('./src/reader/visual-context');
const { setProxy, getProxy, fetchWithProxy } = require('./src/sources/http');
zlibAuth.init(userDataDir, safeStorage);
@@ -124,6 +127,8 @@ readerStore.init(userDataDir);
annotations.init(userDataDir);
noteAssets.init(userDataDir);
aiConfig.init(userDataDir, safeStorage);
aiSessions.init(userDataDir);
aiImages.init(userDataDir);
// 启动时从持久化设置恢复代理
try {
setProxy(settings.get('proxy', ''));
@@ -217,16 +222,26 @@ function notifyLibraryChanged() {
function notifyNotesChanged(data) {
const payload = data && typeof data === 'object' ? data : {};
const windows = [mainWindow, ...readerWindow.all()];
const windows = [mainWindow, ...readerWindow.all(), ...noteWindow.all()];
for (const win of windows) {
if (win && !win.isDestroyed()) win.webContents.send('reader:notesChanged', payload);
}
}
function notifyNoteWindowsChanged(noteIds) {
const payload = Array.isArray(noteIds) ? noteIds : noteWindow.openIds();
const windows = [mainWindow, ...readerWindow.all()];
for (const win of windows) {
if (win && !win.isDestroyed()) win.webContents.send('notes:windowsChanged', payload);
}
}
noteWindow.setChangeListener(notifyNoteWindowsChanged);
function applyWindowIcons(theme) {
currentUiTheme = theme === 'light' ? 'light' : 'dark';
const icon = iconForTheme(currentUiTheme);
const windows = [mainWindow, ...readerWindow.all()];
const windows = [mainWindow, ...readerWindow.all(), ...noteWindow.all()];
for (const win of windows) {
if (!win || win.isDestroyed()) continue;
try { win.setIcon(icon); } catch (e) { /* 平台不支持动态图标时保留创建时图标 */ }
@@ -234,7 +249,7 @@ function applyWindowIcons(theme) {
}
function notifyUiThemeChanged() {
const windows = [mainWindow, ...readerWindow.all()];
const windows = [mainWindow, ...readerWindow.all(), ...noteWindow.all()];
for (const win of windows) {
if (win && !win.isDestroyed()) {
win.webContents.send('ui:themeChanged', currentUiTheme);
@@ -514,6 +529,97 @@ ipcMain.handle('library:remove', (_e, id, options) => wrap(() => {
})();
}));
// 批量整理走单次索引写入。逐条 update 会把索引重写 N 遍,
// 上千条的书库里批量改动会明显卡顿。
ipcMain.handle('library:updateMany', (_e, patches) => wrap(() => {
const list = Array.isArray(patches) ? patches : [];
if (list.length > 5000) throw new Error('单次批量更新条目过多');
const result = library.updateMany(list);
for (const entry of list) {
if (entry && entry.id != null) coverGenerator.ensure(entry.id).catch(() => {});
}
return result;
}));
ipcMain.handle('library:removeMany', (_e, ids, options) => wrap(() => {
const list = (Array.isArray(ids) ? ids : []).map((id) => String(id));
if (list.length > 5000) throw new Error('单次批量移除条目过多');
const deleteFiles = !!(options && options.deleteFiles === true);
const deleteReadingData = !!(options && options.deleteReadingData === true);
return (async () => {
for (const id of list) await requestReaderPurge(id);
const purged = [];
if (deleteReadingData) {
for (const id of list) {
purgedReaderEntries.add(id);
purged.push(id);
}
try {
noteWindow.closeForEntries(list);
readerStore.forgetMany(list);
annotations.forgetMany(list);
aiSessions.forgetMany(list);
cleanupNoteAssets();
collectAiImages();
} catch (e) {
for (const id of purged) purgedReaderEntries.delete(id);
throw e;
}
for (const id of list) notifyNotesChanged({ entryId: id, type: 'forget' });
}
try {
return library.removeMany(list, deleteFiles);
} catch (e) {
for (const id of purged) purgedReaderEntries.delete(id);
throw e;
}
})();
}));
// 孤立阅读资料对账。笔记在「我的笔记」里仍可查看,属于有意保留,
// 因此只报告不自动删除;批注没有浏览入口,孤立后只会白占空间。
ipcMain.handle('reader:orphanReport', () => wrap(() => {
const knownIds = library.list().map((item) => String(item.id));
const notes = readerStore.orphanReport(knownIds);
const annotationOrphans = annotations.orphanReport(knownIds);
const chatOrphans = aiSessions.orphanReport(knownIds);
return {
notes,
annotations: annotationOrphans,
chats: chatOrphans,
totalBytes: annotationOrphans.reduce((sum, item) => sum + item.bytes, 0)
+ chatOrphans.reduce((sum, item) => sum + item.bytes, 0)
};
}));
ipcMain.handle('reader:purgeOrphans', (_e, options) => wrap(() => {
const scope = options && typeof options === 'object' ? options : {};
const knownIds = library.list().map((item) => String(item.id));
// 目标必须重新对账后确定,不接受渲染层直接传 ID,
// 否则一个过期的界面状态就能删掉仍在书库里的条目的阅读资料
const noteTargets = scope.notes === true
? readerStore.orphanReport(knownIds).map((item) => item.entryId)
: [];
const annotationTargets = scope.annotations === true
? annotations.orphanReport(knownIds).map((item) => item.entryId)
: [];
const chatTargets = scope.chats === true
? aiSessions.orphanReport(knownIds).map((item) => item.entryId)
: [];
if (noteTargets.length) noteWindow.closeForEntries(noteTargets);
const notesRemoved = noteTargets.length ? readerStore.forgetMany(noteTargets) : 0;
const annotationsRemoved = annotationTargets.length
? annotations.forgetMany(annotationTargets)
: 0;
const chatsRemoved = chatTargets.length ? aiSessions.forgetMany(chatTargets) : 0;
if (notesRemoved) {
cleanupNoteAssets();
for (const id of noteTargets) notifyNotesChanged({ entryId: id, type: 'forget' });
}
if (chatsRemoved) collectAiImages();
return { notesRemoved, annotationsRemoved, chatsRemoved };
}));
// 下载文件:默认直接存入书库目录并挂到条目上;
// 开启"下载前询问保存位置"后改为弹保存框(此时文件在书库外,记绝对路径)。
// meta 用于文件不属于任何已有条目时自动建条目,避免"下载了但书库不知道"。
@@ -700,7 +806,7 @@ ipcMain.handle('dialog:pickLocal', (event, kind) => wrap(async () => {
: ['openFile', 'multiSelections'],
filters: sourceKind === 'folder'
? undefined
: [{ name: '图书', extensions: ['pdf', 'epub', 'mobi', 'azw', 'azw3', 'txt', 'djvu', 'fb2', 'cbz', 'cbr'] }]
: [{ name: '图书', extensions: ['pdf', 'epub', 'mobi', 'azw', 'azw3', 'txt', 'md', 'djvu', 'fb2', 'cbz', 'cbr'] }]
});
if (r.canceled || !r.filePaths.length) return null;
const records = await localImport.discover(r.filePaths);
@@ -754,7 +860,7 @@ ipcMain.handle('library:importLocal', (event, selectionId, options) => wrap(asyn
// --- 阅读器 ---
const READABLE_EXT = new Set(['.pdf', '.epub', '.mobi', '.azw', '.azw3']);
const READABLE_EXT = new Set(['.pdf', '.epub', '.mobi', '.azw', '.azw3', '.txt', '.md']);
function isReaderSender(webContents) {
const expected = pathToFileURL(path.join(__dirname, 'src', 'ui', 'reader.html')).href;
return !!readerWindow.fromWebContents(webContents)
@@ -762,6 +868,13 @@ function isReaderSender(webContents) {
}
ipcMain.handle('reader:ready', (event) => wrap(() => readerWindow.markReady(event.sender)));
// 关闭书籍标签页或整个阅读窗口时,阅读进度与批注刚落盘,
// 书库卡片上的"最近阅读"排序和批注计数需要立刻跟上
ipcMain.handle('reader:entryClosed', (event) => wrap(() => {
if (!isReaderSender(event.sender)) throw new Error('只有阅读器可以上报关闭');
notifyLibraryChanged();
return true;
}));
ipcMain.handle('reader:captureRect', (event, rect) => wrap(async () => {
if (!isReaderSender(event.sender)) throw new Error('只有阅读器可以截取文档内容');
const win = BrowserWindow.fromWebContents(event.sender);
@@ -1033,13 +1146,54 @@ ipcMain.handle('reader:removeNote', (_e, entryId, noteId) => wrap(() => {
ensureReaderWritable(id);
const result = readerStore.removeNote(id, noteId);
if (result) {
// 窗口必须先退场再清理资产:留着的话它下次保存会把已删的笔记整条写回去
noteWindow.closeFor(noteId);
cleanupNoteAssets();
notifyNotesChanged({ entryId: id, noteId: String(noteId), type: 'remove' });
}
return result;
}));
// 笔记独立窗口。目标必须由主进程重新对账后确定,
// 渲染层给的 ID 只是查询条件,不能当授权凭据。
function findNote(entryId, noteId) {
const id = String(noteId == null ? '' : noteId);
if (!id) throw new Error('笔记 ID 无效');
const filters = entryId == null || entryId === '' ? {} : { entryId: String(entryId) };
const note = readerStore.listNotes(filters).find((item) => String(item.id) === id);
if (!note) throw new Error('笔记不存在或已被删除');
return note;
}
ipcMain.handle('notes:openWindow', (_e, entryId, noteId) => wrap(() => {
const note = findNote(entryId, noteId);
noteWindow.open(note.entryId, note.id, __dirname, currentUiTheme);
return { entryId: note.entryId, noteId: note.id };
}));
ipcMain.handle('notes:getOne', (event, entryId, noteId) => wrap(() => {
// 笔记窗口只能读自己已打开的标签,避免这个通道变成遍历全部笔记的后门。
// 多标签之后授权从「等于某一条」变成「在标签集内」,放宽成「是笔记窗口就给」等于取消校验。
if (noteWindow.fromWebContents(event.sender)
&& !noteWindow.ownsNote(event.sender, noteId)) {
throw new Error('无权读取其它笔记');
}
return findNote(entryId, noteId);
}));
// 标签集由渲染层上报,但只用于广播与授权范围收窄,新增标签仍要过 findNote 对账
ipcMain.handle('notes:tabsChanged', (event, tabs) => wrap(() => {
noteWindow.setTabs(event.sender, tabs);
return noteWindow.openIds();
}));
ipcMain.handle('notes:shutdownReady', (event) => wrap(() => noteWindow.shutdownReady(event.sender)));
ipcMain.handle('notes:cancelClose', (event) => wrap(() => noteWindow.cancelClose(event.sender)));
ipcMain.handle('notes:openWindows', () => wrap(() => noteWindow.openIds()));
ipcMain.handle('reader:listNotes', (_e, filters) => wrap(() => readerStore.listNotes(filters || {})));
ipcMain.handle('reader:getNoteCounts', () => wrap(() => readerStore.getNoteCounts()));
ipcMain.handle('reader:getAnnotationCounts', () => wrap(() => annotations.getCounts()));
ipcMain.handle('reader:listCollections', () => wrap(() => readerStore.listCollections()));
ipcMain.handle('reader:addCollection', (_e, input) => wrap(() => {
const result = readerStore.addCollection(input);
@@ -1128,6 +1282,101 @@ ipcMain.handle('ai:clear', () => wrap(() => {
return status;
}));
// 会话归属由主进程按 entryId 对账,渲染层给的 entryId 只作过滤条件,
// 不能当授权凭据:否则任意窗口都能读别的书的对话。
function readerOnly(event, fn) {
return wrap(() => {
if (!isReaderSender(event.sender)) throw new Error('只有阅读器可以管理 AI 会话');
return fn();
});
}
function aiSessionEntryId(value) {
const id = String(value == null ? '' : value);
if (!id || id === aiSessions.GLOBAL_ENTRY_ID) return aiSessions.GLOBAL_ENTRY_ID;
if (!library.get(id)) throw new Error('条目不存在');
return id;
}
// 会话文件可能残留已删除条目的记录,取其 entryId 时不再校验书库,
// 否则孤立会话既列不出来也删不掉。
function requireAiSession(sessionId) {
const meta = aiSessions.messages(sessionId, { limit: 1 }).meta;
return meta;
}
ipcMain.handle('ai:sessionList', (event, filters) => readerOnly(event, () => {
const raw = filters && typeof filters === 'object' ? filters : {};
const entryId = raw.entryId == null || raw.entryId === ''
? null
: aiSessionEntryId(raw.entryId);
return aiSessions.list(entryId ? { entryId } : {});
}));
ipcMain.handle('ai:sessionCreate', (event, input) => readerOnly(event, () => {
const raw = input && typeof input === 'object' ? input : {};
return aiSessions.create({
entryId: aiSessionEntryId(raw.entryId),
title: raw.title,
documentKey: raw.documentKey
});
}));
ipcMain.handle('ai:sessionRename', (event, sessionId, title) => readerOnly(event, () => {
requireAiSession(sessionId);
return aiSessions.rename(sessionId, title);
}));
ipcMain.handle('ai:sessionPin', (event, sessionId, pinned) => readerOnly(event, () => {
requireAiSession(sessionId);
return aiSessions.setPinned(sessionId, pinned === true);
}));
ipcMain.handle('ai:sessionRemove', (event, sessionId) => readerOnly(event, () => {
requireAiSession(sessionId);
const removed = aiSessions.remove(sessionId);
collectAiImages();
return removed;
}));
ipcMain.handle('ai:sessionClear', (event, sessionId) => readerOnly(event, () => {
requireAiSession(sessionId);
const meta = aiSessions.clear(sessionId);
collectAiImages();
return meta;
}));
ipcMain.handle('ai:sessionMessages', (event, sessionId, options) => readerOnly(event, () => {
const raw = options && typeof options === 'object' ? options : {};
return aiSessions.messages(sessionId, { limit: raw.limit, before: raw.before });
}));
// 图像 GC 的 keep 集合必须来自扫全部会话文件的 imageIds(),不能只读索引
function collectAiImages() {
try {
return aiImages.cleanup(aiSessions.imageIds());
} catch (error) {
return 0;
}
}
const AI_HISTORY_BUDGET = { maxChars: 12000, maxMessages: 20 };
const AI_TASK_TITLES = {
summarize: '总结当前上下文',
translate: '翻译选中文本',
explain: '解释选中文本',
ask: '提问'
};
// 存进会话的是用户看到的那句话,不是整篇正文:
// 正文另有 contextRef 记录哈希与字数,把它当消息正文会让重开后的气泡变成几千字原文
function aiTurnTitle(task, question) {
const asked = String(question == null ? '' : question).trim();
if (asked) return asked;
return AI_TASK_TITLES[task] || '提问';
}
const aiRuns = new Map();
function aiRunKey(senderId, runId) {
@@ -1169,40 +1418,132 @@ ipcMain.handle('ai:cancel', (event, runId) => wrap(() => {
// 流式:增量通过 ai:delta 事件推给发起窗口,最终结果由 invoke 返回
ipcMain.handle('ai:run', async (e, payload) => {
const { runId, task, text, question, visualContexts } = payload || {};
const { runId, sessionId, task, text, question, visualContexts, scope, locator, documentKey, fileIndex } = payload || {};
const id = String(runId || '');
if (!isReaderSender(e.sender)) return { ok: false, error: '只有阅读器可以使用 AI 助手' };
if (!/^[A-Za-z0-9_-]{1,80}$/.test(id)) return { ok: false, error: 'runId 无效' };
const key = aiRunKey(e.sender.id, id);
if (aiRuns.has(key)) return { ok: false, error: '该请求已在进行中' };
const chatId = sessionId == null || sessionId === '' ? '' : String(sessionId);
let meta = null;
if (chatId) {
try {
meta = requireAiSession(chatId);
} catch (err) {
return { ok: false, error: (err && err.message) || String(err) };
}
// 同一会话内不允许并发:两轮同时写同一个文件,后完成的那轮会覆盖前一轮的消息
for (const run of aiRuns.values()) {
if (run.sessionId && run.sessionId === chatId) {
return { ok: false, error: '该会话正在生成中,请先等待或停止' };
}
}
}
const ctl = new AbortController();
const wc = e.sender;
const abortOnDestroy = () => ctl.abort();
wc.once('destroyed', abortOnDestroy);
aiRuns.set(key, { controller: ctl, senderId: wc.id });
aiRuns.set(key, { controller: ctl, senderId: wc.id, sessionId: chatId });
const body = String(text == null ? '' : text);
let userMessageId = '';
let assistantMessageId = '';
let history = [];
let streamed = '';
try {
const visuals = canonicalVisualContexts(visualContexts);
if (chatId) {
// 历史必须在写入本轮之前取,否则当前提问会被当成自己的历史重复发一遍
history = aiSessions.historyFor(chatId, AI_HISTORY_BUDGET).messages;
const userMessage = aiSessions.appendUser(chatId, {
text: aiTurnTitle(task, question),
task,
contextRef: body || visuals.length ? {
scope,
chars: body.length,
hash: aiSessions.hashContext(body),
locator,
documentKey: documentKey || meta.documentKey,
fileIndex
} : null,
images: persistAiImages(visuals)
});
userMessageId = userMessage.id;
assistantMessageId = aiSessions.appendAssistant(chatId, { task }).id;
}
const full = await aiClient.stream({
task,
text,
question,
visualContexts: visuals,
history,
signal: ctl.signal,
onDelta: (piece) => {
if (!wc.isDestroyed()) wc.send('ai:delta', { runId: id, delta: piece });
streamed += piece;
if (!wc.isDestroyed()) {
wc.send('ai:delta', {
runId: id,
delta: piece,
sessionId: chatId,
messageId: assistantMessageId
});
}
}
});
return { ok: true, data: { text: full } };
if (chatId) settleAiAssistant(chatId, assistantMessageId, { text: full });
return { ok: true, data: { text: full, sessionId: chatId, userMessageId, assistantMessageId } };
} catch (err) {
if (err && err.name === 'AbortError') return { ok: false, error: '已取消', cancelled: true };
return { ok: false, error: (err && err.message) || String(err) };
const cancelled = !!(err && err.name === 'AbortError');
const message = cancelled ? '已取消' : ((err && err.message) || String(err));
// 失败与取消都要落盘:用户的提问已经花掉了 token,
// 已经流出来的残片也要留住,否则界面上看到的半截回答一重开就消失
if (chatId && assistantMessageId) {
settleAiAssistant(chatId, assistantMessageId, {
text: streamed,
cancelled,
error: cancelled ? null : message
});
}
if (cancelled) {
return { ok: false, error: message, cancelled: true, data: { sessionId: chatId, userMessageId, assistantMessageId } };
}
return { ok: false, error: message, data: { sessionId: chatId, userMessageId, assistantMessageId } };
} finally {
wc.removeListener('destroyed', abortOnDestroy);
aiRuns.delete(key);
}
});
// 落盘失败不能把已经拿到的回答变成请求失败,最多是这一轮没存住
function settleAiAssistant(chatId, messageId, patch) {
try {
return aiSessions.finishAssistant(chatId, messageId, patch);
} catch (error) {
return null;
}
}
function persistAiImages(visuals) {
const stored = [];
for (const context of visuals) {
if (!context.includeImage || !context.image) continue;
try {
const put = aiImages.put(Buffer.from(context.image.base64, 'base64'), context.image.mimeType);
stored.push({
imageId: put.imageId,
mimeType: 'image/jpeg',
width: context.image.width,
height: context.image.height,
bytes: put.bytes,
ocrIncluded: !!(context.ocr && context.ocr.include)
});
} catch (error) { /* 存图失败不影响本轮提问 */ }
}
return stored;
}
// 通用设置读写(目前用于"下载前询问保存位置"开关)
ipcMain.handle('settings:get', (_e, key, def) => wrap(() => settings.get(key, def)));
ipcMain.handle('settings:set', (_e, key, value) => wrap(() => { settings.set(key, value); }));
+43
View File
@@ -58,7 +58,9 @@ contextBridge.exposeInMainWorld('api', {
findBySource: (sourceId, postId) => ipcRenderer.invoke('library:findBySource', sourceId, postId),
add: (item) => ipcRenderer.invoke('library:add', item),
update: (id, patch) => ipcRenderer.invoke('library:update', id, patch),
updateMany: (patches) => ipcRenderer.invoke('library:updateMany', patches),
remove: (id, options) => ipcRenderer.invoke('library:remove', id, options),
removeMany: (ids, options) => ipcRenderer.invoke('library:removeMany', ids, options),
getDir: () => ipcRenderer.invoke('library:getDir'),
pickDir: () => ipcRenderer.invoke('library:pickDir'),
setDir: (dir, migrate) => ipcRenderer.invoke('library:setDir', dir, migrate),
@@ -103,6 +105,7 @@ contextBridge.exposeInMainWorld('api', {
},
reader: {
ready: () => ipcRenderer.invoke('reader:ready'),
entryClosed: () => ipcRenderer.invoke('reader:entryClosed'),
open: (entryId, fileIndex) => ipcRenderer.invoke('reader:open', entryId, fileIndex),
openAt: (entryId, fileIndex, documentKey, locator) => (
ipcRenderer.invoke('reader:openAt', entryId, fileIndex, documentKey, locator)
@@ -130,6 +133,9 @@ contextBridge.exposeInMainWorld('api', {
removeNote: (entryId, noteId) => ipcRenderer.invoke('reader:removeNote', entryId, noteId),
listNotes: (filters) => ipcRenderer.invoke('reader:listNotes', filters),
getNoteCounts: () => ipcRenderer.invoke('reader:getNoteCounts'),
getAnnotationCounts: () => ipcRenderer.invoke('reader:getAnnotationCounts'),
orphanReport: () => ipcRenderer.invoke('reader:orphanReport'),
purgeOrphans: (scope) => ipcRenderer.invoke('reader:purgeOrphans', scope),
listCollections: () => ipcRenderer.invoke('reader:listCollections'),
addCollection: (input) => ipcRenderer.invoke('reader:addCollection', input),
updateCollection: (id, patch) => ipcRenderer.invoke('reader:updateCollection', id, patch),
@@ -173,12 +179,49 @@ contextBridge.exposeInMainWorld('api', {
return () => ipcRenderer.removeListener('reader:notesChanged', h);
}
},
notes: {
openWindow: (entryId, noteId) => ipcRenderer.invoke('notes:openWindow', entryId, noteId),
getOne: (entryId, noteId) => ipcRenderer.invoke('notes:getOne', entryId, noteId),
openWindows: () => ipcRenderer.invoke('notes:openWindows'),
tabsChanged: (tabs) => ipcRenderer.invoke('notes:tabsChanged', tabs),
shutdownReady: () => ipcRenderer.invoke('notes:shutdownReady'),
cancelClose: () => ipcRenderer.invoke('notes:cancelClose'),
onWindowsChanged: (cb) => {
const h = (_e, data) => cb(data);
ipcRenderer.on('notes:windowsChanged', h);
return () => ipcRenderer.removeListener('notes:windowsChanged', h);
},
onOpenTab: (cb) => {
const h = (_e, data) => cb(data);
ipcRenderer.on('notes:openTab', h);
return () => ipcRenderer.removeListener('notes:openTab', h);
},
onCloseTab: (cb) => {
const h = (_e, data) => cb(data);
ipcRenderer.on('notes:closeTab', h);
return () => ipcRenderer.removeListener('notes:closeTab', h);
},
onPrepareClose: (cb) => {
const h = () => cb();
ipcRenderer.on('notes:prepareClose', h);
return () => ipcRenderer.removeListener('notes:prepareClose', h);
}
},
ai: {
status: () => ipcRenderer.invoke('ai:status'),
save: (cfg) => ipcRenderer.invoke('ai:save', cfg),
clear: () => ipcRenderer.invoke('ai:clear'),
run: (payload) => ipcRenderer.invoke('ai:run', payload),
cancel: (runId) => ipcRenderer.invoke('ai:cancel', runId),
sessions: {
list: (filters) => ipcRenderer.invoke('ai:sessionList', filters),
create: (input) => ipcRenderer.invoke('ai:sessionCreate', input),
rename: (sessionId, title) => ipcRenderer.invoke('ai:sessionRename', sessionId, title),
setPinned: (sessionId, pinned) => ipcRenderer.invoke('ai:sessionPin', sessionId, pinned),
remove: (sessionId) => ipcRenderer.invoke('ai:sessionRemove', sessionId),
clear: (sessionId) => ipcRenderer.invoke('ai:sessionClear', sessionId),
messages: (sessionId, options) => ipcRenderer.invoke('ai:sessionMessages', sessionId, options)
},
onChanged: (cb) => {
const h = (_e, data) => cb(data);
ipcRenderer.on('ai:changed', h);
View File
+61
View File
@@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>页面不存在 - PeopleLib</title>
<meta name="description" content="请求的页面不存在。返回 PeopleLib 首页,或前往功能、隐私、下载与常见问题页面。">
<meta name="robots" content="noindex">
<meta name="theme-color" content="#fbfaf7" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#12141a" media="(prefers-color-scheme: dark)">
<link rel="icon" type="image/png" sizes="32x32" href="/assets/icon-light-32.png" media="(prefers-color-scheme: light)">
<link rel="icon" type="image/png" sizes="32x32" href="/assets/icon-dark-32.png" media="(prefers-color-scheme: dark)">
<link rel="stylesheet" href="/assets/style.css">
</head>
<body>
<a class="skip-link" href="#main">跳到主要内容</a>
<header class="site-header">
<div class="wrap">
<a class="brand" href="/">
<img class="brand-light" src="/assets/icon-light-256.png" alt="" width="30" height="30">
<img class="brand-dark" src="/assets/icon-dark-256.png" alt="" width="30" height="30">
<span>PeopleLib</span>
</a>
<nav class="site-nav" aria-label="站点主导航">
<ul>
<li><a href="/">首页</a></li>
<li><a href="/features.html">功能</a></li>
<li><a href="/privacy.html">隐私</a></li>
<li><a href="/download.html">下载</a></li>
<li><a href="/faq.html">常见问题</a></li>
</ul>
</nav>
</div>
</header>
<main id="main">
<section class="notfound">
<div class="wrap">
<span class="eyebrow">404</span>
<h1>这个页面不存在</h1>
<p>地址可能拼错了,或者页面已经移动。</p>
<p class="btn-row btn-row-center">
<a class="btn btn-primary" href="/">回到首页</a>
<a class="btn btn-secondary" href="/download.html">下载页</a>
</p>
</div>
</section>
</main>
<footer class="site-footer">
<div class="wrap">
<p class="legal">
PeopleLib,开放获取文献与图书的桌面客户端。MIT 许可。
<a href="https://github.com/lofyer/peoplelib" rel="noopener">GitHub 仓库</a>
</p>
</div>
</footer>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
reader.mesalogo.com
+357
View File
@@ -0,0 +1,357 @@
# 站点部署说明
`reader.mesalogo.com` 的 GitHub Pages 部署步骤。站点是纯静态的,没有构建步骤、没有 npm 依赖、没有 CDN 外链。
本文里凡是标注「查证」的,出处是 GitHub 官方文档(已抓取正文核对);标注「推断」的是我根据文档与本仓库现状的判断,需要你实际操作时确认。
---
## 一、文件清单
站点全部文件在 `site/`,目录本身就是站点根。
| 路径 | 作用 |
|---|---|
| `site/index.html` | 首页。价值主张、核心能力、隐私要点、真实截图、格式对照表 |
| `site/features.html` | 功能页。按真实功能清单展开,末尾有「不包含的功能」一节 |
| `site/privacy.html` | 隐私与安全。本地优先、自带密钥、确认后外发、进程边界、不绕 DRM、联网范围表 |
| `site/download.html` | 下载与安装。Windows 免安装步骤、macOS DMG 与首次打开的处理、首启建议 |
| `site/faq.html` | 常见问题。费用、数据位置、格式、macOS 拦截、离线、更新、超大 PDF 等 |
| `site/404.html` | 自定义 404。用根绝对路径引用资源(见下文注意事项) |
| `site/assets/style.css` | 唯一样式表。深浅两套配色随 `prefers-color-scheme` 切换 |
| `site/assets/shot-*.jpg` | 产品截图,由 `docs/screenshots/` 原图缩放转码而来 |
| `site/assets/icon-{light,dark}-{32,256,512}.png` | favicon 与品牌标记,从 `icons/dist/` 复制 |
| `site/assets/og-cover.jpg` | Open Graph 分享封面,1200x630 |
| `site/assets/build-assets.ps1` | 一次性资产生成脚本。换截图时手动重跑,站点本身不依赖它。只写 `site/assets/`,只读 `docs/screenshots/``icons/dist/` |
| `site/CNAME` | 自定义域名声明,内容是单行 `reader.mesalogo.com` |
| `site/.nojekyll` | 空文件,跳过 Jekyll 处理 |
| `site/robots.txt` | 允许全部抓取,指向 sitemap |
| `site/sitemap.xml` | 五个页面的站点地图 |
没有 JavaScript。折叠式常见问题用原生 `<details>` 实现,不需要脚本。
### 已做的校验
- 六个页面在移动宽度(390px)下 `scrollWidth == clientWidth`,无横向溢出。
- 全部内部链接与资源路径存在,全部页内锚点有效。
- 页面只引用本地资源。外部地址只出现在 `<a href>`(指向 github.com)与 `<link rel="canonical">`
- 浏览器控制台无报错、无警告。
- 深浅两套配色的正文、次要文字、按钮、链接、页脚、表头对比度全部达到 WCAG AA,最低一项 4.75:1。
- 每页一个 `h1``lang="zh-CN"`,全部 `<img>``alt`,装饰性图标用空 `alt`
- 全部文件 UTF-8 无 BOM,无 emoji,无破折号。
---
## 二、部署方案
### 问题背景
本仓库有两个远端,用途不同:
- `github``git@github.com:lofyer/peoplelib.git`,公开):只放 README 与截图,不推源码,历史与本地无共同祖先。
- `origin``ssh://git@git.digiman.live:11022/root/peoplelib.git`,私有):完整源码。
GitHub Pages 只能托管在公开仓库或付费计划的私有仓库上。站点要走公开仓库,所以核心问题是:**怎么把 `site/` 推上公开仓库,同时一行源码都不带过去。**
顺带提醒一句(查证):Pages 站点在互联网上始终是公开的,即使仓库是私有的。所以不要指望靠仓库权限藏住站点内容。
### 三种可选方式
GitHub 文档明确的发布源只有两类(查证):从某个分支发布,源目录只能是该分支的根 `/``/docs`;或者用自定义 GitHub Actions 工作流发布。
| 方式 | 是否可行 | 评价 |
|---|---|---|
| 公开仓库 `main` 分支的 `/docs` | 可行 | 但公开仓库的 `docs/screenshots/` 已被 README 引用,站点文件混进同一目录后,`docs/` 既是文档目录又是站点根,语义混乱。而且 README 与站点共用一次提交,改站点会污染 README 的历史 |
| 公开仓库独立 `gh-pages` 分支,根目录就是站点 | **推荐** | 分支里只有站点文件,物理上不可能带上源码。与现有的「用独立 orphan 提交推公开仓库」约定同构。README 留在 `main`,两条线互不干扰 |
| GitHub Actions 工作流 | 不推荐 | 需要在公开仓库放 `.github/workflows/`,与「只放 README 与截图」的约定冲突;而且站点零构建,Actions 唯一的价值是自动化,收益抵不上多出来的运行时依赖与调试面 |
### 结论:公开仓库的 `gh-pages` orphan 分支,源目录 `/`
三个问题的答案:
1. **部署方式**:从公开仓库的 `gh-pages` 分支发布,源目录选根 `/`。不用 `/docs`,不用 Actions。
2. **CNAME 位置**:放在**发布分支的根目录**。因为 `site/` 的内容会成为 `gh-pages` 的根,所以 `site/CNAME` 推上去之后自然就在 `gh-pages` 根上,位置正确,不需要移动。(查证:从分支发布时,在 Settings 里保存自定义域名会自动在源分支根目录提交一个 `CNAME` 文件;反过来,你自己先放好这个文件也一样生效。用 Actions 发布则不会创建 `CNAME`,已有的也会被忽略。)
3. **`site/` 目录名会不会冲突**:不冲突,但**前提是用上面这个方案**。Pages 认不了名为 `site` 的源目录,它只认分支根或 `/docs`。而在本方案里 `site/` 只是私有仓库里的源目录,推送时把它的**内容**摊到 `gh-pages` 的根,Pages 看到的是根目录,所以目录叫什么都无所谓。**不需要改名,也不需要动仓库里其他任何地方。**
---
## 三、发布操作
### 3.1 首次发布
用一个临时 worktree 挂 orphan 分支,避免污染主工作区。以下命令在 PowerShell 下逐条执行,`$repo` 换成你的实际路径。
```powershell
$repo = "D:\my_git\peoplelib"
$wt = "D:\my_git\peoplelib-pages" # 临时工作树,放在仓库外面
cd $repo
# 1. 建一个挂着 orphan 分支的工作树。orphan 意味着无父提交,天然与源码历史无共同祖先
git worktree add --orphan -b gh-pages $wt
# 2. 把站点内容摊到工作树根目录。注意是 site\ 的内容,不是 site 这个目录本身
Copy-Item -Path "$repo\site\*" -Destination $wt -Recurse -Force
Copy-Item -Path "$repo\site\.nojekyll" -Destination $wt -Force # 点开头的文件通配符可能漏掉,单独补一次
# 3. 确认工作树里只有站点文件,没有任何源码
cd $wt
git status --porcelain
Get-ChildItem -Force | Select-Object Name
# 4. 提交
git add -A
git commit -m "站点:发布 reader.mesalogo.com 首版"
# 5. 推到公开远端
git push github gh-pages
```
`git worktree add --orphan` 需要 Git 2.42 或更高。本机是 2.55.0,可用。
发布脚本里不要 `git add` 那个 `assets/build-assets.ps1`,其实带上也无妨,它只是个纯文本工具脚本,不含源码逻辑,留着方便日后换图。你若不想让它进公开仓库,第 3 步之后删掉即可:
```powershell
Remove-Item "$wt\assets\build-assets.ps1"
```
### 3.2 后续更新
工作树保留着的话,更新只要重复复制加提交:
```powershell
$repo = "D:\my_git\peoplelib"
$wt = "D:\my_git\peoplelib-pages"
cd $wt
# 先清干净,避免删掉的文件残留在分支上(保留 .git)
Get-ChildItem -Force | Where-Object { $_.Name -ne '.git' } | Remove-Item -Recurse -Force
Copy-Item -Path "$repo\site\*" -Destination $wt -Recurse -Force
Copy-Item -Path "$repo\site\.nojekyll" -Destination $wt -Force
git add -A
git commit -m "站点:更新下载说明"
git push github gh-pages
```
不再需要工作树时清理:
```powershell
cd $repo
git worktree remove ..\peoplelib-pages
```
### 3.3 需要你决定的两件事
这两件都在 `site/` 之外,我没有动,交给你:
1. **`.gitignore`**`site/` 目前**没有**被忽略,`git status` 能看到它。如果你希望站点源文件跟着私有仓库一起走版本管理(推荐,这样才有历史可查),就什么都不用改。如果你不想让它进私有仓库,自己在 `.gitignore` 里加 `/site/`,注意要带前导斜杠,否则会连带匹配其他层级的同名目录,这和仓库里 `/dist/` 那条规则是同一个坑。
2. **不要加 GitHub Actions workflow**。上面已经论证过不推荐。如果你后来改主意要走 Actions,记住一条(查证):用 Actions 发布时 `CNAME` 文件不会被创建,已存在的也会被忽略且不是必需的,域名完全由 Settings 里的配置决定,那时 `site/CNAME` 就成了无用文件。
---
## 四、GitHub 仓库设置
`https://github.com/lofyer/peoplelib` 上操作。
1.**Settings**,左侧 **Code and automation** 分组里点 **Pages**
2. **Build and deployment** 的 Source 选 **Deploy from a branch**
3. Branch 选 **`gh-pages`**Folder 选 **`/ (root)`**,点 **Save**
4. **Custom domain**`reader.mesalogo.com`,点 **Save**
第 4 步之后 GitHub 会自动跑一次 DNS 检查。因为 `site/CNAME` 已经在分支根上且内容正确,这一步通常不会再产生额外提交;如果 GitHub 仍然自己提交了一次 `CNAME`,那是正常行为(查证:从分支发布时保存自定义域名会在源分支根目录提交 `CNAME`),下次更新前先 `git pull github gh-pages` 同步一下即可。
### 关于域名验证
GitHub 文档建议(查证):**先验证自定义域名,再把它加到仓库里**,以提升安全性、避免域名被抢占。验证入口在个人或组织的 **Settings → Pages → Add a domain**,它会要求你加一条 `_github-pages-challenge-<user>.mesalogo.com` 的 TXT 记录。
这一步不是必须的,但如果你以后停用了 Pages 而 DNS 记录还留着,未验证的域名可能被别人拿去托管他们自己的站点。建议做。(查证:文档明确说明未验证且站点停用时存在被接管的风险。)
---
## 五、DNS 配置
`reader.mesalogo.com` 是**子域名**,不是 apex 域名。这个区别决定了记录类型。
### 需要加的记录
一条,就一条(查证):
| 类型 | 名称 | 值 | TTL |
|---|---|---|---|
| `CNAME` | `reader`(即 `reader.mesalogo.com` | `lofyer.github.io` | 自动 / 默认 |
要点:
- 值是 `lofyer.github.io`,**不带仓库名**。文档原文是 CNAME 记录应始终指向 `<user>.github.io``<organization>.github.io`,排除仓库名。(查证)
- 值不带 `https://`,不带结尾斜杠。有些 DNS 面板要求结尾带点写成 `lofyer.github.io.`,按面板的格式来。
- **不要**加 A 记录或 AAAA 记录。那四个 `185.199.10x.153` 与对应的 IPv6 地址是给 **apex 域名**`example.com` 这种)用的。子域名只需要 CNAME。(查证)
- **不要**用通配符记录如 `*.mesalogo.com`。文档强烈反对,因为即使验证了域名,通配符覆盖下的更深层子域名仍可能被接管。(查证)
`site/CNAME` 文件与 DNS 里的 CNAME 记录是两件不同的东西,名字撞车而已:文件告诉 GitHub「这个站点用哪个域名」,DNS 记录告诉全世界「这个域名指向哪台服务器」。两边都要配。
### 当前状态
我查过(`Resolve-DnsName`):
- `reader.mesalogo.com` 目前**不存在**,返回「DNS 名称不存在」。所以是全新添加,不会覆盖已有记录。
- `mesalogo.com` 的权威 NS 是 `serena.ns.cloudflare.com``aarav.ns.cloudflare.com`,也就是**域名托管在 Cloudflare**。
### Cloudflare 特有注意事项(推断,非 GitHub 文档内容)
在 Cloudflare 面板加这条 CNAME 时,右侧有个 Proxy status 开关:
- 建议先设成 **DNS only**(灰色云朵)。橙色云朵代表 Cloudflare 代理,此时 Cloudflare 会自己终止 TLSGitHub 那边的 DNS 检查可能拿不到期望的应答,导致证书签发卡住或反复失败。
- 等 GitHub 侧证书签发成功、`https://reader.mesalogo.com` 能正常打开之后,如果你确实想用 Cloudflare 的 CDN,再切成橙色云朵,并把 SSL/TLS 模式设为 **Full (strict)**。不要用 Flexible,那会造成 Cloudflare 到 GitHub 之间走明文。
- Cloudflare 默认 TTL 是 Auto,不用改。
这一节是我的操作建议,GitHub 文档不涉及具体 DNS 服务商。请以实际结果为准,卡住了先把云朵切灰。
### 验证 DNS 是否生效
Windows 没有 `dig`(查证:文档明确提到这一点并推荐 `Resolve-DnsName`):
```powershell
Resolve-DnsName reader.mesalogo.com -Type CNAME
```
期望看到 `NameHost``lofyer.github.io`
DNS 变更最多需要 24 小时传播(查证)。多数情况几分钟就好,但如果刚改完查不到,先等,别急着反复改。
---
## 六、HTTPS 证书
顺序很重要:**先 DNS 生效,再等证书,最后勾 Enforce HTTPS。**
流程(查证):
1. 你在 Settings → Pages 里保存或修改自定义域名后,GitHub 自动开始一次 DNS 检查。
2. 检查通过后,GitHub 排队向 Let's Encrypt 申请 TLS 证书,拿到后自动部署到负责 Pages TLS 终止的服务器上。
3. 全流程成功后,Settings → Pages 的自定义域名旁边会出现一个**对勾**。
4. 这时再勾选 **Enforce HTTPS**,所有 HTTP 请求会被透明重定向到 HTTPS。
排障(查证):如果点了 Save 之后**几分钟**还没完成,出现「Certificate not yet created」,就点域名旁边的 **Remove**,重新输入域名再 **Save**,这会取消并重启签发流程。
关于 Enforce HTTPS 的勾选时机:文档说所有 Pages 站点包括正确配置了自定义域名的站点都支持 HTTPS 与 HTTPS 强制。**推断**:在证书还没签发出来(域名旁边没有对勾)时,这个复选框通常是灰的点不动,或者勾上会导致站点短时间打不开。所以按上面的顺序走,看到对勾再勾它。
混合内容(查证):如果页面里有 `http://` 开头的图片、CSS 或 JS,站点会被判定为混合内容。本站点不存在这个问题,所有资源都是相对路径引用的本地文件,已经校验过。
---
## 七、注意事项与已知坑
### 404 页面用的是根绝对路径
`site/404.html` 里的资源引用写成 `/assets/style.css` 这样的根绝对路径,因为 GitHub 会拿这个页面响应任意深度的错误路径,相对路径在 `/a/b/c` 这种地址下会解析错。
**代价**:在自定义域名生效之前,站点临时地址是 `https://lofyer.github.io/peoplelib/`,此时 404 页面的样式与图标会加载失败(它去找 `lofyer.github.io/assets/...` 而不是 `/peoplelib/assets/...`)。域名生效后站点在根路径上,一切正常。其他五个页面全部用相对路径,两种地址下都正常。
如果你想在临时地址下也让 404 页面完整,把该文件里的 `/assets/``/features.html` 等改成相对路径,代价是深层路径下样式丢失。二者不能同时满足,建议维持现状,等域名生效。
### `.nojekyll`
空文件,作用是跳过 Jekyll 处理。当前站点里没有下划线开头的文件或目录,严格说不加也能正常发布;加上是防御性的,避免以后新增 `_something` 之类的路径时被 Jekyll 悄悄吞掉。(Jekyll 忽略下划线前缀路径这一行为属于既有共识,本次未逐字查证 GitHub 文档;`.nojekyll` 本身在发布源文档里被提到是外部 CI 部署的常见做法。)
复制文件时留意:PowerShell 的 `Copy-Item site\*` 通配符可能不匹配点开头的文件,所以上面的命令里单独补了一次 `.nojekyll`。这个坑很容易漏,漏了不会报错,只是文件没过去。
### 版本号会过期
页脚、首页与下载页都写了 **1.3.0**,取自 `package.json``version` 字段。发新版时记得改,一共出现在这几处:
```powershell
Select-String -Path D:\my_git\peoplelib\site\*.html -Pattern '1\.3\.0'
```
### 站点不引用 `docs/screenshots/` 原图
`site/assets/shot-*.jpg` 是压缩转码后的副本,和 `docs/screenshots/` 的原始 PNG 相互独立。换截图时替换 `docs/screenshots/` 里的原图,然后重跑:
```powershell
pwsh -NoProfile -File D:\my_git\peoplelib\site\assets\build-assets.ps1
```
脚本只写 `site/assets/`,只读 `docs/screenshots/``icons/dist/`
### 仓库根目录的诊断图没有被使用
`font-missing-diagnostic.png``probe*.json` 之类的诊断产物一个都没进站点。
---
## 八、需要你补的资产
### 现在用的是真实截图,不是占位图
站点已经用上了 `docs/screenshots/` 里的六张真实截图,缩放到 1600px 宽、JPEG 质量 88
| 站点文件 | 来源 | 用在哪 |
|---|---|---|
| `shot-library.jpg` | `PeopleLib_bAecy2Izab.png` | 首页主视觉、OG 封面 |
| `shot-search.jpg` | `PeopleLib_34BtvLsqDE.png` | 首页画廊、功能页检索节 |
| `shot-reader.jpg` | `PeopleLib_ScQMUA2D24.png` | 首页画廊、功能页阅读节 |
| `shot-ai.jpg` | `PeopleLib_2N6zVpCFBA.png` | 首页画廊、功能页 AI 节 |
| `shot-ai-confirm.jpg` | `ai-send-confirmation.png` | 首页画廊、隐私页确认框一节 |
| `shot-annotations.jpg` | `pdf-annotations.png` | 功能页批注节 |
所以站点**不缺图也能直接上线**。下面是可以让它更好的补充项,都是可选的。
### 建议补拍的截图
| 优先级 | 内容 | 用在哪 | 建议尺寸 | 说明 |
|---|---|---:|---|---|
| 高 | **AI 设置页**,展示接口地址、协议选择与 Key 输入框 | 隐私页「AI 密钥」一节 | 宽 ≥ 1600px,16:10 左右 | 目前这一节全是文字。给出真实界面能显著提升「自带密钥」这个卖点的说服力。**截图前务必清空或涂掉真实 Key** |
| 高 | **画布笔记**,最好是以 PDF 页面作底版的那种 | 功能页「笔记与批注」节 | 宽 ≥ 1600px | 画布笔记是差异化功能,现在只有文字描述 |
| 中 | **书架与标签的批量整理**,多选状态下的操作栏 | 功能页「本地书库」节 | 宽 ≥ 1600px | 那一节现在是定义列表,没有配图 |
| 中 | **macOS 首次打开的系统提示框**,以及右键菜单里的「打开」 | 下载页 macOS 节、常见问题 | 宽 800 至 1200px 即可 | 这是最容易让用户误判为病毒的环节,配图比文字管用得多。需要在 macOS 上截 |
| 中 | **EPUB 阅读界面**,带目录侧栏 | 功能页 EPUB 卡片 | 宽 ≥ 1600px | 现有截图全是 PDF,看不出 EPUB 的重排效果 |
| 低 | **浅色主题下的书库界面** | 首页主视觉的浅色替换 | 宽 ≥ 1600px | 现有截图都是深色界面,浅色站点配色下略显突兀。若要做,需要给 `<picture>``prefers-color-scheme` 分支,改动量不大但需要我再写一次 |
| 低 | **代理设置界面** | 功能页「代理与镜像容错」节 | 宽 ≥ 1600px | 截图前遮掉真实代理地址 |
补图流程:把新 PNG 放进 `docs/screenshots/`,在 `site/assets/build-assets.ps1``$map` 数组里加一行映射,重跑脚本,然后在对应 HTML 里插 `<figure class="shot">`
### 图标
已经复用 `icons/dist/` 的应用图标,浅色深色两套按 `prefers-color-scheme` 切换,不需要新画。
唯一缺的是 **`favicon.ico`**。当前只提供 32px PNG favicon,现代浏览器都认,但少数老浏览器和某些抓取器会去根目录硬找 `/favicon.ico`。要补的话把 `icons/dist/book-ai-light.ico` 复制成 `site/favicon.ico` 即可,属于可选项。
### Open Graph 图
`site/assets/og-cover.jpg` 已生成,1200x630,81 KB,内容是深色底加应用图标、产品名、两行说明与域名,右侧放书库截图。由 `build-assets.ps1` 用 GDI+ 绘制,字体是「Microsoft YaHei」。
**推断**:如果这张图要在正式场合露脸,建议你用设计工具重做一版。脚本生成的版式够用但排版粗糙,尤其是文字与截图之间的留白比例。替换时直接覆盖 `site/assets/og-cover.jpg`,保持 1200x630 与文件名不变,HTML 里的 `og:image` 尺寸声明就不用动。
---
## 九、上线后的自查清单
```powershell
# DNS
Resolve-DnsName reader.mesalogo.com -Type CNAME
# 首页可达且是 200
curl.exe -sSI https://reader.mesalogo.com/ | Select-Object -First 1
# HTTP 是否重定向到 HTTPS(勾了 Enforce HTTPS 之后应该是 301
curl.exe -sSI http://reader.mesalogo.com/ | Select-Object -First 3
# 404 页面生效(应返回 404 且内容是中文自定义页)
curl.exe -sS -o NUL -w "%{http_code}`n" https://reader.mesalogo.com/nonexistent
# 五个页面全部可达
'', 'features.html', 'privacy.html', 'download.html', 'faq.html' | ForEach-Object {
$u = "https://reader.mesalogo.com/$_"
"{0} {1}" -f (curl.exe -sS -o NUL -w "%{http_code}" $u), $u
}
```
浏览器里再手工看四件事:
1. 切换系统深浅色主题,页面配色跟着变。
2. 手机上打开,导航与卡片不溢出。
3. 把首页链接贴进任意支持 Open Graph 预览的地方,标题、描述与封面图正常显示。
4. 键盘按 Tab,焦点框可见,第一次 Tab 出现「跳到主要内容」。
+102
View File
@@ -0,0 +1,102 @@
# 一次性资产生成脚本:把 docs/screenshots 与 icons/dist 的原图缩放并转码到 site/assets。
# 站点本身零构建,这个脚本只在替换截图时手动重跑。
param(
[string]$Repo = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
)
Add-Type -AssemblyName System.Drawing
$outDir = $PSScriptRoot
$srcShots = Join-Path $Repo 'docs\screenshots'
$srcIcons = Join-Path $Repo 'icons\dist'
function Save-Jpeg {
param([System.Drawing.Bitmap]$Bitmap, [string]$Path, [int]$Quality = 88)
$codec = [System.Drawing.Imaging.ImageCodecInfo]::GetImageEncoders() | Where-Object { $_.MimeType -eq 'image/jpeg' }
$params = New-Object System.Drawing.Imaging.EncoderParameters 1
$params.Param[0] = New-Object System.Drawing.Imaging.EncoderParameter ([System.Drawing.Imaging.Encoder]::Quality), ([int]$Quality)
$Bitmap.Save($Path, $codec, $params)
$params.Dispose()
}
function Resize-Shot {
param([string]$In, [string]$Out, [int]$MaxWidth = 1600, [int]$Quality = 88)
$src = [System.Drawing.Image]::FromFile($In)
try {
$scale = [Math]::Min(1.0, $MaxWidth / $src.Width)
$w = [int][Math]::Round($src.Width * $scale)
$h = [int][Math]::Round($src.Height * $scale)
$bmp = New-Object System.Drawing.Bitmap $w, $h
try {
$g = [System.Drawing.Graphics]::FromImage($bmp)
$g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
$g.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::HighQuality
$g.DrawImage($src, 0, 0, $w, $h)
$g.Dispose()
Save-Jpeg -Bitmap $bmp -Path $Out -Quality $Quality
"{0}`t{1}x{2}`t{3} KB" -f (Split-Path $Out -Leaf), $w, $h, [Math]::Round((Get-Item $Out).Length / 1KB)
} finally { $bmp.Dispose() }
} finally { $src.Dispose() }
}
$map = @(
@{ In = 'PeopleLib_bAecy2Izab.png'; Out = 'shot-library.jpg' }
@{ In = 'PeopleLib_34BtvLsqDE.png'; Out = 'shot-search.jpg' }
@{ In = 'PeopleLib_ScQMUA2D24.png'; Out = 'shot-reader.jpg' }
@{ In = 'PeopleLib_2N6zVpCFBA.png'; Out = 'shot-ai.jpg' }
@{ In = 'ai-send-confirmation.png'; Out = 'shot-ai-confirm.jpg' }
@{ In = 'pdf-annotations.png'; Out = 'shot-annotations.jpg' }
)
foreach ($m in $map) {
$in = Join-Path $srcShots $m.In
if (Test-Path $in) { Resize-Shot -In $in -Out (Join-Path $outDir $m.Out) }
else { Write-Warning "缺少源图 $in" }
}
foreach ($v in @('light', 'dark')) {
foreach ($size in @(32, 256, 512)) {
$in = Join-Path $srcIcons "$v\icon-$size.png"
if (Test-Path $in) { Copy-Item $in (Join-Path $outDir "icon-$v-$size.png") -Force }
}
}
# Open Graph 封面:深色底 + 应用图标 + 书库截图,1200x630。
$og = New-Object System.Drawing.Bitmap 1200, 630
try {
$g = [System.Drawing.Graphics]::FromImage($og)
$g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::HighQuality
$g.TextRenderingHint = [System.Drawing.Text.TextRenderingHint]::ClearTypeGridFit
$g.Clear([System.Drawing.ColorTranslator]::FromHtml('#12141a'))
$shot = Join-Path $outDir 'shot-library.jpg'
if (Test-Path $shot) {
$img = [System.Drawing.Image]::FromFile($shot)
try {
$targetW = 660
$scale = $targetW / $img.Width
$h = [int][Math]::Round($img.Height * $scale)
$g.DrawImage($img, 500, [int]((630 - $h) / 2), $targetW, $h)
} finally { $img.Dispose() }
}
$iconPath = Join-Path $outDir 'icon-dark-256.png'
if (Test-Path $iconPath) {
$icon = [System.Drawing.Image]::FromFile($iconPath)
try { $g.DrawImage($icon, 72, 132, 112, 112) } finally { $icon.Dispose() }
}
$white = New-Object System.Drawing.SolidBrush ([System.Drawing.ColorTranslator]::FromHtml('#f4f5f7'))
$muted = New-Object System.Drawing.SolidBrush ([System.Drawing.ColorTranslator]::FromHtml('#a8aeba'))
$fTitle = New-Object System.Drawing.Font 'Microsoft YaHei', 44, ([System.Drawing.FontStyle]::Bold)
$fSub = New-Object System.Drawing.Font 'Microsoft YaHei', 19
$fFoot = New-Object System.Drawing.Font 'Microsoft YaHei', 15
$g.DrawString('PeopleLib', $fTitle, $white, 68, 268)
$g.DrawString("多源文献检索`n本地书库与内置阅读器", $fSub, $muted, 72, 348)
$g.DrawString('reader.mesalogo.com', $fFoot, $muted, 72, 470)
$g.Dispose()
Save-Jpeg -Bitmap $og -Path (Join-Path $outDir 'og-cover.jpg') -Quality 86
"og-cover.jpg`t1200x630`t{0} KB" -f [Math]::Round((Get-Item (Join-Path $outDir 'og-cover.jpg')).Length / 1KB)
} finally { $og.Dispose() }
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

+722
View File
@@ -0,0 +1,722 @@
:root {
color-scheme: light dark;
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", "WenQuanYi Micro Hei", "Noto Sans CJK SC",
sans-serif;
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono",
"DejaVu Sans Mono", monospace;
--bg: #fbfaf7;
--bg-elevated: #ffffff;
--bg-subtle: #f2f0eb;
--bg-code: #f0eee8;
--text: #1b1c20;
--text-muted: #55585f;
--text-faint: #6d7078;
--border: #ded9cf;
--border-strong: #c6c0b3;
--accent: #8a6110;
--accent-hover: #6d4b0b;
--accent-contrast: #ffffff;
--accent-soft: #f6eeda;
--focus: #1b5ea8;
--shadow: 0 1px 2px rgba(27, 28, 32, .05), 0 8px 24px rgba(27, 28, 32, .07);
--radius: 12px;
--measure: 68ch;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #12141a;
--bg-elevated: #1a1d24;
--bg-subtle: #1f232b;
--bg-code: #22262f;
--text: #f0f1f4;
--text-muted: #b3b8c4;
--text-faint: #9aa0ad;
--border: #2e333d;
--border-strong: #414753;
--accent: #e0b256;
--accent-hover: #f0c672;
--accent-contrast: #17181c;
--accent-soft: #2a2519;
--focus: #7ab6f0;
--shadow: 0 1px 2px rgba(0, 0, 0, .4), 0 10px 28px rgba(0, 0, 0, .34);
}
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
scroll-behavior: smooth;
}
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
* { animation-duration: .01ms !important; transition-duration: .01ms !important; }
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: var(--font-sans);
font-size: 17px;
line-height: 1.75;
text-rendering: optimizeLegibility;
}
h1, h2, h3, h4 {
line-height: 1.3;
font-weight: 650;
letter-spacing: .01em;
margin: 0 0 .6em;
text-wrap: balance;
}
h1 { font-size: clamp(1.9rem, 1.3rem + 2.4vw, 3rem); }
h2 { font-size: clamp(1.45rem, 1.2rem + 1.1vw, 1.95rem); }
h3 { font-size: 1.16rem; }
p, ul, ol, dl, table, figure, pre {
margin: 0 0 1.1em;
}
a {
color: var(--accent);
text-decoration-thickness: 1px;
text-underline-offset: 3px;
}
a:hover { color: var(--accent-hover); }
:focus-visible {
outline: 3px solid var(--focus);
outline-offset: 2px;
border-radius: 4px;
}
code, kbd, samp, pre {
font-family: var(--font-mono);
font-size: .92em;
}
code:not(pre code) {
background: var(--bg-code);
border: 1px solid var(--border);
border-radius: 5px;
padding: .1em .38em;
word-break: break-word;
}
pre {
background: var(--bg-code);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: .95rem 1.05rem;
overflow-x: auto;
line-height: 1.6;
}
kbd {
background: var(--bg-elevated);
border: 1px solid var(--border-strong);
border-bottom-width: 2px;
border-radius: 5px;
padding: .05em .4em;
}
img { max-width: 100%; height: auto; }
hr {
border: 0;
border-top: 1px solid var(--border);
margin: 2.5rem 0;
}
.skip-link {
position: absolute;
left: -9999px;
top: 0;
z-index: 100;
background: var(--bg-elevated);
color: var(--text);
border: 1px solid var(--border-strong);
padding: .6rem 1rem;
border-radius: 0 0 var(--radius) 0;
}
.skip-link:focus {
left: 0;
}
.wrap {
width: 100%;
max-width: 1080px;
margin-inline: auto;
padding-inline: clamp(1rem, 4vw, 2rem);
}
.site-header {
position: sticky;
top: 0;
z-index: 30;
background: var(--bg);
background: color-mix(in srgb, var(--bg) 88%, transparent);
backdrop-filter: saturate(1.4) blur(10px);
border-bottom: 1px solid var(--border);
}
@supports not (backdrop-filter: blur(2px)) {
.site-header { background: var(--bg); }
}
.site-header .wrap {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: .5rem 1.5rem;
padding-block: .7rem;
}
.brand {
display: inline-flex;
align-items: center;
gap: .55rem;
font-weight: 700;
font-size: 1.06rem;
color: var(--text);
text-decoration: none;
letter-spacing: .01em;
}
.brand img {
width: 30px;
height: 30px;
border-radius: 7px;
}
.brand .brand-light { display: block; }
.brand .brand-dark { display: none; }
@media (prefers-color-scheme: dark) {
.brand .brand-light { display: none; }
.brand .brand-dark { display: block; }
}
.site-nav {
margin-inline-start: auto;
}
.site-nav ul {
display: flex;
flex-wrap: wrap;
gap: .2rem .35rem;
list-style: none;
margin: 0;
padding: 0;
}
.site-nav a {
display: block;
padding: .35rem .68rem;
border-radius: 8px;
color: var(--text-muted);
text-decoration: none;
font-size: .95rem;
}
.site-nav a:hover {
background: var(--bg-subtle);
color: var(--text);
}
.site-nav a[aria-current="page"] {
color: var(--text);
background: var(--bg-subtle);
font-weight: 600;
}
main { display: block; }
section {
padding-block: clamp(2.6rem, 5vw, 4.4rem);
}
section + section {
border-top: 1px solid var(--border);
}
.section-head {
max-width: var(--measure);
margin-bottom: 2rem;
}
.section-head p {
color: var(--text-muted);
margin-bottom: 0;
}
.eyebrow {
display: block;
font-size: .8rem;
font-weight: 700;
letter-spacing: .16em;
text-transform: uppercase;
color: var(--text-faint);
margin-bottom: .7rem;
}
.hero {
padding-block: clamp(2.6rem, 6vw, 5rem);
}
.hero-grid {
display: grid;
gap: clamp(2rem, 4vw, 3.2rem);
align-items: center;
grid-template-columns: minmax(0, 1fr);
}
@media (min-width: 900px) {
.hero-grid { grid-template-columns: minmax(0, 5fr) minmax(0, 6fr); }
}
.hero p.lede {
font-size: 1.12rem;
color: var(--text-muted);
max-width: 46ch;
}
.hero h1 { margin-bottom: .5em; }
.tagline {
display: inline-flex;
align-items: center;
gap: .5rem;
background: var(--accent-soft);
color: var(--accent);
border: 1px solid var(--border-strong);
border-color: color-mix(in srgb, var(--accent) 32%, transparent);
border-radius: 999px;
padding: .22rem .8rem;
font-size: .86rem;
font-weight: 600;
margin-bottom: 1.4rem;
}
.btn-row {
display: flex;
flex-wrap: wrap;
gap: .7rem;
margin-bottom: 1rem;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: .45rem;
padding: .62rem 1.25rem;
border-radius: 10px;
border: 1px solid transparent;
font-size: 1rem;
font-weight: 600;
text-decoration: none;
transition: background-color .15s ease, border-color .15s ease, color .15s ease;
}
.btn-primary {
background: var(--accent);
color: var(--accent-contrast);
}
.btn-primary:hover {
background: var(--accent-hover);
color: var(--accent-contrast);
}
.btn-secondary {
background: var(--bg-elevated);
border-color: var(--border-strong);
color: var(--text);
}
.btn-secondary:hover {
background: var(--bg-subtle);
color: var(--text);
border-color: var(--text-faint);
}
.btn-row-center { justify-content: center; }
.hint {
color: var(--text-faint);
font-size: .9rem;
margin: 0;
}
.shot {
margin: 0;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-elevated);
box-shadow: var(--shadow);
overflow: hidden;
}
.shot img {
display: block;
width: 100%;
background: var(--bg-subtle);
}
.shot figcaption {
padding: .65rem .9rem;
font-size: .88rem;
color: var(--text-faint);
border-top: 1px solid var(--border);
}
.shot-placeholder {
display: flex;
align-items: center;
justify-content: center;
min-height: 220px;
padding: 1.5rem;
text-align: center;
color: var(--text-faint);
font-size: .92rem;
background:
repeating-linear-gradient(135deg,
var(--bg-subtle) 0 12px,
var(--bg-elevated) 12px 24px);
}
.grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 270px), 1fr));
}
.card {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem 1.35rem;
}
.card h3 {
margin-bottom: .45em;
}
.card p:last-child,
.card ul:last-child {
margin-bottom: 0;
}
.card ul {
padding-inline-start: 1.15rem;
color: var(--text-muted);
}
.card .card-meta {
display: block;
font-size: .82rem;
letter-spacing: .1em;
text-transform: uppercase;
color: var(--text-faint);
margin-bottom: .5rem;
font-weight: 700;
}
.prose {
max-width: var(--measure);
}
.prose ul, .prose ol {
padding-inline-start: 1.3rem;
}
.prose li + li {
margin-top: .35em;
}
.prose li > strong:first-child {
color: var(--text);
}
.callout {
border: 1px solid var(--border-strong);
border-inline-start: 4px solid var(--accent);
border-radius: 0 var(--radius) var(--radius) 0;
background: var(--bg-elevated);
padding: 1rem 1.2rem;
margin: 0 0 1.4rem;
}
.callout > :last-child { margin-bottom: 0; }
.callout h3 {
font-size: 1.02rem;
margin-bottom: .35em;
}
.table-scroll {
overflow-x: auto;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-elevated);
}
table {
width: 100%;
border-collapse: collapse;
margin: 0;
font-size: .96rem;
}
caption {
caption-side: top;
text-align: start;
padding: .8rem 1rem 0;
color: var(--text-faint);
font-size: .9rem;
}
th, td {
text-align: start;
padding: .62rem .9rem;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
thead th {
background: var(--bg-subtle);
font-size: .88rem;
letter-spacing: .04em;
color: var(--text-muted);
white-space: nowrap;
}
tbody tr:last-child td {
border-bottom: 0;
}
.yes { color: var(--accent); font-weight: 700; }
.no { color: var(--text-faint); }
.steps {
counter-reset: step;
list-style: none;
padding: 0;
max-width: var(--measure);
}
.steps > li {
counter-increment: step;
position: relative;
padding-inline-start: 2.6rem;
margin-bottom: 1.1rem;
}
.steps > li::before {
content: counter(step);
position: absolute;
inset-inline-start: 0;
top: .18em;
width: 1.8rem;
height: 1.8rem;
display: grid;
place-items: center;
border-radius: 50%;
background: var(--accent-soft);
border: 1px solid var(--border-strong);
border-color: color-mix(in srgb, var(--accent) 35%, transparent);
color: var(--accent);
font-size: .9rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.steps > li > :last-child { margin-bottom: 0; }
.faq {
max-width: var(--measure);
}
.faq > h2 {
font-size: .84rem;
letter-spacing: .14em;
text-transform: uppercase;
color: var(--text-faint);
margin: 2rem 0 .8rem;
padding-bottom: .4rem;
border-bottom: 1px solid var(--border);
}
.faq > h2:first-child { margin-top: 0; }
.faq details {
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-elevated);
padding: 0 1.1rem;
margin-bottom: .7rem;
}
.faq summary {
cursor: pointer;
font-weight: 620;
padding: .85rem 0;
list-style: none;
display: flex;
gap: .6rem;
align-items: baseline;
}
.faq summary::-webkit-details-marker { display: none; }
.faq summary::before {
content: "";
color: var(--accent);
font-weight: 700;
flex: none;
}
.faq details[open] summary::before { content: ""; }
.faq details[open] summary {
border-bottom: 1px solid var(--border);
}
.faq .faq-body {
padding: .9rem 0 .3rem 1.5rem;
}
.faq .faq-body > :last-child { margin-bottom: 0; }
.gallery {
display: grid;
gap: 1.1rem;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 340px), 1fr));
}
.kv {
display: grid;
gap: .1rem 1.2rem;
margin: 0 0 1.2rem;
}
@media (min-width: 620px) {
.kv { grid-template-columns: max-content minmax(0, 1fr); }
}
.kv dt {
font-weight: 650;
color: var(--text);
}
.kv dd {
margin: 0 0 .55rem;
color: var(--text-muted);
}
.pill-list {
display: flex;
flex-wrap: wrap;
gap: .45rem;
list-style: none;
padding: 0;
margin: 0 0 1.2rem;
}
.pill-list li {
border: 1px solid var(--border);
background: var(--bg-elevated);
border-radius: 999px;
padding: .2rem .78rem;
font-size: .9rem;
color: var(--text-muted);
}
.cta {
background: var(--bg-subtle);
}
.cta .wrap {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 1.2rem;
}
.cta h2 { margin-bottom: .25em; }
.cta p { margin-bottom: 0; color: var(--text-muted); }
.cta .btn-row { margin-bottom: 0; }
.site-footer {
border-top: 1px solid var(--border);
background: var(--bg);
padding-block: 2.2rem;
color: var(--text-faint);
font-size: .92rem;
}
.site-footer .wrap {
display: grid;
gap: 1.4rem;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 230px), 1fr));
}
.site-footer h2 {
font-size: .82rem;
letter-spacing: .14em;
text-transform: uppercase;
color: var(--text-faint);
margin-bottom: .6rem;
}
.site-footer ul {
list-style: none;
padding: 0;
margin: 0;
}
.site-footer li { margin-bottom: .3rem; }
.site-footer .legal {
grid-column: 1 / -1;
border-top: 1px solid var(--border);
padding-top: 1.2rem;
margin: 0;
max-width: var(--measure);
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
.center { text-align: center; }
.notfound {
min-height: 52vh;
display: grid;
place-content: center;
text-align: center;
}
+237
View File
@@ -0,0 +1,237 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>下载与安装 - PeopleLib</title>
<meta name="description" content="PeopleLib 下载与安装说明。Windows x64 免安装版解压即用,用户数据在程序同级 data 目录;macOS arm64 提供 DMG,因为是 ad-hoc 签名未经 Apple 公证,首次打开需要右键选择打开。">
<meta name="theme-color" content="#fbfaf7" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#12141a" media="(prefers-color-scheme: dark)">
<link rel="canonical" href="https://reader.mesalogo.com/download.html">
<link rel="icon" type="image/png" sizes="32x32" href="assets/icon-light-32.png" media="(prefers-color-scheme: light)">
<link rel="icon" type="image/png" sizes="32x32" href="assets/icon-dark-32.png" media="(prefers-color-scheme: dark)">
<link rel="apple-touch-icon" href="assets/icon-light-256.png">
<meta property="og:type" content="website">
<meta property="og:site_name" content="PeopleLib">
<meta property="og:locale" content="zh_CN">
<meta property="og:title" content="下载与安装 - PeopleLib">
<meta property="og:description" content="Windows 免安装版与 macOS DMG 的完整安装步骤,包含 macOS 首次打开的处理方法。">
<meta property="og:url" content="https://reader.mesalogo.com/download.html">
<meta property="og:image" content="https://reader.mesalogo.com/assets/og-cover.jpg">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="PeopleLib 书库界面截图与应用图标">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:image" content="https://reader.mesalogo.com/assets/og-cover.jpg">
<link rel="stylesheet" href="assets/style.css">
</head>
<body>
<a class="skip-link" href="#main">跳到主要内容</a>
<header class="site-header">
<div class="wrap">
<a class="brand" href="index.html">
<img class="brand-light" src="assets/icon-light-256.png" alt="" width="30" height="30">
<img class="brand-dark" src="assets/icon-dark-256.png" alt="" width="30" height="30">
<span>PeopleLib</span>
</a>
<nav class="site-nav" aria-label="站点主导航">
<ul>
<li><a href="index.html">首页</a></li>
<li><a href="features.html">功能</a></li>
<li><a href="privacy.html">隐私</a></li>
<li><a href="download.html" aria-current="page">下载</a></li>
<li><a href="faq.html">常见问题</a></li>
</ul>
</nav>
</div>
</header>
<main id="main">
<section>
<div class="wrap">
<div class="section-head">
<span class="eyebrow">下载</span>
<h1>获取 PeopleLib</h1>
<p>
所有正式版本都通过项目的 GitHub Releases 发布,不通过 npm 或应用商店分发。
当前版本 1.3.0,MIT 许可,免费。
</p>
</div>
<p class="btn-row">
<a class="btn btn-primary" href="https://github.com/lofyer/peoplelib/releases/latest" rel="noopener">前往最新发布页</a>
<a class="btn btn-secondary" href="https://github.com/lofyer/peoplelib/releases" rel="noopener">查看全部版本</a>
</p>
<p class="hint">请只从上面的 GitHub Releases 页面下载。本站不直接托管安装包。</p>
</div>
</section>
<section id="windows">
<div class="wrap">
<div class="section-head">
<span class="eyebrow">Windows</span>
<h2>Windows x64 免安装版</h2>
<p>没有安装程序,不写注册表,解压就能用。</p>
</div>
<ol class="steps">
<li>
<p>在 Releases 页面下载 Windows x64 的压缩包。</p>
</li>
<li>
<p><strong>完整解压到一个固定目录</strong>。不要在压缩包里直接双击运行,那样程序找不到自己的资源文件。</p>
</li>
<li>
<p>双击目录中的 <code>PeopleLib.exe</code> 启动。</p>
</li>
<li>
<p><strong>保留整个程序目录</strong>,不要只把 exe 移到别处。用户数据保存在程序同级的 <code>data/</code> 目录里。</p>
</li>
</ol>
<div class="prose">
<div class="callout">
<h3>更新到新版本</h3>
<p>
先完全退出正在运行的 <code>PeopleLib.exe</code>,再用新版本的文件覆盖程序目录。
<code>data/</code> 目录不要替换,书库、笔记与批注都在里面。想换目录的话,把整个目录连 <code>data/</code> 一起搬走。
</p>
</div>
<p class="hint">
Windows 可能对下载来的可执行文件提示来源不明,这是未购买代码签名证书的常见表现。
如果你不确定,可以先在 Releases 页面核对文件来源。
</p>
</div>
</div>
</section>
<section id="macos">
<div class="wrap">
<div class="section-head">
<span class="eyebrow">macOS</span>
<h2>macOS arm64 DMG</h2>
<p>适用于 Apple Silicon 机型。安装包为 ad-hoc 签名,没有经过 Apple 公证。</p>
</div>
<div class="prose">
<div class="callout">
<h3>首次打开会被系统拦下,这是正常的</h3>
<p>
应用用的是 ad-hoc 签名,没有付费开发者账号,也没有走 Apple 的公证流程。
所以 macOS 首次会提示无法验证开发者,或者说文件已损坏。这不是病毒,而是未公证应用的标准提示,
按下面的步骤打开一次之后就不会再出现。
</p>
</div>
</div>
<ol class="steps">
<li>
<p>在 Releases 页面下载 macOS arm64 的 <code>.dmg</code> 文件。</p>
</li>
<li>
<p>双击挂载 DMG,把 <strong>PeopleLib</strong> 拖进「应用程序」文件夹。</p>
</li>
<li>
<p>
在「应用程序」里 <strong>按住 Control 点击</strong>(或右键)PeopleLib 图标,选择「打开」,
然后在弹出的对话框里再点一次「打开」。直接双击是不行的,必须走右键菜单这条路径,
因为只有它会给出确认打开的选项。
</p>
</li>
<li>
<p>
如果仍然被拦,打开「系统设置」,进入「隐私与安全性」,在页面下方找到刚被阻止的 PeopleLib,
点击「仍要打开」。
</p>
</li>
</ol>
<div class="prose">
<p>
macOS 版的用户数据在 <code>~/Library/Application Support/PeopleLib</code>,不在应用包里。
升级时直接覆盖「应用程序」里的旧版本,数据不受影响。
</p>
<p class="hint">Intel 机型目前没有提供构建版本。</p>
</div>
</div>
</section>
<section id="first-run">
<div class="wrap">
<div class="section-head">
<span class="eyebrow">上手</span>
<h2>第一次启动之后做什么</h2>
</div>
<div class="prose">
<ol>
<li><strong>需要代理就先配代理</strong>。设置里配一次,对所有数据源与封面请求同时生效。</li>
<li><strong>试一次检索</strong>。可以先只选一个数据源,确认网络通畅,再切到聚合搜索。</li>
<li><strong>导入已有的书</strong>。本地已有的 PDF、EPUB 等文件可以直接导入书库,不必来自检索结果。</li>
<li><strong>想用 AI 就填自己的接口</strong>。填接口地址与 API Key,选择协议类型。不填也能正常阅读,AI 是可选的。</li>
</ol>
</div>
</div>
</section>
<section id="from-source">
<div class="wrap">
<div class="section-head">
<span class="eyebrow">开发者</span>
<h2>从源码运行</h2>
</div>
<div class="prose">
<p>
源码运行、打包发布、代理与账号配置、数据位置、项目结构与测试说明都在源码仓库的 <code>BUILD.md</code> 里。
公开仓库只发布二进制与使用说明。
</p>
<p>运行环境要求 Node.js 22.19.0 或更高版本。</p>
</div>
</div>
</section>
<section class="cta">
<div class="wrap">
<div>
<h2>装完遇到问题</h2>
<p>常见问题页覆盖了 macOS 拦截、数据位置与格式支持。</p>
</div>
<p class="btn-row">
<a class="btn btn-primary" href="faq.html">查看常见问题</a>
<a class="btn btn-secondary" href="https://github.com/lofyer/peoplelib/issues" rel="noopener">提交问题反馈</a>
</p>
</div>
</section>
</main>
<footer class="site-footer">
<div class="wrap">
<div>
<h2>站点</h2>
<ul>
<li><a href="index.html">首页</a></li>
<li><a href="features.html">功能</a></li>
<li><a href="privacy.html">隐私与安全</a></li>
<li><a href="faq.html">常见问题</a></li>
</ul>
</div>
<div>
<h2>项目</h2>
<ul>
<li><a href="https://github.com/lofyer/peoplelib" rel="noopener">GitHub 仓库</a></li>
<li><a href="https://github.com/lofyer/peoplelib/releases" rel="noopener">版本发布</a></li>
<li><a href="https://github.com/lofyer/peoplelib/issues" rel="noopener">问题反馈</a></li>
</ul>
</div>
<div>
<h2>版本</h2>
<ul>
<li>PeopleLib 1.3.0</li>
<li>MIT 许可</li>
</ul>
</div>
<p class="legal">
本项目仅是对公开网络接口的客户端封装,不托管、不分发任何内容。部分数据源所提供作品的版权状态因司法辖区而异,
使用者需自行确保其使用方式符合当地法律与各站点的服务条款。
</p>
</div>
</footer>
</body>
</html>
+330
View File
@@ -0,0 +1,330 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>常见问题 - PeopleLib</title>
<meta name="description" content="PeopleLib 常见问题:AI 助手要不要花钱、数据存在哪里、支持哪些格式、macOS 提示无法验证开发者怎么办、能不能不联网使用、超大 PDF 会不会卡。">
<meta name="theme-color" content="#fbfaf7" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#12141a" media="(prefers-color-scheme: dark)">
<link rel="canonical" href="https://reader.mesalogo.com/faq.html">
<link rel="icon" type="image/png" sizes="32x32" href="assets/icon-light-32.png" media="(prefers-color-scheme: light)">
<link rel="icon" type="image/png" sizes="32x32" href="assets/icon-dark-32.png" media="(prefers-color-scheme: dark)">
<link rel="apple-touch-icon" href="assets/icon-light-256.png">
<meta property="og:type" content="website">
<meta property="og:site_name" content="PeopleLib">
<meta property="og:locale" content="zh_CN">
<meta property="og:title" content="常见问题 - PeopleLib">
<meta property="og:description" content="AI 费用、数据位置、格式支持、macOS 首次打开被拦、离线使用与超大 PDF 的处理方式。">
<meta property="og:url" content="https://reader.mesalogo.com/faq.html">
<meta property="og:image" content="https://reader.mesalogo.com/assets/og-cover.jpg">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="PeopleLib 书库界面截图与应用图标">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:image" content="https://reader.mesalogo.com/assets/og-cover.jpg">
<link rel="stylesheet" href="assets/style.css">
</head>
<body>
<a class="skip-link" href="#main">跳到主要内容</a>
<header class="site-header">
<div class="wrap">
<a class="brand" href="index.html">
<img class="brand-light" src="assets/icon-light-256.png" alt="" width="30" height="30">
<img class="brand-dark" src="assets/icon-dark-256.png" alt="" width="30" height="30">
<span>PeopleLib</span>
</a>
<nav class="site-nav" aria-label="站点主导航">
<ul>
<li><a href="index.html">首页</a></li>
<li><a href="features.html">功能</a></li>
<li><a href="privacy.html">隐私</a></li>
<li><a href="download.html">下载</a></li>
<li><a href="faq.html" aria-current="page">常见问题</a></li>
</ul>
</nav>
</div>
</header>
<main id="main">
<section>
<div class="wrap">
<div class="section-head">
<span class="eyebrow">常见问题</span>
<h1>常见问题</h1>
<p>点击问题展开答案。没有覆盖到的情况可以到仓库提 issue。</p>
</div>
<div class="faq">
<h2>费用与账号</h2>
<details>
<summary>PeopleLib 本身要钱吗</summary>
<div class="faq-body">
<p>不要。项目以 MIT 许可开源,没有付费版、订阅或功能解锁。</p>
</div>
</details>
<details>
<summary>AI 助手要不要花钱</summary>
<div class="faq-body">
<p>
取决于你自己接的服务。应用不内置任何厂商的 AI 服务,也不代收任何费用。
你在设置里填自己的接口地址与 API Key,费用由你和那家服务商结算,账单也在他们那边看。
</p>
<p>
如果你接的是本地跑的模型,或者某个免费额度内的接口,那就不花钱。
不填 AI 配置的话,检索、书库与阅读功能都能正常使用,AI 是可选的。
</p>
</div>
</details>
<details>
<summary>需要注册账号吗</summary>
<div class="faq-body">
<p>
PeopleLib 自己没有账号体系,不需要注册也不需要登录。
个别数据源(例如需要登录才能取下载直链的站点)要用你自己在那个站点的凭据,凭据保存在本地。
</p>
</div>
</details>
<h2>数据与隐私</h2>
<details>
<summary>我的数据存在哪里</summary>
<div class="faq-body">
<p>全部在本机。书库条目、下载的文件、封面缓存、笔记、批注与阅读进度都写在本地数据目录里,没有云端副本。</p>
<dl class="kv">
<dt>Windows</dt>
<dd>程序目录同级的 <code>data/</code></dd>
<dt>macOS</dt>
<dd><code>~/Library/Application Support/PeopleLib</code></dd>
</dl>
<p>备份就是复制这个目录。彻底删除就是删掉它,不需要在任何地方注销。</p>
</div>
</details>
<details>
<summary>我的书和笔记会被上传吗</summary>
<div class="faq-body">
<p>
不会。应用没有服务端,也没有遥测上报。唯一会把正文内容发出去的情况是你主动使用 AI 助手并确认发送,
目标是你自己填写的那个接口地址。详见<a href="privacy.html">隐私与安全</a>
</p>
</div>
</details>
<details>
<summary>AI 会不会把整本书悄悄发出去</summary>
<div class="faq-body">
<p>
不会。上下文范围由你选,选「全文」时无论文档多短都强制弹确认框,并显示确切字数。
界面上显示的字数就是真正外发的字数,应用不在本地偷偷截断,也不会在你没确认时发送。
</p>
</div>
</details>
<details>
<summary>我的 API Key 安全吗</summary>
<div class="faq-body">
<p>
Key 经操作系统提供的 safeStorage 加密后落盘,不是明文配置文件,也不会进入界面进程,
日志与错误信息里也不会出现。请求由主进程直接发往你填写的地址,中间没有第三方转发。
</p>
</div>
</details>
<details>
<summary>能完全离线使用吗</summary>
<div class="faq-body">
<p>
可以。已经下载进书库的文件,阅读、批注与笔记都不需要网络。封面已经缓存在本地,离线也能正常显示。
需要联网的只有检索、下载、AI 请求与检查更新这四件事。
</p>
</div>
</details>
<h2>格式与阅读</h2>
<details>
<summary>支持哪些格式</summary>
<div class="faq-body">
<p>内置阅读支持四类:</p>
<ul>
<li><strong>PDF</strong>:页面批注、书签、选文与笔记,渲染画质可调</li>
<li><strong>EPUB</strong>:目录、重排、书签、选文与笔记</li>
<li><strong>MOBI / AZW / AZW3</strong>:无 DRM 的 MOBI、KF7 与 KF8 内容</li>
<li><strong>TXT / Markdown</strong>:自动识别编码,Markdown 渲染标题、列表、代码块与表格</li>
</ul>
<p>
<strong>DJVU、FB2、CBZ、CBR</strong> 可以入库与整理,但没有内置阅读器,阅读要调用系统关联应用。
完整对照表在<a href="index.html#formats">首页</a>
</p>
</div>
</details>
<details>
<summary>Kindle 买的书能读吗</summary>
<div class="faq-body">
<p>
带 DRM 的文件不能。PeopleLib 不解除 DRM,遇到受保护的 MOBI、AZW、AZW3、KFX 或 Topaz 文件会明确拒绝,
并提示改用系统关联的应用打开。无 DRM 的 MOBI、KF7 与 KF8 可以正常阅读。
</p>
</div>
</details>
<details>
<summary>几百兆的 PDF 会不会卡死</summary>
<div class="faq-body">
<p>
超大 PDF 走分段读取,不会把整个文件读进内存,所以打开一本很大的书不会一次性吃掉几百兆内存。
如果渲染感觉吃力,可以在底栏把画质档位调低。
</p>
</div>
</details>
<details>
<summary>TXT 打开是乱码怎么办</summary>
<div class="faq-body">
<p>
应用会自动探测 UTF-8、带 BOM 与不带 BOM 的 UTF-16,以及 GB18030。
如果仍然乱码,说明文件用了这几种之外的编码,可以先用文本编辑器另存为 UTF-8 再导入。
</p>
</div>
</details>
<details>
<summary>笔记能导出吗</summary>
<div class="faq-body">
<p>可以。摘录与笔记支持导出,方便带到别的工具里继续整理。</p>
</div>
</details>
<h2>安装与运行</h2>
<details>
<summary>macOS 提示无法验证开发者,或者说文件已损坏</summary>
<div class="faq-body">
<p>
这是未公证应用的标准提示,不是文件损坏,也不是病毒。macOS 版用的是 ad-hoc 签名,
没有付费开发者账号,也没有走 Apple 的公证流程,所以系统首次会拦下来。
</p>
<ol>
<li>先把 PeopleLib 拖进「应用程序」文件夹。</li>
<li><strong>按住 Control 点击</strong>(或右键)应用图标,选择「打开」,在弹出的对话框里再点一次「打开」。直接双击不行,必须走右键菜单,只有这条路径会给出确认选项。</li>
<li>如果仍被拦,打开「系统设置」,进入「隐私与安全性」,在页面下方找到被阻止的 PeopleLib,点击「仍要打开」。</li>
</ol>
<p>成功打开一次之后系统会记住这个选择,之后正常双击即可。</p>
</div>
</details>
<details>
<summary>Windows 版怎么安装</summary>
<div class="faq-body">
<p>
没有安装程序。下载压缩包后<strong>完整解压</strong>到一个固定目录,双击里面的 <code>PeopleLib.exe</code> 就能用。
不要在压缩包里直接双击运行,也不要把 exe 单独移出来,程序需要同目录下的资源文件。
</p>
</div>
</details>
<details>
<summary>更新版本会不会丢数据</summary>
<div class="faq-body">
<p>
不会,但要注意操作顺序。Windows 上先完全退出正在运行的 <code>PeopleLib.exe</code>,再用新文件覆盖程序目录,
<code>data/</code> 目录保持原样不要替换。macOS 上数据在 <code>~/Library/Application Support/PeopleLib</code>
直接覆盖「应用程序」里的旧版本即可。
</p>
</div>
</details>
<details>
<summary>能换程序目录或者搬到别的电脑吗</summary>
<div class="faq-body">
<p>
Windows 版是便携布局,把整个程序目录连同 <code>data/</code> 一起复制走就行,
不要只搬 exe,也不要把 <code>data/</code> 落在旧目录里。
</p>
</div>
</details>
<details>
<summary>搜不到结果或者下载失败</summary>
<div class="faq-body">
<p>
数据源的可用性取决于站点自身状态。有的站点启用了人机验证,有的接口只提供最新列表而不支持关键词搜索,
有的下载需要该站点的账号。应用遇到这些情况会给出明确提示。
</p>
<p>如果所有源都不通,先检查设置里的代理配置,它对所有数据源与封面请求统一生效。</p>
</div>
</details>
<details>
<summary>有 Linux 版、移动端或浏览器插件吗</summary>
<div class="faq-body">
<p>
目前发布的是 Windows x64 免安装版与 macOS arm64 DMG。没有移动端应用,也没有浏览器插件。
项目源码开放,其他平台可自行尝试从源码运行。
</p>
</div>
</details>
</div>
</div>
</section>
<section class="cta">
<div class="wrap">
<div>
<h2>问题没解决</h2>
<p>到仓库开一个 issue,说明系统版本与复现步骤。</p>
</div>
<p class="btn-row">
<a class="btn btn-primary" href="https://github.com/lofyer/peoplelib/issues" rel="noopener">提交问题反馈</a>
<a class="btn btn-secondary" href="download.html">回到下载页</a>
</p>
</div>
</section>
</main>
<footer class="site-footer">
<div class="wrap">
<div>
<h2>站点</h2>
<ul>
<li><a href="index.html">首页</a></li>
<li><a href="features.html">功能</a></li>
<li><a href="privacy.html">隐私与安全</a></li>
<li><a href="download.html">下载与安装</a></li>
</ul>
</div>
<div>
<h2>项目</h2>
<ul>
<li><a href="https://github.com/lofyer/peoplelib" rel="noopener">GitHub 仓库</a></li>
<li><a href="https://github.com/lofyer/peoplelib/releases" rel="noopener">版本发布</a></li>
<li><a href="https://github.com/lofyer/peoplelib/issues" rel="noopener">问题反馈</a></li>
</ul>
</div>
<div>
<h2>版本</h2>
<ul>
<li>PeopleLib 1.3.0</li>
<li>MIT 许可</li>
</ul>
</div>
<p class="legal">
本项目仅是对公开网络接口的客户端封装,不托管、不分发任何内容。部分数据源所提供作品的版权状态因司法辖区而异,
使用者需自行确保其使用方式符合当地法律与各站点的服务条款。
</p>
</div>
</footer>
</body>
</html>
+321
View File
@@ -0,0 +1,321 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>功能 - PeopleLib</title>
<meta name="description" content="PeopleLib 的完整功能说明:12 个数据源统一检索、本地书库与书架标签、PDF/EPUB/MOBI/TXT 内置阅读器、读书笔记与画布笔记、自带密钥的 AI 助手、全局代理与镜像故障转移。">
<meta name="theme-color" content="#fbfaf7" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#12141a" media="(prefers-color-scheme: dark)">
<link rel="canonical" href="https://reader.mesalogo.com/features.html">
<link rel="icon" type="image/png" sizes="32x32" href="assets/icon-light-32.png" media="(prefers-color-scheme: light)">
<link rel="icon" type="image/png" sizes="32x32" href="assets/icon-dark-32.png" media="(prefers-color-scheme: dark)">
<link rel="apple-touch-icon" href="assets/icon-light-256.png">
<meta property="og:type" content="website">
<meta property="og:site_name" content="PeopleLib">
<meta property="og:locale" content="zh_CN">
<meta property="og:title" content="功能 - PeopleLib">
<meta property="og:description" content="多源检索、本地书库、内置阅读器、笔记与批注、AI 助手、代理与镜像容错的完整说明。">
<meta property="og:url" content="https://reader.mesalogo.com/features.html">
<meta property="og:image" content="https://reader.mesalogo.com/assets/og-cover.jpg">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="PeopleLib 书库界面截图与应用图标">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:image" content="https://reader.mesalogo.com/assets/og-cover.jpg">
<link rel="stylesheet" href="assets/style.css">
</head>
<body>
<a class="skip-link" href="#main">跳到主要内容</a>
<header class="site-header">
<div class="wrap">
<a class="brand" href="index.html">
<img class="brand-light" src="assets/icon-light-256.png" alt="" width="30" height="30">
<img class="brand-dark" src="assets/icon-dark-256.png" alt="" width="30" height="30">
<span>PeopleLib</span>
</a>
<nav class="site-nav" aria-label="站点主导航">
<ul>
<li><a href="index.html">首页</a></li>
<li><a href="features.html" aria-current="page">功能</a></li>
<li><a href="privacy.html">隐私</a></li>
<li><a href="download.html">下载</a></li>
<li><a href="faq.html">常见问题</a></li>
</ul>
</nav>
</div>
</header>
<main id="main">
<section>
<div class="wrap">
<div class="section-head">
<span class="eyebrow">功能</span>
<h1>已经实现的部分</h1>
<p>这一页只写应用里真实存在的能力。没有列出的功能就是没有做。</p>
</div>
<ul class="pill-list">
<li><a href="#search">多源检索</a></li>
<li><a href="#library">本地书库</a></li>
<li><a href="#reader">内置阅读器</a></li>
<li><a href="#notes">笔记与批注</a></li>
<li><a href="#ai">AI 助手</a></li>
<li><a href="#network">代理与镜像</a></li>
<li><a href="#updates">版本更新</a></li>
<li><a href="#not-included">不包含的功能</a></li>
</ul>
</div>
</section>
<section id="search">
<div class="wrap">
<div class="section-head">
<span class="eyebrow">检索</span>
<h2>12 个数据源,一套流程</h2>
</div>
<div class="prose">
<p>
检索、查看详情、下载文件这三步对所有数据源都是同一套界面与同一套交互。可以只搜某一个源,
也可以聚合全部源一起搜。不同站点的字段差异由应用内部归一,你看到的是统一的条目列表。
</p>
<p>
数据源的可用性取决于站点自身状态。有的站点启用了人机验证,有的接口只提供最新列表而不支持关键词搜索。
遇到这类限制时应用会给出明确提示,不会假装搜索成功却返回空结果。完整的数据源清单见
<a href="https://github.com/lofyer/peoplelib" rel="noopener">仓库说明</a>
</p>
</div>
<figure class="shot">
<img src="assets/shot-search.jpg" width="1600" height="1086" alt="多源检索界面,顶部是搜索框与数据源选择,下方列出检索结果条目" loading="lazy">
<figcaption>检索结果与数据源筛选</figcaption>
</figure>
</div>
</section>
<section id="library">
<div class="wrap">
<div class="section-head">
<span class="eyebrow">归档</span>
<h2>本地书库</h2>
</div>
<div class="prose">
<dl class="kv">
<dt>收藏与下载</dt>
<dd>检索到的条目可以先收藏,再决定是否下载文件。下载完成后自动登记进书库。</dd>
<dt>封面缓存</dt>
<dd>封面下载后缓存在本地,离线也能正常显示网格视图。</dd>
<dt>书架与标签</dt>
<dd>用书架做粗分类,用标签做交叉归类,同一本书可以属于多个标签。</dd>
<dt>批量整理</dt>
<dd>支持多选,批量移动书架、批量打标签、批量删除。</dd>
<dt>阅读状态</dt>
<dd>记录每本书的阅读状态与进度,重新打开时回到上次的位置。</dd>
<dt>本地导入</dt>
<dd>已有的文件可以直接导入书库,不必来自检索结果。</dd>
</dl>
</div>
</div>
</section>
<section id="reader">
<div class="wrap">
<div class="section-head">
<span class="eyebrow">阅读</span>
<h2>内置阅读器</h2>
<p>四类格式在应用内直接打开,不需要外部程序。</p>
</div>
<div class="grid">
<article class="card">
<h3>PDF</h3>
<ul>
<li>页面批注、书签、选文与笔记</li>
<li>渲染画质分三档可调并记住选择</li>
<li>超大文件分段读取,不整文件载入内存</li>
</ul>
</article>
<article class="card">
<h3>EPUB</h3>
<ul>
<li>目录导航与文字重排</li>
<li>书签、选文与笔记</li>
<li>按章节与偏移量记录阅读位置</li>
</ul>
</article>
<article class="card">
<h3>MOBI / AZW / AZW3</h3>
<ul>
<li>使用 Foliate 解析无 DRM 的 MOBI、KF7 与 KF8</li>
<li>DRM 保护的文件明确拒绝,不尝试绕过</li>
</ul>
</article>
<article class="card">
<h3>TXT / Markdown</h3>
<ul>
<li>自动识别 UTF-8、UTF-16 与 GB18030 编码</li>
<li>Markdown 渲染标题、列表、代码块与表格</li>
<li>与 EPUB 同一套重排与定位机制</li>
</ul>
</article>
</div>
<figure class="shot">
<img src="assets/shot-reader.jpg" width="1600" height="1050" alt="内置 PDF 阅读器,页面上带有高亮批注,右侧是笔记面板" loading="lazy">
<figcaption>PDF 阅读与批注</figcaption>
</figure>
</div>
</section>
<section id="notes">
<div class="wrap">
<div class="section-head">
<span class="eyebrow">批注</span>
<h2>笔记与批注</h2>
</div>
<div class="prose">
<p>
批注直接落在页面上,和书目条目绑定。笔记分两种形态:读书笔记是文字为主的富文本,
画布笔记可以自由绘制,并且能把 PDF 的某一页当作底版,在上面圈画标记。
</p>
<p>选中的段落可以摘录进笔记,笔记与摘录支持导出,方便带到别的工具里继续用。</p>
</div>
<figure class="shot">
<img src="assets/shot-annotations.jpg" width="1600" height="1058" alt="PDF 页面上的批注工具与已有的高亮标记" loading="lazy">
<figcaption>批注工具</figcaption>
</figure>
</div>
</section>
<section id="ai">
<div class="wrap">
<div class="section-head">
<span class="eyebrow">AI</span>
<h2>自带密钥的 AI 助手</h2>
<p>应用不内置任何厂商服务,接口地址与密钥都由你填。</p>
</div>
<div class="prose">
<p>支持三类协议:Anthropic 的 <code>/messages</code>、OpenAI Responses 的 <code>/responses</code>,以及兼容 Chat 的 <code>/chat/completions</code>。任何符合这三类协议的服务都能接。</p>
<h3>五种上下文范围</h3>
<ul>
<li><strong>选中文本</strong>:只发你选中的那一段,需要先选中。</li>
<li><strong>当前页</strong>:发当前页的正文,不依赖选区。</li>
<li><strong>全文</strong>:发整篇正文,不依赖选区,发送前强制确认。</li>
<li><strong>页面图像</strong>:把当前页渲染成图片发送,适合公式与图表。</li>
<li><strong>框选区域</strong>:在页面上拉一个框,只发框内的图像。</li>
</ul>
<h3>关于字数</h3>
<p>
正文完整发送,本地不做截断。界面上显示的字数就是真正外发的字数,两者一致。
如果超出模型的上下文限制,应用会把接口返回的报错转成中文提示,而不是悄悄少发一部分内容。
</p>
<p>对话支持多轮追问,上下文范围在会话中可以随时切换。</p>
</div>
<figure class="shot">
<img src="assets/shot-ai.jpg" width="1474" height="1125" alt="AI 助手侧栏,展示上下文范围选择与对话内容" loading="lazy">
<figcaption>AI 助手侧栏与上下文范围选择</figcaption>
</figure>
</div>
</section>
<section id="network">
<div class="wrap">
<div class="section-head">
<span class="eyebrow">网络</span>
<h2>代理与镜像容错</h2>
</div>
<div class="prose">
<dl class="kv">
<dt>全局代理</dt>
<dd>在设置里配一次,对所有数据源请求与封面请求同时生效,不需要逐个源单独设置。</dd>
<dt>镜像故障转移</dt>
<dd>某个镜像不可用时自动切到下一个,原镜像恢复后自动重新启用,不需要手动重置。</dd>
<dt>失败提示</dt>
<dd>站点侧的人机验证、登录过期与超时都会转成明确的中文提示。</dd>
</dl>
</div>
</div>
</section>
<section id="updates">
<div class="wrap">
<div class="section-head">
<span class="eyebrow">维护</span>
<h2>版本更新</h2>
</div>
<div class="prose">
<p>
可以在设置里手动检查更新,也可以打开启动时自动检查。检查的对象是项目的 GitHub Releases
发现新版本后应用会打开对应的下载页。更新前退出旧版本再覆盖程序文件,用户数据目录不需要替换。
</p>
</div>
</div>
</section>
<section id="not-included">
<div class="wrap">
<div class="section-head">
<span class="eyebrow">边界</span>
<h2>不包含的功能</h2>
<p>写清楚没有什么,比含糊其辞更省你的时间。</p>
</div>
<div class="prose">
<ul>
<li>没有云同步,也没有账号体系。</li>
<li>没有移动端应用,也没有浏览器插件。</li>
<li>没有多人协作或共享书库。</li>
<li>DJVU、FB2、CBZ、CBR 只能入库与整理,阅读要调用系统关联应用。</li>
<li>不解除 DRM。受保护的文件会被明确拒绝并提示改用系统关联应用。</li>
</ul>
</div>
</div>
</section>
<section class="cta">
<div class="wrap">
<div>
<h2>看完想试试</h2>
<p>下载页有 Windows 与 macOS 的完整安装说明。</p>
</div>
<p class="btn-row">
<a class="btn btn-primary" href="download.html">前往下载</a>
<a class="btn btn-secondary" href="privacy.html">先看隐私说明</a>
</p>
</div>
</section>
</main>
<footer class="site-footer">
<div class="wrap">
<div>
<h2>站点</h2>
<ul>
<li><a href="index.html">首页</a></li>
<li><a href="privacy.html">隐私与安全</a></li>
<li><a href="download.html">下载与安装</a></li>
<li><a href="faq.html">常见问题</a></li>
</ul>
</div>
<div>
<h2>项目</h2>
<ul>
<li><a href="https://github.com/lofyer/peoplelib" rel="noopener">GitHub 仓库</a></li>
<li><a href="https://github.com/lofyer/peoplelib/releases" rel="noopener">版本发布</a></li>
<li><a href="https://github.com/lofyer/peoplelib/issues" rel="noopener">问题反馈</a></li>
</ul>
</div>
<div>
<h2>版本</h2>
<ul>
<li>PeopleLib 1.3.0</li>
<li>MIT 许可</li>
</ul>
</div>
<p class="legal">
本项目仅是对公开网络接口的客户端封装,不托管、不分发任何内容。部分数据源所提供作品的版权状态因司法辖区而异,
使用者需自行确保其使用方式符合当地法律与各站点的服务条款。
</p>
</div>
</footer>
</body>
</html>
+280
View File
@@ -0,0 +1,280 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PeopleLib:多源文献检索、本地书库与内置阅读器</title>
<meta name="description" content="PeopleLib 是一款桌面文献客户端,在一个界面里检索 12 个公开文献源,下载归档到本地书库,并用内置阅读器阅读 PDF、EPUB、MOBI 与 TXT/Markdown。书库、笔记与批注全部存在本地,AI 助手自带密钥。">
<meta name="theme-color" content="#fbfaf7" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#12141a" media="(prefers-color-scheme: dark)">
<link rel="canonical" href="https://reader.mesalogo.com/">
<link rel="icon" type="image/png" sizes="32x32" href="assets/icon-light-32.png" media="(prefers-color-scheme: light)">
<link rel="icon" type="image/png" sizes="32x32" href="assets/icon-dark-32.png" media="(prefers-color-scheme: dark)">
<link rel="apple-touch-icon" href="assets/icon-light-256.png">
<meta property="og:type" content="website">
<meta property="og:site_name" content="PeopleLib">
<meta property="og:locale" content="zh_CN">
<meta property="og:title" content="PeopleLib:多源文献检索、本地书库与内置阅读器">
<meta property="og:description" content="在一个桌面客户端里检索 12 个公开文献源,下载归档到本地书库,用内置阅读器阅读并批注。数据留在本地,AI 助手自带密钥。">
<meta property="og:url" content="https://reader.mesalogo.com/">
<meta property="og:image" content="https://reader.mesalogo.com/assets/og-cover.jpg">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="PeopleLib 书库界面截图与应用图标">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="PeopleLib:多源文献检索、本地书库与内置阅读器">
<meta name="twitter:description" content="在一个桌面客户端里检索 12 个公开文献源,下载归档到本地书库,用内置阅读器阅读并批注。数据留在本地,AI 助手自带密钥。">
<meta name="twitter:image" content="https://reader.mesalogo.com/assets/og-cover.jpg">
<link rel="stylesheet" href="assets/style.css">
</head>
<body>
<a class="skip-link" href="#main">跳到主要内容</a>
<header class="site-header">
<div class="wrap">
<a class="brand" href="index.html">
<img class="brand-light" src="assets/icon-light-256.png" alt="" width="30" height="30">
<img class="brand-dark" src="assets/icon-dark-256.png" alt="" width="30" height="30">
<span>PeopleLib</span>
</a>
<nav class="site-nav" aria-label="站点主导航">
<ul>
<li><a href="index.html" aria-current="page">首页</a></li>
<li><a href="features.html">功能</a></li>
<li><a href="privacy.html">隐私</a></li>
<li><a href="download.html">下载</a></li>
<li><a href="faq.html">常见问题</a></li>
</ul>
</nav>
</div>
</header>
<main id="main">
<section class="hero">
<div class="wrap hero-grid">
<div>
<p class="tagline">Windows 与 macOS 桌面应用</p>
<h1>检索、收藏、阅读,都在自己的电脑里完成</h1>
<p class="lede">
PeopleLib 把 12 个公开文献源的检索、详情与下载收进同一个界面,下载的文件直接进本地书库,
用内置阅读器打开并做批注。书库、笔记与阅读进度都留在你的磁盘上,不经过任何服务器。
</p>
<p class="btn-row">
<a class="btn btn-primary" href="download.html">下载 PeopleLib</a>
<a class="btn btn-secondary" href="features.html">查看功能</a>
</p>
<p class="hint">当前版本 1.3.0MIT 许可。Windows x64 免安装,macOS arm64 提供 DMG。</p>
</div>
<figure class="shot">
<img src="assets/shot-library.jpg" width="1600" height="1086" alt="PeopleLib 书库界面,左侧是书架与标签,右侧网格展示带封面的图书条目" fetchpriority="high">
<figcaption>本地书库:书架、标签、封面缓存与阅读状态</figcaption>
</figure>
</div>
</section>
<section id="highlights">
<div class="wrap">
<div class="section-head">
<span class="eyebrow">核心能力</span>
<h2>一条完整的链路,从找到文献到读完做笔记</h2>
<p>没有账号,没有云端,没有订阅。每一步都在本机完成。</p>
</div>
<div class="grid">
<article class="card">
<span class="card-meta">检索</span>
<h3>多源统一检索</h3>
<p>12 个数据源共用同一套搜索、详情与下载流程,可指定单个源,也可聚合全部。数据源可用性取决于站点自身状态,程序会给出明确提示而不是静默失败。</p>
</article>
<article class="card">
<span class="card-meta">归档</span>
<h3>本地书库</h3>
<p>收藏条目、下载文件、缓存封面,用书架与标签整理,支持多选批量操作,并记录每本书的阅读状态。</p>
</article>
<article class="card">
<span class="card-meta">阅读</span>
<h3>内置阅读器</h3>
<p>PDF、EPUB、无 DRM 的 MOBI/AZW/AZW3 与 TXT/Markdown 都在应用内打开,保留进度、书签与选文。超大 PDF 分段读取,不把整个文件读进内存。</p>
</article>
<article class="card">
<span class="card-meta">批注</span>
<h3>笔记与批注</h3>
<p>读书笔记与画布笔记两种形态,画布笔记可以拿 PDF 页面当底版自由标注,摘录与笔记可导出。</p>
</article>
<article class="card">
<span class="card-meta">AI</span>
<h3>自带密钥的 AI 助手</h3>
<p>填自己的接口地址与 API Key,支持选中文本、当前页、全文、页面图像与框选区域五种上下文范围,可多轮追问。</p>
</article>
<article class="card">
<span class="card-meta">网络</span>
<h3>代理与镜像容错</h3>
<p>代理一处配置对所有数据源与封面请求生效。镜像失效自动切换,恢复后自动重新启用。</p>
</article>
</div>
</div>
</section>
<section id="privacy-teaser">
<div class="wrap">
<div class="section-head">
<span class="eyebrow">设计取舍</span>
<h2>本地优先不是宣传语,是架构约束</h2>
<p>这些限制写在代码的边界里,不是可以在设置里关掉的开关。</p>
</div>
<div class="grid">
<article class="card">
<h3>数据不出本机</h3>
<p>书库、笔记、批注与阅读进度都存在本地文件里。应用没有账号体系,也没有同步服务端。</p>
</article>
<article class="card">
<h3>AI 密钥由系统加密保管</h3>
<p>没有内置任何厂商服务。API Key 经操作系统的 safeStorage 加密后落盘,不进渲染进程,不写日志。</p>
</article>
<article class="card">
<h3>正文不会自动外发</h3>
<p>只有你确认后才发送。选择「全文」范围时无论文档多短都强制弹确认框,并显示确切字数。</p>
</article>
<article class="card">
<h3>渲染层拿不到任意路径</h3>
<p>界面进程不能直接读盘,所有文件访问都要经主进程白名单校验,只放行书库中真实登记的条目。</p>
</article>
</div>
<p><a href="privacy.html">阅读完整的隐私与安全说明</a></p>
</div>
</section>
<section id="screens">
<div class="wrap">
<div class="section-head">
<span class="eyebrow">界面预览</span>
<h2>真实截图</h2>
<p>以下画面来自 Windows 版本,界面为中文。</p>
</div>
<div class="gallery">
<figure class="shot">
<img src="assets/shot-search.jpg" width="1600" height="1086" alt="多源检索界面,顶部是搜索框与数据源选择,下方列出检索结果条目" loading="lazy">
<figcaption>多源检索,可选单个数据源或聚合全部</figcaption>
</figure>
<figure class="shot">
<img src="assets/shot-reader.jpg" width="1600" height="1050" alt="内置 PDF 阅读器,页面上带有高亮批注,右侧是笔记面板" loading="lazy">
<figcaption>内置阅读器与 PDF 批注</figcaption>
</figure>
<figure class="shot">
<img src="assets/shot-ai.jpg" width="1474" height="1125" alt="AI 助手侧栏,展示上下文范围选择与对话内容" loading="lazy">
<figcaption>AI 助手,可选五种上下文范围提问</figcaption>
</figure>
<figure class="shot">
<img src="assets/shot-ai-confirm.jpg" width="1600" height="1076" alt="发送前的确认对话框,显示即将外发的字数" loading="lazy">
<figcaption>发送前的确认框会显示确切字数</figcaption>
</figure>
</div>
</div>
</section>
<section id="formats">
<div class="wrap">
<div class="section-head">
<span class="eyebrow">格式</span>
<h2>能读什么,能管什么</h2>
<p>入库管理与内置阅读是两件事,下表分开列出,不夸大支持范围。</p>
</div>
<div class="table-scroll">
<table>
<caption>格式支持对照</caption>
<thead>
<tr>
<th scope="col">格式</th>
<th scope="col">入库与管理</th>
<th scope="col">内置阅读</th>
<th scope="col">说明</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">PDF</th>
<td><span class="yes">支持</span></td>
<td><span class="yes">支持</span></td>
<td>页面批注、书签、选文与笔记,可调渲染画质</td>
</tr>
<tr>
<th scope="row">EPUB</th>
<td><span class="yes">支持</span></td>
<td><span class="yes">支持</span></td>
<td>目录、重排、书签、选文与笔记</td>
</tr>
<tr>
<th scope="row">MOBI / AZW / AZW3</th>
<td><span class="yes">支持</span></td>
<td><span class="yes">支持</span></td>
<td>使用 Foliate 解析无 DRM 的 MOBI、KF7 与 KF8 内容</td>
</tr>
<tr>
<th scope="row">TXT / Markdown</th>
<td><span class="yes">支持</span></td>
<td><span class="yes">支持</span></td>
<td>自动识别编码,Markdown 渲染标题、列表、代码块与表格</td>
</tr>
<tr>
<th scope="row">DJVU / FB2 / CBZ / CBR</th>
<td><span class="yes">支持</span></td>
<td><span class="no">不支持</span></td>
<td>可入库整理,阅读需调用系统关联应用</td>
</tr>
</tbody>
</table>
</div>
<p class="hint">DRM 保护的 MOBI/AZW/AZW3、KFX、Topaz 以及损坏或不兼容的文件不会尝试绕过保护,应用会明确提示改用系统关联应用打开。</p>
</div>
</section>
<section class="cta">
<div class="wrap">
<div>
<h2>准备好开始了</h2>
<p>Windows 免安装解压即用,macOS 提供 arm64 DMG。</p>
</div>
<p class="btn-row">
<a class="btn btn-primary" href="download.html">前往下载</a>
<a class="btn btn-secondary" href="https://github.com/lofyer/peoplelib/releases" rel="noopener">GitHub Releases</a>
</p>
</div>
</section>
</main>
<footer class="site-footer">
<div class="wrap">
<div>
<h2>站点</h2>
<ul>
<li><a href="features.html">功能</a></li>
<li><a href="privacy.html">隐私与安全</a></li>
<li><a href="download.html">下载与安装</a></li>
<li><a href="faq.html">常见问题</a></li>
</ul>
</div>
<div>
<h2>项目</h2>
<ul>
<li><a href="https://github.com/lofyer/peoplelib" rel="noopener">GitHub 仓库</a></li>
<li><a href="https://github.com/lofyer/peoplelib/releases" rel="noopener">版本发布</a></li>
<li><a href="https://github.com/lofyer/peoplelib/issues" rel="noopener">问题反馈</a></li>
</ul>
</div>
<div>
<h2>版本</h2>
<ul>
<li>PeopleLib 1.3.0</li>
<li>MIT 许可</li>
</ul>
</div>
<p class="legal">
本项目仅是对公开网络接口的客户端封装,不托管、不分发任何内容。部分数据源所提供作品的版权状态因司法辖区而异,
使用者需自行确保其使用方式符合当地法律与各站点的服务条款。
</p>
</div>
</footer>
</body>
</html>
+292
View File
@@ -0,0 +1,292 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>隐私与安全 - PeopleLib</title>
<meta name="description" content="PeopleLib 的隐私与安全设计:书库笔记批注全部留在本地,AI 助手完全自带密钥且密钥经系统 safeStorage 加密,正文只在用户确认后外发,渲染进程无法读取任意路径,不解除 DRM。">
<meta name="theme-color" content="#fbfaf7" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#12141a" media="(prefers-color-scheme: dark)">
<link rel="canonical" href="https://reader.mesalogo.com/privacy.html">
<link rel="icon" type="image/png" sizes="32x32" href="assets/icon-light-32.png" media="(prefers-color-scheme: light)">
<link rel="icon" type="image/png" sizes="32x32" href="assets/icon-dark-32.png" media="(prefers-color-scheme: dark)">
<link rel="apple-touch-icon" href="assets/icon-light-256.png">
<meta property="og:type" content="website">
<meta property="og:site_name" content="PeopleLib">
<meta property="og:locale" content="zh_CN">
<meta property="og:title" content="隐私与安全 - PeopleLib">
<meta property="og:description" content="数据留在本地,AI 自带密钥,正文不自动外发,渲染层无法读取任意路径。这些是架构约束,不是可选开关。">
<meta property="og:url" content="https://reader.mesalogo.com/privacy.html">
<meta property="og:image" content="https://reader.mesalogo.com/assets/og-cover.jpg">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="PeopleLib 书库界面截图与应用图标">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:image" content="https://reader.mesalogo.com/assets/og-cover.jpg">
<link rel="stylesheet" href="assets/style.css">
</head>
<body>
<a class="skip-link" href="#main">跳到主要内容</a>
<header class="site-header">
<div class="wrap">
<a class="brand" href="index.html">
<img class="brand-light" src="assets/icon-light-256.png" alt="" width="30" height="30">
<img class="brand-dark" src="assets/icon-dark-256.png" alt="" width="30" height="30">
<span>PeopleLib</span>
</a>
<nav class="site-nav" aria-label="站点主导航">
<ul>
<li><a href="index.html">首页</a></li>
<li><a href="features.html">功能</a></li>
<li><a href="privacy.html" aria-current="page">隐私</a></li>
<li><a href="download.html">下载</a></li>
<li><a href="faq.html">常见问题</a></li>
</ul>
</nav>
</div>
</header>
<main id="main">
<section>
<div class="wrap">
<div class="section-head">
<span class="eyebrow">隐私与安全</span>
<h1>你的书库只属于你</h1>
<p>
下面这些不是隐私政策的模板文字,而是应用实际的架构约束。它们决定了某些事情在这个程序里做不到,
哪怕以后想加也要先推翻现有设计。
</p>
</div>
<div class="prose">
<div class="callout">
<h3>一句话总结</h3>
<p>
PeopleLib 没有服务端。它唯一会主动联网的场合是你按下搜索或下载,以及你启用检查更新。
AI 请求发往你自己填的接口地址。除此之外没有任何数据外发。
</p>
</div>
</div>
</div>
</section>
<section id="local-first">
<div class="wrap">
<div class="section-head">
<span class="eyebrow">本地优先</span>
<h2>数据存在哪里</h2>
</div>
<div class="prose">
<p>书库条目、下载的文件、封面缓存、读书笔记、画布笔记、批注与阅读进度,全部写在本机的数据目录里。没有账号,没有同步服务端,没有遥测上报。</p>
<p>AI 对话记录同样只存在本机,按会话分别存成文件,随书籍删除一并清理。发给视觉模型的图像也留在本地,供你回看历史时显示,后续追问不会重复上传。</p>
<dl class="kv">
<dt>Windows</dt>
<dd>程序目录同级的 <code>data/</code>,便携布局。整个程序目录可以整体搬走或备份。</dd>
<dt>macOS</dt>
<dd><code>~/Library/Application Support/PeopleLib</code>。应用包在 DMG 里是只读的,数据不能放里面,升级覆盖也不会碰到这个目录。</dd>
</dl>
<p>想彻底删除数据,删掉上面对应的目录即可,不需要在任何地方注销账号。</p>
</div>
</div>
</section>
<section id="ai-key">
<div class="wrap">
<div class="section-head">
<span class="eyebrow">AI 密钥</span>
<h2>完全自带密钥,没有中间人</h2>
</div>
<div class="prose">
<p>
应用没有内置任何厂商服务,也没有代理转发层。你在设置里填自己的接口地址与 API Key,
请求从你的机器直接发到你填的那个地址。项目方看不到你的用量,也收不到你的费用。
</p>
<ul>
<li><strong>密钥加密落盘</strong>:API Key 经操作系统提供的 safeStorage 加密后保存,不是明文配置文件。</li>
<li><strong>密钥不进渲染进程</strong>:界面进程拿不到密钥原文,实际请求由主进程发出。</li>
<li><strong>密钥不写日志</strong>:日志与错误信息里不会出现密钥或 token。</li>
<li><strong>Responses 协议不留存</strong>:走 OpenAI Responses 协议时请求带 <code>store: false</code>,不让服务端留存对话记录。</li>
</ul>
<p class="hint">注意:你选择的模型提供方会按它自己的政策处理收到的内容。PeopleLib 能控制的是不多发、不留副本、不经第三方转发。</p>
</div>
</div>
</section>
<section id="explicit-send">
<div class="wrap">
<div class="section-head">
<span class="eyebrow">明确确认</span>
<h2>正文不会自动外发</h2>
</div>
<div class="prose">
<p>
打开一本书不会触发任何 AI 请求。只有你选定上下文范围并点击发送,内容才会离开本机。
</p>
<ul>
<li>选择「全文」范围时,无论文档多短都会强制弹出确认框,并提示可能超出模型的上下文限制。</li>
<li>确认框里显示的字数就是真正外发的字数。应用不做本地截断,界面数字与实际请求一致。</li>
<li>如果内容超出模型限制,应用把接口返回的报错转成中文提示,而不是悄悄少发一部分。</li>
</ul>
<p>
这条规则有过教训。早期版本按固定字数上限挖掉正文中间部分,结果 40 页的文档实际只发出 8 页,
而界面仍显示全文字数。那属于静默的数据丢失,用户还会把答非所问归因于模型,所以该行为已经移除。
</p>
</div>
<figure class="shot">
<img src="assets/shot-ai-confirm.jpg" width="1600" height="1076" alt="发送前的确认对话框,显示即将外发的字数与超出上下文限制的提示" loading="lazy">
<figcaption>发送确认框</figcaption>
</figure>
</div>
</section>
<section id="sandbox">
<div class="wrap">
<div class="section-head">
<span class="eyebrow">进程边界</span>
<h2>界面进程读不到任意文件</h2>
</div>
<div class="prose">
<p>
Electron 应用最常见的风险是渲染进程能拿着任意路径去读盘。PeopleLib 把这条路堵住了:
渲染层不能传任意路径,所有文件访问都要经主进程的白名单校验,只放行书库中真实登记的条目。
</p>
<ul>
<li><strong>路径白名单</strong>:不在书库里的路径一律拒绝,无论渲染层怎么构造参数。</li>
<li><strong>会话与发送方绑定</strong>:超大 PDF 的分段读取会话与请求方绑定,句柄不透明,界面窗口销毁后立即回收。</li>
<li><strong>Markdown 双重净化</strong>Markdown 渲染禁用裸 HTML,再过一层 DOMPurify,防止文件内容里的脚本被执行。</li>
</ul>
</div>
</div>
</section>
<section id="drm">
<div class="wrap">
<div class="section-head">
<span class="eyebrow">合规</span>
<h2>不绕过 DRM</h2>
</div>
<div class="prose">
<p>
带 DRM 的 MOBI、AZW、AZW3、KFX 与 Topaz 文件,以及损坏或不兼容的文件,应用不会尝试解除保护。
遇到这类文件会直接给出提示,建议改用系统关联的应用打开。
</p>
<p>
同样地,PeopleLib 只是对公开网络接口的客户端封装,不托管也不分发任何内容。
各数据源所提供作品的版权状态因司法辖区而异,使用者需自行确保用法符合当地法律与各站点的服务条款。
</p>
</div>
</div>
</section>
<section id="network-scope">
<div class="wrap">
<div class="section-head">
<span class="eyebrow">联网范围</span>
<h2>什么时候会联网</h2>
</div>
<div class="prose">
<div class="table-scroll">
<table>
<caption>应用发起网络请求的全部场合</caption>
<thead>
<tr>
<th scope="col">场合</th>
<th scope="col">目标</th>
<th scope="col">触发方式</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">检索与下载</th>
<td>你选择的数据源站点</td>
<td>你点击搜索或下载</td>
</tr>
<tr>
<th scope="row">封面加载</th>
<td>条目对应的封面地址</td>
<td>展示书库或检索结果</td>
</tr>
<tr>
<th scope="row">AI 请求</th>
<td>你自己填写的接口地址</td>
<td>你确认发送</td>
</tr>
<tr>
<th scope="row">检查更新</th>
<td>项目的 GitHub Releases</td>
<td>手动检查,或你启用了启动时检查</td>
</tr>
</tbody>
</table>
</div>
<p>所有这些请求都走你在设置里配置的全局代理,包括封面请求。</p>
</div>
</div>
</section>
<section id="site-privacy">
<div class="wrap">
<div class="section-head">
<span class="eyebrow">本站点</span>
<h2>这个网站本身</h2>
</div>
<div class="prose">
<p>
本站是托管在 GitHub Pages 上的静态页面,没有分析脚本,没有 Cookie,没有第三方字体或 CDN 资源,
页面用的是你系统里已有的字体。作为托管方,GitHub 会按其自身政策记录访问日志,这一点本站无法控制。
</p>
</div>
</div>
</section>
<section class="cta">
<div class="wrap">
<div>
<h2>还有疑问</h2>
<p>常见问题里覆盖了费用、数据位置、格式支持与 macOS 首次打开。</p>
</div>
<p class="btn-row">
<a class="btn btn-primary" href="faq.html">查看常见问题</a>
<a class="btn btn-secondary" href="download.html">下载与安装</a>
</p>
</div>
</section>
</main>
<footer class="site-footer">
<div class="wrap">
<div>
<h2>站点</h2>
<ul>
<li><a href="index.html">首页</a></li>
<li><a href="features.html">功能</a></li>
<li><a href="download.html">下载与安装</a></li>
<li><a href="faq.html">常见问题</a></li>
</ul>
</div>
<div>
<h2>项目</h2>
<ul>
<li><a href="https://github.com/lofyer/peoplelib" rel="noopener">GitHub 仓库</a></li>
<li><a href="https://github.com/lofyer/peoplelib/releases" rel="noopener">版本发布</a></li>
<li><a href="https://github.com/lofyer/peoplelib/issues" rel="noopener">问题反馈</a></li>
</ul>
</div>
<div>
<h2>版本</h2>
<ul>
<li>PeopleLib 1.3.0</li>
<li>MIT 许可</li>
</ul>
</div>
<p class="legal">
本项目仅是对公开网络接口的客户端封装,不托管、不分发任何内容。部分数据源所提供作品的版权状态因司法辖区而异,
使用者需自行确保其使用方式符合当地法律与各站点的服务条款。
</p>
</div>
</footer>
</body>
</html>
+4
View File
@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://reader.mesalogo.com/sitemap.xml
+28
View File
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://reader.mesalogo.com/</loc>
<changefreq>monthly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://reader.mesalogo.com/features.html</loc>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://reader.mesalogo.com/privacy.html</loc>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://reader.mesalogo.com/download.html</loc>
<changefreq>monthly</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://reader.mesalogo.com/faq.html</loc>
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
</urlset>
+862
View File
@@ -0,0 +1,862 @@
const test = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const crypto = require('crypto');
const sessionsPath = require.resolve('../reader/ai-sessions.js');
const imagesPath = require.resolve('../reader/ai-images.js');
const dirs = [];
function tmp(tag) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `peoplelib-ai-sessions-${tag}-`));
dirs.push(dir);
return dir;
}
function fresh(tag = 'x') {
const dir = tmp(tag);
return at(dir);
}
function at(dir) {
delete require.cache[sessionsPath];
delete require.cache[imagesPath];
const images = require(imagesPath);
const sessions = require(sessionsPath);
images.init(dir);
sessions.init(dir);
return { sessions, images, dir };
}
function sessionDir(dir) {
return path.join(dir, 'reader-ai-sessions');
}
function sessionFile(dir, id) {
return path.join(sessionDir(dir), `${id}.json`);
}
function imageDir(dir) {
return path.join(dir, 'reader-ai-images');
}
// 最小可解码 JPEGSOI + APP0 + 一段填充 + EOI。put 只校验 SOI 魔数,内容差异即哈希差异。
function jpeg(seed, size = 64) {
const bytes = Buffer.alloc(size, seed & 0xff);
bytes[0] = 0xff;
bytes[1] = 0xd8;
bytes[2] = 0xff;
bytes[3] = 0xe0;
bytes[size - 2] = 0xff;
bytes[size - 1] = 0xd9;
return bytes;
}
// atomic-file 通过 fd 写入,因此统计 openSync('<file>.tmp','w') 而不是 writeFileSync 的路径
function countWrites(matcher, fn) {
const originalOpen = fs.openSync;
let writes = 0;
fs.openSync = function counting(target, flags, ...rest) {
if (matcher(String(target)) && String(flags).startsWith('w')) writes++;
return originalOpen.call(this, target, flags, ...rest);
};
try { fn(); } finally { fs.openSync = originalOpen; }
return writes;
}
function round(s, id, question, answer) {
s.appendUser(id, { text: question, task: 'ask' });
const placeholder = s.appendAssistant(id, {});
return s.finishAssistant(id, placeholder.id, { text: answer });
}
test.after(() => {
for (const dir of dirs) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch (e) { /* ignore */ }
}
});
// --- 基本读写 ---
test('会话按 per-session 文件落盘,索引可枚举', () => {
const { sessions, dir } = fresh('basic');
const a = sessions.create({ entryId: 'e1', title: '甲会话' });
const b = sessions.create({ entryId: 'e2' });
assert.match(a.id, /^chat_[a-z0-9]+_[a-z0-9]+$/, '会话 ID 必须是可安全拼进文件名的形状');
assert.strictEqual(a.entryId, 'e1');
assert.strictEqual(b.entryId, 'e2');
const files = fs.readdirSync(sessionDir(dir)).sort();
assert.deepStrictEqual(files, ['index.json', `${a.id}.json`, `${b.id}.json`].sort(),
'每个会话应独占一个文件,加上一份索引');
assert.deepStrictEqual(sessions.list().map((row) => row.id).sort(), [a.id, b.id].sort());
assert.deepStrictEqual(sessions.list({ entryId: 'e1' }).map((row) => row.id), [a.id]);
assert.deepStrictEqual(sessions.list({ entryId: 'e404' }), []);
});
test('未指定 entryId 的会话落到 GLOBAL_ENTRY_ID,与独立笔记伪条目不同', () => {
const { sessions } = fresh('global');
const g = sessions.create({});
assert.strictEqual(g.entryId, 'system:global-chat');
assert.notStrictEqual(sessions.GLOBAL_ENTRY_ID, 'system:standalone-notes',
'全局会话与独立笔记是两个伪条目,混用会让笔记与会话互相污染');
});
test('list 按 pinned desc, updatedAt desc 排序', () => {
const { sessions } = fresh('sort');
const a = sessions.create({ title: 'a' });
const b = sessions.create({ title: 'b' });
const c = sessions.create({ title: 'c' });
sessions.rename(b.id, 'b2');
sessions.setPinned(a.id, true);
const ids = sessions.list().map((row) => row.id);
assert.strictEqual(ids[0], a.id, 'pinned 的会话必须排最前');
assert.ok(ids.indexOf(b.id) < ids.indexOf(c.id), '未 pin 的按 updatedAt 倒序');
});
test('一轮对话可完整读回,占位回答在 finish 时才成为消息', () => {
const { sessions } = fresh('round');
const meta = sessions.create({ entryId: 'e1' });
sessions.appendUser(meta.id, { text: '这是问题', task: 'ask' });
assert.strictEqual(sessions.messages(meta.id).messages.length, 1);
const placeholder = sessions.appendAssistant(meta.id, {});
assert.strictEqual(sessions.messages(meta.id).messages.length, 1,
'占位回答不落盘,否则流式过程中每个增量都会整文件重写');
const done = sessions.finishAssistant(meta.id, placeholder.id, { text: '这是回答' });
assert.strictEqual(done.id, placeholder.id, 'finish 必须复用占位 ID,渲染层靠它对齐流式片段');
const read = sessions.messages(meta.id);
assert.deepStrictEqual(read.messages.map((m) => m.role), ['user', 'assistant']);
assert.strictEqual(read.messages[1].text, '这是回答');
assert.strictEqual(read.meta.messageCount, 2);
assert.strictEqual(read.meta.title, '这是问题', '首轮问题应自动成为标题');
});
test('finishAssistant 可记录取消与错误,且 cancelled 保留残片', () => {
const { sessions } = fresh('finish');
const meta = sessions.create({});
sessions.appendUser(meta.id, { text: 'q' });
const p1 = sessions.appendAssistant(meta.id, {});
const cancelled = sessions.finishAssistant(meta.id, p1.id, { text: '半句', cancelled: true });
assert.strictEqual(cancelled.cancelled, true);
assert.strictEqual(cancelled.text, '半句', '中途停止时残片必须保留,用户看到的就是它');
sessions.appendUser(meta.id, { text: 'q2' });
const p2 = sessions.appendAssistant(meta.id, {});
const failed = sessions.finishAssistant(meta.id, p2.id, { text: '', error: '接口超时' });
assert.strictEqual(failed.error, '接口超时');
assert.throws(() => sessions.finishAssistant(meta.id, 'msg_nope', { text: 'x' }), /会话消息不存在/);
});
test('clear 清空消息但保留会话,remove 删除会话与索引行', () => {
const { sessions, dir } = fresh('lifecycle');
const meta = sessions.create({ entryId: 'e1', title: '保留' });
round(sessions, meta.id, '问', '答');
const cleared = sessions.clear(meta.id);
assert.strictEqual(cleared.messageCount, 0);
assert.strictEqual(cleared.droppedMessages, 0);
assert.strictEqual(cleared.title, '保留', 'clear 不应丢标题');
assert.strictEqual(sessions.list().length, 1);
assert.strictEqual(sessions.remove(meta.id), true);
assert.strictEqual(sessions.remove(meta.id), false);
assert.strictEqual(sessions.list().length, 0);
assert.ok(!fs.existsSync(sessionFile(dir, meta.id)));
assert.throws(() => sessions.messages(meta.id), /会话不存在/);
});
test('messages 支持 limit 与 before 游标翻页', () => {
const { sessions } = fresh('paging');
const meta = sessions.create({});
for (let i = 0; i < 5; i++) round(sessions, meta.id, `${i}`, `${i}`);
const tail = sessions.messages(meta.id, { limit: 4 });
assert.strictEqual(tail.messages.length, 4);
assert.strictEqual(tail.hasMore, true);
assert.strictEqual(tail.messages[3].text, '答4');
const older = sessions.messages(meta.id, { limit: 4, before: tail.messages[0].id });
assert.strictEqual(older.messages[older.messages.length - 1].id !== tail.messages[0].id, true,
'before 必须是排他的,否则翻页会重复一条');
assert.throws(() => sessions.messages(meta.id, { before: 'msg_missing' }), /会话消息不存在/);
});
// --- 字段限长 ---
test('字段限长与 store.limitedString 一致:超长截断而不是抛错', () => {
const { sessions } = fresh('limits');
const L = sessions.LIMITS;
const meta = sessions.create({ title: '标'.repeat(L.title + 50) });
assert.strictEqual(meta.title.length, L.title, `title 应截到 ${L.title}`);
const user = sessions.appendUser(meta.id, { text: '问'.repeat(L.question + 100) });
assert.strictEqual(user.text.length, L.question, `user 文本应截到 question=${L.question}`);
const p = sessions.appendAssistant(meta.id, {});
const done = sessions.finishAssistant(meta.id, p.id, {
text: '答'.repeat(L.messageText + 100),
error: '错'.repeat(L.errorText + 100)
});
assert.strictEqual(done.text.length, L.messageText, `assistant 文本应截到 messageText=${L.messageText}`);
assert.strictEqual(done.error.length, L.errorText, `error 应截到 ${L.errorText}`);
const renamed = sessions.rename(meta.id, '新'.repeat(L.title + 10));
assert.strictEqual(renamed.title.length, L.title);
});
test('标题规范化去掉控制字符与换行,自动标题取首轮问题前 40 字', () => {
const { sessions } = fresh('title');
const meta = sessions.create({ title: ' 带\n换行\u0007和控制符 ' });
assert.strictEqual(meta.title, '带 换行 和控制符', '标题会进索引 JSON,控制字符必须清掉');
const auto = sessions.create({});
sessions.appendUser(auto.id, { text: '标'.repeat(100) });
assert.strictEqual(sessions.messages(auto.id).meta.title.length, 40, '自动标题只取前 40 字');
});
test('非法上下文与任务被拒,contextRef 只在 user 消息上存在', () => {
const { sessions } = fresh('shape');
const meta = sessions.create({});
assert.throws(() => sessions.appendUser(meta.id, {
text: 'q', contextRef: { scope: 'whatever' }
}), /会话上下文范围无效/);
assert.throws(() => sessions.appendUser(meta.id, { text: 'q', task: 'hack' }), /会话任务类型无效/);
const withContext = sessions.appendUser(meta.id, {
text: 'q',
contextRef: { scope: 'page', text: '正文内容', clipped: true, locator: { page: 3 }, documentKey: 'dk', fileIndex: 2 }
});
assert.strictEqual(withContext.contextRef.chars, 4);
assert.strictEqual(withContext.contextRef.hash, sessions.hashContext('正文内容'));
assert.strictEqual(withContext.contextRef.hash.length, 32, 'hash 固定 32 hex,用于判断上下文是否变了');
assert.strictEqual(withContext.contextRef.clipped, true);
assert.deepStrictEqual(withContext.contextRef.locator, { page: 3 });
const p = sessions.appendAssistant(meta.id, { contextRef: { scope: 'page', text: 'x' } });
assert.strictEqual(p.contextRef, null, 'assistant 消息不应带 contextRef');
assert.throws(() => sessions.appendUser(meta.id, {
text: 'q', contextRef: { scope: 'page', locator: { blob: 'x'.repeat(60000) } }
}), /定位信息/);
});
// --- 上限策略 ---
test('会话数达上限抛中文错误,且不删除已有会话', () => {
const { sessions, dir } = fresh('total');
const total = sessions.LIMITS.sessionsTotal;
const ids = [];
for (let i = 0; i < total; i++) ids.push(sessions.create({ title: `会话${i}` }).id);
const before = fs.readdirSync(sessionDir(dir)).length;
assert.throws(() => sessions.create({ title: '溢出' }), /会话数量已达上限/,
'达到上限必须拒绝新建,绝不能静默删用户的旧会话');
assert.strictEqual(fs.readdirSync(sessionDir(dir)).length, before, '拒绝新建不应改动磁盘');
assert.strictEqual(sessions.list().length, total);
sessions.remove(ids[0]);
assert.ok(sessions.create({ title: '腾出位置后可建' }).id, '删掉一个后应能继续新建');
});
test('单会话消息达上限成对淘汰,首轮保留且不出现孤立 assistant', () => {
const { sessions } = fresh('evict');
const max = sessions.LIMITS.messagesPerSession;
const meta = sessions.create({});
sessions.appendUser(meta.id, { text: '首轮问题', contextRef: { scope: 'document', text: '正文' } });
const first = sessions.appendAssistant(meta.id, {});
sessions.finishAssistant(meta.id, first.id, { text: '首轮回答' });
for (let i = 0; i < max; i++) round(sessions, meta.id, `${i}`, `${i}`);
const read = sessions.messages(meta.id, { limit: max });
assert.ok(read.meta.messageCount <= max, `消息数不得超过 ${max}`);
assert.strictEqual(read.messages[0].text, '首轮问题', '承载正文的首轮必须永久保留');
assert.ok(read.messages[0].contextRef, '首轮的 contextRef 不能被淘汰掉');
assert.ok(read.meta.droppedMessages > 0, 'droppedMessages 必须累加,供历史里的省略标记使用');
assert.strictEqual(read.meta.droppedMessages % 2, 0, '成对淘汰时丢弃数应为偶数');
assert.strictEqual(
read.meta.messageCount + read.meta.droppedMessages,
(max + 1) * 2,
'保留数加丢弃数应等于写入总数,说明没有额外丢失'
);
const roles = read.messages.map((m) => m.role);
for (let i = 1; i < roles.length; i++) {
assert.notStrictEqual(roles[i], roles[i - 1],
'成对淘汰后不允许出现连续同角色,否则历史里会有没有提问的孤立回答');
}
});
// appendUser 之后、finishAssistant 之前的这个中间态就是构造请求时 historyFor 看到的状态。
// 逐条丢弃在整轮结束时会被第二次丢弃恰好补回来,只有在中间态才能看出角色错位。
test('淘汰后的中间态也不出现连续同角色', () => {
const { sessions } = fresh('evict-mid');
const max = sessions.LIMITS.messagesPerSession;
const meta = sessions.create({});
for (let i = 0; i < max / 2; i++) round(sessions, meta.id, `${i}`, `${i}`);
assert.strictEqual(sessions.messages(meta.id, { limit: max }).meta.messageCount, max, '先填满到上限');
sessions.appendUser(meta.id, { text: '新问题' });
const read = sessions.messages(meta.id, { limit: max });
assert.strictEqual(read.meta.droppedMessages, 2, '一次淘汰应成对丢弃两条,而不是单条');
const roles = read.messages.map((m) => m.role);
for (let i = 1; i < roles.length; i++) {
assert.notStrictEqual(roles[i], roles[i - 1],
'中间态出现连续同角色,说明淘汰是逐条而非成对,这会让历史里出现两条相邻的回答');
}
assert.strictEqual(roles[0], 'user');
assert.strictEqual(read.messages[0].text, '问0', '首轮提问必须还在');
assert.strictEqual(roles[roles.length - 1], 'user');
const out = sessions.historyFor(meta.id, { maxChars: 100000, maxMessages: max, maxMessageChars: 2000 });
assert.strictEqual(
out.messages.length,
read.messages.length,
'中间态角色已经交替,historyFor 不该再需要合并任何消息;需要合并说明淘汰留下了错位'
);
});
test('图像数达上限时丢弃最早消息的图像引用但保留文本', () => {
const { sessions, images } = fresh('img-evict');
const max = sessions.LIMITS.imagesPerSession;
const meta = sessions.create({});
const ids = [];
for (let i = 0; i <= max; i++) {
const put = images.put(jpeg(i + 1), 'image/jpeg');
ids.push(put.imageId);
sessions.appendUser(meta.id, {
text: `${i}`,
images: [{ imageId: put.imageId, mimeType: 'image/jpeg', width: 10, height: 10, bytes: put.bytes }]
});
const p = sessions.appendAssistant(meta.id, {});
sessions.finishAssistant(meta.id, p.id, { text: `${i}` });
}
const read = sessions.messages(meta.id, { limit: 500 });
const totalImages = read.messages.reduce((n, m) => n + m.images.length, 0);
assert.ok(totalImages <= max, `会话内图像引用不得超过 ${max}`);
assert.strictEqual(read.messages[0].text, '第 0 张', '丢图像引用不应丢文本');
assert.strictEqual(read.messages[0].images.length, 0, '最早的图像引用先被丢弃');
assert.ok(!sessions.imageIds().includes(ids[0]), '被丢弃的引用不应再出现在 GC 白名单里');
});
// --- 文件隔离与写放大 ---
test('追加消息只重写自己那个会话文件', () => {
const { sessions, dir } = fresh('isolate');
const a = sessions.create({ title: 'a' });
const b = sessions.create({ title: 'b' });
const bFile = sessionFile(dir, b.id);
const before = crypto.createHash('sha256').update(fs.readFileSync(bFile)).digest('hex');
round(sessions, a.id, '问', '答');
const after = crypto.createHash('sha256').update(fs.readFileSync(bFile)).digest('hex');
assert.strictEqual(after, before, '写 a 不应碰 b 的字节,否则 100 个会话就是 100 倍写放大');
const writesToB = countWrites((t) => t === `${bFile}.tmp`, () => {
round(sessions, a.id, '问2', '答2');
});
assert.strictEqual(writesToB, 0, '其他会话文件的写入次数必须是 0');
});
test('一轮对话对会话文件只写两次,索引写入不随消息数增长', () => {
const { sessions, dir } = fresh('writes');
const meta = sessions.create({});
const file = sessionFile(dir, meta.id);
const isSession = (t) => t === `${file}.tmp`;
const oneRound = countWrites(isSession, () => {
sessions.appendUser(meta.id, { text: '问' });
const p = sessions.appendAssistant(meta.id, {});
for (let i = 0; i < 50; i++) sessions.appendAssistant; // 流式增量不经过存储层
sessions.finishAssistant(meta.id, p.id, { text: '答'.repeat(500) });
});
assert.strictEqual(oneRound, 2, '一轮对话最多两次会话文件写入:appendUser 一次,finishAssistant 一次');
// 对照:两轮就是四次,证明计数器没失灵
const twoRounds = countWrites(isSession, () => {
round(sessions, meta.id, '问2', '答2');
round(sessions, meta.id, '问3', '答3');
});
assert.strictEqual(twoRounds, 4, '两轮应写四次,用于对照说明上面的 2 不是计数器失灵');
const idxFile = path.join(sessionDir(dir), 'index.json');
const idxWrites = countWrites((t) => t === `${idxFile}.tmp`, () => {
round(sessions, meta.id, '问4', '答4');
});
assert.strictEqual(idxWrites, 2, '索引跟着会话文件一起更新,一轮两次');
});
test('会话文件解析结果按内容缓存,同 mtime 同体积但内容不同也不会读到旧值', () => {
const { sessions, dir } = fresh('cache');
const meta = sessions.create({});
round(sessions, meta.id, '原始问题', '原始回答');
const file = sessionFile(dir, meta.id);
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
// utimesSync 会丢掉 mtime 的小数毫秒,先钉成整秒再取基准,否则构造本身对不上
const fixed = new Date(1700000000000);
fs.utimesSync(file, fixed, fixed);
const stat = fs.statSync(file);
assert.strictEqual(sessions.messages(meta.id).messages[0].text, '原始问题');
// 等长替换 + 还原 mtime:按 mtime+size 判定的缓存在这里会返回旧内容
raw.messages[0].text = '篡改问题';
const rewritten = JSON.stringify(raw, null, 2);
assert.strictEqual(Buffer.byteLength(rewritten, 'utf8'), stat.size, '构造用例要求体积不变');
fs.writeFileSync(file, rewritten);
fs.utimesSync(file, fixed, fixed);
assert.strictEqual(fs.statSync(file).mtimeMs, stat.mtimeMs, '构造用例要求 mtime 不变');
assert.strictEqual(fs.statSync(file).size, stat.size, '构造用例要求体积不变');
assert.strictEqual(sessions.messages(meta.id).messages[0].text, '篡改问题',
'缓存必须按内容哈希失效,按 mtime+size 会漏掉同毫秒内的改写');
});
// --- 损坏自愈 ---
test('主文件损坏时从 .bak 恢复', () => {
const { sessions, dir } = fresh('bak');
const meta = sessions.create({ entryId: 'e1' });
round(sessions, meta.id, '问', '答');
const file = sessionFile(dir, meta.id);
fs.copyFileSync(file, `${file}.bak`);
fs.writeFileSync(file, '{ 这不是 JSON');
const read = sessions.messages(meta.id);
assert.strictEqual(read.messages.length, 2, '.bak 完好时必须恢复出完整对话');
assert.strictEqual(read.messages[0].text, '问');
const corrupt = fs.readdirSync(sessionDir(dir)).filter((n) => n.includes('.corrupt-'));
assert.strictEqual(corrupt.length, 1, '损坏的主文件应被隔离留档而不是直接删除');
});
test('主文件与 .bak 都损坏时隔离成 .corrupt- 并返回空会话', () => {
const { sessions, dir } = fresh('corrupt');
const meta = sessions.create({ entryId: 'e1', title: '标题' });
round(sessions, meta.id, '问', '答');
const file = sessionFile(dir, meta.id);
fs.writeFileSync(file, 'broken');
fs.writeFileSync(`${file}.bak`, 'also broken');
const read = sessions.messages(meta.id);
assert.deepStrictEqual(read.messages, [], '两份都坏只能返回空,不能抛错让界面卡死');
assert.strictEqual(read.meta.entryId, 'e1', '空会话应从索引行恢复出归属,否则会变成孤立会话');
const corrupt = fs.readdirSync(sessionDir(dir)).filter((n) => n.includes('.corrupt-'));
assert.strictEqual(corrupt.length, 1);
round(sessions, meta.id, '新问', '新答');
assert.strictEqual(sessions.messages(meta.id).messages.length, 2, '自愈后应能继续写入');
});
test('index.json 丢失后扫目录重建,list 结果正确', () => {
const { sessions, dir } = fresh('reindex');
const a = sessions.create({ entryId: 'e1', title: '甲' });
const b = sessions.create({ entryId: 'e2', title: '乙' });
round(sessions, a.id, '问', '答');
const idxFile = path.join(sessionDir(dir), 'index.json');
fs.unlinkSync(idxFile);
const reloaded = at(dir).sessions;
const rows = reloaded.list();
assert.strictEqual(rows.length, 2, '索引是派生缓存,丢失后必须能从会话文件重建');
const rowA = rows.find((r) => r.id === a.id);
assert.strictEqual(rowA.title, '甲');
assert.strictEqual(rowA.entryId, 'e1');
assert.strictEqual(rowA.messageCount, 2);
assert.ok(rowA.bytes > 0, '重建的索引行应带上真实体积');
assert.strictEqual(rows.find((r) => r.id === b.id).messageCount, 0);
assert.ok(fs.existsSync(idxFile), '重建结果应回写磁盘');
});
test('index.json 损坏时重建,且损坏的索引不会让会话消失', () => {
const { sessions, dir } = fresh('reindex2');
const a = sessions.create({ title: '甲' });
fs.writeFileSync(path.join(sessionDir(dir), 'index.json'), '[[[not json');
const reloaded = at(dir).sessions;
assert.deepStrictEqual(reloaded.list().map((r) => r.id), [a.id]);
});
test('索引里混入非法行时被丢弃,真实会话仍在', () => {
const { sessions, dir } = fresh('reindex3');
const a = sessions.create({ title: '甲' });
const idxFile = path.join(sessionDir(dir), 'index.json');
const idx = JSON.parse(fs.readFileSync(idxFile, 'utf8'));
idx.sessions.push({ id: '../escape', title: '恶意' });
idx.sessions.push({ id: '__proto__', title: '污染' });
fs.writeFileSync(idxFile, JSON.stringify(idx, null, 2));
const rows = at(dir).sessions.list();
assert.deepStrictEqual(rows.map((r) => r.id), [a.id], '索引里的非法 ID 必须被丢弃');
});
test('会话文件里的坏消息被逐条丢弃,其余消息仍可读', () => {
const { sessions, dir } = fresh('bad-msg');
const meta = sessions.create({});
round(sessions, meta.id, '好问题', '好回答');
const file = sessionFile(dir, meta.id);
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
raw.messages.splice(1, 0, { role: 'nobody', text: '坏消息' }, null, 'not an object');
raw.messages.push({ role: 'assistant', text: '尾部回答', images: 'not an array', task: '非法' });
fs.writeFileSync(file, JSON.stringify(raw, null, 2));
const read = at(dir).sessions.messages(meta.id);
assert.deepStrictEqual(read.messages.map((m) => m.text), ['好问题', '好回答', '尾部回答'],
'会话内容部分来自模型输出,坏条目要丢弃而不是让整个会话读不出来');
assert.deepStrictEqual(read.messages[2].images, []);
assert.strictEqual(read.messages[2].task, null);
});
// --- ID 校验 ---
test('非法会话 ID 被拒,不产生任何文件', () => {
const { sessions, dir } = fresh('ids');
const bad = ['', '../escape', 'a/b', 'a\\b', '__proto__', 'prototype', 'constructor',
'.', '..', '-lead', 'x'.repeat(200), 'a.b', 'a:b', null, undefined, 'chat_ok\u0000'];
for (const id of bad) {
assert.throws(() => sessions.messages(id), /会话 ID 无效/, `会话 ID ${JSON.stringify(id)} 必须被拒`);
assert.throws(() => sessions.remove(id), /会话 ID 无效/);
assert.throws(() => sessions.appendUser(id, { text: 'x' }), /会话 ID 无效/);
}
assert.ok(!fs.existsSync(sessionDir(dir)) || !fs.readdirSync(sessionDir(dir)).some((n) => n !== 'index.json'),
'被拒的 ID 不应在磁盘上留下文件');
});
test('非法 entryId 与 messageId 被拒', () => {
const { sessions } = fresh('ids2');
for (const entryId of ['../x', 'a/b', '__proto__', 'x'.repeat(200), '.', '..']) {
assert.throws(() => sessions.create({ entryId }), /会话条目 ID 无效/);
}
const meta = sessions.create({ entryId: 'e1' });
for (const msgId of ['', '../x', 'a/b', '__proto__', 'x'.repeat(200)]) {
assert.throws(() => sessions.finishAssistant(meta.id, msgId, { text: 'x' }), /会话消息 ID 无效/);
}
for (const msgId of ['../x', 'a/b', '__proto__', 'x'.repeat(200)]) {
assert.throws(() => sessions.messages(meta.id, { before: msgId }), /会话消息 ID 无效/);
}
assert.doesNotThrow(() => sessions.messages(meta.id, { before: '' }), '空游标等价于不传,取最新一页');
});
test('finishAssistant 不能跨会话认领占位消息', () => {
const { sessions } = fresh('cross');
const a = sessions.create({});
const b = sessions.create({});
sessions.appendUser(a.id, { text: 'q' });
const p = sessions.appendAssistant(a.id, {});
assert.throws(() => sessions.finishAssistant(b.id, p.id, { text: '越界' }), /会话消息不存在/,
'占位消息必须绑定会话,否则渲染层可以把回答写进别的会话');
});
// --- historyFor ---
function history(sessions, id, budget) {
return sessions.historyFor(id, budget);
}
test('historyFor 始终 pin 首条 user 消息', () => {
const { sessions } = fresh('hist-pin');
const meta = sessions.create({});
sessions.appendUser(meta.id, { text: `正文${'甲'.repeat(300)}`, contextRef: { scope: 'document', text: 'x' } });
const p = sessions.appendAssistant(meta.id, {});
sessions.finishAssistant(meta.id, p.id, { text: '首答' });
for (let i = 0; i < 20; i++) round(sessions, meta.id, `${i}`.repeat(20), `${i}`.repeat(20));
const out = history(sessions, meta.id, { maxChars: 600, maxMessages: 6, maxMessageChars: 400 });
assert.strictEqual(out.messages[0].role, 'user');
assert.ok(out.messages[0].text.includes('正文'),
'承载正文的首条 user 必须永远在历史里,否则后续追问会失去参照');
assert.ok(out.messages.length < 42, '预算内应确实丢掉了中间轮次');
assert.ok(out.dropped > 0);
});
test('historyFor 的省略标记折进现有消息而不是新增消息', () => {
const { sessions } = fresh('hist-mark');
const meta = sessions.create({});
for (let i = 0; i < 12; i++) round(sessions, meta.id, `问题${i}`.repeat(20), `回答${i}`.repeat(20));
const out = history(sessions, meta.id, { maxChars: 500, maxMessages: 5, maxMessageChars: 400 });
const marked = out.messages.filter((m) => /已省略较早的 \d+ 轮对话/.test(m.text));
assert.strictEqual(marked.length, 1, '省略标记只应出现一次');
assert.strictEqual(marked[0], out.messages[0], '标记必须折进最旧那条保留消息');
assert.ok(marked[0].text.length > '[……已省略较早的 1 轮对话……]\n'.length,
'标记是折进去的,所以这条消息里还应有原本的正文,而不是一条只有标记的空消息');
assert.ok(out.messages.every((m) => m.role === 'user' || m.role === 'assistant'),
'不允许为了放标记而造出 system 之类的新角色');
});
test('historyFor 总字符不超过 maxChars', () => {
const { sessions } = fresh('hist-total');
const meta = sessions.create({});
for (let i = 0; i < 15; i++) round(sessions, meta.id, `${i}`.repeat(50), `${i}`.repeat(50));
for (const maxChars of [200, 500, 1200, 3000]) {
const out = history(sessions, meta.id, { maxChars, maxMessages: 30, maxMessageChars: 2000 });
const total = out.messages.reduce((n, m) => n + m.text.length, 0);
assert.ok(total <= maxChars, `maxChars=${maxChars} 时实际 ${total} 字符,超预算会直接被接口拒绝`);
}
});
test('historyFor 单条超限中间挖空且保留尾部', () => {
const { sessions } = fresh('hist-clip');
const meta = sessions.create({});
const head = '开头标记';
const tail = '结尾标记';
sessions.appendUser(meta.id, { text: `${head}${'填'.repeat(3000)}${tail}` });
const p = sessions.appendAssistant(meta.id, {});
sessions.finishAssistant(meta.id, p.id, { text: '答' });
const out = history(sessions, meta.id, { maxChars: 4000, maxMessages: 10, maxMessageChars: 600 });
const first = out.messages[0];
assert.ok(first.text.length <= 600, '单条应被压到 maxMessageChars 以内');
assert.ok(first.text.startsWith(head), '挖空必须保留头部');
assert.ok(first.text.endsWith(tail), '挖空必须保留尾部,尾部往往是真正的提问');
assert.ok(first.text.includes('中间内容已省略'), '挖空处要有可见标记');
assert.strictEqual(first.truncated, true, 'truncated 标记供界面提示用户');
});
test('historyFor 裁剪后若首条是 assistant 则丢掉它', () => {
const { sessions, dir } = fresh('hist-lead');
const meta = sessions.create({});
round(sessions, meta.id, '问', '答');
// 恶性输入:直接把会话文件改成 assistant 领头,模拟历史数据或模型侧写坏
const file = sessionFile(dir, meta.id);
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
raw.messages = [
{ id: 'msg_a1', role: 'assistant', text: '孤立回答', createdAt: 1 },
{ id: 'msg_u1', role: 'user', text: '真正的提问', createdAt: 2 },
{ id: 'msg_a2', role: 'assistant', text: '真正的回答', createdAt: 3 }
];
fs.writeFileSync(file, JSON.stringify(raw, null, 2));
const out = at(dir).sessions.historyFor(meta.id, { maxChars: 4000, maxMessages: 10, maxMessageChars: 400 });
assert.strictEqual(out.messages[0].role, 'user',
'Anthropic 的 /messages 要求 messages[0].role === "user",领头的 assistant 必须丢掉');
assert.ok(!out.messages.some((m) => m.text.includes('孤立回答')));
});
test('historyFor 不产生连续同角色消息', () => {
const { sessions, dir } = fresh('hist-roles');
const meta = sessions.create({});
round(sessions, meta.id, '问', '答');
const file = sessionFile(dir, meta.id);
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
raw.messages = [
{ id: 'msg_u1', role: 'user', text: '问一', createdAt: 1 },
{ id: 'msg_u2', role: 'user', text: '问二', createdAt: 2 },
{ id: 'msg_u3', role: 'user', text: '问三', createdAt: 3 },
{ id: 'msg_a1', role: 'assistant', text: '答一', createdAt: 4 },
{ id: 'msg_a2', role: 'assistant', text: '答二', createdAt: 5 },
{ id: 'msg_u4', role: 'user', text: '问四', createdAt: 6 }
];
fs.writeFileSync(file, JSON.stringify(raw, null, 2));
const reloaded = at(dir).sessions;
for (const budget of [
{ maxChars: 4000, maxMessages: 10, maxMessageChars: 400 },
{ maxChars: 20, maxMessages: 3, maxMessageChars: 50 },
{ maxChars: 4000, maxMessages: 2, maxMessageChars: 400 }
]) {
const out = reloaded.historyFor(meta.id, budget);
const roles = out.messages.map((m) => m.role);
for (let i = 1; i < roles.length; i++) {
assert.notStrictEqual(roles[i], roles[i - 1],
`预算 ${JSON.stringify(budget)} 下出现了连续同角色,Anthropic 会直接 400`);
}
if (roles.length) assert.strictEqual(roles[0], 'user');
assert.ok(out.messages.find((m) => m.text.includes('问一')), '首条 user 仍应被 pin');
}
});
test('historyFor 把已被物理丢弃的轮次计入省略标记', () => {
const { sessions } = fresh('hist-dropped');
const max = sessions.LIMITS.messagesPerSession;
const meta = sessions.create({});
for (let i = 0; i < max; i++) round(sessions, meta.id, `${i}`, `${i}`);
const read = sessions.messages(meta.id, { limit: max });
assert.ok(read.meta.droppedMessages > 0);
const out = history(sessions, meta.id, { maxChars: 100000, maxMessages: max, maxMessageChars: 2000 });
assert.ok(out.dropped >= read.meta.droppedMessages,
'磁盘上已丢的轮次也要计入 dropped,否则模型会以为它看到了完整对话');
assert.match(out.messages[0].text, /已省略较早的 \d+ 轮对话/);
});
test('historyFor 在空会话与单条会话上不炸', () => {
const { sessions } = fresh('hist-edge');
const meta = sessions.create({});
assert.deepStrictEqual(sessions.historyFor(meta.id, {}), { messages: [], dropped: 0 });
sessions.appendUser(meta.id, { text: '只有一条' });
const out = sessions.historyFor(meta.id, { maxChars: 5, maxMessages: 1, maxMessageChars: 50 });
assert.strictEqual(out.messages.length, 1, '首条 user 即使超预算也要留下,否则请求没有内容可发');
assert.strictEqual(out.messages[0].role, 'user');
});
// --- imageIds 与 GC ---
test('imageIds 遍历全部会话文件,索引缺行也能找到引用', () => {
const { sessions, images, dir } = fresh('gc-scan');
const a = sessions.create({ title: '甲' });
const b = sessions.create({ title: '乙' });
const imgA = images.put(jpeg(11), 'image/jpeg');
const imgB = images.put(jpeg(22), 'image/jpeg');
sessions.appendUser(a.id, { text: '带图甲', images: [{ imageId: imgA.imageId, bytes: imgA.bytes }] });
sessions.appendUser(b.id, { text: '带图乙', images: [{ imageId: imgB.imageId, bytes: imgB.bytes }] });
// 故意让索引缺掉 b:只读索引的 GC 会把 b 引用的图当垃圾删掉
const idxFile = path.join(sessionDir(dir), 'index.json');
const idx = JSON.parse(fs.readFileSync(idxFile, 'utf8'));
idx.sessions = idx.sessions.filter((row) => row.id !== b.id);
fs.writeFileSync(idxFile, JSON.stringify(idx, null, 2));
const reloaded = at(dir);
assert.ok(!reloaded.sessions.list().some((row) => row.id === b.id) || true);
const ids = reloaded.sessions.imageIds();
assert.ok(ids.includes(imgB.imageId),
'imageIds 必须扫目录而不是读索引,索引损坏时只读索引会静默删掉仍被引用的图');
assert.ok(ids.includes(imgA.imageId));
const removed = reloaded.images.cleanup(ids, { graceMs: 0 });
assert.strictEqual(removed, 0, '全部图仍被引用时不该删任何东西');
assert.ok(fs.existsSync(path.join(imageDir(dir), `${imgB.imageId}.jpg`)));
});
test('imageIds 覆盖 .bak 里的引用与内存中的占位消息', () => {
const { sessions, images, dir } = fresh('gc-bak');
const meta = sessions.create({});
const img = images.put(jpeg(33), 'image/jpeg');
sessions.appendUser(meta.id, { text: '带图', images: [{ imageId: img.imageId, bytes: img.bytes }] });
const file = sessionFile(dir, meta.id);
fs.copyFileSync(file, `${file}.bak`);
fs.writeFileSync(file, 'broken');
const reloaded = at(dir);
assert.ok(reloaded.sessions.imageIds().includes(img.imageId),
'主文件坏掉但 .bak 还引用着这张图,GC 不能删它');
const pendingImg = images.put(jpeg(44), 'image/jpeg');
const s2 = fresh('gc-pending');
const m2 = s2.sessions.create({});
s2.sessions.appendUser(m2.id, { text: 'q' });
s2.sessions.appendAssistant(m2.id, { images: [{ imageId: pendingImg.imageId, bytes: pendingImg.bytes }] });
assert.ok(s2.sessions.imageIds().includes(pendingImg.imageId),
'未落盘的占位消息引用的图也要在白名单里,否则流式期间的 GC 会删掉它');
});
test('会话删除后其图像被 cleanup 回收', () => {
const { sessions, images, dir } = fresh('gc-remove');
const keep = sessions.create({ title: '留' });
const drop = sessions.create({ title: '删' });
const imgKeep = images.put(jpeg(55), 'image/jpeg');
const imgDrop = images.put(jpeg(66), 'image/jpeg');
sessions.appendUser(keep.id, { text: 'k', images: [{ imageId: imgKeep.imageId, bytes: imgKeep.bytes }] });
sessions.appendUser(drop.id, { text: 'd', images: [{ imageId: imgDrop.imageId, bytes: imgDrop.bytes }] });
sessions.remove(drop.id);
const removed = images.cleanup(sessions.imageIds(), { graceMs: 0 });
assert.strictEqual(removed, 1);
assert.ok(fs.existsSync(path.join(imageDir(dir), `${imgKeep.imageId}.jpg`)));
assert.ok(!fs.existsSync(path.join(imageDir(dir), `${imgDrop.imageId}.jpg`)));
});
// --- ai-images ---
test('ai-images 只收 JPEG,魔数不对直接拒', () => {
const { images } = fresh('img-mime');
const png = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex');
assert.throws(() => images.put(png, 'image/png'), /仅支持 JPEG/);
assert.throws(() => images.put(png, 'image/jpeg'), /不是有效的 JPEG/,
'声明 JPEG 但内容是 PNG 必须被拒,否则视觉接口会收到无法解码的负载');
assert.throws(() => images.put(Buffer.alloc(0), 'image/jpeg'), /数据为空/);
assert.throws(() => images.put(Buffer.alloc(64, 0), 'image/jpeg'), /不是有效的 JPEG/);
assert.throws(() => images.put('not a buffer', 'image/jpeg'), /数据为空/);
assert.throws(() => images.put(jpeg(1, 4 * 1024 * 1024), 'image/jpeg'), /超过 3 MB/);
});
test('ai-images 内容寻址去重,同图两次 put 只占一份磁盘', () => {
const { images, dir } = fresh('img-dedup');
const bytes = jpeg(77, 1024);
const first = images.put(bytes, 'image/jpeg');
const second = images.put(Buffer.from(bytes), 'image/jpeg');
assert.strictEqual(second.imageId, first.imageId, '同样的字节必须得到同样的 imageId');
assert.strictEqual(fs.readdirSync(imageDir(dir)).length, 1, '去重后磁盘上只应有一份');
assert.strictEqual(images.totalBytes(), 1024);
assert.match(first.imageId, /^img_[a-f0-9]{64}$/);
const other = images.put(jpeg(78, 1024), 'image/jpeg');
assert.notStrictEqual(other.imageId, first.imageId, '不同字节必须得到不同 imageId');
assert.strictEqual(images.totalBytes(), 2048);
});
test('ai-images 读回与 dataUrl 一致,非法 imageId 被拒', () => {
const { images } = fresh('img-read');
const bytes = jpeg(88, 256);
const { imageId } = images.put(bytes, 'image/jpeg');
assert.ok(images.read(imageId).equals(bytes));
assert.strictEqual(images.dataUrl(imageId), `data:image/jpeg;base64,${bytes.toString('base64')}`);
for (const bad of ['', 'img_short', '../escape', 'img_' + 'g'.repeat(64), '__proto__',
`img_${'a'.repeat(64)}/../x`, null, 'pdf_' + 'a'.repeat(64)]) {
assert.throws(() => images.read(bad), /会话图像标识无效/, `imageId ${JSON.stringify(bad)} 必须被拒`);
assert.throws(() => images.dataUrl(bad), /会话图像标识无效/);
assert.throws(() => images.safeImageId(bad), /会话图像标识无效/);
}
assert.throws(() => images.read(`img_${'a'.repeat(64)}`), /会话图像不存在/);
});
test('ai-images cleanup 只删未引用的,宽限期内的新文件不删', () => {
const { images, dir } = fresh('img-cleanup');
const a = images.put(jpeg(1, 128), 'image/jpeg');
const b = images.put(jpeg(2, 128), 'image/jpeg');
const c = images.put(jpeg(3, 128), 'image/jpeg');
assert.strictEqual(images.cleanup([a.imageId]), 0,
'刚落盘的图还没被会话引用,宽限期内删掉就是删正在提交的数据');
assert.strictEqual(images.cleanup([a.imageId], { graceMs: 0 }), 2);
assert.deepStrictEqual(fs.readdirSync(imageDir(dir)), [`${a.imageId}.jpg`]);
assert.ok(images.read(a.imageId));
assert.strictEqual(images.cleanup([], { graceMs: 0 }), 1);
assert.strictEqual(images.totalBytes(), 0);
assert.strictEqual(images.cleanup([], { graceMs: 0 }), 0, '目录空了也不该报错');
// 白名单里的垃圾值不应意外保住任何文件
images.put(jpeg(4, 128), 'image/jpeg');
assert.strictEqual(images.cleanup(['../escape', null, '__proto__'], { graceMs: 0 }), 1);
});
test('ai-images 目录不存在时 totalBytes 与 cleanup 返回零值', () => {
const { images } = fresh('img-empty');
assert.strictEqual(images.totalBytes(), 0);
assert.strictEqual(images.cleanup([]), 0);
});
test('会话里的图像元数据被规范化,非法引用被拒', () => {
const { sessions, images } = fresh('img-meta');
const meta = sessions.create({});
const img = images.put(jpeg(99, 512), 'image/jpeg');
assert.throws(() => sessions.appendUser(meta.id, {
text: 'q', images: [{ imageId: '../escape' }]
}), /会话图像标识无效/);
assert.throws(() => sessions.appendUser(meta.id, {
text: 'q', images: [{ imageId: img.imageId, mimeType: 'image/png' }]
}), /仅支持 JPEG/);
const stored = sessions.appendUser(meta.id, {
text: 'q',
images: [
{ imageId: img.imageId, width: 100, height: 200, bytes: 512, ocrIncluded: true },
{ imageId: img.imageId, width: 100, height: 200, bytes: 512 }
]
});
assert.strictEqual(stored.images.length, 1, '同一张图重复引用应折成一条');
assert.deepStrictEqual(stored.images[0], {
imageId: img.imageId,
mimeType: 'image/jpeg',
width: 100,
height: 200,
bytes: 512,
ocrIncluded: true
});
});
// --- 对账 ---
test('orphanReport 只报书库里已不存在的条目,GLOBAL_ENTRY_ID 永不判为孤立', () => {
const { sessions } = fresh('orphan');
const inLib = sessions.create({ entryId: 'e1', title: '在库' });
const gone = sessions.create({ entryId: 'e404', title: '已删' });
const global = sessions.create({ entryId: sessions.GLOBAL_ENTRY_ID, title: '全局' });
round(sessions, gone.id, '问', '答');
const report = sessions.orphanReport(['e1']);
assert.deepStrictEqual(report.map((r) => r.sessionId), [gone.id]);
assert.strictEqual(report[0].entryId, 'e404');
assert.strictEqual(report[0].title, '已删', '报告要带标题,否则用户无法判断是否回收');
assert.strictEqual(report[0].messageCount, 2);
assert.ok(report[0].bytes > 0);
assert.ok(!report.some((r) => r.sessionId === global.id), '全局会话不绑书籍,永远不是孤立数据');
assert.ok(!report.some((r) => r.sessionId === inLib.id));
});
test('forgetMany 尊重白名单,且显式点名 GLOBAL_ENTRY_ID 也不删', () => {
const { sessions } = fresh('forget');
const keep = sessions.create({ entryId: 'e1', title: '在库' });
const dropA = sessions.create({ entryId: 'e404', title: '已删甲' });
const dropB = sessions.create({ entryId: 'e404', title: '已删乙' });
const global = sessions.create({ entryId: sessions.GLOBAL_ENTRY_ID, title: '全局' });
assert.strictEqual(sessions.forgetMany([]), 0);
assert.strictEqual(sessions.forgetMany(['e404', sessions.GLOBAL_ENTRY_ID]), 2);
const remaining = sessions.list().map((row) => row.id).sort();
assert.deepStrictEqual(remaining, [keep.id, global.id].sort(),
'在库条目与全局会话都不该被回收,误删的是用户无法找回的对话');
assert.throws(() => sessions.forgetMany(['../escape']), /会话条目 ID 无效/);
assert.strictEqual(sessions.list().length, 2, '非法输入不应造成部分删除');
});
test('rebuildIndex 返回索引形状并与 list 一致', () => {
const { sessions } = fresh('rebuild');
const a = sessions.create({ entryId: 'e1', title: '甲' });
round(sessions, a.id, '问', '答');
const idx = sessions.rebuildIndex();
assert.strictEqual(idx.version, 1);
assert.strictEqual(idx.sessions.length, 1);
assert.deepStrictEqual(Object.keys(idx.sessions[0]).sort(),
['bytes', 'entryId', 'id', 'messageCount', 'pinned', 'title', 'updatedAt'].sort());
assert.deepStrictEqual(sessions.list()[0], idx.sessions[0]);
});
test('list 返回的是副本,改动不会污染内存索引', () => {
const { sessions } = fresh('clone');
const meta = sessions.create({ title: '原标题' });
const rows = sessions.list();
rows[0].title = '被改坏';
assert.strictEqual(sessions.list()[0].title, '原标题');
assert.strictEqual(sessions.messages(meta.id).meta.title, '原标题');
});
+338 -1
View File
@@ -326,7 +326,7 @@ test('OCR-only 契约不要求视觉模型且不会发送图像', async () => {
assert.doesNotMatch(body.messages[1].content, /data:image/);
});
test('超长上下文被截断且保留首尾', () => {
test('显式调用 clipContext 时中间挖空并保留首尾', () => {
const ai = setup();
const long = 'A'.repeat(5000) + 'MIDDLE' + 'B'.repeat(5000) + 'TAIL_MARK';
const clipped = ai.clipContext(long, 2000);
@@ -336,6 +336,49 @@ test('超长上下文被截断且保留首尾', () => {
assert.ok(clipped.includes('省略'), '未标注截断');
});
test('正文完整外发,不再按 MAX_CHARS 静默截断', () => {
const ai = setup();
// 40 页文档实测:旧行为只发出 8 页,其余 32 页静默丢失,而界面仍显示全文字数
const body = 'X'.repeat(ai.MAX_CHARS * 4) + 'TAIL_MARK';
const msgs = ai.buildMessages('ask', body, '这讲了什么');
assert.ok(msgs[1].content.includes(body), '正文被截断了');
assert.ok(!msgs[1].content.includes('中间省略'), '不应再自动挖空正文');
assert.ok(msgs[1].content.includes('TAIL_MARK'));
});
test('接口报的上下文超限被转成可操作的中文提示', async () => {
const ai = setup();
for (const [status, raw] of [
[400, "This model's maximum context length is 8192 tokens, however you requested 90000 tokens"],
[400, 'prompt is too long: 250000 tokens > 200000 maximum'],
[413, 'Payload Too Large']
]) {
h.setHandler(() => streamResponse(JSON.stringify({ error: { message: raw } }), { status }));
await assert.rejects(
() => ai.stream({ task: 'ask', text: '正文', question: '问题' }),
(err) => {
assert.match(err.message, /上下文超出模型窗口/);
assert.match(err.message, /范围改小|更大窗口/);
return true;
},
`HTTP ${status} 未被识别为上下文超限`
);
}
// 普通错误不应被误判成超限
h.setHandler(() => streamResponse(
JSON.stringify({ error: { message: 'invalid temperature value' } }),
{ status: 400 }
));
await assert.rejects(
() => ai.stream({ task: 'ask', text: '正文', question: '问题' }),
(err) => {
assert.doesNotMatch(err.message, /上下文超出模型窗口/);
return true;
}
);
});
test('不支持的任务类型被拒绝', () => {
const ai = setup();
assert.throws(() => ai.buildMessages('hack', 'x'), /不支持的任务/);
@@ -359,3 +402,297 @@ test('取消请求时抛出 AbortError 而不是静默返回', async () => {
(e) => e.name === 'AbortError'
);
});
// 多轮对话历史
const PROTOCOL_STREAMS = {
'chat-completions': () => streamResponse(sseBody(['答'])),
anthropic: () => streamResponse(
`data: ${JSON.stringify({ type: 'content_block_delta', delta: { type: 'text_delta', text: '答' } })}\n\n`
+ `data: ${JSON.stringify({ type: 'message_stop' })}\n\n`
),
'openai-responses': () => streamResponse(
`data: ${JSON.stringify({ type: 'response.output_text.delta', delta: '答' })}\n\n`
+ `data: ${JSON.stringify({ type: 'response.completed' })}\n\n`
)
};
async function captureBody(protocol, args, options) {
const ai = setup({ protocol, ...options });
let body = null;
h.setHandler((_url, opts) => {
body = JSON.parse(opts.body);
return PROTOCOL_STREAMS[protocol]();
});
await ai.stream(args);
return body;
}
// history 缺省时请求体必须与单轮时代逐字节一致,否则等于悄悄改了单轮行为
test('未传 history 时三种协议请求体与单轮完全一致', async () => {
// 写死单轮的 user 正文,只比对"传与不传 history"两次结果会同时被同一个 bug 污染
const expectedUser = '文档片段:\n"""\n正文\n"""\n\n问题:问题';
for (const protocol of Object.keys(PROTOCOL_STREAMS)) {
const base = await captureBody(protocol, { task: 'ask', text: '正文', question: '问题' });
if (protocol === 'chat-completions') {
assert.strictEqual(base.messages.length, 2, '单轮只该有 system + user');
assert.strictEqual(base.messages[1].content, expectedUser);
} else if (protocol === 'anthropic') {
assert.strictEqual(base.messages.length, 1, '单轮只该有一条 user');
assert.strictEqual(base.messages[0].content, expectedUser);
} else {
assert.strictEqual(base.input.length, 1, '单轮只该有一条 user');
assert.deepStrictEqual(base.input[0].content, [{ type: 'input_text', text: expectedUser }]);
}
for (const history of [undefined, null, [], 'not-an-array', {}]) {
const withArg = await captureBody(
protocol,
{ task: 'ask', text: '正文', question: '问题', history }
);
assert.strictEqual(
JSON.stringify(withArg),
JSON.stringify(base),
`${protocol} 的空 history 改变了请求体(history=${JSON.stringify(history)}`
);
}
}
});
test('chat-completions 把历史插在 system 之后、当前轮之前', async () => {
const body = await captureBody('chat-completions', {
task: 'ask',
text: '正文',
question: '第三个问题',
history: [
{ id: 'a', role: 'user', text: '第一个问题' },
{ id: 'b', role: 'assistant', text: '第一个回答' },
{ id: 'c', role: 'user', text: '第二个问题' },
{ id: 'd', role: 'assistant', text: '第二个回答' }
]
});
assert.deepStrictEqual(
body.messages.map((m) => m.role),
['system', 'user', 'assistant', 'user', 'assistant', 'user'],
'历史必须在 messages 里,且当前轮排最后'
);
assert.strictEqual(body.messages[1].content, '第一个问题');
assert.strictEqual(body.messages[2].content, '第一个回答');
assert.strictEqual(body.messages[3].content, '第二个问题');
assert.strictEqual(body.messages[4].content, '第二个回答');
assert.match(body.messages[5].content, /第三个问题/, '当前轮问题丢失');
assert.match(body.messages[5].content, /正文/, '当前轮正文丢失');
assert.ok(
!/第一个问题/.test(body.messages[0].content),
'历史不该被塞进 system,模型会把它当成指令'
);
});
test('anthropic 历史进 messagessystem 仍在顶层', async () => {
const body = await captureBody('anthropic', {
task: 'ask',
text: '正文',
question: '新问题',
history: [
{ id: 'a', role: 'user', text: '旧问题' },
{ id: 'b', role: 'assistant', text: '旧回答' }
]
});
assert.strictEqual(typeof body.system, 'string');
assert.ok(!/旧问题|旧回答/.test(body.system), 'Anthropic 的 system 是顶层字段,历史不该混进去');
assert.deepStrictEqual(body.messages.map((m) => m.role), ['user', 'assistant', 'user']);
assert.strictEqual(body.messages[0].content, '旧问题');
assert.strictEqual(body.messages[1].content, '旧回答');
assert.match(body.messages[2].content, /新问题/);
assert.ok(!body.messages.some((m) => m.role === 'system'), 'system 不能出现在 messages 里');
});
test('openai-responses 历史进 inputinstructions 与 store:false 保持', async () => {
const body = await captureBody('openai-responses', {
task: 'ask',
text: '正文',
question: '新问题',
history: [
{ id: 'a', role: 'user', text: '旧问题' },
{ id: 'b', role: 'assistant', text: '旧回答' }
]
});
assert.strictEqual(typeof body.instructions, 'string');
assert.ok(!/旧问题|旧回答/.test(body.instructions), 'instructions 是顶层字段,历史不该混进去');
assert.strictEqual(body.store, false, 'store 必须保持 false,服务端不留存对话');
assert.ok(!('previous_response_id' in body), '多轮不能靠服务端留存,与 store:false 冲突');
assert.deepStrictEqual(body.input.map((m) => m.role), ['user', 'assistant', 'user']);
// 纯字符串是 Responses 输入消息的合法简写,避开 input_text 不接受 assistant 的限制
assert.strictEqual(body.input[0].content, '旧问题');
assert.strictEqual(body.input[1].content, '旧回答');
assert.ok(Array.isArray(body.input[2].content), '当前轮仍用结构化 content');
assert.strictEqual(body.input[2].content[0].type, 'input_text');
assert.match(body.input[2].content[0].text, /新问题/);
});
test('历史里的图像不被重发,只发当前轮的图', async () => {
const image = visualContext().image;
const history = [
{ id: 'a', role: 'user', text: '看这张图', images: [image, image] },
{ id: 'b', role: 'assistant', text: '看到了' }
];
const args = {
task: 'ask',
text: '',
question: '这张呢',
visualContexts: [visualContext()],
history
};
const chat = await captureBody('chat-completions', args, { vision: true });
const chatImages = chat.messages.flatMap(
(m) => (Array.isArray(m.content) ? m.content.filter((c) => c.type === 'image_url') : [])
);
assert.strictEqual(chatImages.length, 1, '历史图像被重发了,长会话费用会随轮数累积');
const anthropic = await captureBody('anthropic', args, { vision: true });
const anthropicImages = anthropic.messages.flatMap(
(m) => (Array.isArray(m.content) ? m.content.filter((c) => c.type === 'image') : [])
);
assert.strictEqual(anthropicImages.length, 1, '历史图像被重发了');
const responses = await captureBody('openai-responses', args, { vision: true });
const responseImages = responses.input.flatMap(
(m) => (Array.isArray(m.content) ? m.content.filter((c) => c.type === 'input_image') : [])
);
assert.strictEqual(responseImages.length, 1, '历史图像被重发了');
const historyJson = JSON.stringify(history);
assert.strictEqual(historyJson, JSON.stringify([
{ id: 'a', role: 'user', text: '看这张图', images: [image, image] },
{ id: 'b', role: 'assistant', text: '看到了' }
]), '不该原地改写调用方传进来的历史数组');
});
test('anthropic 历史领头是 assistant 时首条仍是 user', async () => {
const body = await captureBody('anthropic', {
task: 'ask',
text: '正文',
question: '新问题',
history: [
{ id: 'a', role: 'assistant', text: '孤立的开场回答' },
{ id: 'b', role: 'user', text: '真正的第一问' },
{ id: 'c', role: 'assistant', text: '第一答' }
]
});
// Anthropic 的 /messages 直接 400 拒绝领头 assistant
assert.strictEqual(body.messages[0].role, 'user', '首条必须是 user,否则 Anthropic 直接 400');
assert.deepStrictEqual(body.messages.map((m) => m.role), ['user', 'assistant', 'user']);
assert.strictEqual(body.messages[0].content, '真正的第一问');
});
test('历史末条是 user 时与当前轮合并,两段文本都保留', async () => {
for (const protocol of Object.keys(PROTOCOL_STREAMS)) {
const body = await captureBody(protocol, {
task: 'ask',
text: '正文',
question: '当前问题',
history: [
{ id: 'a', role: 'user', text: '上一问' },
{ id: 'b', role: 'assistant', text: '上一答' },
{ id: 'c', role: 'user', text: '没等到回答的追问' }
]
});
const items = protocol === 'openai-responses' ? body.input : body.messages;
const roles = items.map((m) => m.role).filter((r) => r !== 'system');
for (let i = 1; i < roles.length; i++) {
assert.notStrictEqual(roles[i], roles[i - 1], `${protocol} 出现相邻同角色,Anthropic 会直接 400`);
}
const flat = JSON.stringify(items);
assert.match(flat, /没等到回答的追问/, `${protocol} 静默丢弃了用户内容`);
assert.match(flat, /当前问题/, `${protocol} 当前轮问题丢失`);
}
});
test('历史内部相邻同角色被合并而不是丢弃', async () => {
for (const protocol of Object.keys(PROTOCOL_STREAMS)) {
const body = await captureBody(protocol, {
task: 'ask',
text: '正文',
question: '当前问题',
history: [
{ id: 'a', role: 'user', text: '连问一' },
{ id: 'b', role: 'user', text: '连问二' },
{ id: 'c', role: 'assistant', text: '连答一' },
{ id: 'd', role: 'assistant', text: '连答二' }
]
});
const items = protocol === 'openai-responses' ? body.input : body.messages;
const roles = items.map((m) => m.role).filter((r) => r !== 'system');
assert.deepStrictEqual(
roles,
['user', 'assistant', 'user'],
`${protocol} 未合并相邻同角色,Anthropic 会直接 400`
);
const flat = JSON.stringify(items);
for (const mark of ['连问一', '连问二', '连答一', '连答二', '当前问题']) {
assert.match(flat, new RegExp(mark), `${protocol} 静默丢弃了 ${mark}`);
}
}
});
test('历史含空文本时不产生空 content', async () => {
for (const protocol of Object.keys(PROTOCOL_STREAMS)) {
const body = await captureBody(protocol, {
task: 'ask',
text: '正文',
question: '当前问题',
history: [
{ id: 'a', role: 'user', text: '有效提问' },
{ id: 'b', role: 'assistant', text: ' ' },
{ id: 'c', role: 'assistant', text: '' },
{ id: 'd', role: 'user', text: null },
{ id: 'e', role: 'assistant', text: '有效回答' },
null
]
});
const items = protocol === 'openai-responses' ? body.input : body.messages;
for (const item of items) {
const text = typeof item.content === 'string'
? item.content
: JSON.stringify(item.content);
assert.ok(text && text.trim(), `${protocol} 出现空 contentAnthropic 不接受空字符串`);
}
const roles = items.map((m) => m.role).filter((r) => r !== 'system');
assert.deepStrictEqual(roles, ['user', 'assistant', 'user'], `${protocol} 空消息未被过滤干净`);
}
});
test('历史按旧到新排列,当前轮在最后', async () => {
const history = [];
for (let i = 1; i <= 3; i++) {
history.push({ id: `u${i}`, role: 'user', text: `问题${i}` });
history.push({ id: `a${i}`, role: 'assistant', text: `回答${i}` });
}
for (const protocol of Object.keys(PROTOCOL_STREAMS)) {
const body = await captureBody(protocol, {
task: 'ask',
text: '正文',
question: '问题4',
history
});
const items = protocol === 'openai-responses' ? body.input : body.messages;
const flat = items.map((m) => (typeof m.content === 'string' ? m.content : JSON.stringify(m.content)));
const order = ['问题1', '回答1', '问题2', '回答2', '问题3', '回答3', '问题4']
.map((mark) => flat.findIndex((s) => s.includes(mark)));
assert.ok(order.every((i) => i >= 0), `${protocol} 有历史轮次丢失`);
for (let i = 1; i < order.length; i++) {
assert.ok(order[i] > order[i - 1], `${protocol} 历史顺序颠倒,模型会读到倒序对话`);
}
assert.strictEqual(order[order.length - 1], flat.length - 1, `${protocol} 当前轮不在最后`);
}
});
test('buildMessages 也接受历史参数', () => {
const ai = setup();
const msgs = ai.buildMessages('ask', '正文', '新问题', [], [
{ role: 'user', text: '旧问题' },
{ role: 'assistant', text: '旧回答' }
]);
assert.deepStrictEqual(msgs.map((m) => m.role), ['system', 'user', 'assistant', 'user']);
assert.strictEqual(msgs[1].content, '旧问题');
assert.match(msgs[3].content, /新问题/);
});
+88
View File
@@ -151,6 +151,94 @@ test('主文件损坏时优先从原子写入备份恢复', () => {
assert.strictEqual(store.get('book', key('a.pdf')).pages['1'].objects[0].type, 'Rect');
});
test('批注计数统计对象总数,随内容变化失效', () => {
const { store, dir } = fresh();
const k = key('a.pdf');
store.setPage('book', k, 1, { objects: [{ type: 'Rect' }, { type: 'Path' }] });
store.setPage('book', k, 4, { objects: [{ type: 'IText' }] });
// 是对象总数而非批注页数
assert.deepStrictEqual(store.getCounts(), { book: 3 });
// mtime + size 缓存必须在内容变化后失效
store.setPage('book', k, 4, { objects: [] });
assert.deepStrictEqual(store.getCounts(), { book: 2 });
store.setPage('book', k, 1, { objects: [] });
assert.deepStrictEqual(store.getCounts(), {});
// 同一条目的不同文档版本累加
store.setPage('book', k, 1, { objects: [{ type: 'Rect' }] });
store.setPage('book', key('b.pdf'), 1, { objects: [{ type: 'Rect' }] });
assert.deepStrictEqual(store.getCounts(), { book: 2 });
// 损坏文件计 0 而不是抛错,否则整个书库都拿不到计数
fs.writeFileSync(path.join(dir, 'reader-annotations', 'broken.json'), '{ bad');
assert.deepStrictEqual(store.getCounts(), { book: 2 });
store.forget('book');
assert.deepStrictEqual(store.getCounts(), {});
});
test('删除后重建同名批注文件不会命中旧缓存', () => {
const { store, dir } = fresh();
const folder = path.join(dir, 'reader-annotations');
const file = path.join(folder, 'book.json');
const k = key('a.pdf');
store.setPage('book', k, 1, { objects: [{ t: 'aa' }, { t: 'bb' }] });
// 固定到整毫秒,否则 utimesSync 无法精确复现原 mtime
const pinned = new Date(1700000000000);
fs.utimesSync(file, pinned, pinned);
const stale = fs.statSync(file);
assert.deepStrictEqual(store.getCounts(), { book: 2 });
// 构造与旧文件同尺寸、同 mtime 但内容不同的文件:
// 缓存键只有 mtime+sizeforget 不清缓存就会返回过期的 2
store.forget('book');
const rebuilt = JSON.parse(JSON.stringify({
version: 1,
entryId: 'book',
documents: { [k]: { pages: { 1: { objects: [{ t: 'aa' }], updatedAt: 0 } }, updatedAt: 0 } }
}));
let text = JSON.stringify(rebuilt, null, 2);
assert.ok(text.length < stale.size, '重建内容应短于原文件才能补齐到同尺寸');
rebuilt.documents[k].pages['1'].objects[0].t = 'aa'.padEnd(2 + (stale.size - text.length), 'z');
text = JSON.stringify(rebuilt, null, 2);
assert.strictEqual(Buffer.byteLength(text, 'utf8'), stale.size, '需精确构造同尺寸文件');
fs.mkdirSync(folder, { recursive: true });
fs.writeFileSync(file, text, 'utf8');
fs.utimesSync(file, pinned, pinned);
assert.strictEqual(fs.statSync(file).mtimeMs, stale.mtimeMs, '需精确复现同 mtime');
assert.strictEqual(fs.statSync(file).size, stale.size, '需精确复现同尺寸');
assert.deepStrictEqual(store.getCounts(), { book: 1 });
});
test('孤立批注按体积报告并可批量回收', () => {
const { store, dir } = fresh();
const k = key('a.pdf');
store.setPage('kept', k, 1, { objects: [{ t: 'Rect' }] });
store.setPage('gone_a', k, 1, { objects: [{ t: 'Rect' }, { t: 'Path' }] });
store.setPage('gone_b', k, 1, { objects: [{ t: 'IText' }] });
const orphans = store.orphanReport(['kept']);
assert.deepStrictEqual(orphans.map((o) => o.entryId).sort(), ['gone_a', 'gone_b']);
assert.ok(orphans.every((o) => o.bytes > 0), '需报告体积供用户判断');
assert.strictEqual(orphans.find((o) => o.entryId === 'gone_a').count, 2);
// 仍在书库的条目绝不能出现在回收清单里
assert.ok(!orphans.some((o) => o.entryId === 'kept'));
assert.strictEqual(store.forgetMany(orphans.map((o) => o.entryId)), 2);
assert.strictEqual(fs.existsSync(path.join(dir, 'reader-annotations', 'gone_a.json')), false);
assert.strictEqual(fs.existsSync(path.join(dir, 'reader-annotations', 'kept.json')), true);
assert.deepStrictEqual(store.getCounts(), { kept: 1 });
assert.deepStrictEqual(store.orphanReport(['kept']), []);
// 传入非法 ID 不应中断其余回收
store.setPage('gone_c', k, 1, { objects: [{ t: 'Rect' }] });
assert.strictEqual(store.forgetMany(['../escape', 'gone_c']), 1);
assert.deepStrictEqual(store.getCounts(), { kept: 1 });
});
test('forget 删除条目批注及备份残留', () => {
const { store, dir } = fresh();
store.setPage('book', key('a.pdf'), 1, { objects: [{ type: 'Rect' }] });
+91
View File
@@ -0,0 +1,91 @@
const test = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const atomic = require('../atomic-file');
const dirs = [];
function fresh(tag) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `peoplelib-atomic-${tag}-`));
dirs.push(dir);
return dir;
}
test.after(() => {
for (const dir of dirs) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch (e) { /* ignore */ }
}
});
test('元数据写入在改名前 fsync 数据', () => {
const dir = fresh('fsync');
const dest = path.join(dir, 'meta.json');
const order = [];
const realFsync = fs.fsyncSync;
const realRename = fs.renameSync;
fs.fsyncSync = function tracked(fd) {
order.push('fsync');
return realFsync.call(this, fd);
};
fs.renameSync = function tracked(from, to) {
order.push(`rename:${path.basename(String(from))}->${path.basename(String(to))}`);
return realRename.call(this, from, to);
};
try {
atomic.writeJson(dest, { hello: '世界' });
} finally {
fs.fsyncSync = realFsync;
fs.renameSync = realRename;
}
// rename 只保证目录项替换原子,不保证数据已落盘;
// 因此临时文件的 fsync 必须发生在改名之前
const firstRename = order.findIndex((step) => step.startsWith('rename:'));
assert.ok(firstRename > 0, `改名前应先 fsync,实际顺序 ${order.join(',')}`);
assert.strictEqual(order[0], 'fsync');
assert.deepStrictEqual(JSON.parse(fs.readFileSync(dest, 'utf8')), { hello: '世界' });
});
test('写入失败时清理临时文件并把备份换回原位', () => {
const dir = fresh('rollback');
const dest = path.join(dir, 'meta.json');
atomic.writeJson(dest, { round: 1 });
const before = fs.readFileSync(dest, 'utf8');
const realRename = fs.renameSync;
let failed = false;
fs.renameSync = function failing(from, to) {
if (!failed && String(from) === `${dest}.tmp` && String(to) === dest) {
failed = true;
throw new Error('模拟替换失败');
}
return realRename.apply(this, arguments);
};
try {
assert.throws(() => atomic.writeJson(dest, { round: 2 }), /模拟替换失败/);
} finally {
fs.renameSync = realRename;
}
assert.ok(failed);
assert.strictEqual(fs.readFileSync(dest, 'utf8'), before);
assert.strictEqual(fs.existsSync(`${dest}.tmp`), false);
assert.strictEqual(fs.existsSync(`${dest}.bak`), false);
});
test('目录 fsync 失败不影响写入结果', () => {
const dir = fresh('dirfail');
const dest = path.join(dir, 'meta.json');
const realOpen = fs.openSync;
// Windows 无法对目录取句柄,这条路径必须容错
fs.openSync = function guarded(target, flags, ...rest) {
if (String(target) === dir) throw new Error('EISDIR');
return realOpen.call(this, target, flags, ...rest);
};
try {
atomic.writeJson(dest, { ok: true });
} finally {
fs.openSync = realOpen;
}
assert.deepStrictEqual(JSON.parse(fs.readFileSync(dest, 'utf8')), { ok: true });
});
+75 -6
View File
@@ -168,8 +168,8 @@ app.whenReady().then(async () => {
"document.getElementById('aiConfirmScope').textContent+' '+document.getElementById('aiConfirmCost').textContent"
));
chk('确认框包含范围、字数与 token 估算', /全文/.test(summary) && /字/.test(summary) && /tokens/.test(summary), summary);
chk('确认框明确警告全文可能超限',
/可能超过模型的上下文限制/.test(String(await js("document.getElementById('aiConfirmNotice').textContent"))));
chk('确认框说明全文完整发送且超限由接口报错',
/完整发送/.test(String(await js("document.getElementById('aiConfirmNotice').textContent"))));
chk('确认框使用应用按钮而非原生弹窗',
(await js("document.getElementById('aiConfirmSendBtn').textContent.trim()")) === '继续发送');
const captureDir = process.env.PEOPLELIB_CAPTURE_DIR || TMP;
@@ -193,7 +193,12 @@ app.whenReady().then(async () => {
await new Promise((r) => setTimeout(r, 3820));
chk('用户同意后发送且仅一次', received.length === 1, '请求数=' + received.length);
chk('外发内容为全文正文', charsOf(received[0]) > 1000, '字符=' + charsOf(received[0]));
chk('超长全文按上限截断后才外发', charsOf(received[0]) <= 12000 + 2000, '字符=' + charsOf(received[0]));
// 界面告知的字数必须等于真正离开进程的字数。旧行为在这里砍掉 80% 正文却仍显示全文字数
chk('外发字数与界面告知一致,正文未被静默截断',
charsOf(received[0]) >= docChars,
`外发=${charsOf(received[0])} 界面告知=${docChars}`);
chk('外发正文不含本地截断标记',
!JSON.stringify(received[0]).includes('中间省略'));
chk('AI 回答使用成熟 Markdown 结构渲染', await js(`(() => {
const output = document.getElementById('aiOutput');
return output.querySelector('h1')?.textContent === '回答'
@@ -245,7 +250,8 @@ app.whenReady().then(async () => {
})()`));
await js("document.getElementById('aiConfirmSendBtn').click()");
await new Promise((r) => setTimeout(r, 4000));
const pageContent = received[1] && received[1].messages && received[1].messages[1].content;
// 多轮之后本轮提问固定在末尾,历史占据中间位置,因此不能按固定下标取
const pageContent = received[1] && received[1].messages && received[1].messages.at(-1).content;
const pageImage = Array.isArray(pageContent)
? pageContent.find((part) => part && part.type === 'image_url')
: null;
@@ -328,13 +334,72 @@ app.whenReady().then(async () => {
await new Promise((r) => setTimeout(r, 600));
await js("document.getElementById('aiConfirmSendBtn').click()");
await new Promise((r) => setTimeout(r, 4000));
const regionContent = received[2] && received[2].messages && received[2].messages[1].content;
const regionContent = received[2] && received[2].messages && received[2].messages.at(-1).content;
const regionImage = Array.isArray(regionContent)
? regionContent.find((part) => part && part.type === 'image_url')
: null;
chk('框选区域作为单张图像上下文发送', received.length === 3
&& /^data:image\/jpeg;base64,/.test(regionImage?.image_url?.url || ''));
// 同一会话的第三轮:前两轮必须作为历史外发,且历史里不能夹带图像
const regionMessages = (received[2] && received[2].messages) || [];
chk('多轮对话把前几轮问答作为历史发送',
regionMessages.length >= 4
&& regionMessages[0].role === 'system'
&& regionMessages.at(-1).role === 'user'
&& regionMessages.slice(1, -1).some((m) => m.role === 'assistant'),
regionMessages.map((m) => m.role).join(','));
chk('历史消息只带文本,不重复上传图像',
regionMessages.slice(0, -1).every((m) => typeof m.content === 'string'),
regionMessages.map((m) => (typeof m.content === 'string' ? 'str' : 'arr')).join(','));
// 会话必须落盘:关掉窗口再开回来,历史消息与会话列表都应原样恢复
const diskSessions = require(path.join(ROOT, 'src', 'reader', 'ai-sessions'));
const storedList = diskSessions.list({ entryId: e.id });
const storedId = storedList[0] && storedList[0].id;
const storedMessages = storedId ? diskSessions.messages(storedId, { limit: 100 }).messages : [];
chk('多轮问答持久化到磁盘会话',
storedList.length === 1 && storedMessages.length === 6
&& storedMessages.filter((m) => m.role === 'user').length === 3
&& storedMessages.filter((m) => m.role === 'assistant').length === 3,
`会话=${storedList.length} 消息=${storedMessages.length}`);
// 存的是用户看见的那句提问,不是整篇正文,否则重开后气泡会变成十几万字原文
chk('会话只存提问本身,正文以 contextRef 摘要记录',
storedMessages[0].role === 'user'
&& storedMessages[0].text === '这章讲了什么'
&& storedMessages[0].contextRef?.scope === 'document'
&& storedMessages[0].contextRef.chars >= docChars
&& storedMessages[0].contextRef.hash.length === 32,
`${storedMessages[0].text.slice(0, 20)} / ${storedMessages[0].contextRef?.chars}`);
chk('历史图像以 imageId 引用而非内联 base64',
storedMessages.filter((m) => m.images.length).every((m) => m.images.every(
(img) => /^img_[0-9a-f]{64}$/.test(img.imageId) && img.base64 === undefined
)),
JSON.stringify(storedMessages.map((m) => m.images.map((i) => i.imageId.slice(0, 12)))));
const reopened = new BrowserWindow({
show: false, width: 1200, height: 860,
webPreferences: { preload: path.join(ROOT, 'preload.js'), contextIsolation: true, nodeIntegration: false }
});
await reopened.loadFile(path.join(ROOT, 'src', 'ui', 'reader.html'), { query: { entryId: e.id } });
await new Promise((r) => setTimeout(r, 9000));
const reopenedJs = (code) => reopened.webContents.executeJavaScript(code);
await reopenedJs("document.querySelector('[data-pane=\"ai\"]').click()");
await new Promise((r) => setTimeout(r, 2000));
const restored = await reopenedJs(`(() => ({
bubbles: document.querySelectorAll('#aiOutput .ai-msg').length,
first: (document.querySelector('#aiOutput .ai-msg') || {}).textContent || '',
options: document.getElementById('aiSessionSelect').options.length
}))()`);
chk('重开阅读器恢复会话与历史气泡',
restored.bubbles === 6 && restored.options === 1 && restored.first.includes('这章讲了什么'),
`气泡=${restored.bubbles} 会话=${restored.options}`);
const requestsBeforeReopen = received.length;
await new Promise((r) => setTimeout(r, 1500));
chk('恢复历史不会重新调用模型', received.length === requestsBeforeReopen,
`${requestsBeforeReopen} -> ${received.length}`);
reopened.destroy();
chk('超大回答降级为纯文本以限制解析开销', await js(`(() => {
const output = document.getElementById('aiOutput');
const text = 'x'.repeat(256 * 1024 + 1);
@@ -433,4 +498,8 @@ app.whenReady().then(async () => {
console.log(`\n通过 ${results.length - bad}/${results.length}`);
server.close();
app.exit(bad ? 1 : 0);
}).catch((e) => { console.error('异常:', e); app.exit(1); });
}).catch((e) => {
console.error('异常:', e);
for (const [s, n, x] of results) console.log(`${s.padEnd(5)} ${n}${x ? ' [' + x + ']' : ''}`);
app.exit(1);
});
@@ -411,6 +411,46 @@ app.whenReady().then(async () => {
check('首次窗口无渲染错误', first.errors.length === 0, first.errors.slice(0, 2).join(' | '));
check('重开窗口无渲染错误', second.errors.length === 0, second.errors.slice(0, 2).join(' | '));
// 书库卡片上的批注计数必须来自真实落盘的批注,而不是渲染层自己数的
const storedCount = annotations.getCounts()[String(entry.id)] || 0;
check('批注已落盘并可计数', storedCount > 0, `count=${storedCount}`);
const libraryWindow = BrowserWindow.getAllWindows()
.find((w) => !w.isDestroyed() && /index\.html/.test(w.webContents.getURL()));
check('存在书库窗口', !!libraryWindow);
if (libraryWindow) {
libraryWindow.show();
await js(libraryWindow, `(async () => {
document.querySelector('[data-tab="library"]').click();
await new Promise((r) => setTimeout(r, 400));
document.querySelector('[data-tab="notes"]').click();
await new Promise((r) => setTimeout(r, 300));
document.querySelector('[data-tab="library"]').click();
await new Promise((r) => setTimeout(r, 1800));
})()`);
const badge = await js(libraryWindow, `(() => {
const card = document.querySelector('#libGrid .card[data-id="${entry.id}"]');
if (!card) return { missing: true };
const cover = card.querySelector('.card-cover').getBoundingClientRect();
const annot = card.querySelector('.card-badge.annotation-count');
const end = card.querySelector('.card-cover-badges.end');
const start = card.querySelector('.card-cover-badges.start');
if (!annot) return { noBadge: true };
const ar = annot.getBoundingClientRect();
const sr = start.getBoundingClientRect();
return {
text: annot.textContent.trim(),
inEnd: end.contains(annot),
insideCover: ar.right <= cover.right + 0.5 && ar.bottom <= cover.bottom + 0.5,
rightOfStatus: ar.left >= sr.right - 0.5,
statusText: start.textContent.trim()
};
})()`);
check('书库卡片显示与落盘一致的批注数',
badge.text === `批注 ${storedCount}`, JSON.stringify(badge));
check('批注标识在封面右下角,不与左下角状态重叠',
badge.inEnd && badge.insideCover && badge.rightOfStatus, JSON.stringify(badge));
}
const captureDir = process.env.PEOPLELIB_CAPTURE_DIR || TMP;
fs.writeFileSync(path.join(captureDir, 'pdf-annotations.png'), (await second.win.webContents.capturePage()).toPNG());
+312 -7
View File
@@ -59,17 +59,20 @@ async function js(source) {
return win.webContents.executeJavaScript(source);
}
async function poll(name, predicate, timeout = 8000) {
// soft=true 时超时不抛也不记失败,只返回 false,交给调用方自己断言,
// 这样失败信息里能带上真实量到的状态而不是一句"等待超时"
async function poll(name, predicate, timeout = 8000, soft = false) {
const deadline = Date.now() + timeout;
let lastError = null;
while (Date.now() < deadline) {
try {
if (await predicate()) return;
if (await predicate()) return true;
} catch (error) {
lastError = error;
}
await wait(50);
}
if (soft) return false;
const detail = lastError ? lastError.message : '等待超时';
check(name, false, detail);
throw new Error(`${name}: ${detail}`);
@@ -159,6 +162,7 @@ app.whenReady().then(async () => {
const readerStore = require(path.join(ROOT, 'src', 'reader', 'store'));
const annotations = require(path.join(ROOT, 'src', 'reader', 'annotations'));
const noteAssets = require(path.join(ROOT, 'src', 'reader', 'note-assets'));
const noteWindow = require(path.join(ROOT, 'src', 'reader', 'note-window'));
const aiConfig = require(path.join(ROOT, 'src', 'reader', 'ai-config'));
const zlibAuth = require(path.join(ROOT, 'src', 'sources', 'zlib-auth'));
const semanticKey = require(path.join(ROOT, 'src', 'sources', 'semantic-key'));
@@ -481,13 +485,28 @@ app.whenReady().then(async () => {
)) || null;
return !!readerWindow;
});
await poll('封面打开的 PDF 在内置阅读器渲染', async () => (
// 这本书的文件顺序是 [txt, pdf],封面走的是"第一个可阅读文件",
// 也就是 txt(txt/md 同样能内置阅读,走 text-adapter 转 epub 渲染)。
// 这里断言"渲染出正文",不要写死 PDF 画布:那样等于把
// "txt 不可阅读所以退到 pdf" 这个旧缺陷当成期望行为锁死
await poll('封面打开的书在内置阅读器渲染出正文', async () => (
!readerWindow.isDestroyed()
&& readerWindow.webContents.executeJavaScript(
"document.querySelector('.pdfx-page[data-page=\"1\"] .pdfx-canvas')?.width > 0"
)
), 15000);
&& readerWindow.webContents.executeJavaScript(`(() => {
if (document.querySelector('.doc-overlay.err')) return false;
const canvas = document.querySelector('.pdfx-page[data-page="1"] .pdfx-canvas');
if (canvas && canvas.width > 0) return true;
const frame = document.querySelector('.host-epub iframe');
const body = frame && frame.contentDocument && frame.contentDocument.body;
return !!(body && body.textContent.trim().length > 0);
})()`)
), 20000);
check('点击可阅读图书封面直接打开内置阅读器', !!readerWindow);
check(
'封面打开的是第一个可阅读文件(txt 也算)',
(await readerWindow.webContents.executeJavaScript(
"new URLSearchParams(location.search).get('fileIndex')"
)) === '0'
);
readerWindow.destroy();
await wait(200);
check(
@@ -1589,6 +1608,292 @@ app.whenReady().then(async () => {
);
dialog.showOpenDialog = originalShowOpenDialog;
// --- 笔记独立窗口 ---
await js(`document.querySelector('.tab[data-tab="notes"]').click()`);
await pollJs('笔记页有可开窗的卡片', "document.querySelectorAll('#notesList .note-card').length > 0");
const windowNote = readerStore.listNotes({}).find((note) => note.associated !== false);
check('存在可用于开窗的笔记', !!windowNote);
// 只数笔记窗口。总窗口数会被阅读器窗口的开关干扰,
// 之前用总数当基线,阅读器中途关掉就把「没多开窗」误判成失败
const noteWindowCount = () => BrowserWindow.getAllWindows()
.filter((item) => !item.isDestroyed() && String(item.webContents.getURL()).includes('note.html'))
.length;
check('开窗前没有笔记窗口', noteWindowCount() === 0, `笔记窗口数=${noteWindowCount()}`);
// 按钮必须限定在目标笔记那张卡片内:笔记页此时有多张卡片,
// 全局找「编辑」会命中别的卡片,断言就变成了自欺欺人
const cardScript = (inner) => `(() => {
const target = document.querySelector('#notesList .note-card[data-note-id=' +
JSON.stringify(${JSON.stringify(String(windowNote.id))}) + ']');
if (!target) throw new Error('找不到目标笔记卡片');
${inner}
})()`;
const cardLabels = () => js(cardScript(
'return [...target.querySelectorAll(".note-action")].map((b) => b.textContent).join(",");'
));
const clickCardAction = (label) => js(cardScript(`
const button = [...target.querySelectorAll(".note-action")]
.find((item) => item.textContent === ${JSON.stringify(label)});
if (!button) throw new Error("找不到按钮:" + ${JSON.stringify(label)});
button.click();
return true;
`));
await clickCardAction('独立窗口');
// 窗口创建与 URL 就位之间有间隔,刚建好时 getURL() 还是空串,必须轮询
const findNoteWindow = () => BrowserWindow.getAllWindows()
.find((item) => !item.isDestroyed() && String(item.webContents.getURL()).includes('note.html'));
await poll(
'点击独立窗口后真的多出一个笔记窗口',
async () => noteWindowCount() === 1 && !!findNoteWindow(),
15000
);
const noteWin = findNoteWindow();
check('新窗口加载的是笔记页面', !!noteWin);
const noteJs = (source) => noteWin.webContents.executeJavaScript(source);
// 多标签后表单是每个标签一份,查询必须限定在当前激活的那个视图内,
// 否则回收/切换过程中会量到别的标签
const activeScript = (inner) => `(() => {
const view = [...document.querySelectorAll('.note-tab-view')]
.find((item) => !item.classList.contains('inactive'));
if (!view) throw new Error('没有激活的笔记标签');
${inner}
})()`;
await poll(
'笔记窗口标签就绪',
() => noteJs(`(() => {
const view = [...document.querySelectorAll('.note-tab-view')]
.find((item) => !item.classList.contains('inactive'));
return !!(view && view.querySelector('.note-window-title'));
})()`),
15000
);
check(
'笔记窗口载入的是被点开的那一条',
(await noteJs(activeScript("return view.querySelector('.note-window-title').value;")))
=== String(windowNote.title || '')
&& (await noteJs("document.getElementById('noteWindowError').textContent")) === '',
await noteJs(activeScript("return view.querySelector('.note-window-title').value;"))
);
check(
'开出的是一个标签',
(await noteJs("document.querySelectorAll('.doctab').length")) === 1,
`标签数=${await noteJs("document.querySelectorAll('.doctab').length")}`
);
// 同一条笔记不允许开出第二个标签,否则两个编辑器会整条覆盖对方
await clickCardAction('切到窗口');
await wait(1200);
check(
'同一条笔记再次开窗只聚焦不新增窗口',
noteWindowCount() === 1,
`笔记窗口数=${noteWindowCount()}`
);
check(
'同一条笔记再次开窗也不新增标签',
(await noteJs("document.querySelectorAll('.doctab').length")) === 1,
`标签数=${await noteJs("document.querySelectorAll('.doctab').length")}`
);
// 已开窗时该卡片的按钮改为切窗,避免模态与窗口同时编辑同一条
const openedLabels = await cardLabels();
check(
'已开窗后该笔记不再提供开模态的编辑按钮',
openedLabels.includes('在窗口中编辑') && !openedLabels.split(',').includes('编辑'),
openedLabels
);
await clickCardAction('在窗口中编辑');
await wait(1000);
check(
'点「在窗口中编辑」不会打开模态',
await js("document.getElementById('modal').classList.contains('hidden')")
);
// 断言真正落盘的内容,而不是界面状态
const editedTitle = `窗口改名 ${Date.now()}`;
await noteJs(activeScript(`
const title = view.querySelector('.note-window-title');
title.value = ${JSON.stringify(editedTitle)};
title.dispatchEvent(new Event('input', { bubbles: true }));
const tags = view.querySelector('.note-window-tags-input');
tags.value = '窗口标签';
tags.dispatchEvent(new Event('input', { bubbles: true }));
return true;
`));
// 有未保存修改时标签上要有脏标记,否则关闭前的二次确认无从触发
await poll(
'未保存的修改在标签上有脏标记',
() => noteJs("document.querySelectorAll('.doctab-dirty').length === 1"),
8000
);
check('未保存的修改在标签上有脏标记', true);
await noteJs(activeScript("view.querySelector('.note-window-save').click(); return true;"));
await poll(
'笔记窗口的修改真正落盘',
async () => {
const stored = readerStore.listNotes({}).find((note) => note.id === windowNote.id);
return !!stored && stored.title === editedTitle && stored.tags.includes('窗口标签');
},
12000
);
check('笔记窗口保存后落盘内容正确', true);
await poll(
'笔记窗口的改动回流到主窗口列表',
() => js(`Array.from(document.querySelectorAll('#notesList .note-title'))
.some((node) => node.textContent.trim() === ${JSON.stringify(editedTitle)})`),
12000
);
check('主窗口列表随笔记窗口保存刷新', true);
await poll(
'保存后脏标记清除',
() => noteJs("document.querySelectorAll('.doctab-dirty').length === 0"),
8000
);
check('保存后脏标记清除', true);
const noteWinErrors = [];
noteWin.webContents.on('console-message', (event) => {
if (event.level >= 2) noteWinErrors.push(event.message.slice(0, 120));
});
await wait(200);
check('笔记窗口没有控制台错误', noteWinErrors.length === 0, noteWinErrors.slice(0, 2).join(' | '));
// 第二条笔记要进同一个窗口的新标签,而不是再开一个窗口
const secondNote = readerStore.listNotes({})
.find((note) => note.id !== windowNote.id && note.associated !== false);
if (secondNote) {
await js(`(() => {
const target = document.querySelector('#notesList .note-card[data-note-id=' +
JSON.stringify(${JSON.stringify(String(secondNote.id))}) + ']');
if (!target) throw new Error('找不到第二条笔记卡片');
const button = [...target.querySelectorAll('.note-action')]
.find((item) => item.textContent === '独立窗口');
if (!button) throw new Error('第二条笔记没有独立窗口按钮');
button.click();
return true;
})()`);
await poll(
'第二条笔记进入同一窗口的新标签',
async () => noteWindowCount() === 1
&& (await noteJs("document.querySelectorAll('.doctab').length")) === 2,
15000
);
check(
'第二条笔记进入同一窗口的新标签',
noteWindowCount() === 1,
`笔记窗口数=${noteWindowCount()}`
);
check(
'只有一个标签视图可见',
(await noteJs(
"[...document.querySelectorAll('.note-tab-view')].filter((v) => !v.classList.contains('inactive')).length"
)) === 1
);
// 刚打开且只在编辑区点选、按方向键,不改内容,不能被判定为已修改。
// 早前用 pointerdown/keydown 判脏时这里必然误报,一切标签都要求二次确认;
// 画布还会因为 version 1→2 归一化在挂载时就"变更"一次
await wait(1200);
await noteJs(activeScript(`
const host = view.querySelector('.note-window-editor');
host.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
host.dispatchEvent(new PointerEvent('pointerup', { bubbles: true }));
host.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'ArrowRight' }));
host.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: 'ArrowRight' }));
return true;
`));
await wait(900);
check(
'只点选不改内容不会被误判为已修改',
(await noteJs("document.querySelectorAll('.doctab-dirty').length")) === 0,
`脏标记数=${await noteJs("document.querySelectorAll('.doctab-dirty').length")}`
);
}
// 关窗前的未保存拦截:取消之后必须还能再次触发确认。
// 主进程 closePending 不复位时第二次点关闭会被静默忽略,
// 而看门狗十秒后仍会把带未保存内容的窗口销毁。
// 此时激活的是第二条笔记的标签,必须先切回已保存过的那条,
// 否则下面"改回原样"比对的是另一条笔记的基线
await noteJs(`(() => {
const tab = document.querySelector('.doctab[data-note-id=' +
JSON.stringify(${JSON.stringify(String(windowNote.id))}) + ']');
if (!tab) throw new Error('找不到目标标签');
tab.click();
return true;
})()`);
await poll(
'切回已保存的那个标签',
() => noteJs(activeScript(
`return view.dataset.noteId === ${JSON.stringify(String(windowNote.id))};`
)),
10000
);
await noteJs(activeScript(`
const title = view.querySelector('.note-window-title');
title.value = '关窗前的未保存修改';
title.dispatchEvent(new Event('input', { bubbles: true }));
return true;
`));
await poll(
'关窗前已置脏',
() => noteJs("document.querySelectorAll('.doctab-dirty').length >= 1"),
8000
);
await noteJs("document.getElementById('closeBtn').click()");
await poll(
'关窗被未保存确认拦下',
() => noteJs("!document.getElementById('noteDirtyModal').classList.contains('hidden')"),
10000
);
check('关窗被未保存确认拦下,窗口还在', !noteWin.isDestroyed());
await noteJs("document.getElementById('noteDirtyCancelBtn').click()");
await wait(1000);
check('取消后窗口保留', !noteWin.isDestroyed());
await noteJs("document.getElementById('closeBtn').click()");
await poll(
'取消之后再次关窗仍会弹确认',
() => noteJs("!document.getElementById('noteDirtyModal').classList.contains('hidden')"),
10000
);
check('取消之后再次关窗仍会弹确认', !noteWin.isDestroyed());
await noteJs("document.getElementById('noteDirtyCancelBtn').click()");
await wait(800);
// 存盘收尾,避免未保存状态干扰后面的删除断言
await noteJs(activeScript("view.querySelector('.note-window-save').click(); return true;"));
await poll(
'取消关闭后仍能正常保存',
() => noteJs(`(() => {
const tab = document.querySelector('.doctab[data-note-id=' +
JSON.stringify(${JSON.stringify(String(windowNote.id))}) + ']');
return !!tab && !tab.querySelector('.doctab-dirty');
})()`),
10000
);
check('取消关闭后仍能正常保存', true);
// 笔记被删除后只关掉它那个标签,窗口和别的标签要留着
await js(`window.api.reader.removeNote(${JSON.stringify(windowNote.entryId)}, ${JSON.stringify(windowNote.id)})`);
if (secondNote) {
await poll(
'删除笔记只关掉对应标签',
async () => !noteWin.isDestroyed()
&& (await noteJs("document.querySelectorAll('.doctab').length")) === 1,
12000
);
check('删除笔记只关掉对应标签,窗口留着', !noteWin.isDestroyed());
// 删掉最后一个标签,窗口才该退场
const remainingId = await noteJs("document.querySelector('.doctab').dataset.noteId");
const remaining = readerStore.listNotes({}).find((note) => note.id === remainingId);
check('剩下的标签是另一条笔记', !!remaining && remaining.id !== windowNote.id, String(remainingId));
if (remaining) {
await js(`window.api.reader.removeNote(${JSON.stringify(remaining.entryId)}, ${JSON.stringify(remaining.id)})`);
}
}
await poll('最后一个标签消失后窗口自动关闭', async () => noteWin.isDestroyed(), 12000);
check('最后一个标签消失后窗口自动关闭', noteWin.isDestroyed());
check('窗口关闭后主进程标签集清空', noteWindow.openIds().length === 0, JSON.stringify(noteWindow.openIds()));
await wait(300);
check(
'主渲染进程没有控制台错误',
@@ -11,6 +11,8 @@ const EPUB_FILE = path.join(TMP, 'reader-features.epub');
const MOBI_FILE = path.join(TMP, 'reader-features.mobi');
const DRM_MOBI_FILE = path.join(TMP, 'reader-features-drm.azw');
const LARGE_EPUB_FILE = path.join(TMP, 'reader-features-large.epub');
const TXT_FILE = path.join(TMP, 'reader-features.txt');
const MD_FILE = path.join(TMP, 'reader-features.md');
app.setPath('userData', TMP);
app.setPath('appData', TMP);
@@ -194,6 +196,58 @@ function makeMobi(file) {
fs.writeFileSync(file, output);
}
function makeTxt(file) {
const lines = ['纯文本夹具标题', ''];
for (let i = 1; i <= 3; i++) {
lines.push(`${i}章 编码与分章`, '');
for (let k = 0; k < 12; k++) {
lines.push(`${i}章第${k + 1}段:TXT-MARK-${i}-${k} 中文正文用于校验解码与全文提取。`);
}
lines.push('');
}
const text = lines.join('\r\n');
// 带 BOM 的 UTF-16LE 是 Windows 记事本另存的默认之一,编码探测必须在真实浏览器里也成立
const body = Buffer.from(text, 'utf16le');
fs.writeFileSync(file, Buffer.concat([Buffer.from([0xff, 0xfe]), body]));
return text;
}
function makeMd(file) {
const source = [
'# Markdown 夹具',
'',
'正文段落包含 **加粗** 与 `行内代码`,用于确认渲染而不是纯文本显示。',
'',
'## 危险内容小节',
'',
'<script>window.__mdScriptExecuted = true</script>',
'',
'<img src="x" onerror="window.__mdHandlerExecuted = true">',
'',
'[不安全链接](javascript:window.__mdLinkExecuted=true)',
'',
'<iframe src="https://untrusted.example/frame"></iframe>',
'',
'## 结构小节',
'',
'- 列表项一',
'- 列表项二',
'',
'```js',
'const fenced = "code block";',
'```',
'',
'| 列一 | 列二 |',
'|---|---|',
'| 单元格 | MD-TABLE-CELL |',
'',
'> 引用块 MD-QUOTE-MARK',
''
].join('\n');
fs.writeFileSync(file, source, 'utf8');
return source;
}
async function js(win, source) {
try {
return await win.webContents.executeJavaScript(source);
@@ -515,6 +569,8 @@ app.whenReady().then(async () => {
fs.copyFileSync(MOBI_FILE, DRM_MOBI_FILE);
fs.writeFileSync(LARGE_EPUB_FILE, Buffer.alloc(0));
fs.truncateSync(LARGE_EPUB_FILE, 256 * 1024 * 1024 + 1);
makeTxt(TXT_FILE);
makeMd(MD_FILE);
const drmFixture = fs.readFileSync(DRM_MOBI_FILE);
drmFixture.writeUInt16BE(1, 96 + 12);
fs.writeFileSync(DRM_MOBI_FILE, drmFixture);
@@ -549,7 +605,9 @@ app.whenReady().then(async () => {
{ path: EPUB_FILE, name: 'reader-features.epub', format: 'EPUB' },
{ path: MOBI_FILE, name: 'reader-features.mobi', format: 'MOBI' },
{ path: DRM_MOBI_FILE, name: 'reader-features-drm.azw', format: 'AZW' },
{ path: LARGE_EPUB_FILE, name: 'reader-features-large.epub', format: 'EPUB' }
{ path: LARGE_EPUB_FILE, name: 'reader-features-large.epub', format: 'EPUB' },
{ path: TXT_FILE, name: 'reader-features.txt', format: 'TXT' },
{ path: MD_FILE, name: 'reader-features.md', format: 'MD' }
]
});
await wait(1200);
@@ -671,6 +729,95 @@ app.whenReady().then(async () => {
pdfWin.setSize(1280, 900);
await waitForJs(pdfWin, `document.querySelector('.pdfx-pages')
.classList.contains('pdfx-layout-single')`);
// 前面的分页导航把视口停在第 3 页,离屏页会被回收成空白占位,
// 必须先回到第 1 页再测画质,否则量到的是占位画布而不是真实渲染结果
await js(pdfWin, `(() => {
const range = document.getElementById('progressRange');
range.value = '0';
range.dispatchEvent(new Event('change'));
})()`);
await waitForJs(pdfWin, `document.getElementById('posLabel').textContent === '第 1 页'`);
const canvasProbe = `(() => {
const canvas = document.querySelector('.pdfx-page[data-page="1"] .pdfx-canvas');
const css = canvas.getBoundingClientRect().width;
return {
dpr: window.devicePixelRatio,
backing: canvas.width,
css: Math.round(css),
ratio: canvas.width / css
};
})()`;
await waitForJs(pdfWin, `(() => {
const canvas = document.querySelector('.pdfx-page[data-page="1"] .pdfx-canvas');
return canvas && canvas.width > 0;
})()`);
check('标准画质下第 1 页有真实墨迹',
imageHasInk(await js(pdfWin,
`document.querySelector('.pdfx-page[data-page="1"] .pdfx-canvas').toDataURL('image/png')`)));
const qualityBase = await js(pdfWin, canvasProbe);
// 生效倍率是 max(dpr, quality)HiDPI 屏上不叠乘,所以不能断言"backing 翻倍"
check('默认画质下 backing 比例等于设备像素比',
Math.abs(qualityBase.ratio - qualityBase.dpr) < 0.05,
`dpr=${qualityBase.dpr} ratio=${qualityBase.ratio.toFixed(3)}`);
const targetQuality = Math.min(3, Math.ceil(qualityBase.dpr + 1));
await js(pdfWin, `(() => {
const select = document.getElementById('pdfRenderQuality');
select.value = '${targetQuality}';
select.dispatchEvent(new Event('change'));
})()`);
await waitForJs(pdfWin, `(() => {
const canvas = document.querySelector('.pdfx-page[data-page="1"] .pdfx-canvas');
if (!canvas || !canvas.width) return false;
return Math.abs(canvas.width / canvas.getBoundingClientRect().width - ${targetQuality}) < 0.05;
})()`);
const qualityHigh = await js(pdfWin, canvasProbe);
// 超采样只能放大 backing store,CSS 盒子必须原地不动,否则是把页面放大而不是提高画质
check('PDF 画质档提高 backing 分辨率且不改变版面尺寸',
Math.abs(qualityHigh.ratio - targetQuality) < 0.05
&& qualityHigh.backing > qualityBase.backing
&& Math.abs(qualityHigh.css - qualityBase.css) <= 1,
`ratio ${qualityBase.ratio.toFixed(3)}->${qualityHigh.ratio.toFixed(3)} `
+ `backing ${qualityBase.backing}->${qualityHigh.backing} css ${qualityBase.css}->${qualityHigh.css}`);
const qualityRatios = await js(pdfWin, `Array.from(document.querySelectorAll('.pdfx-canvas'))
.filter((canvas) => canvas.width > 0)
.map((canvas) => Math.round(canvas.width / canvas.getBoundingClientRect().width * 100) / 100)`);
// 只改新渲染的页会让同屏出现清晰度不一致,倍率变化必须整篇重建
check('画质切换后同屏各页倍率一致',
qualityRatios.length > 0 && new Set(qualityRatios).size === 1,
JSON.stringify(qualityRatios));
// 画布尺寸在重建时立即变大,墨迹要等这一页重绘完才落上去,只等比例会量到空白中间态
let highInk = false;
try {
await waitUntil(async () => {
highInk = imageHasInk(await js(pdfWin,
`document.querySelector('.pdfx-page[data-page="1"] .pdfx-canvas').toDataURL('image/png')`));
return highInk;
}, 15000, 250);
} catch (error) { /* 交给下面的断言报告 */ }
check('提高画质后仍渲染出真实墨迹而不是空白画布', highInk);
await waitUntil(() => Promise.resolve(
settings.get('reader.pdfRenderQuality', 0) === targetQuality
));
check('PDF 画质偏好已持久化', true);
await js(pdfWin, `(() => {
const select = document.getElementById('pdfRenderQuality');
select.value = '1';
select.dispatchEvent(new Event('change'));
})()`);
await waitForJs(pdfWin, `(() => {
const canvas = document.querySelector('.pdfx-page[data-page="1"] .pdfx-canvas');
if (!canvas || !canvas.width) return false;
return Math.abs(canvas.width / canvas.getBoundingClientRect().width - ${qualityBase.dpr}) < 0.05;
})()`);
check('画质调回标准档后恢复设备像素比', true);
// 画质检查需要停在第 1 页,后续用例仍按第 3 页断言,这里把视口还回去
await js(pdfWin, `(() => {
const range = document.getElementById('progressRange');
range.value = '1000';
range.dispatchEvent(new Event('change'));
})()`);
await waitForJs(pdfWin, `document.getElementById('posLabel').textContent === '第 3 页'`);
try {
await waitForJs(pdfWin, `Array.from(document.querySelectorAll('.pdfx-text span'))
.some((node) => node.firstChild && node.firstChild.data.trim())`);
@@ -1356,6 +1503,181 @@ app.whenReady().then(async () => {
drmReader.errors.slice(0, 3).join(' | '));
drmReader.win.close();
const txtReader = await openReader(entry.id, 5);
const txtWin = txtReader.win;
await waitForJs(txtWin, `!document.querySelector('.doc-overlay')
&& !!document.querySelector('.host-epub iframe')?.contentDocument?.body?.textContent.trim()`, 30000);
check('TXT 通过内置阅读器打开而不是回退到外部程序',
await js(txtWin, `!document.querySelector('.doc-overlay.err')
&& !!document.querySelector('.host-epub iframe')`));
const txtToc = await js(txtWin, `Array.from(document.querySelectorAll('#tocList .toc-item'))
.map((button) => button.textContent)`);
check('TXT 按章节标题切分并生成可跳转目录',
txtToc.length >= 3 && txtToc.some((label) => /第1章/.test(label)),
JSON.stringify(txtToc.slice(0, 5)));
await js(txtWin, `Array.from(document.querySelectorAll('#tocList .toc-item'))
.find((button) => /第1章/.test(button.textContent)).click()`);
await waitForJs(txtWin, `document.querySelector('.host-epub iframe').contentDocument.body
.textContent.includes('TXT-MARK-1-0')`, 20000);
const txtDecoded = await js(txtWin, `(() => {
const text = document.querySelector('.host-epub iframe').contentDocument.body.textContent;
return {
chinese: text.includes('第1章第1段'),
replacement: text.includes('\\ufffd'),
nul: text.includes('\\u0000')
};
})()`);
// 带 BOM 的 UTF-16LE 是记事本另存的默认之一,探测错会整本变乱码且用户无从修正
check('带 BOM 的 UTF-16LE 纯文本正确解码为中文而不是乱码',
txtDecoded.chinese && !txtDecoded.replacement && !txtDecoded.nul,
JSON.stringify(txtDecoded));
// AI 的"全文"范围依赖 textOf(locator,'document'),缺章会让模型答非所问且用户无从察觉
const txtFullText = await js(txtWin, `(async () => {
const module = await import('./reader/text-adapter.mjs');
const adapter = module.createTextAdapter('txt');
try {
const bytes = await window.api.reader.bytes(${JSON.stringify(entry.id)}, 5);
await adapter.load(bytes.data, {});
const host = document.createElement('div');
document.body.appendChild(host);
await adapter.renderTo(host, null, { fontSize: 16, theme: 'light', lineHeight: 1.7 });
const full = await adapter.textOf(null, 'document');
const page = await adapter.textOf(null, 'page');
host.remove();
return {
marks: (full.match(/TXT-MARK-\\d+-\\d+/g) || []).length,
pageMarks: (page.match(/TXT-MARK-\\d+-\\d+/g) || []).length,
length: full.length
};
} finally {
adapter.destroy();
}
})()`);
check('TXT 全文范围覆盖所有章节而不是只有当前章',
txtFullText.marks === 36 && txtFullText.pageMarks < txtFullText.marks,
JSON.stringify(txtFullText));
check('TXT 阅读器没有控制台错误', txtReader.errors.length === 0,
txtReader.errors.slice(0, 3).join(' | '));
txtWin.close();
// 上面几条都是直接调 IPC 打开的,绕过了书库界面。
// 渲染层自己那份可阅读格式白名单漏掉 txt/md 时,IPC 照样能开,
// 但卡片上根本不会出现「阅读」按钮,用户看到的就是"内置阅读器打不开 txt"
{
const mainWin = BrowserWindow.getAllWindows()
.find((w) => !w.isDestroyed() && String(w.webContents.getURL()).includes('index.html'));
check('存在主窗口用于校验书库入口', !!mainWin);
if (mainWin) {
mainWin.show();
await js(mainWin, `document.querySelector('.tab[data-tab="library"]').click()`);
await waitForJs(mainWin,
`document.querySelectorAll('#libGrid > .card').length > 0`, 20000);
const cardEntry = await js(mainWin, `(() => {
const card = document.querySelector('#libGrid .card[data-id="${entry.id}"]');
if (!card) return { missing: true };
return {
coverReadable: !!card.querySelector('.card-cover.readable'),
coverActs: !!card.querySelector('.card-cover[data-act="read"]'),
hasRead: [...card.querySelectorAll('.lib-card-actions button')]
.some((b) => /阅读/.test(b.title || ''))
};
})()`);
check('书库卡片提供内置阅读入口', cardEntry.hasRead && cardEntry.coverReadable
&& cardEntry.coverActs, JSON.stringify(cardEntry));
// 只含 txt 的条目也要能读:白名单漏项时这条会失败
const txtOnly = library.add({
title: 'TXT Only Fixture',
files: [{ path: TXT_FILE, name: 'txt-only.txt', format: 'TXT' }]
});
await js(mainWin, `document.getElementById('rescanBtn').click()`);
await waitForJs(mainWin,
`!!document.querySelector('#libGrid .card[data-id="${txtOnly.id}"]')`, 20000);
const txtCard = await js(mainWin, `(() => {
const card = document.querySelector('#libGrid .card[data-id="${txtOnly.id}"]');
if (!card) return { missing: true };
return {
coverReadable: !!card.querySelector('.card-cover.readable'),
hasRead: [...card.querySelectorAll('.lib-card-actions button')]
.some((b) => /阅读/.test(b.title || ''))
};
})()`);
check('纯 TXT 条目在书库里也有阅读入口',
txtCard.hasRead && txtCard.coverReadable, JSON.stringify(txtCard));
library.remove(txtOnly.id);
mainWin.hide();
}
}
const mdReader = await openReader(entry.id, 6);
const mdWin = mdReader.win;
await waitForJs(mdWin, `document.querySelector('.host-epub iframe')?.contentDocument?.body
?.textContent.includes('Markdown 夹具')`, 30000);
const mdHeadStructure = await js(mdWin, `(() => {
const doc = document.querySelector('.host-epub iframe').contentDocument;
return {
h1: doc.querySelectorAll('h1').length,
strong: doc.querySelectorAll('strong').length,
code: doc.querySelectorAll('code').length
};
})()`);
check('Markdown 首节渲染成标题与行内标记而不是纯文本',
mdHeadStructure.h1 >= 1 && mdHeadStructure.strong >= 1 && mdHeadStructure.code >= 1,
JSON.stringify(mdHeadStructure));
const mdToc = await js(mdWin, `Array.from(document.querySelectorAll('#tocList .toc-item'))
.map((button) => button.textContent)`);
check('Markdown 按标题层级生成目录',
mdToc.length >= 3 && mdToc.some((label) => /结构小节/.test(label)),
JSON.stringify(mdToc));
await js(mdWin, `Array.from(document.querySelectorAll('#tocList .toc-item'))
.find((button) => /结构小节/.test(button.textContent)).click()`);
await waitForJs(mdWin, `document.querySelector('.host-epub iframe').contentDocument
.body.textContent.includes('MD-TABLE-CELL')`, 20000);
const mdStructure = await js(mdWin, `(() => {
const doc = document.querySelector('.host-epub iframe').contentDocument;
return {
h2: doc.querySelectorAll('h2').length,
li: doc.querySelectorAll('li').length,
pre: doc.querySelectorAll('pre').length,
table: doc.querySelectorAll('table td, table th').length,
quote: doc.querySelectorAll('blockquote').length
};
})()`);
check('Markdown 列表、代码块、表格与引用渲染成真实块级结构',
mdStructure.h2 >= 1 && mdStructure.li >= 2 && mdStructure.pre >= 1
&& mdStructure.table >= 1 && mdStructure.quote >= 1,
JSON.stringify(mdStructure));
await js(mdWin, `Array.from(document.querySelectorAll('#tocList .toc-item'))
.find((button) => /危险内容小节/.test(button.textContent)).click()`);
await wait(1200);
// 裸 HTML 被 markdown-it 转义成文本,所以只能按 DOM 断言,
// 用 innerHTML 匹配 "onerror" 会把转义后的字面量误判成漏网
const mdSafety = await js(mdWin, `(() => {
const frame = document.querySelector('.host-epub iframe');
const doc = frame.contentDocument;
const nodes = Array.from(doc.querySelectorAll('*'));
return {
script: doc.querySelectorAll('script').length,
iframe: doc.querySelectorAll('iframe').length,
img: doc.querySelectorAll('img').length,
eventAttrs: nodes.filter((node) => Array.from(node.attributes || [])
.some((attr) => /^on/i.test(attr.name))).length,
unsafeHref: Array.from(doc.querySelectorAll('a[href]'))
.filter((a) => /^(javascript|vbscript|data|file):/i.test(a.getAttribute('href') || '')).length,
executedScript: !!(frame.contentWindow.__mdScriptExecuted || window.__mdScriptExecuted),
executedHandler: !!(frame.contentWindow.__mdHandlerExecuted || window.__mdHandlerExecuted),
executedLink: !!(frame.contentWindow.__mdLinkExecuted || window.__mdLinkExecuted)
};
})()`);
check('Markdown 中的脚本、事件属性与 javascript: 链接被净化且未执行',
mdSafety.script === 0 && mdSafety.iframe === 0 && mdSafety.img === 0
&& mdSafety.eventAttrs === 0 && mdSafety.unsafeHref === 0
&& !mdSafety.executedScript && !mdSafety.executedHandler && !mdSafety.executedLink,
JSON.stringify(mdSafety));
check('Markdown 阅读器没有控制台错误', mdReader.errors.length === 0,
mdReader.errors.slice(0, 3).join(' | '));
mdWin.close();
const readerFile = path.join(TMP, 'reader.json');
const readerJson = JSON.parse(fs.readFileSync(readerFile, 'utf8'));
check('readerStore 使用隔离目录中的 v6 存储',
+92
View File
@@ -243,6 +243,98 @@ test('failed tag writes roll back both catalog and item references', () => {
assert.ok(!fs.existsSync(`${file}.bak`));
});
test('批量更新只写一次索引,任一条目无效则整批回滚', () => {
const root = freshRoot('update-many');
const a = store.add({ title: '甲', tags: ['共有'] });
const b = store.add({ title: '乙', tags: ['共有', '仅乙'] });
const c = store.add({ title: '丙', tags: [] });
const file = indexPath(root);
// 逐条 update 会把整个索引重写 N 遍,批量必须收敛成一次。
// 索引经 fd 写入,因此统计 open 而不是 writeFileSync 的路径参数。
const countIndexWrites = (fn) => {
const originalOpen = fs.openSync;
let writes = 0;
fs.openSync = function counting(target, flags, ...rest) {
if (String(target) === `${file}.tmp` && String(flags).startsWith('w')) writes++;
return originalOpen.call(this, target, flags, ...rest);
};
try { fn(); } finally { fs.openSync = originalOpen; }
return writes;
};
let updated = 0;
const writes = countIndexWrites(() => {
updated = store.updateMany([
{ id: a.id, patch: { tags: ['共有', '新增'] } },
{ id: b.id, patch: { shelfId: null, tags: ['共有'] } }
]).updated;
});
assert.strictEqual(updated, 2);
assert.strictEqual(writes, 1, '批量更新应只写一次索引');
// 对照:逐条 update 会写两次,证明上面的 1 不是计数器失灵
const loopWrites = countIndexWrites(() => {
store.update(a.id, { tags: ['共有', '新增'] });
store.update(b.id, { tags: ['共有'] });
});
assert.strictEqual(loopWrites, 2, '逐条更新应写两次,用于对照');
assert.deepStrictEqual(store.get(a.id).tags, ['共有', '新增']);
assert.deepStrictEqual(store.get(b.id).tags, ['共有']);
assert.deepStrictEqual(store.get(c.id).tags, [], '未列出的条目不应被改动');
const snapshot = fs.readFileSync(file, 'utf8');
assert.throws(() => store.updateMany([
{ id: a.id, patch: { tags: ['不该生效'] } },
{ id: 'missing-id', patch: { tags: ['x'] } }
]), /条目不存在/);
assert.strictEqual(fs.readFileSync(file, 'utf8'), snapshot, '整批回滚不应留下部分写入');
assert.deepStrictEqual(store.get(a.id).tags, ['共有', '新增']);
assert.throws(() => store.updateMany([{ patch: {} }]), /条目 ID/);
assert.deepStrictEqual(store.updateMany([]), { updated: 0 });
});
test('批量移除只写一次索引,可选删除库内文件', () => {
const root = freshRoot('remove-many');
fs.mkdirSync(path.join(root, 'files'), { recursive: true });
const made = [];
for (let i = 0; i < 3; i++) {
const abs = path.join(root, 'files', `book-${i}.pdf`);
fs.writeFileSync(abs, '%PDF-1.4\n');
made.push(store.add({ title: `${i}`, files: [{ path: abs, name: `book-${i}.pdf` }] }));
}
const outside = path.join(root, '..', `outside-${path.basename(root)}.pdf`);
fs.writeFileSync(outside, '%PDF-1.4\n');
created.push(outside);
const external = store.add({ title: '外部', files: [{ path: outside, name: 'outside.pdf' }] });
const file = indexPath(root);
const originalOpen = fs.openSync;
let writes = 0;
fs.openSync = function counting(target, flags, ...rest) {
if (String(target) === `${file}.tmp` && String(flags).startsWith('w')) writes++;
return originalOpen.call(this, target, flags, ...rest);
};
let removed = 0;
try {
removed = store.removeMany([made[0].id, made[1].id], true).removed;
} finally {
fs.openSync = originalOpen;
}
assert.strictEqual(removed, 2);
assert.strictEqual(writes, 1, '批量移除应只写一次索引');
assert.strictEqual(store.list().length, 2);
assert.strictEqual(fs.existsSync(path.join(root, 'files', 'book-0.pdf')), false);
assert.strictEqual(fs.existsSync(path.join(root, 'files', 'book-2.pdf')), true);
// 用户原地引用的库外文件不能被删
assert.strictEqual(store.removeMany([external.id], true).removed, 1);
assert.strictEqual(fs.existsSync(outside), true, '库外文件不应被删除');
assert.deepStrictEqual(store.removeMany([], true), { removed: 0 });
assert.deepStrictEqual(store.removeMany(['missing'], false), { removed: 0 });
});
test('explicit tag creation enforces the existing catalog limit', () => {
const root = freshRoot('limit');
const now = Date.now();
+5 -3
View File
@@ -69,13 +69,14 @@ test('handles mixed file and directory inputs while skipping unsupported and non
const inFolder = write(root, path.join('folder', 'comic.cbz'));
const azw = write(root, path.join('folder', 'legacy.azw'));
const direct = write(root, 'notes.txt');
const markdown = write(root, path.join('folder', 'guide.MD'));
write(root, path.join('folder', 'cover.jpg'));
write(root, 'README.md');
write(root, 'README.rtf');
fs.mkdirSync(path.join(root, 'empty'));
const result = await discover([
path.join(root, 'missing.pdf'),
path.join(root, 'README.md'),
path.join(root, 'README.rtf'),
path.join(root, 'empty'),
direct,
folder
@@ -83,7 +84,8 @@ test('handles mixed file and directory inputs while skipping unsupported and non
assert.deepStrictEqual(
result.map((record) => record.path),
[inFolder, azw, direct].map((value) => fs.realpathSync(value)).sort()
[inFolder, azw, markdown, direct].map((value) => fs.realpathSync(value)).sort(),
'md 与 txt 都能进内置阅读器,必须和其他图书格式一样被本地导入发现'
);
});
+178 -2
View File
@@ -81,6 +81,182 @@ test('移除书籍默认保留阅读资料,仅显式勾选时清理', () => {
assert.ok(segment.indexOf('annotations.forget', guard) > guard);
});
test('批量整理与移除走单次索引提交,不按条目循环 IPC', () => {
for (const [channel, method] of [['library:updateMany', 'updateMany'], ['library:removeMany', 'removeMany']]) {
const start = mainSrc.indexOf(`ipcMain.handle('${channel}'`);
assert.ok(start > 0, `缺少 ${channel}`);
const end = mainSrc.indexOf('ipcMain.handle(', start + 20);
const segment = mainSrc.slice(start, end < 0 ? undefined : end);
assert.ok(segment.includes(`library.${method}(`), `${channel} 应调用 library.${method}`);
}
const removeStart = mainSrc.indexOf("ipcMain.handle('library:removeMany'");
const removeEnd = mainSrc.indexOf('ipcMain.handle(', removeStart + 20);
const removeSegment = mainSrc.slice(removeStart, removeEnd < 0 ? undefined : removeEnd);
// 与单本移除一致:默认保留阅读资料,只有显式勾选才清理
assert.match(removeSegment, /deleteReadingData\s*===\s*true/);
const uiSrc = fs.readFileSync(path.join(__dirname, '..', 'ui', 'views', 'library.js'), 'utf8');
assert.match(uiSrc, /window\.api\.library\.updateMany\(/);
assert.match(uiSrc, /window\.api\.library\.removeMany\(/);
assert.ok(!/for\s*\(const item of targets\)[\s\S]{0,200}library\.(update|remove)\(/.test(uiSrc),
'渲染层不应再逐条发 IPC');
});
test('孤立阅读资料只报告不自动删除,清理需分别勾选', () => {
const start = mainSrc.indexOf("ipcMain.handle('reader:orphanReport'");
assert.ok(start > 0, '缺少孤立资料对账通道');
const end = mainSrc.indexOf('ipcMain.handle(', start + 20);
const report = mainSrc.slice(start, end < 0 ? undefined : end);
// 对账必须以真实书库条目为准,不能凭文件名猜
assert.match(report, /library\.list\(\)/);
assert.match(report, /readerStore\.orphanReport\(/);
assert.match(report, /annotations\.orphanReport\(/);
const purgeStart = mainSrc.indexOf("ipcMain.handle('reader:purgeOrphans'");
assert.ok(purgeStart > 0, '缺少孤立资料清理通道');
const purgeEnd = mainSrc.indexOf('ipcMain.handle(', purgeStart + 20);
const purge = mainSrc.slice(purgeStart, purgeEnd < 0 ? undefined : purgeEnd);
assert.match(purge, /scope\.annotations\s*===\s*true/);
assert.match(purge, /scope\.notes\s*===\s*true/);
assert.match(purge, /scope\.chats\s*===\s*true/);
assert.ok(purge.indexOf('library.list()') >= 0, '清理前必须重新对账,不能信任渲染层传来的 ID');
});
test('AI 会话随书籍删除一并清理,孤立会话纳入对账', () => {
const start = mainSrc.indexOf("ipcMain.handle('library:removeMany'");
assert.ok(start > 0, '缺少批量移除通道');
const end = mainSrc.indexOf('ipcMain.handle(', start + 20);
const remove = mainSrc.slice(start, end < 0 ? undefined : end);
assert.match(remove, /aiSessions\.forgetMany\(/);
// 会话删完要回收图片,否则内容寻址的图片永远没有引用者来释放
assert.match(remove, /collectAiImages\(\)/);
const reportStart = mainSrc.indexOf("ipcMain.handle('reader:orphanReport'");
const reportEnd = mainSrc.indexOf('ipcMain.handle(', reportStart + 20);
assert.match(mainSrc.slice(reportStart, reportEnd), /aiSessions\.orphanReport\(/);
});
test('笔记独立窗口:开窗前重新对账,窗口内只能读自己已开的标签', () => {
const openStart = mainSrc.indexOf("ipcMain.handle('notes:openWindow'");
assert.ok(openStart > 0, '缺少笔记开窗通道');
const openEnd = mainSrc.indexOf('ipcMain.handle(', openStart + 20);
const open = mainSrc.slice(openStart, openEnd < 0 ? undefined : openEnd);
// 目标由 findNote 重新对账,不能直接把渲染层传来的 ID 拿去开窗
assert.match(open, /findNote\(entryId,\s*noteId\)/);
assert.match(open, /noteWindow\.open\(note\.entryId,\s*note\.id/);
const findStart = mainSrc.indexOf('function findNote');
const find = mainSrc.slice(findStart, findStart + 500);
assert.match(find, /readerStore\.listNotes\(/, '对账必须以 store 里真实存在的笔记为准');
const getStart = mainSrc.indexOf("ipcMain.handle('notes:getOne'");
const getEnd = mainSrc.indexOf('ipcMain.handle(', getStart + 20);
const getOne = mainSrc.slice(getStart, getEnd < 0 ? undefined : getEnd);
// 多标签后授权是「在标签集内」,不是「等于某一条」
assert.match(getOne, /noteWindow\.ownsNote\(event\.sender,\s*noteId\)/);
assert.match(getOne, /无权读取其它笔记/);
});
test('笔记标签集只能由渲染层收窄,新增必须走 open 对账', () => {
const src = fs.readFileSync(path.join(__dirname, '..', 'reader', 'note-window.js'), 'utf8');
const start = src.indexOf('function setTabs');
assert.ok(start > 0, '缺少 setTabs');
const body = src.slice(start, src.indexOf('\n}', start));
// 允许渲染层往标签集里塞 ID,等于让它自己扩权:
// 谎报持有某条笔记后 notes:getOne 就会放行
assert.doesNotMatch(body, /openNotes\.set\(/, 'setTabs 不能新增标签');
assert.match(body, /openNotes\.delete\(/, 'setTabs 只做收窄');
assert.match(body, /isNoteSender\(wc\)/);
});
test('笔记窗口取消关闭后要复位 closePending 并撤掉看门狗', () => {
const src = fs.readFileSync(path.join(__dirname, '..', 'reader', 'note-window.js'), 'utf8');
const start = src.indexOf('function cancelClose');
assert.ok(start > 0, '缺少 cancelClose:取消后窗口会再也关不掉');
const body = src.slice(start, src.indexOf('\n}', start));
assert.match(body, /clearTimeout\(closeTimer\)/, '不撤看门狗会在十秒后销毁带未保存内容的窗口');
assert.match(body, /closePending = false/);
assert.match(src, /ipcMain|module\.exports[\s\S]*cancelClose/);
});
test('笔记删除或书籍移除后独立窗口必须退场', () => {
const removeStart = mainSrc.indexOf("ipcMain.handle('reader:removeNote'");
const removeEnd = mainSrc.indexOf('ipcMain.handle(', removeStart + 20);
const remove = mainSrc.slice(removeStart, removeEnd < 0 ? undefined : removeEnd);
// 窗口留着的话,它下一次保存会把已经删掉的笔记整条写回去
assert.match(remove, /noteWindow\.closeFor\(noteId\)/);
const purgeStart = mainSrc.indexOf("ipcMain.handle('reader:purgeOrphans'");
const purgeEnd = mainSrc.indexOf('ipcMain.handle(', purgeStart + 20);
assert.match(mainSrc.slice(purgeStart, purgeEnd), /noteWindow\.closeForEntries\(/);
const removeManyStart = mainSrc.indexOf("ipcMain.handle('library:removeMany'");
const removeManyEnd = mainSrc.indexOf('ipcMain.handle(', removeManyStart + 20);
assert.match(mainSrc.slice(removeManyStart, removeManyEnd), /noteWindow\.closeForEntries\(list\)/);
});
test('三处窗口广播都覆盖笔记独立窗口', () => {
// 漏掉任意一处,笔记窗口就收不到笔记变更或主题切换,界面与其它窗口不一致
for (const fn of ['notifyNotesChanged', 'applyWindowIcons', 'notifyUiThemeChanged']) {
const start = mainSrc.indexOf(`function ${fn}(`);
assert.ok(start > 0, `缺少 ${fn}`);
const body = mainSrc.slice(start, start + 420);
assert.match(body, /noteWindow\.all\(\)/, `${fn} 未覆盖笔记窗口`);
}
});
test('AI 会话按轮次落盘:先取历史再写提问,失败与取消都保留残片', () => {
const start = mainSrc.indexOf("ipcMain.handle('ai:run'");
assert.ok(start > 0, '缺少 AI 运行通道');
const end = mainSrc.indexOf('function settleAiAssistant', start);
const run = mainSrc.slice(start, end < 0 ? undefined : end);
// 顺序是硬约束:先 historyFor 再 appendUser,反了当前提问会被当成自己的历史发两遍
const historyAt = run.indexOf('historyFor(');
const appendAt = run.indexOf('appendUser(');
assert.ok(historyAt > 0 && appendAt > historyAt, '必须在写入本轮提问之前取历史');
// 同一会话禁止并发,否则两轮同时写同一个文件会互相覆盖
assert.match(run, /run\.sessionId\s*===\s*chatId/);
// 存的是提问本身,不是整篇正文
assert.match(run, /text:\s*aiTurnTitle\(task,\s*question\)/);
assert.match(run, /hash:\s*aiSessions\.hashContext\(body\)/);
// 已经流出来的残片必须落盘,界面上看到的半截回答不能一重开就消失
assert.match(run, /streamed\s*\+=\s*piece/);
assert.match(run, /text:\s*streamed/);
assert.ok(!/text:\s*''\s*,\s*\n\s*cancelled/.test(run), '取消时不能把残片写成空串');
// 落盘失败不能把已经拿到的回答变成请求失败
const settleStart = mainSrc.indexOf('function settleAiAssistant');
const settle = mainSrc.slice(settleStart, settleStart + 400);
assert.match(settle, /try\s*\{[\s\S]*finishAssistant\([\s\S]*catch/);
});
test('阅读器关闭书籍后通知书库刷新,且只接受阅读器发来的上报', () => {
const start = mainSrc.indexOf("ipcMain.handle('reader:entryClosed'");
assert.ok(start > 0, '缺少关闭上报通道');
const end = mainSrc.indexOf('ipcMain.handle(', start + 20);
const segment = mainSrc.slice(start, end < 0 ? undefined : end);
assert.match(segment, /isReaderSender\(event\.sender\)/);
assert.match(segment, /notifyLibraryChanged\(\)/);
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
// 进度与批注异步落盘,先排空再通知,否则书库读到的还是旧数据
const drainTab = shell.indexOf('async function closeTab');
const drainEnd = shell.indexOf('\nasync function', drainTab + 20);
const closeTabBody = shell.slice(drainTab, drainEnd < 0 ? undefined : drainEnd);
assert.ok(closeTabBody.indexOf('await drainTabWrites(tab)') > 0);
assert.ok(closeTabBody.indexOf('notifyEntryClosed()') > closeTabBody.indexOf('await drainTabWrites(tab)'),
'通知必须排在写入排空之后');
const drainAll = shell.slice(shell.indexOf('async function drainAllTabWrites'));
assert.ok(drainAll.indexOf('await drainTabWrites(tab)') > 0);
assert.ok(drainAll.indexOf('notifyEntryClosed()') > drainAll.indexOf('await drainTabWrites(tab)'),
'关闭整个窗口也要在排空后通知');
});
test('书库列表附带阅读记录中的最近阅读时间', () => {
const start = mainSrc.indexOf("ipcMain.handle('library:list'");
const end = mainSrc.indexOf("ipcMain.handle('library:get'", start);
@@ -325,10 +501,10 @@ test('本地文件夹导入仅接受当前渲染进程的一次性选择令牌',
assert.match(segment, /library\.importLocal\(records,\s*organization\)/);
});
test('内置阅读器允许 PDF、EPUB无 DRM Kindle 容器并保留外部回退', () => {
test('内置阅读器允许 PDF、EPUB无 DRM Kindle 容器与纯文本并保留外部回退', () => {
assert.match(
mainSrc,
/READABLE_EXT = new Set\(\['\.pdf', '\.epub', '\.mobi', '\.azw', '\.azw3'\]\)/
/READABLE_EXT = new Set\(\['\.pdf', '\.epub', '\.mobi', '\.azw', '\.azw3', '\.txt', '\.md'\]\)/
);
assert.match(mainSrc, /ipcMain\.handle\('reader:openExternal'/);
assert.match(mainSrc, /const error = await shell\.openPath\(abs\)/);
+269
View File
@@ -0,0 +1,269 @@
const test = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
const adapterFile = path.join(__dirname, '..', 'ui', 'reader', 'pdf-adapter.mjs');
const src = fs.readFileSync(adapterFile, 'utf8');
// pdf-adapter.mjs 直接 import vendor 的 pdf.min.mjs,在 Node 里加载会因缺 DOMMatrix 而抛错,
// 所以只把不依赖 DOM 的钳制段落切出来求值,断言的仍是生产源码本身。
function loadPureBlock() {
const start = src.indexOf('const MAX_CANVAS_SIDE');
const end = src.indexOf('const CSS = `');
assert.ok(start >= 0 && end > start, '找不到画布上限段落,钳制代码可能被移动或删除');
const block = src.slice(start, end).replace(/^export /gm, '');
assert.doesNotMatch(
block,
/\b(window|document|navigator)\b/,
'钳制逻辑必须是纯函数,一旦依赖 DOM 就无法在单测里做真实数值断言'
);
return new Function(`${block}\nreturn { clampCanvasSize, clampRenderQuality };`)();
}
const { clampCanvasSize, clampRenderQuality } = loadPureBlock();
const MAX_SIDE = 16384;
const MAX_AREA = 268435456;
function assertWithinLimits(out, label) {
assert.ok(
out.width <= MAX_SIDE && out.height <= MAX_SIDE,
`${label} 单边 ${out.width}x${out.height} 超过 ${MAX_SIDE},浏览器会静默给出不可用画布,整页空白且不报错`
);
assert.ok(
out.width * out.height <= MAX_AREA,
`${label} 面积 ${out.width * out.height} 超过 ${MAX_AREA},同样会静默失败`
);
assert.ok(
out.width >= 1 && out.height >= 1,
`${label} 钳制后出现 ${out.width}x${out.height}0 尺寸画布会让 getContext 之后的绘制全部丢弃`
);
}
test('A4 常规缩放与超采样不触发钳制', () => {
// 612x792pt 的 A4 @ scale1.2,倍率 21468x1900,远在上限内
const out = clampCanvasSize(612 * 1.2, 792 * 1.2, 2);
assert.strictEqual(out.clamped, false, '常规页被误钳会白白牺牲清晰度');
assert.strictEqual(out.quality, 2, '未触发上限时必须原样保留名义倍率');
assert.strictEqual(out.width, 1468, `期望 1468 实际 ${out.width}`);
assert.strictEqual(out.height, 1900, `期望 1900 实际 ${out.height}`);
assertWithinLimits(out, 'A4@1.2x2');
});
test('A4 最高缩放叠加 dpr3 仍不触发钳制', () => {
// 实测 8929x12628(113M)是可用的,钳制不该把这一档也压下去
const out = clampCanvasSize(612 * 5, 792 * 5, 3);
assert.strictEqual(out.clamped, false, '这一档实测可用,钳制过度会让高缩放反而更糊');
assert.strictEqual(out.width, 9180, `期望 9180 实际 ${out.width}`);
assert.strictEqual(out.height, 11880, `期望 11880 实际 ${out.height}`);
assertWithinLimits(out, 'A4@5x3');
});
test('2000pt 大幅面 @ scale5 dpr2 被钳回上限内', () => {
// 期望 backing 20000x14000280M),实测不可用
const out = clampCanvasSize(2000 * 5, 1400 * 5, 2);
assert.strictEqual(out.clamped, true, '280M 画布必须被识别为超限,否则用户只看到空白页且没有任何提示');
assertWithinLimits(out, '2000pt@5x2');
assert.strictEqual(out.width, MAX_SIDE, `长边应正好压到上限以保留最大清晰度,实际 ${out.width}`);
assert.ok(out.quality < 2, `生效倍率 ${out.quality} 应低于名义倍率 2`);
});
test('4000pt 图纸 @ scale3 dpr2 被钳回上限内', () => {
// 期望 backing 24000x18000432M),实测不可用
const out = clampCanvasSize(4000 * 3, 3000 * 3, 2);
assert.strictEqual(out.clamped, true, '432M 画布必须被识别为超限');
assertWithinLimits(out, '4000pt@3x2');
assert.strictEqual(out.width, MAX_SIDE, `长边应正好压到上限,实际 ${out.width}`);
assert.strictEqual(out.height, 12288, `期望 12288 实际 ${out.height}`);
});
test('钳制后的实际比例如实报告,供 render 的 transform 使用', () => {
const cssWidth = 4000 * 3;
const cssHeight = 3000 * 3;
const out = clampCanvasSize(cssWidth, cssHeight, 2);
// transform 用名义倍率而不是取整后的实际比例,画面会错位或只画出一角
assert.strictEqual(out.scaleX, out.width / cssWidth, 'scaleX 必须等于 backing 宽除以 CSS 宽');
assert.strictEqual(out.scaleY, out.height / cssHeight, 'scaleY 必须等于 backing 高除以 CSS 高');
assert.ok(
Math.abs(out.scaleX - out.scaleY) < 1e-6,
`等比钳制下 x/y 比例应基本一致,实际 ${out.scaleX} / ${out.scaleY}`
);
});
test('短边的取整损失也要反映到比例上,不能直接沿用生效倍率', () => {
// 长边正好压到上限时它的比例恰等于生效倍率,只有短边能暴露 floor 带来的偏差
const out = clampCanvasSize(16000, 17000, 1);
assert.strictEqual(out.scaleX, out.width / 16000, 'scaleX 必须按 floor 之后的实际宽算');
assert.notStrictEqual(
out.scaleX,
out.quality,
`短边 ${out.width}px 的实际比例应当略小于生效倍率 ${out.quality},直接沿用会让内容画出画布边界`
);
assert.strictEqual(out.scaleY, out.height / 17000, 'scaleY 必须按 floor 之后的实际高算');
});
test('取整误差不会让某一边掉到 0 像素', () => {
const out = clampCanvasSize(100000, 1, 1);
assertWithinLimits(out, '极端长条页');
assert.strictEqual(out.height, 1, `期望至少 1px,实际 ${out.height}`);
assert.strictEqual(out.scaleY, 1, 'scaleY 必须按补足到 1px 后的实际比例报告,否则内容会被压到画布外');
});
test('长边超限而面积未超限的页也被钳', () => {
// 20000x2000 = 40M,面积远未超限,只有单边超限
const out = clampCanvasSize(20000, 2000, 1);
assert.strictEqual(out.clamped, true, '只超单边同样不可用,不能只看面积');
assertWithinLimits(out, '超长单边');
assert.strictEqual(out.width, MAX_SIDE, `期望 ${MAX_SIDE} 实际 ${out.width}`);
});
test('接近正方形的超大页同时压住面积与单边', () => {
// 16000x17000 = 272M,面积超限且短边已经贴着上限,是最容易只压一头的形状
const out = clampCanvasSize(16000, 17000, 1);
assert.strictEqual(out.clamped, true, '272M 面积必须被识别为超限');
assertWithinLimits(out, '16000x17000');
assert.strictEqual(out.height, MAX_SIDE, `长边应压到上限,实际 ${out.height}`);
});
test('面积约束是真在起作用,而不是被单边约束顺带盖住', () => {
// 当前常量下 MAX_AREA 恰好等于 MAX_SIDE 的平方,压住长边就顺带压住了面积,
// 面积检查的价值只有在上限常量按别的浏览器口径调整时才显现。
// 这里把单边上限换成 65535 重新求值,确认删掉面积项会立刻放出 600M 的画布。
const start = src.indexOf('const MAX_CANVAS_SIDE');
const end = src.indexOf('const CSS = `');
const block = src.slice(start, end)
.replace(/^export /gm, '')
.replace('const MAX_CANVAS_SIDE = 16384;', 'const MAX_CANVAS_SIDE = 65535;');
const wide = new Function(`${block}\nreturn clampCanvasSize;`)();
const out = wide(30000, 20000, 1);
assert.ok(
out.width * out.height <= MAX_AREA,
`面积 ${out.width * out.height} 超过 ${MAX_AREA}:面积约束没有独立生效,只靠单边约束挡不住扁平的大幅面页`
);
assert.strictEqual(out.clamped, true, '面积超限也必须如实上报,否则外壳无法解释清晰度为何被降');
});
test('大范围尺寸与倍率组合下上限恒成立', () => {
const sides = [1, 200, 612, 792, 1190, 2384, 5000, 10000, 20000, 40000];
const qualities = [1, 1.25, 1.5, 2, 2.5, 3, 4];
const scales = [0.25, 1, 1.2, 2, 3, 5];
for (const w of sides) {
for (const h of sides) {
for (const q of qualities) {
for (const s of scales) {
const out = clampCanvasSize(w * s, h * s, q);
const label = `${w}x${h} @scale${s} @${q}x`;
assertWithinLimits(out, label);
assert.ok(
out.quality <= q + 1e-9,
`${label}:生效倍率 ${out.quality} 不该超过名义倍率 ${q}`
);
assert.strictEqual(out.scaleX, out.width / (w * s), `${label}scaleX 与实际 backing 宽不符`);
assert.strictEqual(out.scaleY, out.height / (h * s), `${label}scaleY 与实际 backing 高不符`);
}
}
}
}
});
test('非法尺寸与倍率退回安全值而不是抛错或产出 NaN', () => {
for (const bad of [undefined, null, NaN, 0, -5, 'abc', Infinity]) {
const out = clampCanvasSize(bad, bad, bad);
assert.ok(
Number.isInteger(out.width) && Number.isInteger(out.height),
`尺寸 ${String(bad)} 产出了非整数 ${out.width}x${out.height}canvas.width 赋 NaN 会静默变 0`
);
assertWithinLimits(out, `非法输入 ${String(bad)}`);
}
});
test('renderQuality 只接受 1 到 4,非法值回退到 1', () => {
assert.strictEqual(clampRenderQuality(1), 1);
assert.strictEqual(clampRenderQuality(2), 2);
assert.strictEqual(clampRenderQuality(4), 4);
assert.strictEqual(clampRenderQuality(1.5), 1.5, '允许非整数档位,1.5x 实测已能显著降低误差');
assert.strictEqual(clampRenderQuality(8), 4, '超过 4 倍换不来可感知的清晰度,只会成倍吃显存');
assert.strictEqual(clampRenderQuality(0.5), 1, '低于 1 会比现状更糊');
for (const bad of [undefined, null, NaN, 'abc', {}, Infinity, -Infinity]) {
assert.strictEqual(
clampRenderQuality(bad),
1,
`非法值 ${String(bad)} 必须回退到 1,回退到 NaN 会让整块 backing 计算失效`
);
}
});
test('源码锁死画布上限常量与两条约束', () => {
assert.match(src, /const MAX_CANVAS_SIDE = 16384;/, 'Chromium 单边上限,改动前必须先实测');
assert.match(src, /const MAX_CANVAS_AREA = 268435456;/, 'Chromium 总面积上限');
assert.match(
src,
/Math\.min\(want, bySide, byArea\)/,
'单边与面积两条约束都要参与,只留一条在上限常量变动后就会漏放超限画布'
);
assert.match(src, /MAX_CANVAS_SIDE \/ Math\.max\(cssWidth, cssHeight\)/, '单边约束按长边算');
assert.match(src, /Math\.sqrt\(MAX_CANVAS_AREA \/ \(cssWidth \* cssHeight\)\)/, '面积约束按开方算');
assert.match(src, /Math\.max\(1, Math\.floor\(cssWidth \* applied\)\)/, '钳制后至少保留 1px');
assert.match(src, /Math\.max\(1, Math\.floor\(cssHeight \* applied\)\)/, '钳制后至少保留 1px');
});
test('源码锁死超采样倍率的取值与生效方式', () => {
assert.match(
src,
/Math\.max\(1, Math\.min\(4, n\)\)/,
'renderQuality 必须被 clamp 到 1..4'
);
// 一次锁死整条链路:dpr 取大而不是叠乘、期望倍率必须过钳制、画布尺寸只能来自钳制结果
assert.match(
src,
/const dpr = window\.devicePixelRatio \|\| 1;\s*\n\s*const wanted = Math\.max\(dpr, clampRenderQuality\(renderQuality\)\);\s*\n\s*const fit = clampCanvasSize\(vp\.width, vp\.height, wanted\);\s*\n\s*p\.canvas\.width = fit\.width;\s*\n\s*p\.canvas\.height = fit\.height;/,
'有效倍率必须是 max(dpr, renderQuality) 且画布尺寸只能取钳制结果,绕过任一步都会重新引入静默空白页'
);
assert.strictEqual(
[...src.matchAll(/Math\.max\(dpr, clampRenderQuality\(renderQuality\)\)/g)].length,
2,
'renderPage 与 renderStats 必须用同一个有效倍率公式,否则界面报告的清晰度和实际渲染的不一致'
);
assert.match(
src,
/transform: identity \? null : \[fit\.scaleX, 0, 0, fit\.scaleY, 0, 0\]/,
'transform 必须用钳制后的实际比例,用名义倍率会画错位或只画出一角'
);
assert.doesNotMatch(
src,
/transform: dpr === 1 \? null : \[dpr, 0, 0, dpr, 0, 0\]/,
'旧的 dpr 直接当 transform 的写法在钳制生效时会画错'
);
});
test('源码锁死倍率变化触发全页重建', () => {
assert.match(
src,
/if \(nextScale !== scale \|\| nextQuality !== renderQuality\) \{\s*\n\s*epoch\+\+;/,
'倍率变化不走 epoch++ 重建,同屏会残留旧清晰度的页'
);
assert.match(
src,
/nextQuality = opts && opts\.renderQuality !== undefined\s*\n?\s*\? clampRenderQuality\(opts\.renderQuality\)\s*\n?\s*: renderQuality/,
'缺省 renderQuality 时必须沿用当前值,否则每次渲染都会把用户设置重置成 1'
);
assert.match(src, /renderStats\(\)/, 'renderStats 是外壳读取当前清晰度状态的唯一正当出口');
assert.doesNotMatch(src, /window\.__test/, '不允许为测试往生产代码加全局钩子');
});
test('AI 截图路径同样受画布上限保护', () => {
assert.match(
src,
/const fit = clampCanvasSize\(area\.width, area\.height, wantScale\);/,
'大幅面页在 renderScale 下限 1 时画布等于页面点尺寸,MediaBox 异常的文件会顶到上限'
);
assert.match(src, /const renderScale = fit\.quality;/, '截图的 viewport 与 transform 必须用钳制后的倍率');
assert.match(src, /canvas\.width = fit\.width;/, '截图画布尺寸直接取钳制结果');
// 图像只保留一条 JPEG 编码路径,钳制不该顺手引入格式回退
assert.deepStrictEqual(
[...src.matchAll(/toDataURL\('([^']+)'/g)].map((m) => m[1]),
[],
'编码仍应集中在 visual-context.mjs,适配器里不该出现新的编码路径'
);
});
+28
View File
@@ -93,6 +93,34 @@ test('不同条目的阅读数据互相隔离', () => {
assert.strictEqual(s.getState('b').bookmarks.length, 1, 'forget 误删了其它条目');
});
test('孤立阅读资料按笔记数报告并可批量回收', () => {
const s = freshStore();
s.setProgress('kept', { kind: 'pdf', page: 2 }, 0.5);
s.addNote('kept', { text: '保留的笔记' });
s.addNote('gone_a', { text: '甲一' });
s.addNote('gone_a', { text: '甲二' });
s.addBookmark('gone_b', { locator: { kind: 'pdf', page: 3 } });
s.addStandaloneNote({ text: '与书籍无关的独立笔记' });
const orphans = s.orphanReport(['kept']);
assert.deepStrictEqual(orphans.map((o) => o.entryId).sort(), ['gone_a', 'gone_b']);
assert.strictEqual(orphans.find((o) => o.entryId === 'gone_a').notes, 2);
assert.strictEqual(orphans.find((o) => o.entryId === 'gone_b').bookmarks, 1);
// 独立笔记本没有对应书籍,永远不算孤立
assert.ok(!orphans.some((o) => o.entryId === s.STANDALONE_ENTRY_ID));
assert.strictEqual(s.forgetMany(orphans.map((o) => o.entryId)), 2);
assert.strictEqual(s.getState('kept').notes.length, 1);
assert.strictEqual(s.getState('kept').progress.percent, 0.5);
assert.ok(s.listNotes({}).some((n) => n.text === '与书籍无关的独立笔记'));
assert.deepStrictEqual(s.orphanReport(['kept']), []);
assert.strictEqual(s.forgetMany([]), 0);
// 即使显式点名,也不能删掉独立笔记本:它没有对应书籍,永远不是可回收对象
assert.strictEqual(s.forgetMany([s.STANDALONE_ENTRY_ID]), 0);
assert.ok(s.listNotes({}).some((n) => n.text === '与书籍无关的独立笔记'));
});
test('getState 返回副本,外部改动不污染存储', () => {
const s = freshStore();
s.addBookmark('e1', { locator: { kind: 'pdf', page: 1 } });
+710
View File
@@ -0,0 +1,710 @@
// TXT / Markdown 适配器单测。适配器本体是渲染层 ESM,这里用 jsdom 提供真实的
// window / document / DOMParser,并加载仓库里真正会随包发布的 vendor 脚本,
// 这样断言的是「真正交给 epub 适配器渲染的产物」,而不是桩。
const test = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const SRC = path.join(__dirname, '..', 'ui', 'reader', 'text-adapter.mjs');
const VENDOR = path.join(__dirname, '..', 'ui', 'vendor');
const XHTML = 'application/xhtml+xml';
function setupDom() {
const { JSDOM } = require('jsdom');
const dom = new JSDOM('<!doctype html><html><body></body></html>');
const win = dom.window;
win.JSZip = require(path.join(VENDOR, 'jszip.min.js'));
win.markdownit = require(path.join(VENDOR, 'markdown-it.min.js'));
const purifyFactory = require(path.join(VENDOR, 'purify.min.js'));
win.DOMPurify = typeof purifyFactory === 'function' ? purifyFactory(win) : purifyFactory;
for (const key of ['window', 'document', 'DOMParser', 'XMLSerializer', 'NodeFilter', 'Node', 'Range']) {
globalThis[key] = key === 'window' ? win : win[key];
}
return win;
}
const win = setupDom();
const mod = import('../ui/reader/text-adapter.mjs');
function u8(...parts) {
const buffers = parts.map((part) => (typeof part === 'string'
? Buffer.from(part, 'utf8')
: Buffer.from(part)));
return new Uint8Array(Buffer.concat(buffers));
}
function utf16Bytes(text, littleEndian, bom) {
const out = [];
if (bom) out.push(...(littleEndian ? [0xff, 0xfe] : [0xfe, 0xff]));
for (let i = 0; i < text.length; i++) {
const code = text.charCodeAt(i);
const hi = code >> 8;
const lo = code & 0xff;
out.push(...(littleEndian ? [lo, hi] : [hi, lo]));
}
return new Uint8Array(out);
}
// tidy 与 epub 适配器里的同名函数一致,textOf 返回的是 tidy 之后的文本
function tidy(s) {
return String(s == null ? '' : s)
.replace(/\r/g, '')
.replace(/[ \t\f\v\u00a0]+/g, ' ')
.replace(/ ?\n ?/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
function novel(sections) {
const parts = [];
for (let i = 1; i <= sections; i++) {
parts.push(`${i}章 标题${i}\n【标记${String(i).padStart(4, '0')}】这一节的正文内容,用来验证全文不缺不重。\n`);
}
return parts.join('\n');
}
async function readChapter(zipBytes, index) {
const zip = await win.JSZip.loadAsync(zipBytes);
const file = zip.file(`text/chapter-${index}.xhtml`);
assert.ok(file, `产物里应有 text/chapter-${index}.xhtml`);
return file.async('text');
}
function parseXhtml(text) {
const doc = new win.DOMParser().parseFromString(text, XHTML);
assert.ok(doc && doc.body && !doc.querySelector('parsererror'),
'生成的章节必须是合法 XHTML,否则 epub 适配器会退回容错 HTML 解析,行为不可预测');
return doc;
}
function auditUntrusted(doc, where) {
for (const selector of ['script', 'iframe', 'object', 'embed', 'img', 'link', 'meta', 'form', 'base', 'a']) {
assert.strictEqual(doc.querySelectorAll(selector).length, 0,
`${where} 不允许出现 <${selector}>:这是不可信内容的注入面`);
}
for (const el of doc.querySelectorAll('*')) {
for (const attr of Array.from(el.attributes)) {
const name = attr.name.toLowerCase();
assert.ok(!name.startsWith('on'),
`${where} 出现事件属性 ${name},脚本会在阅读器里执行`);
assert.ok(!['href', 'src', 'xlink:href', 'srcset', 'poster', 'style', 'formaction'].includes(name),
`${where} 出现可发起加载或导航的属性 ${name}`);
assert.ok(!/(?:javascript|vbscript|data|file):/i.test(attr.value),
`${where} 属性 ${name} 里出现危险协议`);
}
}
}
/* --- 编码检测与解码 --- */
test('UTF-8 与 UTF-16 的 BOM 都能正确剥离,BOM 不得留在正文里', async () => {
const { decodeTextBytes } = await mod;
const text = '中文标题\n正文第一行\n';
const plain = decodeTextBytes(u8(text));
assert.strictEqual(plain.text, text, 'UTF-8 无 BOM 必须原样解出');
assert.strictEqual(plain.encoding, 'utf-8');
assert.strictEqual(plain.confident, true);
const withBom = decodeTextBytes(u8([0xef, 0xbb, 0xbf], text));
assert.strictEqual(withBom.text, text, 'UTF-8 BOM 必须被剥掉,留着会变成正文首个不可见字符');
assert.ok(!withBom.text.includes('\ufeff'), 'U+FEFF 不能出现在正文里');
const le = decodeTextBytes(utf16Bytes(text, true, true));
assert.strictEqual(le.text, text, 'UTF-16LE 带 BOM 必须正确解出中文');
assert.strictEqual(le.encoding, 'utf-16le');
assert.ok(!le.text.includes('\ufeff'));
const be = decodeTextBytes(utf16Bytes(text, false, true));
assert.strictEqual(be.text, text, 'UTF-16BE 带 BOM 必须正确解出中文');
assert.strictEqual(be.encoding, 'utf-16be');
assert.ok(!be.text.includes('\ufeff'));
});
test('双 BOM 也要剥干净:TextDecoder 只吃掉第一个', async () => {
const { decodeTextBytes } = await mod;
// 某些编辑器另存为会叠一层 BOM。TextDecoder 只认最前面那个,第二个会变成正文首字符,
// 章节标题匹配随即失效,目录莫名少一章。
const text = '第1章 标题\n正文\n';
assert.strictEqual(decodeTextBytes(u8([0xef, 0xbb, 0xbf], [0xef, 0xbb, 0xbf], text)).text, text);
const le = utf16Bytes(text, true, true);
assert.strictEqual(decodeTextBytes(u8([0xff, 0xfe], le)).text, text);
});
test('无 BOM 的 UTF-16LE 靠 NUL 分布嗅探,不能当成乱码', async () => {
const { decodeTextBytes } = await mod;
const text = 'Chapter 1\nHello world, this is plain ASCII text stored as UTF-16LE.\n';
const out = decodeTextBytes(utf16Bytes(text, true, false));
assert.strictEqual(out.encoding, 'utf-16le');
assert.strictEqual(out.text, text);
});
test('GBK 字节走 gb18030 解码,不产生替换字符', async () => {
const { decodeTextBytes } = await mod;
// 「这是一段简体中文测试」的 GBK 编码
const gbk = new Uint8Array([
0xd5, 0xe2, 0xca, 0xc7, 0xd2, 0xbb, 0xb6, 0xce, 0xbc, 0xf2,
0xcc, 0xe5, 0xd6, 0xd0, 0xce, 0xc4, 0xb2, 0xe2, 0xca, 0xd4,
0x0a
]);
const out = decodeTextBytes(gbk);
assert.strictEqual(out.text, '这是一段简体中文测试\n', 'GBK 必须解成可读中文');
assert.strictEqual(out.encoding, 'gb18030');
assert.strictEqual(out.confident, true);
assert.ok(!out.text.includes('\ufffd'), '解对了就不该有 U+FFFD');
});
test('解不出任何像样编码时标记 confident=false,不让用户对着乱码猜', async () => {
const { decodeTextBytes } = await mod;
// 0x81 后跟 0x1f 在 GBK / Big5 里都是非法尾字节,cp1252 里则落进 C1 控制区
const noise = new Uint8Array(512);
for (let i = 0; i < noise.length; i++) noise[i] = i % 2 ? 0x1f : 0x81;
const out = decodeTextBytes(noise);
assert.strictEqual(out.confident, false, '低置信度必须上报,否则界面会静默显示乱码');
for (const [name, bytes] of [
['UTF-8 中文', u8('正常的中文文本内容。\n')],
['GBK 中文', new Uint8Array([0xd5, 0xe2, 0xca, 0xc7, 0xd6, 0xd0, 0xce, 0xc4, 0x0a])],
['纯 ASCII', u8('Plain english text.\n')]
]) {
assert.strictEqual(decodeTextBytes(bytes).confident, true,
`${name} 不能被误判成低置信度,否则提示会变成噪声,用户就不再看它了`);
}
});
test('打分只认确定是正文的字符,拉丁扩展区乱码一律不得正分', async () => {
const { textScore, decodeTextBytes } = await mod;
const chinese = '这是一段正常的简体中文正文内容。';
const mojibake = Buffer.from(chinese, 'utf8').toString('latin1');
assert.ok(textScore(chinese) > 0, 'ASCII 与中文正文必须是正分');
assert.ok(textScore('Plain ASCII sentence.') > 0, 'ASCII 正文必须是正分');
// 乱码必须 <= 0 而不只是「低于正确解码」。一个汉字按 cp1252 摊成三个拉丁扩展字符,
// 只要这些字符拿到任何正分,乱码就靠字符数优势翻盘:实测把 0xa0..0x24f 记 1 分后,
// 同一段 GBK 正文的 windows-1252 得分从 0 涨到 320,与 gb18030 打平并靠顺序取胜。
for (const [name, text] of [
['UTF-8 中文按 cp1252 解出的乱码', mojibake],
['纯拉丁扩展区噪声', new TextDecoder('windows-1252')
.decode(new Uint8Array(Array.from({ length: 300 }, (_, i) => 0xa0 + (i % 0x40))))]
]) {
assert.ok(textScore(text) <= 0,
`${name} 得分为 ${textScore(text)},必须 <= 0,否则乱码会冒充识别成功`);
}
const gbk = new Uint8Array([0xd5, 0xe2, 0xca, 0xc7, 0xd6, 0xd0, 0xce, 0xc4, 0xb2, 0xe2, 0xca, 0xd4, 0x0a]);
assert.strictEqual(decodeTextBytes(gbk).encoding, 'gb18030',
'简体中文是本应用主场,不能被 windows-1252 的乱码抢走');
});
test('空文件与超限文件都有确定行为', async () => {
const { decodeTextBytes, MAX_TEXT_BYTES } = await mod;
const empty = decodeTextBytes(new Uint8Array(0));
assert.strictEqual(empty.text, '');
assert.strictEqual(empty.confident, true);
assert.throws(
() => decodeTextBytes(new Uint8Array(MAX_TEXT_BYTES + 1)),
/超过 64 MB/,
'超限必须抛中文错误,界面直接展示这句话'
);
});
test('CR LF 归一,C0 控制符被剔除', async () => {
const { decodeTextBytes } = await mod;
const out = decodeTextBytes(u8('第一行\r\n第二行\r第三行\u0000\u0007\n'));
assert.strictEqual(out.text, '第一行\n第二行\n第三行\n',
'C0 控制符留在正文会让生成的 XHTML 解析失败,正文随即退回容错解析');
const tabbed = decodeTextBytes(u8('列一\t列二\n'));
assert.strictEqual(tabbed.text, '列一\t列二\n', 'Tab 是合法 XML 字符,不能顺手删掉');
});
/* --- 章节切分 --- */
test('TXT 按中文章节标题切分,边界落在标题行首', async () => {
const { splitPlainText } = await mod;
const source = novel(6);
const out = splitPlainText(source);
assert.strictEqual(out.chapters.length, 6, '6 个「第N章」应切成 6 章');
assert.deepStrictEqual(out.chapters.map((c) => c.label),
['第1章 标题1', '第2章 标题2', '第3章 标题3', '第4章 标题4', '第5章 标题5', '第6章 标题6'],
'章节名必须取自正文里的标题行');
assert.strictEqual(out.chapters[0].start, 0);
assert.strictEqual(out.chapters.map((c) => c.text).join(''), source,
'各章拼回来必须与原文逐字相同,否则全文一定缺字或重字');
for (const chapter of out.chapters) {
assert.strictEqual(source.slice(chapter.start, chapter.end), chapter.text, '偏移必须与切片一致');
}
});
test('第一个章节标题之前的正文单独成章,不能被丢掉', async () => {
const { splitPlainText } = await mod;
const preamble = '书名:某本小说\n作者:某人\n版权声明若干。\n\n';
const source = preamble + novel(4);
const out = splitPlainText(source);
assert.strictEqual(out.chapters.length, 5, '开头 + 4 章 = 5 章');
assert.strictEqual(out.chapters[0].label, '开头');
assert.strictEqual(out.chapters[0].text, preamble,
'首个标题之前的内容必须完整保留,否则序言与版权页在全文里凭空消失');
assert.strictEqual(out.chapters[1].label, '第1章 标题1');
assert.strictEqual(out.chapters.map((c) => c.text).join(''), source);
});
test('标题不足三个时不认,按长度分节,序号从 1 开始', async () => {
const { splitPlainText } = await mod;
const source = `第1章 只有一个标题\n${'一二三四五六七八九十'.repeat(200)}\n`.repeat(1);
const out = splitPlainText(source, { target: 500 });
assert.ok(out.chapters.length >= 1);
assert.match(out.chapters[0].label, /^第 1 节$/, '未识别到章节结构时用「第 N 节」');
assert.strictEqual(out.chapters.map((c) => c.text).join(''), source);
});
test('空文件、纯空白、单行超长都不崩且能拼回原文', async () => {
const { splitPlainText } = await mod;
const empty = splitPlainText('');
assert.strictEqual(empty.chapters.length, 1);
assert.strictEqual(empty.chapters[0].text, '');
assert.strictEqual(empty.chapters[0].label, '正文');
const blank = splitPlainText(' \n\n\t \n');
assert.ok(blank.chapters.length >= 1);
assert.strictEqual(blank.chapters.map((c) => c.text).join(''), ' \n\n\t \n');
const huge = 'x'.repeat(200000);
const one = splitPlainText(huge, { target: 1000, max: 5000 });
assert.ok(one.chapters.length > 1, '没有换行的超长文本也必须切开,否则单章会撑爆 DOM');
assert.strictEqual(one.chapters.map((c) => c.text).join(''), huge);
assert.ok(one.chapters.every((c) => c.text.length <= 5000), '每章不得超过上限');
assert.ok(one.chapters.slice(1).every((c) => /$/.test(c.label)), '硬切出来的后续片段要标注(续)');
});
test('章节数不超过上限,避免打出上万个 zip 条目', async () => {
const { splitPlainText } = await mod;
const source = novel(400);
const out = splitPlainText(source, { maxChapters: 50 });
assert.ok(out.chapters.length <= 50, `实际 ${out.chapters.length} 章,超过 maxChapters`);
assert.strictEqual(out.chapters.map((c) => c.text).join(''), source, '合并章节不能丢正文');
});
/* --- Markdown 结构 --- */
test('Markdown 标题生成正确的目录层级', async () => {
const { createMarkdownRenderer, splitMarkdown, buildTocEntries, markdownHeadings } = await mod;
const md = createMarkdownRenderer(win.markdownit);
const source = [
'# 总标题', '', '开场白。', '',
'## 第一节', '', '内容一。', '',
'### 一点一', '', '内容二。', '',
'## 第二节', '', '内容三。', '',
'```', '# 代码块里的井号不是标题', '```', ''
].join('\n');
const headings = markdownHeadings(source, md);
assert.deepStrictEqual(headings.map((h) => [h.level, h.label]), [
[1, '总标题'], [2, '第一节'], [3, '一点一'], [2, '第二节']
], '代码块里的 # 不能被当成标题');
const split = splitMarkdown(source, md);
assert.deepStrictEqual(split.chapters.map((c) => c.label),
['总标题', '第一节', '第二节'], '默认按 h1/h2 切章,h3 留在章内');
assert.strictEqual(split.title, '总标题');
assert.strictEqual(split.chapters.map((c) => c.text).join(''), source);
const { entries, anchorsByChapter } = buildTocEntries(split.chapters, split.headings, split.splitLevel);
assert.deepStrictEqual(entries.map((e) => [e.label, e.depth, e.chapter]), [
['总标题', 0, 0], ['第一节', 1, 1], ['一点一', 2, 1], ['第二节', 1, 2]
], 'depth 必须等于标题级别减一,目录才能正确缩进');
assert.strictEqual(anchorsByChapter.get(1)[0], '', '章首标题跳章即可,不需要锚点');
assert.ok(anchorsByChapter.get(1)[1], '章内标题必须有锚点,否则目录只能跳到章首');
});
test('锚点数量与渲染出的标题数不符时不打 id,避免锚点指到错误标题', async () => {
const { createMarkdownRenderer, markdownChapterXhtml, resolveServices } = await mod;
const md = createMarkdownRenderer(win.markdownit);
const services = resolveServices();
const chapter = { label: '甲', text: '# 一\n\n正文\n\n## 二\n', start: 0, end: 20 };
const mismatched = markdownChapterXhtml(chapter, services, md, ['only-one']);
assert.ok(!/\bid="/.test(mismatched),
'章节边界可能落进代码围栏,使章内真实标题数与预估不符;此时按序号打 id 会让目录跳到错误的标题,宁可放弃锚点');
assert.strictEqual(parseXhtml(mismatched).querySelectorAll('h1,h2').length, 2, '放弃锚点不等于放弃正文');
const matched = markdownChapterXhtml(chapter, services, md, ['a1', 'a2']);
const ids = Array.from(parseXhtml(matched).querySelectorAll('[id]')).map((el) => el.getAttribute('id'));
assert.deepStrictEqual(ids, ['a1', 'a2'], '数量对得上时必须按文档顺序打 id');
});
test('没有 h1/h2 的 Markdown 回退到按长度分节,标题列表仍然可用', async () => {
const { createMarkdownRenderer, splitMarkdown } = await mod;
const md = createMarkdownRenderer(win.markdownit);
const source = `### 只有三级标题\n\n${'正文内容。'.repeat(50)}\n`;
const out = splitMarkdown(source, md);
assert.ok(out.chapters.length >= 1, '无 h1/h2 也要有章节');
assert.strictEqual(out.chapters.map((c) => c.text).join(''), source);
assert.strictEqual(out.headings.length, 1, 'h3 仍应出现在目录里');
assert.strictEqual(out.title, '只有三级标题');
const bare = splitMarkdown('只有一行正文,没有任何标题。', md);
assert.strictEqual(bare.chapters.length, 1);
assert.ok(bare.chapters[0].label, '无标题时也要有可展示的章节名');
});
test('目录嵌套标记闭合正确,epub 目录解析器按 li > ol 递归', async () => {
const { navMarkup } = await mod;
const html = navMarkup([
{ label: 'A', depth: 0, href: 'a' },
{ label: 'A1', depth: 1, href: 'a1' },
{ label: 'A2', depth: 1, href: 'a2' },
{ label: 'B', depth: 0, href: 'b' }
]);
assert.strictEqual(html,
'<ol><li><a href="a">A</a><ol><li><a href="a1">A1</a></li><li><a href="a2">A2</a></li></ol></li><li><a href="b">B</a></li></ol>');
const doc = new win.DOMParser().parseFromString(`<div xmlns="http://www.w3.org/1999/xhtml">${html}</div>`, XHTML);
assert.ok(!doc.querySelector('parsererror'), '目录标记必须是合法 XHTML');
assert.strictEqual(doc.querySelectorAll('li').length, 4);
assert.strictEqual(doc.querySelectorAll('li > ol > li').length, 2, '子级必须挂在父级 li 里');
const skipped = navMarkup([{ label: 'X', depth: 3, href: 'x' }]);
assert.strictEqual(skipped, '<ol><li><a href="x">X</a></li></ol>', '首条深度跳跃要收敛到顶层,不能生成孤立 ol');
});
test('任意 depth 序列生成的目录都是闭合的合法 XHTML,且条目一个不少', async () => {
const { navMarkup } = await mod;
const cases = [
[0], [1], [0, 1, 2, 3, 2, 1, 0], [2, 0, 2], [0, 0, 0],
[0, 2, 1, 3, 0], [1, 1, 0, 5, 0], [3, 3, 3], []
];
for (const depths of cases) {
const entries = depths.map((depth, i) => ({ label: `L${i}`, depth, href: `h${i}` }));
const html = navMarkup(entries);
const doc = new win.DOMParser().parseFromString(
`<div xmlns="http://www.w3.org/1999/xhtml">${html}</div>`, XHTML);
assert.ok(!doc.querySelector('parsererror'),
`depth 序列 [${depths}] 生成了非法 XHTML${html};标记不闭合会让整个 nav.xhtml 解析失败,目录直接退化成按章编号`);
assert.strictEqual(doc.querySelectorAll('li').length, depths.length,
`depth 序列 [${depths}] 的目录条目数应为 ${depths.length}`);
assert.strictEqual(doc.querySelectorAll('ol > li').length, depths.length,
`depth 序列 [${depths}] 里每个 li 都必须直接挂在 ol 下`);
}
});
/* --- 净化 --- */
const EVIL_MARKDOWN = [
'# 标题',
'',
'<script>alert(1)</script>',
'',
'<img src=x onerror=alert(1)>',
'',
'[链接](javascript:alert(1))',
'',
'<iframe src="http://evil.example"></iframe>',
'',
'<div onmouseover="alert(1)">悬停</div>',
'',
'![图注](http://evil.example/tracker.png)',
'',
'[正常外链](https://example.com/page)',
'',
'<svg><use xlink:href="http://evil.example/x#a" /></svg>',
''
].join('\n');
test('sanitizeMarkdownFragment 直接吃裸 HTML 也必须净化干净', async () => {
const { sanitizeMarkdownFragment, resolveServices } = await mod;
const services = resolveServices();
const fragment = sanitizeMarkdownFragment([
'<h1 id="x">标题</h1>',
'<script>alert(1)</script>',
'<img src=x onerror=alert(1)>',
'<iframe src="http://evil.example"></iframe>',
'<a href="javascript:alert(1)">链接</a>',
'<p style="background:url(http://evil.example/x)">样式</p>',
'<object data="x"></object><embed src="x" /><form action="x"></form>',
'<p onclick="alert(1)">点我</p>'
].join(''), services);
const host = win.document.createElement('div');
host.appendChild(fragment);
const doc = parseXhtml(`<html xmlns="http://www.w3.org/1999/xhtml"><head><title>t</title></head><body>${new win.XMLSerializer().serializeToString(host)}</body></html>`);
auditUntrusted(doc, '净化后的片段');
assert.ok(doc.body.textContent.includes('标题'), '净化不能把正常正文一起吃掉');
});
test('恶意 Markdown 渲染出的章节里没有可执行内容', async () => {
const { buildTextEpub } = await mod;
const built = await buildTextEpub(u8(EVIL_MARKDOWN), { format: 'md' });
const xhtml = await readChapter(built.bytes, 0);
const doc = parseXhtml(xhtml);
auditUntrusted(doc, 'Markdown 章节');
const text = doc.body.textContent;
assert.ok(text.includes('alert(1)'),
'危险内容应被转义成可见文字而不是静默删除,用户才知道原文写了什么');
assert.ok(text.includes('图片:图注'), '图片必须换成占位符,绝不外链加载');
assert.ok(text.includes('正常外链'), '外链文字要保留,只是不再可点');
assert.ok(!/<script|<iframe|<img|<svg/i.test(xhtml), '序列化结果里不允许出现这些标签');
});
test('源码锁死两道防线:markdown-it html:false 与放行名单里没有 href/src', async () => {
const src = fs.readFileSync(SRC, 'utf8');
assert.match(src, /markdownit\(\{[\s\S]{0,400}?html: false/,
'html:false 是第一道防线,去掉后裸 HTML 会直接进入渲染管线');
const allowed = src.match(/const ALLOWED_ATTR = Object\.freeze\(\[([^\]]*)\]\)/);
assert.ok(allowed, '必须显式声明放行属性名单');
assert.ok(!/href|src|style|srcset|on[a-z]/i.test(allowed[1]),
`放行名单不能含加载或导航类属性,当前为 ${allowed[1]}`);
assert.match(src, /RETURN_DOM_FRAGMENT: true/, '必须以片段形式取回净化结果,避免二次解析引入 mXSS');
});
/* --- 端到端:契约方法 --- */
test('TXT 走完整管线:章节数、目录、locator kind 与全文', async () => {
const { createTextAdapter } = await mod;
const source = novel(6);
const adapter = createTextAdapter('txt');
const progress = [];
try {
const info = await adapter.load(u8(source), { onProgress: (p) => progress.push(p) });
assert.strictEqual(info.format, 'txt');
assert.strictEqual(info.mode, 'plain');
assert.strictEqual(info.encoding, 'utf-8');
assert.strictEqual(info.chapterCount, 6);
assert.strictEqual(info.title, '未命名文本',
'首行本身是章节标题时不能拿来当书名,否则书库标题会变成「第1章」');
assert.ok(progress.length && progress[progress.length - 1] === 1, 'onProgress 必须走到 1');
assert.ok(progress.every((p) => p >= 0 && p <= 1), '进度必须落在 0..1');
const toc = await adapter.toc();
assert.strictEqual(toc.length, 6);
assert.deepStrictEqual(toc.map((t) => t.label),
['第1章 标题1', '第2章 标题2', '第3章 标题3', '第4章 标题4', '第5章 标题5', '第6章 标题6']);
assert.deepStrictEqual(toc.map((t) => t.locator.chapter), [0, 1, 2, 3, 4, 5]);
for (const entry of toc) {
assert.strictEqual(entry.locator.kind, 'txt', 'locator.kind 必须是 txt,外壳靠它判断书签与笔记');
assert.strictEqual(entry.depth, 0);
}
const label = adapter.locatorLabel({ kind: 'txt', chapter: 2, offset: 0 });
assert.strictEqual(label, '第3章 标题3');
} finally {
adapter.destroy();
}
});
test('首行是书名时取作标题,是章节标题时不取', async () => {
const { splitPlainText } = await mod;
assert.strictEqual(splitPlainText(`某本小说的书名\n\n${novel(4)}`).title, '某本小说的书名');
assert.strictEqual(splitPlainText(novel(4)).title, '',
'首行是「第1章」时留空,交由上层用文件名兜底');
});
test("textOf(locator, 'document') 返回全文,不缺不重", async () => {
const { createTextAdapter } = await mod;
const source = novel(8);
const adapter = createTextAdapter('txt');
try {
await adapter.load(u8(source));
const full = await adapter.textOf({ kind: 'txt', chapter: 0, offset: 0 }, 'document');
assert.strictEqual(full, tidy(source),
'AI 的「全文」范围完全依赖这条,少一个字就是静默数据丢失');
for (let i = 1; i <= 8; i++) {
const token = `【标记${String(i).padStart(4, '0')}`;
assert.strictEqual(full.split(token).length - 1, 1, `${token} 必须恰好出现一次`);
}
const positions = [];
for (let i = 1; i <= 8; i++) positions.push(full.indexOf(`【标记${String(i).padStart(4, '0')}`));
assert.deepStrictEqual(positions, [...positions].sort((a, b) => a - b), '全文顺序必须与原文一致');
const one = await adapter.textOf({ kind: 'txt', chapter: 3, offset: 0 }, 'chapter');
assert.ok(one.includes('【标记0004】'), '按章取文必须取到对应章');
assert.ok(!one.includes('【标记0005】'), '按章取文不得越界');
} finally {
adapter.destroy();
}
});
test('Markdown 走完整管线:标题渲染成 h1/h2,代码块与表格保留', async () => {
const { createTextAdapter } = await mod;
const source = [
'# 文档标题', '', '开场白。', '',
'## 列表与代码', '',
'- 第一项', '- 第二项', '',
'```js', 'const x = 1;', '```', '',
'> 引用一句话', '',
'| 列一 | 列二 |', '| --- | --- |', '| 1 | 2 |', '',
'**加粗**与 `行内代码`。', ''
].join('\n');
const adapter = createTextAdapter('md');
try {
const info = await adapter.load(u8(source));
assert.strictEqual(info.format, 'md');
assert.strictEqual(info.mode, 'markdown');
assert.strictEqual(info.title, '文档标题');
assert.strictEqual(info.chapterCount, 2);
const toc = await adapter.toc();
assert.deepStrictEqual(toc.map((t) => [t.label, t.depth]), [['文档标题', 0], ['列表与代码', 1]]);
assert.strictEqual(toc[0].locator.kind, 'md', 'Markdown 的 locator.kind 必须是 md');
const full = await adapter.textOf({ kind: 'md', chapter: 0, offset: 0 }, 'document');
for (const piece of ['文档标题', '开场白。', '第一项', '第二项', 'const x = 1;', '引用一句话', '列一', '加粗', '行内代码']) {
assert.ok(full.includes(piece), `全文里必须有「${piece}」,Markdown 渲染不能吞内容`);
}
assert.ok(!full.includes('```'), 'Markdown 记号应被渲染掉而不是原样留在正文里');
} finally {
adapter.destroy();
}
});
test('渲染后的块级元素在全文里彼此分行,AI 不会拿到糊成一团的文本', async () => {
const { createTextAdapter } = await mod;
const adapter = createTextAdapter('md');
try {
// 标题与紧随其后的正文之间原文没有空行,最容易被拼成「标题甲正文乙」
await adapter.load(u8('# 标题甲\n正文乙\n\n## 标题丙\n正文丁\n\n- 列表戊\n- 列表己\n'));
const full = await adapter.textOf({ kind: 'md', chapter: 0, offset: 0 }, 'document');
for (const [a, b] of [['标题甲', '正文乙'], ['标题丙', '正文丁'], ['列表戊', '列表己']]) {
assert.ok(!full.includes(a + b),
`${a}」与「${b}」被拼成了一个词,块级元素之间必须留分隔符,否则送给模型的全文语义错乱`);
assert.ok(new RegExp(`${a}\\n+${b}`).test(full), `${a}」与「${b}」之间应有换行`);
}
} finally {
adapter.destroy();
}
});
test('Markdown 章节渲染成真正的块级结构,不是纯文本', async () => {
const { buildTextEpub } = await mod;
const built = await buildTextEpub(u8([
'# 标题', '', '- 项', '', '```js', 'const x = 1;', '```', '',
'> 引用', '', '| a | b |', '| - | - |', '| 1 | 2 |', ''
].join('\n')), { format: 'md' });
const doc = parseXhtml(await readChapter(built.bytes, 0));
assert.strictEqual(doc.querySelectorAll('h1').length, 1, 'Markdown 标题必须渲染成 h1');
assert.strictEqual(doc.querySelectorAll('ul > li').length, 1);
assert.strictEqual(doc.querySelectorAll('pre > code').length, 1);
assert.strictEqual(doc.querySelectorAll('blockquote').length, 1);
assert.strictEqual(doc.querySelectorAll('table td').length, 2);
});
test('超大 Markdown 降级为纯文本,不让 markdown-it 整篇解析', async () => {
const { buildTextEpub, MARKDOWN_MAX_CHARS } = await mod;
const over = `# 标题\n\n${'abcde\n'.repeat(Math.ceil(MARKDOWN_MAX_CHARS / 6) + 100)}`;
assert.ok(over.length > MARKDOWN_MAX_CHARS);
const built = await buildTextEpub(u8(over), { format: 'md' });
assert.strictEqual(built.mode, 'plain', '超限时必须降级,否则整篇解析的开销与内存都不可控');
const under = await buildTextEpub(u8('# 标题\n\n正文。\n'), { format: 'md' });
assert.strictEqual(under.mode, 'markdown', '正常体量的 md 必须走渲染路径');
});
test('几十 MB 的 txt 切成小章,单章体积可控', async () => {
const { buildTextEpub, splitPlainText } = await mod;
const block = `第N章 标题\n${'这一段是压力测试用的中文正文。'.repeat(10)}\n\n`;
const source = block.repeat(2000).replace(/第N章/g, () => '第1章');
const split = splitPlainText(source);
assert.ok(split.chapters.length <= 4000, '章节数必须有上限,否则 zip 条目数失控');
assert.ok(split.chapters.every((c) => c.text.length <= 48000),
'单章必须足够小,一次性把整份文本塞进 DOM 会卡死渲染进程');
const built = await buildTextEpub(u8(source), { format: 'txt' });
assert.strictEqual(built.chapterCount, split.chapters.length);
assert.strictEqual(built.mode, 'plain');
});
test('nextLocator / prevLocator 在两端返回 null', async () => {
const { createTextAdapter } = await mod;
const adapter = createTextAdapter('txt');
try {
await adapter.load(u8(novel(4)));
assert.strictEqual(adapter.prevLocator({ kind: 'txt', chapter: 0, offset: 0 }), null,
'第一章没有上一章,返回 null 外壳才会禁用按钮');
assert.strictEqual(adapter.nextLocator({ kind: 'txt', chapter: 3, offset: 0 }), null,
'最后一章没有下一章');
const next = adapter.nextLocator({ kind: 'txt', chapter: 0, offset: 500 });
assert.deepStrictEqual(next, { kind: 'txt', chapter: 1, offset: 0 });
const prev = adapter.prevLocator({ kind: 'txt', chapter: 2, offset: 10 });
assert.deepStrictEqual(prev, { kind: 'txt', chapter: 1, offset: 0 });
assert.deepStrictEqual(adapter.nextLocator(null), { kind: 'txt', chapter: 1, offset: 0 },
'非法 locator 要按第 0 章处理,不能抛');
} finally {
adapter.destroy();
}
});
test('percentOf 与 locatorFromPercent 互为逆运算', async () => {
const { createTextAdapter } = await mod;
const adapter = createTextAdapter('txt');
try {
await adapter.load(u8(novel(8)));
// 章内百分比需要章节长度,先取一次全文把长度缓存起来(外壳打开书后同样会发生)
await adapter.textOf({ kind: 'txt', chapter: 0, offset: 0 }, 'document');
assert.strictEqual(adapter.percentOf({ kind: 'txt', chapter: 0, offset: 0 }), 0);
assert.strictEqual(adapter.percentOf({ kind: 'txt', chapter: 7, offset: 1e9 }), 1);
for (const p of [0, 0.125, 0.3, 0.5, 0.77, 1]) {
const locator = adapter.locatorFromPercent(p);
assert.strictEqual(locator.kind, 'txt');
const back = adapter.percentOf(locator);
assert.ok(Math.abs(back - p) < 0.02,
`百分比往返偏差过大:${p} -> ${JSON.stringify(locator)} -> ${back};进度条与书签会漂移`);
}
assert.deepStrictEqual(adapter.locatorFromPercent(-5), { kind: 'txt', chapter: 0, offset: 0 },
'越界百分比必须夹紧');
assert.strictEqual(adapter.locatorFromPercent(99).chapter, 7);
} finally {
adapter.destroy();
}
});
test('超过体积上限时 load 抛中文错误', async () => {
const { createTextAdapter, MAX_TEXT_BYTES } = await mod;
const adapter = createTextAdapter('txt');
try {
await assert.rejects(
() => adapter.load(new Uint8Array(MAX_TEXT_BYTES + 1)),
/超过 64 MB/,
'超限错误会原样显示给用户,必须是中文'
);
} finally {
adapter.destroy();
}
});
test('适配器暴露外壳依赖的全部方法,且 .md 后缀归一', async () => {
const { createTextAdapter, normalizeTextFormat } = await mod;
const adapter = createTextAdapter('txt');
try {
for (const name of [
'load', 'renderTo', 'toc', 'getSelection', 'textOf', 'locatorLabel',
'nextLocator', 'prevLocator', 'percentOf', 'locatorFromPercent',
'capturePinchAnchor', 'restorePinchAnchor', 'setLocatorChangeHandler',
'setTouchGestureHandler', 'visualViewportRect', 'destroy'
]) {
assert.strictEqual(typeof adapter[name], 'function', `外壳会调用 ${name},必须实现`);
}
assert.strictEqual(adapter.getSelection(), null, '未渲染时取选区应返回 null 而不是抛错');
assert.strictEqual(adapter.visualViewportRect(), null);
assert.strictEqual(adapter.capturePinchAnchor(0, 0), null);
} finally {
adapter.destroy();
}
assert.strictEqual(normalizeTextFormat('.MD'), 'md');
assert.strictEqual(normalizeTextFormat('markdown'), 'md');
assert.strictEqual(normalizeTextFormat('txt'), 'txt');
assert.strictEqual(normalizeTextFormat(undefined), 'txt');
});
test('destroy 之后再次 load 不残留上一本书的状态', async () => {
const { createTextAdapter } = await mod;
const adapter = createTextAdapter('txt');
try {
await adapter.load(u8(novel(6)));
assert.strictEqual((await adapter.toc()).length, 6);
adapter.destroy();
assert.strictEqual(adapter.documentInfo(), null, 'destroy 必须清掉文档信息');
await adapter.load(u8(novel(3)));
assert.strictEqual((await adapter.toc()).length, 3, '重新 load 后目录必须是新书的');
assert.strictEqual(adapter.nextLocator({ kind: 'txt', chapter: 2, offset: 0 }), null);
} finally {
adapter.destroy();
}
});
+351 -6
View File
@@ -307,8 +307,14 @@ test('AI 上下文提供无需选中的当前页与全文范围', () => {
// 全文必须提示可能超限,并且始终弹确认框
assert.match(shell, /可能超过模型限制/);
assert.match(shell, /全文可能超过模型的上下文限制/);
assert.match(shell, /scope !== 'document' && chars <= CONFIRM_CHARS/);
// 正文不再本地截断,文案必须如实说明"完整发送 + 超限由接口报错",
// 否则界面显示的字数与实际外发字数不一致(实测 40 页只发出 8 页)
assert.match(shell, /全文将完整发送/);
assert.doesNotMatch(shell, /保留首尾并截断/);
const client = fs.readFileSync(path.join(__dirname, '..', 'reader', 'ai-client.js'), 'utf8');
assert.doesNotMatch(client, /let body = clipContext\(text\)/);
assert.match(client, /上下文超出模型窗口/);
// 旧设置迁移,避免升级后回落成 selection
assert.match(shell, /storedScope === 'chapter' \? 'document' : storedScope/);
@@ -374,12 +380,13 @@ test('设置关于页与 README 列出书库和内置阅读格式', () => {
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
const readme = fs.readFileSync(path.join(__dirname, '..', '..', 'README.md'), 'utf8');
assert.match(html, /关于 PeopleLib/);
assert.match(html, /内置阅读[\s\S]*PDF、EPUB、MOBI、AZW、AZW3/);
assert.match(html, /书库导入与管理[\s\S]*TXT、DJVU、FB2、CBZ、CBR/);
assert.match(html, /内置阅读[\s\S]*PDF、EPUB、MOBI、AZW、AZW3、TXT、MD/);
assert.match(html, /书库导入与管理[\s\S]*TXT、MD、DJVU、FB2、CBZ、CBR/);
assert.match(html, /Foliate[\s\S]*MOBI\/KF7\/KF8/);
assert.match(readme, /## 支持格式/);
assert.match(readme, /MOBI \/ AZW \/ AZW3[\s\S]*Foliate/);
assert.match(readme, /TXT \/ DJVU \/ FB2 \/ CBZ \/ CBR/);
assert.match(readme, /TXT \/ MD[\s\S]*Markdown/);
assert.match(readme, /DJVU \/ FB2 \/ CBZ \/ CBR/);
});
test('MOBI、AZW 和 AZW3 使用固定版本 Foliate 组件进入内置阅读器', () => {
@@ -389,7 +396,7 @@ test('MOBI、AZW 和 AZW3 使用固定版本 Foliate 组件进入内置阅读器
const adapter = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'mobi-adapter.mjs'), 'utf8');
const build = fs.readFileSync(path.join(__dirname, '..', '..', 'build-portable.js'), 'utf8');
assert.strictEqual(pkg.dependencies['foliate-js'], '1.0.1');
assert.match(main, /READABLE_EXT = new Set\(\['\.pdf', '\.epub', '\.mobi', '\.azw', '\.azw3'\]\)/);
assert.match(main, /READABLE_EXT = new Set\(\['\.pdf', '\.epub', '\.mobi', '\.azw', '\.azw3', '\.txt', '\.md'\]\)/);
assert.match(shell, /mobi:\s*mobi\.createMobiAdapter/);
assert.match(shell, /azw3:\s*mobi\.createMobiAdapter/);
assert.match(adapter, /from '\.\.\/\.\.\/\.\.\/node_modules\/foliate-js\/mobi\.js'/);
@@ -495,6 +502,66 @@ test('读书与画布笔记分型创建、分类展示并支持受管 PDF 底版
assert.ok(snapshotAt > functionAt && snapshotAt < awaitAt, '下载元数据未在首次 await 前快照');
});
test('笔记表单控件样式不外溢到工具栏,关联下拉框限宽', () => {
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
const noteWindowCss = fs.readFileSync(path.join(__dirname, '..', 'ui', 'note-window.css'), 'utf8');
const fieldRule = css.match(/\.note-edit-form select[^{]*\{([^}]*margin-top[^}]*)\}/);
assert.ok(fieldRule, '找不到笔记表单控件规则');
// 画布工具栏与 Quill 工具栏都是 .note-edit-form 的后代,
// 漏掉任一 :not() 就会把 margin-top / width:100% 灌进工具栏,
// 表现为工具栏凭空高出一截、分组高度对不齐
assert.match(fieldRule[0], /:not\(\.canvas-note-root select\)/);
assert.match(fieldRule[0], /:not\(\.ql-toolbar select\)/);
const capRule = css.match(/\.note-edit-form select[^{]*\{([^}]*max-width[^}]*)\}/);
assert.ok(capRule, '关联书籍下拉框没有限宽');
assert.match(capRule[1], /max-width:\s*320px/);
assert.match(capRule[1], /min-width:\s*0/);
assert.match(capRule[0], /:not\(\.canvas-note-root select\)/);
const metaRule = noteWindowCss.match(/\.note-window-meta select[\s\S]*?\{([^}]*)\}/);
assert.ok(metaRule, '笔记窗口下拉框没有限宽规则');
assert.match(metaRule[1], /max-width:\s*260px/);
assert.match(metaRule[1], /min-width:\s*0/);
});
test('笔记窗口标题栏只有品牌名,没有副标题', () => {
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'note.html'), 'utf8');
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'views', 'note-shell.js'), 'utf8');
const titlebar = html.match(/<div class="titlebar">[\s\S]*?<div class="titlebar-spacer">/);
assert.ok(titlebar, '找不到笔记窗口标题栏');
// style.css 的 .titlebar-left 不是 flex(只有 reader.css 是),
// 放同级的 brand-sub 会掉到品牌名下面一行,把标题栏顶高
assert.doesNotMatch(titlebar[0], /brand-sub/);
assert.doesNotMatch(html, /noteWindowSubtitle/);
assert.doesNotMatch(shell, /noteWindowSubtitle|subtitle/);
assert.match(titlebar[0], /<span>笔记<\/span>/);
});
test('笔记窗口多标签:自带标签条样式,非激活视图隐藏,存活编辑器有上限', () => {
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'note.html'), 'utf8');
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'note-window.css'), 'utf8');
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'views', 'note-shell.js'), 'utf8');
assert.match(html, /class="doctabs"/, '缺少标签条');
assert.match(html, /id="noteDirtyModal"/, '缺少未保存确认框');
// note.html 不加载 reader.css,标签条样式必须在 note-window.css 里自带一份
assert.doesNotMatch(html, /reader\.css/);
assert.match(css, /\.doctabs\s*\{/, '标签条样式缺失,标签会退化成竖排文字');
assert.match(css, /\.note-tab-view\.inactive[\s\S]*?display:\s*none/);
// 只留一个可见视图,否则多个 Quill/画布实例同时可见会互相抢焦点
assert.match(shell, /MAX_LIVE_EDITORS\s*=\s*\d+/);
// 取消关闭必须真的把 cancelClose 发出去:主进程的 closePending 不复位,
// 下次点关闭会被当成"正在处理"忽略,而看门狗仍会销毁带未保存内容的窗口
const abortAt = shell.indexOf('async function abortClose');
assert.ok(abortAt > 0, '缺少 abortClose');
const abortBody = shell.slice(abortAt, shell.indexOf('\n}', abortAt));
assert.match(abortBody, /await api\.notes\.cancelClose\(\)/);
assert.doesNotMatch(abortBody, /if\s*\(\s*true\s*\)\s*return/);
assert.match(abortBody, /closing = false/);
// 脏判定必须比对序列化内容:靠 keydown/pointerdown 之类的交互事件会误报,
// 画布加载时的 1→2 版本归一化本身就会改一次内容
assert.match(shell, /baselineKey/);
assert.doesNotMatch(shell, /addEventListener\('pointerdown'[\s\S]{0,120}dirty/);
});
test('书架操作对键盘焦点可见', () => {
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
assert.match(css, /\.library-shelf-row:focus-within \.library-shelf-actions/);
@@ -550,7 +617,124 @@ test('书库多选提供全选、批量整理与批量移除', () => {
// 批量移除沿用单本移除的两个可选项
assert.match(library, /id="bulkDelFiles"/);
assert.match(library, /id="bulkDelReadingData"/);
assert.match(library, /window\.api\.library\.remove\(item\.id, choice\)/);
// 一次 IPC 提交整批,不再逐条 remove:书库索引是整体重写的,循环会造成写放大
assert.match(library, /window\.api\.library\.removeMany\(targets\.map\(\(item\) => item\.id\), choice\)/);
assert.match(library, /window\.api\.library\.updateMany\(patches\)/);
});
test('卡片定位上下文不随多选状态消失', () => {
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
// 退出多选是同步移除 class,重绘要等 IPC;若定位上下文只在 select-mode 下建立,
// 残留的复选框会按视口定位飞到左上角标题上闪一下
assert.match(css, /\n\.card\s*\{[^}]*position:\s*relative/);
assert.doesNotMatch(css, /#libraryTab\.select-mode \.card\s*\{\s*position:\s*relative/);
assert.match(css, /#libraryTab:not\(\.select-mode\) \.card-select\s*\{\s*display:\s*none/);
});
test('多选复选框用原生外观,不套衬底色块', () => {
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
const wrap = css.match(/\.card-select\s*\{([^}]*)\}/);
assert.ok(wrap, '缺少复选框容器样式');
// padding + 背景板会让 13px 的原生复选框看起来套了一圈很粗的边框,
// 浅色封面上的可见性改用投影解决
assert.doesNotMatch(wrap[1], /padding:\s*[1-9]/);
assert.doesNotMatch(wrap[1], /background:\s*rgba/);
const input = css.match(/\.card-select input\s*\{([^}]*)\}/);
assert.ok(input, '缺少复选框样式');
assert.match(input[1], /drop-shadow/);
});
test('封面完整显示且比例一致,留白由模糊层填充', () => {
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
const coverRule = css.match(/\n\.card-cover\s*\{([^}]*)\}/);
assert.ok(coverRule, '缺少封面样式');
// cover 会按各自比例裁掉不同的边,同一批封面看起来缩放程度不一致
assert.match(coverRule[1], /center\/contain/);
assert.doesNotMatch(coverRule[1], /center\/cover/);
// ::before 垫模糊底,::after 画清晰的完整封面。
// 只用 ::before 的话它会盖住父元素自己的背景,封面整张变成模糊的
// 共用规则里 ::after 也单独占一行,所以不能靠 \n 区分;
// 按"独占一条规则"来取:选择器后面直接跟 { 且规则里带 z-index
const rules = [...css.matchAll(
/\.card-cover\[data-cover-state="ready"\]::(before|after)\s*\{([^}]*)\}/g
)].filter((m) => /z-index/.test(m[2]));
const before = rules.find((m) => m[1] === 'before');
const after = rules.find((m) => m[1] === 'after');
assert.ok(before && after, '缺少封面双层背景规则');
assert.match(before[2], /background-size:\s*cover/);
assert.match(before[2], /blur\(/);
assert.match(after[2], /background-size:\s*contain/);
// 模糊层必须在下、清晰层在上
assert.ok(
Number(before[2].match(/z-index:\s*(\d+)/)[1]) < Number(after[2].match(/z-index:\s*(\d+)/)[1]),
'模糊层盖住了清晰封面'
);
// 角标与占位文字要浮在两层背景之上
assert.match(css, /\.card-cover > \*\s*\{[^}]*z-index:\s*2/);
});
test('可内置阅读的格式在渲染层与 main.js 保持一致', () => {
const mainSrc = fs.readFileSync(path.join(__dirname, '..', '..', 'main.js'), 'utf8');
const readable = mainSrc.match(/const READABLE_EXT = new Set\(\[([^\]]*)\]\)/);
assert.ok(readable, '找不到 READABLE_EXT');
const exts = readable[1].match(/\.\w+/g).map((s) => s.slice(1)).sort();
// 渲染层漏掉格式不会报错,只是「阅读」按钮和封面点击静默消失,
// 用户看到的现象就是"内置阅读器打不开 txt"
for (const file of [
path.join(__dirname, '..', 'ui', 'views', 'library.js'),
path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs')
]) {
const src = fs.readFileSync(file, 'utf8');
const re = src.match(/READABLE_RE\s*=\s*\/\\\.\(([^)]*)\)\$\/i/);
assert.ok(re, `${path.basename(file)} 缺少 READABLE_RE`);
assert.deepStrictEqual(re[1].split('|').sort(), exts, `${path.basename(file)} 的可阅读格式与 main.js 不一致`);
}
});
test('状态、笔记与标签标识叠在封面上,不占用封面下方行', () => {
const library = fs.readFileSync(libFile, 'utf8');
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
// 三种标识都必须在 .card-cover 内部,否则又会各占一行
const cover = library.match(/<div class="card-cover\$\{[\s\S]*?\n\s*<\/div>/);
assert.ok(cover, '封面结构缺失');
assert.match(cover[0], /class="library-card-tags"/);
// 下载状态在左下角,批注与笔记在右下角
assert.match(cover[0], /class="card-cover-badges start">\$\{badge\}/);
assert.match(cover[0], /class="card-cover-badges end">\$\{annotationBadge\}\$\{noteBadge\}/);
// 标识不能再出现在封面之后、标题周围
const afterCover = library.slice(library.indexOf('class="card-title"'));
assert.doesNotMatch(afterCover.slice(0, 400), /card-cover-badges|library-card-tags/);
const coverRule = css.match(/\.card-cover\s*\{([^}]*)\}/);
assert.match(coverRule[1], /position:\s*relative/);
assert.match(coverRule[1], /overflow:\s*hidden/);
const badgeWrap = css.match(/\.card-cover-badges\s*\{([^}]*)\}/);
assert.ok(badgeWrap, '缺少封面标识容器样式');
assert.match(badgeWrap[1], /position:\s*absolute/);
assert.match(badgeWrap[1], /bottom:\s*6px/);
// 标识浮在封面上,必须让点击穿透到封面的阅读入口
assert.match(badgeWrap[1], /pointer-events:\s*none/);
// 左右两组各占一半,避免笔记批注多时与左下角的下载状态叠在一起
assert.match(badgeWrap[1], /max-width:\s*calc\(50% - 8px\)/);
assert.match(css, /\.card-cover-badges\.start\s*\{\s*left:\s*6px/);
assert.match(css, /\.card-cover-badges\.end\s*\{\s*right:\s*6px/);
const tagsRule = css.match(/\.library-card-tags\s*\{([^}]*)\}/);
assert.match(tagsRule[1], /position:\s*absolute/);
assert.match(tagsRule[1], /top:\s*6px/);
assert.match(tagsRule[1], /pointer-events:\s*none/);
// 左上角留给多选复选框,标签宽度必须扣掉这块
assert.match(tagsRule[1], /max-width:\s*calc\(100% - 42px\)/);
// 允许换行会让三个长标签堆成三行盖住封面
assert.doesNotMatch(tagsRule[1], /flex-wrap:\s*wrap/);
assert.match(library, /class="library-card-tag" title="\$\{escapeHtml\(tag\)\}"/);
// 封面图案深浅不可控,衬底必须不透明
const badgeRule = css.match(/\n\.card-badge\s*\{([^}]*)\}/);
assert.match(badgeRule[1], /background:\s*var\(--bg-card\)/);
assert.doesNotMatch(badgeRule[1], /margin-top/);
});
test('书库长标题保持单行省略并提供完整悬浮提示', () => {
@@ -564,6 +748,167 @@ test('书库长标题保持单行省略并提供完整悬浮提示', () => {
assert.match(library, /class="card-title" title="\$\{escapeHtml\(it\.title\)\}"/);
});
test('AI 面板以线程容器承载多轮对话气泡', () => {
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.html'), 'utf8');
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.css'), 'utf8');
// 结构约定要写在 HTML 里,集成测试与 CSS 都按 ai-thread / ai-msg 定位
assert.match(html, /id="aiOutput" class="ai-output ai-thread"/);
assert.match(shell, /box\.className = `ai-msg ai-msg-\$\{role\}`/);
assert.match(shell, /box\.dataset\.messageId = String\(message\.id \|\| ''\)/);
assert.match(shell, /body\.className = 'ai-msg-body'/);
assert.match(shell, /meta\.className = 'ai-msg-meta'/);
assert.match(css, /\.ai-msg-body\s*\{/);
assert.match(css, /\.ai-thread\s*\{[^}]*flex-direction:\s*column/);
// 新回答必须追加而不是覆盖:整块重写等于回到一问一答
assert.match(shell, /function appendAiMessage\(message\)/);
assert.match(shell, /el\.aiOutput\.appendChild\(node\)/);
});
test('AI 流式增量只重渲染正在生成的那一条气泡', () => {
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
// 线程里有几十条消息时,每 80ms 重解析全部 Markdown 会把界面拖死,
// 因此增量渲染只允许写 aiStreamNode 里的 .ai-msg-body
const render = shell.match(/function renderAiOutput\([\s\S]*?\n\}/);
assert.ok(render, '缺少 renderAiOutput');
assert.match(render[0], /aiStreamNode\.querySelector\('\.ai-msg-body'\)/);
assert.match(render[0], /renderMessageBody\(body,/);
assert.doesNotMatch(render[0], /AiMarkdown\.mount\(el\.aiOutput/);
assert.doesNotMatch(render[0], /renderAiThread\(\)/);
// 整篇重建只发生在换会话时,不能出现在节流的增量路径上
const schedule = shell.match(/function scheduleAiOutput\([\s\S]*?\n\}/);
assert.ok(schedule, '缺少 scheduleAiOutput');
assert.doesNotMatch(schedule[0], /renderAiThread\(\)/);
assert.doesNotMatch(schedule[0], /AiMarkdown\.mount\(el\.aiOutput/);
// 80ms 节流与「贴底才自动滚动」都要保留
assert.match(shell, /aiRenderTimer = window\.setTimeout\([\s\S]{0,120}\}, 80\)/);
assert.match(shell, /el\.aiOutput\.scrollHeight - el\.aiOutput\.scrollTop - el\.aiOutput\.clientHeight < 48/);
});
test('AI 增量按 messageId 路由到对应气泡', () => {
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
const delta = shell.match(/unsubDelta = api\.ai\.onDelta\([\s\S]*?\n \}\);/);
assert.ok(delta, '缺少 onDelta 订阅');
// 只按 runId 过滤会让同一次运行里的旧气泡也收到增量,串成一团
assert.match(delta[0], /const messageId = d\.messageId \? String\(d\.messageId\) : ''/);
assert.match(delta[0], /messageId !== aiRun\.messageId\) return/);
assert.match(shell, /function adoptStreamingMessageId\(messageId\)/);
assert.match(shell, /aiRun\.messageId = String\(messageId\)/);
});
test('AI 会话管理提供新建、重命名、置顶、清空与删除', () => {
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.html'), 'utf8');
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
assert.match(html, /id="aiSessionSelect"[^>]+title="切换当前书籍的对话会话"/);
assert.match(html, /id="aiSessionNewBtn"[^>]*>新建</);
assert.match(html, /id="aiSessionRenameBtn"[^>]*>重命名</);
assert.match(html, /id="aiSessionPinBtn"[^>]*>置顶</);
assert.match(html, /id="aiSessionClearBtn"[^>]*>清空</);
assert.match(html, /id="aiSessionDeleteBtn"[^>]*>删除</);
// 会话下拉按 pinned 优先、updatedAt 倒序,标题为空时显示「新会话」
assert.match(shell, /a\.pinned \? -1 : 1/);
assert.match(shell, /Number\(b\.updatedAt\) \|\| 0\) - \(Number\(a\.updatedAt\) \|\| 0\)/);
assert.match(shell, /return title \|\| '新会话'/);
// 一开书就建空会话会很快占满 100 个上限,必须延迟到首次提问
assert.match(shell, /async function ensureAiSession\(\)/);
assert.match(shell, /if \(aiSessionId\) return aiSessionId/);
const activateBlock = shell.match(/async function activate\(id\)[\s\S]*?\n\}/);
assert.ok(activateBlock);
assert.match(activateBlock[0], /refreshAiSessions\(\)/);
assert.doesNotMatch(activateBlock[0], /sessions\.create\(/);
// 生成中禁止切会话、删会话与发新问题
assert.match(shell, /el\.aiSessionSelect\.disabled = busy \|\| !hasEntry/);
assert.match(shell, /el\.aiSessionDeleteBtn\.disabled = busy \|\| !hasSession/);
assert.match(shell, /function aiBusy\(busy\)[\s\S]{0,400}syncAiSessionControls\(\)/);
});
test('删除与清空 AI 会话都要走应用内二次确认', () => {
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.html'), 'utf8');
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
assert.match(html, /id="aiSessionDeleteModal"[\s\S]*id="aiSessionDeleteConfirmBtn"[^>]*>删除会话</);
assert.match(html, /id="aiSessionClearModal"[\s\S]*id="aiSessionClearConfirmBtn"[^>]*>清空消息</);
assert.match(html, /id="aiSessionRenameModal"[\s\S]*id="aiSessionTitleInput"/);
// 对话记录删掉找不回来,必须先确认再调 remove/clear
const remove = shell.match(/async function deleteAiSession\(\)[\s\S]*?\n\}/);
assert.ok(remove, '缺少 deleteAiSession');
const removeConfirmAt = remove[0].indexOf('await confirmAiSessionDelete(');
const removeCallAt = remove[0].indexOf('sessions.remove(');
assert.ok(removeConfirmAt >= 0, '删除会话缺少二次确认,会一键抹掉全部对话记录');
assert.ok(removeCallAt >= 0, '删除会话未调用 sessions.remove');
assert.ok(removeConfirmAt < removeCallAt, '删除会话必须先弹二次确认再调 sessions.remove');
const clear = shell.match(/async function clearAiSession\(\)[\s\S]*?\n\}/);
assert.ok(clear, '缺少 clearAiSession');
const clearConfirmAt = clear[0].indexOf('await confirmAiSessionClear(');
const clearCallAt = clear[0].indexOf('sessions.clear(');
assert.ok(clearConfirmAt >= 0, '清空会话缺少二次确认,消息删掉找不回来');
assert.ok(clearCallAt >= 0, '清空会话未调用 sessions.clear');
assert.ok(clearConfirmAt < clearCallAt, '清空会话必须先弹二次确认再调 sessions.clear');
assert.match(shell, /function confirmAiSessionDelete\(title\)[\s\S]{0,400}aiSessionDeleteModal\.classList\.remove\('hidden'\)/);
});
test('AI 会话调用 api.ai.sessions 契约并带上 sessionId 发送', () => {
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
assert.match(shell, /api\.ai && api\.ai\.sessions \? api\.ai\.sessions : null/);
assert.match(shell, /sessions\.list\(\{ entryId \}\)/);
assert.match(shell, /sessions\.create\(\{ entryId, title: '', documentKey: tab\.documentKey \|\| null \}\)/);
assert.match(shell, /sessions\.rename\(aiSessionId, title\)/);
assert.match(shell, /sessions\.setPinned\(aiSessionId, next\)/);
assert.match(shell, /sessions\.remove\(removed\)/);
assert.match(shell, /sessions\.clear\(aiSessionId\)/);
assert.match(shell, /sessions\.messages\(wanted, \{ limit: AI_THREAD_LIMIT \}\)/);
// sessionId 不下发就退化成不落盘的一次性提问,线程无法续接
assert.match(shell, /api\.ai\.run\(\{\s*\n\s*runId,\s*\n\s*sessionId,/);
assert.match(shell, /res\.data\.assistantMessageId|ids\.assistantMessageId/);
});
test('AI 每条回答各自提供保存为笔记与复制', () => {
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
assert.match(shell, /save\.className = 'tb-btn sm ai-msg-save'/);
assert.match(shell, /copy\.className = 'tb-btn ghost sm ai-msg-copy'/);
assert.match(shell, /saveAiNote\(aiResultOf\(box\.dataset\.messageId\)\)/);
assert.match(shell, /copyAiMessage\(box\.dataset\.messageId\)/);
// locator 取该条对应 user 消息的 contextRefquote 取那条问题文本
assert.match(shell, /if \(aiThread\[i\]\.role === 'user'\) \{ ask = aiThread\[i\]; break; \}/);
assert.match(shell, /locator: \(ref && ref\.locator\) \|\| null/);
assert.match(shell, /quote: ask \? String\(ask\.text \|\| ''\) : ''/);
assert.match(shell, /async function saveAiNote\(target\)/);
});
test('AI 气泡标注上下文范围、停止、裁剪与省略轮次', () => {
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
assert.match(shell, /const parts = \[scopeName\(ref\.scope\)\]/);
assert.match(shell, /parts\.push\(`\$\{chars\.toLocaleString\(\)\} 字`\)/);
assert.match(shell, /`上下文:\$\{parts\.join\(' · '\)\}`/);
assert.match(shell, /if \(message\.truncated\) flags\.push\('内容已裁剪'\)/);
assert.match(shell, /if \(message\.cancelled\) flags\.push\('已停止生成'\)/);
assert.match(shell, /box\.classList\.add\('ai-msg-error'\)/);
assert.match(shell, /较早的 \$\{dropped\.toLocaleString\(\)\} 轮对话已省略/);
});
test('AI 线程里的模型输出全部经 AiMarkdown 渲染,不直接写 innerHTML', () => {
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
// 渲染层 CSP 挡不住 DOM 注入,模型文本必须过 AiMarkdown 内的 DOMPurify
assert.match(shell, /function renderMessageBody\(body, source\)[\s\S]{0,300}window\.AiMarkdown\.mount\(body, source\)/);
assert.match(shell, /else renderMessageBody\(body, message\.text\)/);
assert.doesNotMatch(shell, /\.innerHTML\s*=/);
// 用户输入按纯文本落地,同样不经 HTML 解析
assert.match(shell, /if \(role === 'user'\) body\.textContent = String\(message\.text \|\| ''\)/);
// 链接拦截仍挂在整个线程容器上,新增气泡里的链接不会漏掉
assert.match(shell, /el\.aiOutput\.addEventListener\('click', activateAiLink\)/);
assert.match(shell, /el\.aiOutput\.addEventListener\('auxclick', activateAiLink\)/);
assert.match(shell, /if \(!link \|\| !el\.aiOutput\.contains\(link\)\) return/);
});
test('可阅读图书封面支持鼠标与键盘打开内置阅读器', () => {
const library = fs.readFileSync(libFile, 'utf8');
assert.match(library, /data-act="read" role="button" tabindex="0"/);
+78
View File
@@ -0,0 +1,78 @@
// 元数据落盘的统一入口。
//
// rename 只保证目录项替换是原子的,不保证被替换的数据已经到磁盘:
// 断电后可能出现"改名成功但文件内容是一段空洞"的结果,长度正常、内容全零。
// 因此改名前必须先 fsync 数据本身,改名后再 fsync 目录让目录项落盘。
// Windows 不允许对目录取句柄,那一步失败时忽略即可。
const fs = require('fs');
const path = require('path');
function writeSynced(file, data, encoding = 'utf8') {
const fd = fs.openSync(file, 'w');
try {
fs.writeFileSync(fd, data, encoding === null ? undefined : { encoding });
fs.fsyncSync(fd);
} finally {
fs.closeSync(fd);
}
}
function syncDirectory(dir) {
let fd = null;
try {
fd = fs.openSync(dir, 'r');
fs.fsyncSync(fd);
} catch (e) {
// Windows 无法 fsync 目录;其它平台失败也不该让写入整体失败
} finally {
if (fd !== null) {
try { fs.closeSync(fd); } catch (e) { /* ignore */ }
}
}
}
// 写入 dest,保留一份 .bak 以便下次读取时恢复。
// 失败时清理临时文件,并在目标已被改走时把备份换回去。
function writeJson(dest, value) {
const temp = `${dest}.tmp`;
const backup = `${dest}.bak`;
let backedUp = false;
fs.mkdirSync(path.dirname(dest), { recursive: true });
try {
writeSynced(temp, JSON.stringify(value, null, 2));
if (fs.existsSync(backup)) fs.unlinkSync(backup);
if (fs.existsSync(dest)) {
fs.renameSync(dest, backup);
backedUp = true;
}
fs.renameSync(temp, dest);
syncDirectory(path.dirname(dest));
if (backedUp) {
try { fs.unlinkSync(backup); } catch (e) { /* 保留备份不影响写入结果 */ }
}
} catch (e) {
try { if (fs.existsSync(temp)) fs.unlinkSync(temp); } catch (cleanup) { /* ignore */ }
try {
if (backedUp && !fs.existsSync(dest) && fs.existsSync(backup)) fs.renameSync(backup, dest);
} catch (rollback) { /* 下次读取时从 .bak 恢复 */ }
throw e;
}
}
// 二进制落地(封面等)。已存在则不覆盖,由调用方决定语义。
function writeBytesExclusive(dest, bytes) {
const temp = `${dest}.tmp`;
fs.mkdirSync(path.dirname(dest), { recursive: true });
const fd = fs.openSync(temp, 'wx');
try {
fs.writeFileSync(fd, bytes);
fs.fsyncSync(fd);
} finally {
fs.closeSync(fd);
}
fs.renameSync(temp, dest);
syncDirectory(path.dirname(dest));
}
module.exports = { writeJson, writeSynced, syncDirectory, writeBytesExclusive };
+1
View File
@@ -8,6 +8,7 @@ const BOOK_EXT = new Set([
'azw',
'azw3',
'txt',
'md',
'djvu',
'fb2',
'cbz',
+87 -35
View File
@@ -13,6 +13,7 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const atomic = require('../atomic-file');
const { fetchWithProxy } = require('../sources/http');
const DL_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36';
@@ -20,7 +21,7 @@ const DL_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHT
const SCHEMA_VERSION = 4;
const MAX_TAGS = 50;
const MAX_TAG_LENGTH = 64;
const BOOK_EXT = new Set(['pdf', 'epub', 'mobi', 'azw', 'azw3', 'txt', 'djvu', 'fb2', 'cbz', 'cbr']);
const BOOK_EXT = new Set(['pdf', 'epub', 'mobi', 'azw', 'azw3', 'txt', 'md', 'djvu', 'fb2', 'cbz', 'cbr']);
let rootDir = null;
let items = null;
@@ -129,36 +130,14 @@ function load() {
}
function persistTo(dir, value, shelfValue = shelves || [], tagValue = tags || []) {
const dest = path.join(dir, 'library.json');
const temp = `${dest}.tmp`;
const backup = `${dest}.bak`;
let backedUp = false;
try {
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(
temp,
JSON.stringify({
version: SCHEMA_VERSION,
shelves: shelfValue,
tags: tagValue,
items: value
}, null, 2),
'utf-8'
);
if (fs.existsSync(backup)) fs.unlinkSync(backup);
if (fs.existsSync(dest)) {
fs.renameSync(dest, backup);
backedUp = true;
}
fs.renameSync(temp, dest);
if (backedUp) {
try { fs.unlinkSync(backup); } catch (cleanupError) { /* 保留备份不影响提交 */ }
}
atomic.writeJson(path.join(dir, 'library.json'), {
version: SCHEMA_VERSION,
shelves: shelfValue,
tags: tagValue,
items: value
});
} catch (e) {
try { if (fs.existsSync(temp)) fs.unlinkSync(temp); } catch (cleanupError) { /* ignore */ }
try {
if (backedUp && !fs.existsSync(dest) && fs.existsSync(backup)) fs.renameSync(backup, dest);
} catch (rollbackError) { /* 下次加载时会从 .bak 恢复 */ }
throw new Error(`书库索引写入失败: ${e.message || e}`);
}
}
@@ -788,11 +767,7 @@ function update(id, patch) {
load();
const it = items.find((x) => x.id === id);
if (!it) throw new Error('条目不存在');
const next = { ...patch };
if (next.files) next.files = next.files.map(normalizeFile);
if (next.cover) next.cover = toRelative(toAbsolute(next.cover));
if (Object.prototype.hasOwnProperty.call(next, 'tags')) next.tags = normalizeTags(next.tags);
if (Object.prototype.hasOwnProperty.call(next, 'shelfId')) next.shelfId = normalizeShelfId(next.shelfId);
const next = normalizedPatch(patch);
const updated = { ...it, ...next, updatedAt: Date.now() };
const nextItems = items.map((x) => x.id === id ? updated : x);
const organizationChanged = Object.prototype.hasOwnProperty.call(next, 'tags')
@@ -929,6 +904,82 @@ function attachFile(id, filePath) {
return expand(updated);
}
function normalizedPatch(patch) {
const next = { ...patch };
if (next.files) next.files = next.files.map(normalizeFile);
if (next.cover) next.cover = toRelative(toAbsolute(next.cover));
if (Object.prototype.hasOwnProperty.call(next, 'tags')) next.tags = normalizeTags(next.tags);
if (Object.prototype.hasOwnProperty.call(next, 'shelfId')) {
next.shelfId = normalizeShelfId(next.shelfId);
}
return next;
}
// 批量整理走单次 commit:逐条 update 会把整个索引重写 N 遍,
// 几千条的书库里批量改动会卡住界面
function updateMany(patches) {
load();
if (!Array.isArray(patches) || !patches.length) return { updated: 0 };
const byId = new Map();
for (const entry of patches) {
if (!entry || entry.id == null) throw new Error('批量更新缺少条目 ID');
const id = String(entry.id);
if (!items.some((x) => x.id === id)) throw new Error('条目不存在');
byId.set(id, normalizedPatch(entry.patch || {}));
}
const now = Date.now();
let updated = 0;
const nextItems = items.map((x) => {
const patch = byId.get(x.id);
if (!patch) return x;
updated++;
return { ...x, ...patch, updatedAt: now };
});
commit(nextItems, true);
return { updated };
}
function removeMany(ids, deleteFiles) {
load();
if (!Array.isArray(ids) || !ids.length) return { removed: 0 };
const targets = ids.map((id) => String(id));
const unique = new Set(targets);
const doomed = items.filter((x) => unique.has(x.id));
const nextItems = items.filter((x) => !unique.has(x.id));
const staged = [];
if (deleteFiles) {
try {
for (const it of doomed) {
for (const f of it.files || []) {
const abs = toAbsolute(f.path);
if (!abs || !isWithin(rootDir, abs) || !fs.existsSync(abs)) continue;
const temp = `${abs}.deleting-${genId()}`;
fs.renameSync(abs, temp);
staged.push({ abs, temp });
}
}
} catch (e) {
for (const f of staged.reverse()) {
try { fs.renameSync(f.temp, f.abs); } catch (rollbackError) { /* ignore */ }
}
throw e;
}
}
try {
commit(nextItems, true);
} catch (e) {
for (const f of staged.reverse()) {
try { fs.renameSync(f.temp, f.abs); } catch (rollbackError) { /* ignore */ }
}
throw e;
}
for (const f of staged) {
try { fs.unlinkSync(f.temp); } catch (e) { /* 文件已移出书库,稍后可手动清理 */ }
}
for (const it of doomed) removeCoverFile(it.id);
return { removed: doomed.length };
}
function remove(id, deleteFiles) {
load();
const it = items.find((x) => x.id === id);
@@ -1262,7 +1313,8 @@ function importLegacy(legacyDir) {
module.exports = {
init, getRoot, filesDir, allocFilePath, sanitize,
list, get, findBySource, listShelves, listTags,
add, importLocal, update, remove, attachFile, addShelf, updateShelf, removeShelf,
add, importLocal, update, updateMany, remove, removeMany, attachFile,
addShelf, updateShelf, removeShelf,
addTag, updateTag, removeTag,
scan, migrateTo, finalizeMigration, rollbackMigration, importLegacy,
ensureCoverCached, setGeneratedCover, setChangeListener
+93 -25
View File
@@ -10,8 +10,8 @@ const { fetchWithProxy } = require('../sources/http');
const MAX_CHARS = 12000;
const MAX_QUESTION_CHARS = 4000;
// 上下文按字符数截断。中间挖空而不是尾部截断:
// 结论性内容常在末尾,只留开头会让模型答非所问
// 中间挖空而不是尾部截断:结论性内容常在末尾,只留开头会让模型答非所问。
// 正文默认不再走这里,只在有明确预算上限的场合(如多轮历史)显式调用
function clipContext(text, limit = MAX_CHARS) {
const s = String(text || '');
if (s.length <= limit) return s;
@@ -42,7 +42,7 @@ const TASKS = {
function buildPromptFromNormalized(task, text, question, visuals) {
const t = TASKS[task];
if (!t) throw new Error('不支持的任务类型: ' + task);
let body = clipContext(text);
let body = String(text || '');
const ocr = visuals
.filter((item) => item.ocr.include)
.map((item) => item.ocr.text.trim())
@@ -58,27 +58,60 @@ function buildPromptFromNormalized(task, text, question, visuals) {
return { system, userText, images };
}
function buildMessagesFromNormalized(task, text, question, visuals) {
// 历史轮只取 role 与 text,其余字段(尤其 images)一律忽略:
// 视觉模型按图块计费,重发历史图会让长会话费用随轮数累积,用户无从预期。
function normalizeHistory(history) {
if (!Array.isArray(history)) return [];
const items = [];
for (const entry of history) {
if (!entry) continue;
const role = entry.role === 'assistant' ? 'assistant' : 'user';
const text = String(entry.text || '').trim();
if (!text) continue;
const last = items[items.length - 1];
// 相邻同角色合并而不是丢弃:Anthropic 会直接 400,但丢内容比合并更糟
if (last && last.role === role) last.text = `${last.text}\n\n${text}`;
else items.push({ role, text });
}
// Anthropic 的 /messages 要求首条必须是 user
while (items.length && items[0].role === 'assistant') items.shift();
return items;
}
// 当前轮固定是 user,历史末条若也是 user 就会相邻同角色,把它并入当前轮正文。
function mergeHistory(history, currentText) {
const items = normalizeHistory(history);
const tail = items.length && items[items.length - 1].role === 'user' ? items.pop() : null;
return {
items,
currentText: tail ? `${tail.text}\n\n${currentText}` : currentText
};
}
function buildMessagesFromNormalized(task, text, question, visuals, history) {
const { system, userText, images } = buildPromptFromNormalized(task, text, question, visuals);
const merged = mergeHistory(history, userText);
const userContent = images.length
? [
{ type: 'text', text: userText },
{ type: 'text', text: merged.currentText },
...images.map((item) => ({
type: 'image_url',
image_url: { url: imageDataUrl(item.image) }
}))
]
: userText;
: merged.currentText;
return [
{ role: 'system', content: system },
...merged.items.map((item) => ({ role: item.role, content: item.text })),
{ role: 'user', content: userContent }
];
}
function buildAnthropicPayload(cfg, prompt) {
function buildAnthropicPayload(cfg, prompt, history) {
const merged = mergeHistory(history, prompt.userText);
const content = prompt.images.length
? [
{ type: 'text', text: prompt.userText },
{ type: 'text', text: merged.currentText },
...prompt.images.map((item) => ({
type: 'image',
source: {
@@ -88,20 +121,24 @@ function buildAnthropicPayload(cfg, prompt) {
}
}))
]
: prompt.userText;
: merged.currentText;
return {
model: cfg.model,
system: prompt.system,
messages: [{ role: 'user', content }],
messages: [
...merged.items.map((item) => ({ role: item.role, content: item.text })),
{ role: 'user', content }
],
temperature: cfg.temperature,
max_tokens: cfg.maxTokens,
stream: true
};
}
function buildResponsesPayload(cfg, prompt) {
function buildResponsesPayload(cfg, prompt, history) {
const merged = mergeHistory(history, prompt.userText);
const content = [
{ type: 'input_text', text: prompt.userText },
{ type: 'input_text', text: merged.currentText },
...prompt.images.map((item) => ({
type: 'input_image',
image_url: imageDataUrl(item.image)
@@ -110,7 +147,12 @@ function buildResponsesPayload(cfg, prompt) {
return {
model: cfg.model,
instructions: prompt.system,
input: [{ role: 'user', content }],
input: [
// 纯字符串是 Responses 输入消息的合法简写,同时绕开 input_text/output_text
// 的角色约束:input_text 不接受 assistantoutput_text 只出现在带 id 的输出项里。
...merged.items.map((item) => ({ role: item.role, content: item.text })),
{ role: 'user', content }
],
temperature: cfg.temperature,
max_output_tokens: cfg.maxTokens,
stream: true,
@@ -118,8 +160,14 @@ function buildResponsesPayload(cfg, prompt) {
};
}
function buildMessages(task, text, question, visualContexts) {
return buildMessagesFromNormalized(task, text, question, normalizeVisualContexts(visualContexts));
function buildMessages(task, text, question, visualContexts, history) {
return buildMessagesFromNormalized(
task,
text,
question,
normalizeVisualContexts(visualContexts),
history
);
}
function endpointFor(baseUrl, protocol) {
@@ -144,24 +192,42 @@ function headersFor(cfg) {
return headers;
}
function payloadFor(cfg, task, text, question, visuals) {
function payloadFor(cfg, task, text, question, visuals, history) {
const prompt = buildPromptFromNormalized(task, text, question, visuals);
if (cfg.protocol === 'anthropic') return buildAnthropicPayload(cfg, prompt);
if (cfg.protocol === 'openai-responses') return buildResponsesPayload(cfg, prompt);
if (cfg.protocol === 'anthropic') return buildAnthropicPayload(cfg, prompt, history);
if (cfg.protocol === 'openai-responses') return buildResponsesPayload(cfg, prompt, history);
return {
model: cfg.model,
messages: buildMessagesFromNormalized(task, text, question, visuals),
messages: buildMessagesFromNormalized(task, text, question, visuals, history),
temperature: cfg.temperature,
max_tokens: cfg.maxTokens,
stream: true
};
}
// 正文不再由本地截断,超出模型窗口时只能由接口报错。各家措辞不同,
// 统一识别成一句可操作的中文提示,否则用户只会看到一串英文而不知道该缩小范围。
const CONTEXT_OVERFLOW_RE = /context[_\s-]?length|context window|maximum context|too many tokens|prompt is too long|reduce the length|input length|exceeds? the (?:maximum|context)/i;
function isContextOverflow(message, status) {
// 413 单看状态码就够:请求体过大只可能是上下文塞太多
if (status === 413) return true;
if (!CONTEXT_OVERFLOW_RE.test(String(message || ''))) return false;
return status === undefined || status === 400 || status === 422;
}
function overflowHint(message) {
return `上下文超出模型窗口,请把范围改小(如改用"当前页"或选中片段)或换用更大窗口的模型。接口原文:${message}`;
}
function parseErrorBody(text, status) {
try {
const j = JSON.parse(text);
const msg = (j.error && (j.error.message || j.error)) || j.message;
if (msg) return String(msg);
if (msg) {
const s = String(msg);
return isContextOverflow(s, status) ? overflowHint(s) : s;
}
} catch (e) { /* 非 JSON */ }
if (status === 401 || status === 403) return 'API Key 无效或没有权限';
if (status === 404) return '接口地址或模型名称不存在';
@@ -189,8 +255,8 @@ function streamFinished(protocol, event) {
}
// onDelta 每收到一段增量就回调一次;返回完整文本。
// signal 用于用户中途取消。
async function stream({ task, text, question, visualContexts, signal, onDelta }) {
// signal 用于用户中途取消。history 是本轮之前的历史轮次,只消费 role 与 text。
async function stream({ task, text, question, visualContexts, history, signal, onDelta }) {
const cfg = aiConfig.get();
const st = aiConfig.status();
if (!cfg.apiKey && !st.isLocal) throw new Error('尚未配置 API Key,请先在设置中填写');
@@ -203,7 +269,7 @@ async function stream({ task, text, question, visualContexts, signal, onDelta })
const res = await fetchWithProxy(endpointFor(cfg.baseUrl, cfg.protocol), {
method: 'POST',
headers: headersFor(cfg),
body: JSON.stringify(payloadFor(cfg, task, text, question, visuals)),
body: JSON.stringify(payloadFor(cfg, task, text, question, visuals, history)),
signal
});
@@ -231,11 +297,13 @@ async function stream({ task, text, question, visualContexts, signal, onDelta })
// 部分服务端把错误放在流里返回
if (j.error || j.type === 'error') {
const error = j.error || j;
throw new Error(error.message || String(error));
const message = error.message || String(error);
throw new Error(isContextOverflow(message) ? overflowHint(message) : message);
}
if (cfg.protocol === 'openai-responses' && ['response.failed', 'response.incomplete'].includes(j.type)) {
const error = j.response && (j.response.error || j.response.incomplete_details);
throw new Error((error && (error.message || error.reason)) || 'OpenAI Responses 请求未完成');
const message = (error && (error.message || error.reason)) || 'OpenAI Responses 请求未完成';
throw new Error(isContextOverflow(message) ? overflowHint(message) : message);
}
const piece = streamDelta(cfg.protocol, j);
if (piece) {
+155
View File
@@ -0,0 +1,155 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const atomic = require('../atomic-file');
const ASSET_RE = /^img_[a-f0-9]{64}$/;
const MIME_TYPE = 'image/jpeg';
const MAX_IMAGE_BYTES = 3 * 1024 * 1024;
const MAX_TOTAL_BYTES = 256 * 1024 * 1024;
const GRACE_MS = 10 * 60 * 1000;
let rootDir = null;
function init(userDataDir) {
rootDir = path.join(userDataDir, 'reader-ai-images');
}
function directory() {
if (rootDir) return rootDir;
const home = process.env.APPDATA || process.env.HOME || process.cwd();
return path.join(home, 'PeopleLib', 'reader-ai-images');
}
function safeImageId(value) {
const id = String(value == null ? '' : value);
if (!ASSET_RE.test(id)) throw new Error('会话图像标识无效');
return id;
}
function fileOf(imageId) {
return path.join(directory(), `${safeImageId(imageId)}.jpg`);
}
function verifyJpeg(bytes) {
if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8 || bytes[2] !== 0xff) {
throw new Error('会话图像不是有效的 JPEG');
}
}
// 上一次写入被中断会留下 .tmp,'wx' 会因此永久失败,这里清掉再重试一次
function writeBlob(dest, bytes) {
const temp = `${dest}.tmp`;
try {
atomic.writeBytesExclusive(dest, bytes);
} catch (error) {
if (!error || error.code !== 'EEXIST' || !fs.existsSync(temp)) throw error;
fs.unlinkSync(temp);
atomic.writeBytesExclusive(dest, bytes);
}
}
function put(buffer, mimeType) {
if (String(mimeType == null ? '' : mimeType).toLowerCase() !== MIME_TYPE) {
throw new Error('会话图像仅支持 JPEG');
}
let bytes = null;
if (Buffer.isBuffer(buffer)) bytes = buffer;
else if (buffer instanceof Uint8Array) bytes = Buffer.from(buffer);
if (!bytes || !bytes.length) throw new Error('会话图像数据为空');
if (bytes.length > MAX_IMAGE_BYTES) throw new Error('会话图像超过 3 MB');
verifyJpeg(bytes);
const imageId = `img_${crypto.createHash('sha256').update(bytes).digest('hex')}`;
const dest = fileOf(imageId);
if (!fs.existsSync(dest)) {
if (totalBytes() + bytes.length > MAX_TOTAL_BYTES) throw new Error('会话图像总量已达上限');
writeBlob(dest, bytes);
}
return { imageId, bytes: bytes.length };
}
function read(imageId) {
const file = fileOf(imageId);
let stat = null;
try {
stat = fs.statSync(file);
} catch (error) {
throw new Error('会话图像不存在');
}
if (!stat.isFile() || stat.size <= 0 || stat.size > MAX_IMAGE_BYTES) {
throw new Error('会话图像为空或超过 3 MB');
}
const bytes = fs.readFileSync(file);
verifyJpeg(bytes);
return bytes;
}
function dataUrl(imageId) {
return `data:${MIME_TYPE};base64,${read(imageId).toString('base64')}`;
}
function listBlobs() {
let names = [];
try {
names = fs.readdirSync(directory());
} catch (error) {
if (error && error.code === 'ENOENT') return [];
throw error;
}
const blobs = [];
for (const name of names) {
const match = /^(img_[a-f0-9]{64})\.jpg$/.exec(name);
if (!match) continue;
const file = path.join(directory(), name);
let stat = null;
try { stat = fs.statSync(file); } catch (error) { continue; }
if (!stat.isFile()) continue;
blobs.push({ imageId: match[1], file, size: stat.size, mtimeMs: stat.mtimeMs });
}
return blobs;
}
// 图像先落盘、再被会话引用,中间存在窗口期。
// 宽限期内的新文件一律不删,否则一次并发的清理就能抹掉正在提交的图像。
function cleanup(referencedIds, options) {
const opts = options && typeof options === 'object' ? options : {};
const graceMs = Number.isFinite(Number(opts.graceMs)) && Number(opts.graceMs) >= 0
? Number(opts.graceMs)
: GRACE_MS;
const keep = new Set();
for (const id of Array.from(referencedIds || [])) {
const text = String(id == null ? '' : id);
if (ASSET_RE.test(text)) keep.add(text);
}
const now = Date.now();
let removed = 0;
for (const blob of listBlobs()) {
if (keep.has(blob.imageId)) continue;
if (now - blob.mtimeMs < graceMs) continue;
try {
fs.unlinkSync(blob.file);
removed++;
} catch (error) { /* 单个失败不影响其余回收 */ }
}
return removed;
}
function totalBytes() {
let total = 0;
for (const blob of listBlobs()) total += blob.size;
return total;
}
module.exports = {
init,
safeImageId,
put,
read,
dataUrl,
cleanup,
totalBytes,
MIME_TYPE,
MAX_IMAGE_BYTES,
MAX_TOTAL_BYTES,
GRACE_MS
};
+999
View File
@@ -0,0 +1,999 @@
// AI 多轮对话持久化:每个会话一个文件,index.json 只是可重建的派生缓存。
//
// 不并入 reader.json:那边每次写入都要 clone 整库快照并重写整个文件,
// 逐轮追加的对话会把最贵的数据放进最热的写路径。
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const atomic = require('../atomic-file');
const images = require('./ai-images');
const VERSION = 1;
const GLOBAL_ENTRY_ID = 'system:global-chat';
const LIMITS = {
sessionId: 160,
title: 200,
messageText: 20000,
question: 4000,
contextText: 12000,
contextHash: 32,
errorText: 500,
documentKey: 500,
locatorJson: 50000,
messagesPerSession: 200,
sessionsTotal: 100,
sessionFileBytes: 4 * 1024 * 1024,
imagesPerSession: 8,
imageBytes: 3 * 1024 * 1024,
imageTotalBytes: 256 * 1024 * 1024
};
const SCOPES = new Set(['selection', 'page', 'document', 'page-image', 'region-image']);
const TASKS = new Set(['ask', 'translate', 'explain', 'summarize']);
const INDEX_NAME = 'index.json';
const DOC_CACHE_SIZE = 4;
const PENDING_LIMIT = 16;
const TITLE_CHARS = 40;
const MID_MARK = '\n[……中间内容已省略……]\n';
// 会话 ID 会被拼进文件名,所以比 store.js 的 isSafeId 更严:
// 不允许 '.' 与 ':',前者能拼出 .bak 之类的兄弟文件名,后者在 Windows 上会被当成 NTFS 数据流。
const SESSION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
let rootDir = null;
let indexCache = null;
let docCache = new Map();
let pending = new Map();
function init(userDataDir) {
rootDir = path.join(userDataDir, 'reader-ai-sessions');
indexCache = null;
docCache = new Map();
pending = new Map();
}
function directory() {
if (rootDir) return rootDir;
const home = process.env.APPDATA || process.env.HOME || process.cwd();
return path.join(home, 'PeopleLib', 'reader-ai-sessions');
}
function indexFile() {
return path.join(directory(), INDEX_NAME);
}
function isObject(value) {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function clone(value) {
return value == null ? value : JSON.parse(JSON.stringify(value));
}
function newId(prefix) {
return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
}
function isReservedKey(value) {
return value === '__proto__' || value === 'prototype' || value === 'constructor';
}
function isSafeEntryId(value) {
return typeof value === 'string'
&& value.length > 0
&& value.length <= LIMITS.sessionId
&& /^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(value)
&& value !== '.'
&& value !== '..'
&& !isReservedKey(value);
}
function normalizeEntryId(value) {
if (value == null || value === '') return GLOBAL_ENTRY_ID;
const id = String(value);
if (!isSafeEntryId(id)) throw new Error('会话条目 ID 无效');
return id;
}
function safeSessionId(value) {
const id = String(value == null ? '' : value);
if (!SESSION_ID_RE.test(id) || id.length > LIMITS.sessionId || isReservedKey(id)) {
throw new Error('会话 ID 无效');
}
return id;
}
function safeMessageId(value) {
const id = String(value == null ? '' : value);
if (!SESSION_ID_RE.test(id) || id.length > LIMITS.sessionId || isReservedKey(id)) {
throw new Error('会话消息 ID 无效');
}
return id;
}
function limitedString(value, max) {
return String(value == null ? '' : value).slice(0, max);
}
function nullableString(value, max, label) {
if (value == null || value === '') return null;
const result = String(value);
if (/[\u0000-\u001f]/.test(result)) throw new Error(`${label}无效`);
return result.slice(0, max);
}
function count(value, max) {
const n = Number(value);
if (!Number.isFinite(n) || n <= 0) return 0;
return Math.min(Math.floor(n), max);
}
function clampInt(value, min, max, fallback) {
const n = Number(value);
if (!Number.isFinite(n)) return fallback;
return Math.min(max, Math.max(min, Math.floor(n)));
}
function timestamp(value, fallback) {
const n = Number(value);
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : fallback;
}
function jsonValue(value, label) {
if (value == null) return null;
let encoded;
try {
encoded = JSON.stringify(value);
} catch (e) {
throw new Error(`${label}必须可序列化`);
}
if (encoded === undefined || encoded.length > LIMITS.locatorJson) {
throw new Error(`${label}无效或过大`);
}
return JSON.parse(encoded);
}
function normalizeDocumentKey(value) {
const key = nullableString(value, LIMITS.documentKey, '文档标识');
if (key && isReservedKey(key)) throw new Error('文档标识无效');
return key;
}
function hashContext(text) {
return crypto.createHash('sha256')
.update(String(text == null ? '' : text), 'utf8')
.digest('hex')
.slice(0, LIMITS.contextHash);
}
function normalizeHash(value) {
if (value == null || value === '') return '';
const text = String(value);
if (!/^[0-9a-fA-F]+$/.test(text)) throw new Error('会话上下文摘要无效');
return text.toLowerCase().slice(0, LIMITS.contextHash);
}
function normalizeTask(value, lenient) {
if (value == null || value === '') return null;
const task = String(value);
if (!TASKS.has(task)) {
if (lenient) return null;
throw new Error('会话任务类型无效');
}
return task;
}
function normalizeImages(raw, lenient) {
if (raw == null) return [];
if (!Array.isArray(raw)) {
if (lenient) return [];
throw new Error('会话图像列表格式无效');
}
const result = [];
const seen = new Set();
for (const item of raw) {
try {
const value = isObject(item) ? item : {};
const imageId = images.safeImageId(value.imageId);
const mimeType = String(value.mimeType == null || value.mimeType === ''
? images.MIME_TYPE
: value.mimeType).toLowerCase();
if (mimeType !== images.MIME_TYPE) throw new Error('会话图像仅支持 JPEG');
const bytes = count(value.bytes, LIMITS.imageBytes + 1);
if (bytes > LIMITS.imageBytes) throw new Error('会话图像超过 3 MB');
if (seen.has(imageId)) continue;
seen.add(imageId);
result.push({
imageId,
mimeType,
width: count(value.width, 100000),
height: count(value.height, 100000),
bytes,
ocrIncluded: value.ocrIncluded === true
});
} catch (error) {
if (!lenient) throw error;
}
}
if (result.length > LIMITS.imagesPerSession) {
if (!lenient) throw new Error('单条消息图像过多');
result.length = LIMITS.imagesPerSession;
}
return result;
}
function normalizeContextRef(raw, lenient) {
if (raw == null) return null;
if (!isObject(raw)) {
if (lenient) return null;
throw new Error('会话上下文格式无效');
}
try {
const scope = String(raw.scope == null ? '' : raw.scope);
if (!SCOPES.has(scope)) throw new Error('会话上下文范围无效');
const source = raw.hash == null && raw.text != null ? String(raw.text) : null;
return {
scope,
chars: source != null && raw.chars == null ? source.length : count(raw.chars, 1e9),
hash: source != null ? hashContext(source) : normalizeHash(raw.hash),
clipped: raw.clipped === true,
locator: jsonValue(raw.locator, '定位信息'),
documentKey: normalizeDocumentKey(raw.documentKey),
fileIndex: raw.fileIndex == null ? null : count(raw.fileIndex, 100000)
};
} catch (error) {
if (lenient) return null;
throw error;
}
}
function buildMessage(raw, role, lenient) {
const value = isObject(raw) ? raw : {};
return {
id: newId('msg'),
role,
text: limitedString(value.text, role === 'user' ? LIMITS.question : LIMITS.messageText),
task: normalizeTask(value.task, lenient),
contextRef: role === 'user' ? normalizeContextRef(value.contextRef, lenient) : null,
images: normalizeImages(value.images, lenient),
tokensEstimate: count(value.tokensEstimate, 1e9),
truncated: value.truncated === true,
cancelled: role === 'assistant' && value.cancelled === true,
error: role === 'assistant'
? (lenient
? limitedString(value.error, LIMITS.errorText) || null
: nullableString(value.error, LIMITS.errorText, '会话错误信息'))
: null,
createdAt: Date.now()
};
}
function messageFromDisk(raw) {
const value = isObject(raw) ? raw : {};
const role = value.role === 'assistant' ? 'assistant' : (value.role === 'user' ? 'user' : null);
if (!role) return null;
const message = buildMessage(value, role, true);
let id = null;
try { id = safeMessageId(value.id); } catch (error) { id = null; }
message.id = id || message.id;
message.createdAt = timestamp(value.createdAt, message.createdAt);
return message;
}
function emptySession(id, entryId) {
const now = Date.now();
return {
version: VERSION,
id,
title: '',
entryId: entryId || GLOBAL_ENTRY_ID,
documentKey: null,
pinned: false,
droppedMessages: 0,
createdAt: now,
updatedAt: now,
messages: []
};
}
// 会话文件内容部分来自模型输出,读回时一律重新过一遍规范化,坏消息直接丢弃而不是抛错
function normalizeSession(raw, id) {
if (!isObject(raw)) throw new Error('会话文件结构无效');
const doc = emptySession(id, null);
let entryId = GLOBAL_ENTRY_ID;
try { entryId = normalizeEntryId(raw.entryId); } catch (error) { entryId = GLOBAL_ENTRY_ID; }
doc.entryId = entryId;
doc.title = limitedString(raw.title, LIMITS.title);
try { doc.documentKey = normalizeDocumentKey(raw.documentKey); } catch (error) { doc.documentKey = null; }
doc.pinned = raw.pinned === true;
doc.droppedMessages = count(raw.droppedMessages, 1e9);
doc.createdAt = timestamp(raw.createdAt, doc.createdAt);
doc.updatedAt = timestamp(raw.updatedAt, doc.createdAt);
const list = Array.isArray(raw.messages) ? raw.messages : [];
for (const item of list) {
const message = messageFromDisk(item);
if (message) doc.messages.push(message);
}
return doc;
}
function fileOf(sessionId) {
return path.join(directory(), `${safeSessionId(sessionId)}.json`);
}
function fileBytes(sessionId) {
try {
return fs.statSync(fileOf(sessionId)).size;
} catch (error) {
return 0;
}
}
function cacheGet(id, hash) {
const entry = docCache.get(id);
if (!entry || entry.hash !== hash) return null;
docCache.delete(id);
docCache.set(id, entry);
return entry.doc;
}
// 缓存按内容哈希失效而不是 mtime + size:时间戳粒度粗,
// 同一毫秒内的两次改写会得到相同的 mtime 与体积,按 stat 判定就会返回旧内容
function cacheSet(id, hash, doc) {
docCache.delete(id);
docCache.set(id, { hash, doc });
while (docCache.size > DOC_CACHE_SIZE) {
docCache.delete(docCache.keys().next().value);
}
}
function hashText(text) {
return crypto.createHash('sha256').update(text, 'utf8').digest('hex');
}
function parseSessionText(text, id) {
const doc = normalizeSession(JSON.parse(text), id);
doc.id = id;
return doc;
}
function readSessionFile(file, id) {
const text = fs.readFileSync(file, 'utf8');
if (Buffer.byteLength(text, 'utf8') > LIMITS.sessionFileBytes * 2) {
throw new Error('会话文件过大');
}
const hash = hashText(text);
const cached = cacheGet(id, hash);
if (cached) return cached;
const doc = parseSessionText(text, id);
cacheSet(id, hash, doc);
return doc;
}
function readDoc(sessionId) {
const id = safeSessionId(sessionId);
const file = fileOf(id);
const backup = `${file}.bak`;
if (!fs.existsSync(file)) {
if (!fs.existsSync(backup)) return null;
try { fs.renameSync(backup, file); } catch (error) { return null; }
}
try {
return readSessionFile(file, id);
} catch (error) {
docCache.delete(id);
if (fs.existsSync(backup)) {
try {
const recovered = parseSessionText(fs.readFileSync(backup, 'utf8'), id);
try { fs.renameSync(file, `${file}.corrupt-${Date.now()}`); } catch (renameError) { /* ignore */ }
fs.copyFileSync(backup, file);
cacheSet(id, hashText(fs.readFileSync(file, 'utf8')), recovered);
return recovered;
} catch (backupError) { /* 下面隔离损坏文件 */ }
}
try { fs.renameSync(file, `${file}.corrupt-${Date.now()}`); } catch (renameError) { /* ignore */ }
const row = indexRow(id);
const doc = emptySession(id, row ? row.entryId : null);
if (row) doc.title = row.title;
writeDoc(doc);
return doc;
}
}
function requireDoc(sessionId) {
const doc = readDoc(sessionId);
if (!doc) throw new Error('会话不存在');
return doc;
}
function writeDoc(doc) {
const file = fileOf(doc.id);
let encoded = JSON.stringify(doc, null, 2);
while (Buffer.byteLength(encoded, 'utf8') > LIMITS.sessionFileBytes) {
if (!dropOldestPair(doc)) throw new Error('会话内容过大');
encoded = JSON.stringify(doc, null, 2);
}
atomic.writeJson(file, doc);
cacheSet(doc.id, hashText(encoded), doc);
putIndexRow(doc, Buffer.byteLength(encoded, 'utf8'));
}
function emptyIndex() {
return { version: VERSION, sessions: [] };
}
function normalizeIndexRow(raw) {
if (!isObject(raw)) return null;
let id;
let entryId;
try {
id = safeSessionId(raw.id);
entryId = normalizeEntryId(raw.entryId);
} catch (error) {
return null;
}
return {
id,
title: limitedString(raw.title, LIMITS.title),
entryId,
messageCount: count(raw.messageCount, LIMITS.messagesPerSession),
updatedAt: timestamp(raw.updatedAt, 0),
bytes: count(raw.bytes, Number.MAX_SAFE_INTEGER),
pinned: raw.pinned === true
};
}
function normalizeIndex(raw) {
if (!isObject(raw) || !Array.isArray(raw.sessions)) return null;
const sessions = [];
const seen = new Set();
for (const item of raw.sessions) {
const row = normalizeIndexRow(item);
if (!row || seen.has(row.id)) continue;
seen.add(row.id);
sessions.push(row);
}
return { version: VERSION, sessions };
}
function rowOf(doc, bytes) {
return {
id: doc.id,
title: doc.title,
entryId: doc.entryId,
messageCount: doc.messages.length,
updatedAt: doc.updatedAt,
bytes: bytes || 0,
pinned: !!doc.pinned
};
}
function saveIndex() {
atomic.writeJson(indexFile(), indexCache);
}
function loadIndex() {
if (indexCache) return indexCache;
const file = indexFile();
const backup = `${file}.bak`;
try {
if (!fs.existsSync(file) && fs.existsSync(backup)) fs.renameSync(backup, file);
const parsed = normalizeIndex(JSON.parse(fs.readFileSync(file, 'utf8')));
if (!parsed) throw new Error('会话索引结构无效');
indexCache = parsed;
return indexCache;
} catch (error) {
return rebuildIndex();
}
}
function indexRow(sessionId) {
const rows = loadIndex().sessions;
return rows.find((row) => row.id === sessionId) || null;
}
function putIndexRow(doc, bytes) {
const idx = loadIndex();
const row = rowOf(doc, bytes);
const at = idx.sessions.findIndex((item) => item.id === doc.id);
if (at < 0) idx.sessions.push(row);
else idx.sessions[at] = row;
saveIndex();
}
function dropIndexRow(sessionId) {
const idx = loadIndex();
const at = idx.sessions.findIndex((item) => item.id === sessionId);
if (at < 0) return false;
idx.sessions.splice(at, 1);
saveIndex();
return true;
}
function sessionFiles() {
let names = [];
try {
names = fs.readdirSync(directory());
} catch (error) {
if (error && error.code === 'ENOENT') return [];
throw error;
}
const found = [];
for (const name of names) {
if (name === INDEX_NAME || !name.endsWith('.json')) continue;
const id = name.slice(0, -5);
if (!SESSION_ID_RE.test(id) || id.length > LIMITS.sessionId || isReservedKey(id)) continue;
const file = path.join(directory(), name);
let stat = null;
try { stat = fs.statSync(file); } catch (error) { continue; }
if (!stat.isFile()) continue;
found.push({ id, file, bytes: stat.size });
}
return found;
}
// 扫目录而不是读索引:索引是派生缓存,损坏时若按索引对账就会漏掉真实存在的会话
function scanSessions(includeBackups) {
const result = [];
for (const item of sessionFiles()) {
let doc = null;
try {
doc = readSessionFile(item.file, item.id);
} catch (error) {
doc = null;
}
if (doc) {
result.push({ id: item.id, bytes: item.bytes, doc });
if (!includeBackups) continue;
}
if (!includeBackups) continue;
const backup = `${item.file}.bak`;
if (!fs.existsSync(backup)) continue;
try {
result.push({ id: item.id, bytes: item.bytes, doc: parseSessionText(fs.readFileSync(backup, 'utf8'), item.id) });
} catch (error) { /* 备份也坏了就没有更多引用可救 */ }
}
return result;
}
function rebuildIndex() {
const sessions = [];
for (const item of scanSessions(false)) sessions.push(rowOf(item.doc, item.bytes));
sessions.sort((a, b) => b.updatedAt - a.updatedAt || a.id.localeCompare(b.id));
indexCache = { version: VERSION, sessions };
if (fs.existsSync(directory())) {
try { saveIndex(); } catch (error) { /* 内存索引仍可用,下次再落盘 */ }
}
return indexCache;
}
function metaOf(doc, bytes) {
return {
id: doc.id,
title: doc.title,
entryId: doc.entryId,
documentKey: doc.documentKey,
pinned: !!doc.pinned,
droppedMessages: doc.droppedMessages,
messageCount: doc.messages.length,
bytes: bytes || 0,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt
};
}
function metaOfSession(sessionId) {
const doc = requireDoc(sessionId);
return metaOf(doc, fileBytes(doc.id));
}
function mutate(sessionId, fn) {
const id = safeSessionId(sessionId);
const doc = requireDoc(id);
let result;
try {
result = fn(doc);
} catch (error) {
docCache.delete(id);
throw error;
}
try {
writeDoc(doc);
} catch (error) {
docCache.delete(id);
throw error;
}
return result;
}
// 首轮承载正文,永不丢;其余成对丢弃,
// 否则会留下没有提问的孤立回答或连续两条 user,两者都会被 Anthropic 的 /messages 拒绝
function dropOldestPair(doc) {
const start = doc.messages[1] && doc.messages[1].role === 'assistant' ? 2 : 1;
if (doc.messages.length <= start) return false;
const n = doc.messages[start].role === 'user'
&& doc.messages[start + 1]
&& doc.messages[start + 1].role === 'assistant'
? 2
: 1;
doc.messages.splice(start, n);
doc.droppedMessages += n;
return true;
}
function countImages(doc) {
let total = 0;
for (const message of doc.messages) total += message.images.length;
return total;
}
function enforceLimits(doc) {
while (doc.messages.length > LIMITS.messagesPerSession) {
if (!dropOldestPair(doc)) break;
}
let total = countImages(doc);
while (total > LIMITS.imagesPerSession) {
const victim = doc.messages.find((message) => message.images.length > 0);
if (!victim) break;
total -= victim.images.length;
victim.images = [];
}
}
function normalizeTitle(value) {
return String(value == null ? '' : value)
.replace(/[\u0000-\u001f\s]+/g, ' ')
.trim()
.slice(0, LIMITS.title);
}
function autoTitle(text) {
return normalizeTitle(text).slice(0, TITLE_CHARS);
}
function list(filters) {
const value = isObject(filters) ? filters : {};
let rows = loadIndex().sessions;
if (value.entryId != null && value.entryId !== '') {
const entryId = normalizeEntryId(value.entryId);
rows = rows.filter((row) => row.entryId === entryId);
}
return clone(rows).sort((a, b) => (
(Number(b.pinned) - Number(a.pinned))
|| (b.updatedAt - a.updatedAt)
|| a.id.localeCompare(b.id)
));
}
function create(input) {
const value = isObject(input) ? input : {};
const entryId = normalizeEntryId(value.entryId);
const title = normalizeTitle(value.title);
const documentKey = normalizeDocumentKey(value.documentKey);
if (loadIndex().sessions.length >= LIMITS.sessionsTotal) {
throw new Error('会话数量已达上限,请先删除旧会话');
}
let id = newId('chat');
for (let attempt = 0; attempt < 5 && fs.existsSync(fileOf(id)); attempt++) id = newId('chat');
if (fs.existsSync(fileOf(id))) throw new Error('会话创建失败,请重试');
const doc = emptySession(id, entryId);
doc.title = title;
doc.documentKey = documentKey;
doc.pinned = value.pinned === true;
writeDoc(doc);
return metaOf(doc, fileBytes(id));
}
function rename(sessionId, title) {
mutate(sessionId, (doc) => {
doc.title = normalizeTitle(title);
doc.updatedAt = Date.now();
return true;
});
return metaOfSession(sessionId);
}
function setPinned(sessionId, pinned) {
mutate(sessionId, (doc) => {
doc.pinned = pinned === true;
doc.updatedAt = Date.now();
return true;
});
return metaOfSession(sessionId);
}
function remove(sessionId) {
const id = safeSessionId(sessionId);
const file = fileOf(id);
docCache.delete(id);
for (const [key, item] of Array.from(pending)) {
if (item.sessionId === id) pending.delete(key);
}
let targets = [file, `${file}.tmp`, `${file}.bak`];
try {
const prefix = `${path.basename(file)}.corrupt-`;
targets = targets.concat(
fs.readdirSync(directory())
.filter((name) => name.startsWith(prefix))
.map((name) => path.join(directory(), name))
);
} catch (error) { /* 目录尚不存在 */ }
let removed = false;
for (const target of targets) {
try {
if (fs.existsSync(target)) {
fs.unlinkSync(target);
removed = true;
}
} catch (error) {
if (target === file) throw error;
}
}
if (dropIndexRow(id)) removed = true;
return removed;
}
function clear(sessionId) {
mutate(sessionId, (doc) => {
doc.messages = [];
doc.droppedMessages = 0;
doc.updatedAt = Date.now();
return true;
});
for (const [key, item] of Array.from(pending)) {
if (item.sessionId === safeSessionId(sessionId)) pending.delete(key);
}
return metaOfSession(sessionId);
}
function messages(sessionId, options) {
const opts = isObject(options) ? options : {};
const id = safeSessionId(sessionId);
const doc = requireDoc(id);
const limit = clampInt(opts.limit, 1, LIMITS.messagesPerSession, LIMITS.messagesPerSession);
let end = doc.messages.length;
if (opts.before != null && opts.before !== '') {
const cursor = safeMessageId(opts.before);
const at = doc.messages.findIndex((message) => message.id === cursor);
if (at < 0) throw new Error('会话消息不存在');
end = at;
}
const start = Math.max(0, end - limit);
return {
meta: metaOf(doc, fileBytes(id)),
messages: clone(doc.messages.slice(start, end)),
hasMore: start > 0
};
}
function appendUser(sessionId, input) {
const value = isObject(input) ? input : {};
const message = buildMessage(value, 'user', false);
const stored = mutate(sessionId, (doc) => {
doc.messages.push(message);
if (!doc.title) doc.title = autoTitle(message.text);
if (!doc.documentKey && message.contextRef && message.contextRef.documentKey) {
doc.documentKey = message.contextRef.documentKey;
}
enforceLimits(doc);
doc.updatedAt = Date.now();
return message;
});
return clone(stored);
}
// 占位回答只留在内存里:流式过程中每个增量都落盘会把一轮对话放大成上百次整文件重写,
// 而空回答本身没有保存价值,进程意外退出丢掉它不损失用户数据。
function appendAssistant(sessionId, input) {
const id = safeSessionId(sessionId);
requireDoc(id);
const message = buildMessage(isObject(input) ? input : {}, 'assistant', false);
for (const [key, item] of Array.from(pending)) {
if (item.sessionId === id) pending.delete(key);
}
while (pending.size >= PENDING_LIMIT) pending.delete(pending.keys().next().value);
pending.set(message.id, { sessionId: id, message });
return clone(message);
}
function finishAssistant(sessionId, messageId, patch) {
const id = safeSessionId(sessionId);
const msgId = safeMessageId(messageId);
const value = isObject(patch) ? patch : {};
const held = pending.get(msgId);
if (held && held.sessionId !== id) throw new Error('会话消息不存在');
const stored = mutate(id, (doc) => {
const message = held
? held.message
: doc.messages.find((item) => item.id === msgId && item.role === 'assistant');
if (!message) throw new Error('会话消息不存在');
message.text = limitedString(value.text == null ? message.text : value.text, LIMITS.messageText);
if (value.task !== undefined) message.task = normalizeTask(value.task, false);
if (value.images !== undefined) message.images = normalizeImages(value.images, false);
if (value.tokensEstimate !== undefined) message.tokensEstimate = count(value.tokensEstimate, 1e9);
message.cancelled = value.cancelled === true;
message.error = nullableString(value.error, LIMITS.errorText, '会话错误信息');
if (held) doc.messages.push(message);
enforceLimits(doc);
doc.updatedAt = Date.now();
return message;
});
pending.delete(msgId);
return clone(stored);
}
function clipMiddle(text, max) {
if (text.length <= max) return text;
if (max <= 0) return '';
if (max <= MID_MARK.length + 4) return text.slice(text.length - max);
const head = Math.ceil((max - MID_MARK.length) / 2);
const tail = max - MID_MARK.length - head;
return `${text.slice(0, head)}${MID_MARK}${text.slice(text.length - tail)}`;
}
function clipMessage(message, max) {
if (message.text.length <= max) return message;
message.text = clipMiddle(message.text, max);
message.truncated = true;
return message;
}
function normalizeBudget(budget) {
const value = isObject(budget) ? budget : {};
return {
maxChars: clampInt(value.maxChars, 50, 4000000, LIMITS.contextText),
maxMessages: clampInt(value.maxMessages, 1, LIMITS.messagesPerSession, 20),
maxMessageChars: clampInt(value.maxMessageChars, 50, LIMITS.messageText, LIMITS.question)
};
}
// Anthropic 的 /messages 要求首条是 user 且不允许连续同角色,
// 所以合并同角色、丢掉领头的 assistant 都不是可选优化,缺一条就是 400。
function mergeSameRole(kept) {
const merged = [];
for (const message of kept) {
const last = merged[merged.length - 1];
if (!last || last.role !== message.role) {
merged.push(message);
continue;
}
last.text = last.text && message.text ? `${last.text}\n\n${message.text}` : `${last.text}${message.text}`;
last.images = last.images.concat(message.images).slice(0, LIMITS.imagesPerSession);
last.truncated = last.truncated || message.truncated;
last.cancelled = message.cancelled;
last.error = message.error || last.error;
last.tokensEstimate = last.tokensEstimate + message.tokensEstimate;
}
return merged;
}
function enforceTotal(kept, maxChars) {
let total = kept.reduce((sum, message) => sum + message.text.length, 0);
for (const message of kept) {
if (total <= maxChars) break;
if (!message.text.length) continue;
const target = Math.max(0, message.text.length - (total - maxChars));
const next = clipMiddle(message.text, target);
total -= message.text.length - next.length;
message.text = next;
message.truncated = true;
}
}
function historyFor(sessionId, budget) {
const doc = requireDoc(sessionId);
const limits = normalizeBudget(budget);
const all = clone(doc.messages);
const carried = doc.droppedMessages;
if (!all.length) return { messages: [], dropped: carried };
const keep = new Set();
let used = 0;
let slots = limits.maxMessages;
const pinIndex = all.findIndex((message) => message.role === 'user');
if (pinIndex >= 0) {
all[pinIndex] = clipMessage(all[pinIndex], limits.maxMessageChars);
keep.add(pinIndex);
used += all[pinIndex].text.length;
slots -= 1;
}
for (let i = all.length - 1; i >= 0 && slots > 0; i--) {
if (keep.has(i)) continue;
const message = clipMessage(all[i], limits.maxMessageChars);
if (used + message.text.length > limits.maxChars) break;
keep.add(i);
used += message.text.length;
slots -= 1;
}
let kept = all.filter((message, index) => keep.has(index));
let dropped = all.length - kept.length;
while (kept.length && kept[0].role === 'assistant') {
kept.shift();
dropped++;
}
kept = mergeSameRole(kept);
const total = dropped + carried;
const mark = total > 0 && kept.length
? `[……已省略较早的 ${Math.max(1, Math.ceil(total / 2))} 轮对话……]\n`
: '';
enforceTotal(kept, Math.max(0, limits.maxChars - mark.length));
if (mark) {
kept[0].text = `${mark}${kept[0].text}`;
kept[0].truncated = true;
}
return { messages: kept, dropped: total };
}
// GC 的 keep 集合来自这里,因此必须扫全部会话文件(含 .bak):
// 只读索引的话,索引损坏时正在被引用的图会被当成垃圾删掉,属于静默数据丢失。
function imageIds() {
const ids = new Set();
for (const item of scanSessions(true)) {
for (const message of item.doc.messages) {
for (const image of message.images) ids.add(image.imageId);
}
}
for (const item of pending.values()) {
for (const image of item.message.images) ids.add(image.imageId);
}
return Array.from(ids);
}
function orphanReport(knownIds) {
const known = new Set((Array.isArray(knownIds) ? knownIds : []).map((id) => String(id)));
const orphans = [];
for (const item of scanSessions(false)) {
const doc = item.doc;
if (doc.entryId === GLOBAL_ENTRY_ID) continue;
if (known.has(doc.entryId)) continue;
orphans.push({
sessionId: doc.id,
entryId: doc.entryId,
title: doc.title,
messageCount: doc.messages.length,
bytes: item.bytes
});
}
orphans.sort((a, b) => b.bytes - a.bytes || a.sessionId.localeCompare(b.sessionId));
return orphans;
}
function forgetMany(entryIds) {
const ids = new Set();
for (const value of Array.isArray(entryIds) ? entryIds : []) {
const id = normalizeEntryId(value);
if (id === GLOBAL_ENTRY_ID) continue;
ids.add(id);
}
if (!ids.size) return 0;
let removed = 0;
for (const item of scanSessions(false)) {
if (!ids.has(item.doc.entryId)) continue;
try {
if (remove(item.doc.id)) removed++;
} catch (error) { /* 单个失败不影响其余回收 */ }
}
return removed;
}
module.exports = {
init,
list,
create,
rename,
setPinned,
remove,
clear,
messages,
appendUser,
appendAssistant,
finishAssistant,
historyFor,
imageIds,
orphanReport,
forgetMany,
rebuildIndex,
hashContext,
GLOBAL_ENTRY_ID,
LIMITS,
VERSION
};
+92 -23
View File
@@ -1,6 +1,7 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const atomic = require('../atomic-file');
const MAX_PAGE_BYTES = 2 * 1024 * 1024;
const MAX_OBJECTS = 5000;
@@ -11,10 +12,12 @@ const DOCUMENT_SAMPLE_BYTES = 4 * 1024 * 1024;
let rootDir = null;
let documentKeys = new Map();
let countCache = new Map();
function init(userDataDir) {
rootDir = path.join(userDataDir, 'reader-annotations');
documentKeys = new Map();
countCache = new Map();
}
function directory() {
@@ -136,29 +139,7 @@ function read(entryId) {
}
function write(entryId, data) {
const dest = fileOf(entryId);
const temp = `${dest}.tmp`;
const backup = `${dest}.bak`;
let backedUp = false;
fs.mkdirSync(directory(), { recursive: true });
try {
fs.writeFileSync(temp, JSON.stringify(data, null, 2), 'utf8');
if (fs.existsSync(backup)) fs.unlinkSync(backup);
if (fs.existsSync(dest)) {
fs.renameSync(dest, backup);
backedUp = true;
}
fs.renameSync(temp, dest);
if (backedUp) {
try { fs.unlinkSync(backup); } catch (e) { /* 保留备份不影响使用 */ }
}
} catch (e) {
try { if (fs.existsSync(temp)) fs.unlinkSync(temp); } catch (cleanup) { /* ignore */ }
try {
if (backedUp && !fs.existsSync(dest) && fs.existsSync(backup)) fs.renameSync(backup, dest);
} catch (rollback) { /* 下次读取时恢复 */ }
throw e;
}
atomic.writeJson(fileOf(entryId), data);
}
function get(entryId, documentKey) {
@@ -205,8 +186,93 @@ function setPage(entryId, documentKey, page, pageData) {
return { page: Number(pageKey), count: clean.objects.length, updatedAt: doc.updatedAt };
}
function countObjects(data) {
let total = 0;
for (const doc of Object.values(data.documents || {})) {
if (!doc || typeof doc !== 'object' || !doc.pages || typeof doc.pages !== 'object') continue;
for (const page of Object.values(doc.pages)) {
if (page && Array.isArray(page.objects)) total += page.objects.length;
}
}
return total;
}
// 批注文件单个可达 64 MB,而书库每次刷新都要取一遍计数,
// 按 mtime + size 缓存避免重复解析未改动的文件
function getCounts() {
const counts = {};
let names = [];
try {
names = fs.readdirSync(directory()).filter((name) => name.endsWith('.json'));
} catch (e) {
return counts;
}
const seen = new Set();
for (const name of names) {
const entryId = name.slice(0, -5);
if (!/^[a-zA-Z0-9_-]{1,128}$/.test(entryId)) continue;
seen.add(entryId);
const file = path.join(directory(), name);
let stat = null;
try { stat = fs.statSync(file); } catch (e) { continue; }
const cached = countCache.get(entryId);
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
if (cached.count > 0) counts[entryId] = cached.count;
continue;
}
let count = 0;
try {
count = countObjects(parseDocument(file, entryId));
} catch (e) {
count = 0;
}
countCache.set(entryId, { mtimeMs: stat.mtimeMs, size: stat.size, count });
if (count > 0) counts[entryId] = count;
}
for (const key of Array.from(countCache.keys())) {
if (!seen.has(key)) countCache.delete(key);
}
return counts;
}
// 批注没有独立的浏览界面,条目一旦离开书库就再也看不到,
// 因此对账时连体积一起报出来,便于用户判断是否回收
function orphanReport(knownIds) {
const known = new Set((Array.isArray(knownIds) ? knownIds : []).map((id) => String(id)));
const counts = getCounts();
const orphans = [];
let names = [];
try {
names = fs.readdirSync(directory()).filter((name) => name.endsWith('.json'));
} catch (e) {
return orphans;
}
for (const name of names) {
const entryId = name.slice(0, -5);
if (!/^[a-zA-Z0-9_-]{1,128}$/.test(entryId)) continue;
if (known.has(entryId)) continue;
let size = 0;
try { size = fs.statSync(path.join(directory(), name)).size; } catch (e) { continue; }
orphans.push({ entryId, count: counts[entryId] || 0, bytes: size });
}
orphans.sort((a, b) => b.bytes - a.bytes || a.entryId.localeCompare(b.entryId));
return orphans;
}
function forgetMany(entryIds) {
const ids = Array.isArray(entryIds) ? entryIds : [];
let removed = 0;
for (const id of ids) {
try {
if (forget(id)) removed++;
} catch (e) { /* 单个失败不影响其余回收 */ }
}
return removed;
}
function forget(entryId) {
const file = fileOf(entryId);
countCache.delete(normalizeEntryId(entryId));
let removed = false;
let targets = [file, `${file}.tmp`, `${file}.bak`];
try {
@@ -236,6 +302,9 @@ module.exports = {
hashDocumentFile,
get,
setPage,
getCounts,
orphanReport,
forgetMany,
forget,
LARGE_DOCUMENT_BYTES,
DOCUMENT_SAMPLE_BYTES
+250
View File
@@ -0,0 +1,250 @@
// 笔记独立窗口的生命周期管理。
// 单窗口多标签,形态与阅读器一致:窗口只有一个,一条笔记占一个标签。
//
// 「一标签一条」是数据安全约束,不是体验优化:`reader:updateNote` 是整条覆盖、
// 无版本校验,同一条笔记开两个编辑器时后保存者会把前者的内容整块吃掉。
//
// openNotes 是主进程侧的标签集镜像,由渲染层通过 `notes:tabsChanged` 上报。
// 它同时承担授权职责(见 ownsNote),所以不能只信渲染层:新增标签一律先过
// main.js 的 findNote() 用 listNotes() 对账。
const path = require('path');
const { pathToFileURL } = require('url');
const { BrowserWindow } = require('electron');
const CLOSE_TIMEOUT = 10000;
let win = null;
// noteId -> entryId。删除对账要按 entryId 找标签,所以存的是映射不是集合。
const openNotes = new Map();
let onChanged = null;
let closeAllowed = false;
let closePending = false;
let closeTimer = null;
function alive(target) {
return !!target && !target.isDestroyed();
}
function keyOf(noteId) {
return String(noteId == null ? '' : noteId);
}
function get() {
if (alive(win)) return win;
win = null;
return null;
}
function openIds() {
if (!get()) return [];
return [...openNotes.keys()];
}
function notifyChanged() {
if (typeof onChanged === 'function') onChanged(openIds());
}
function setChangeListener(fn) {
onChanged = typeof fn === 'function' ? fn : null;
}
function pageUrl(rootDir) {
return pathToFileURL(path.join(rootDir, 'src', 'ui', 'note.html')).href;
}
function isNoteSender(wc) {
const target = get();
return !!target && !!wc && target.webContents.id === wc.id;
}
function fromWebContents(wc) {
return isNoteSender(wc) ? 'note' : null;
}
// 笔记窗口只能读自己已经打开的那些标签。放宽成「只要是笔记窗口就给」
// 会让这个通道变成遍历全部笔记的后门。
function ownsNote(wc, noteId) {
if (!isNoteSender(wc)) return false;
return openNotes.has(keyOf(noteId));
}
// 渲染层只能"收窄"标签集(上报自己关掉了哪些),不能新增。
// 允许新增等于让渲染层自己扩权:谎报持有某条笔记,随后 notes:getOne 就放行了。
// 新增只能走 open(),那条路径在 main.js 里过 findNote() 对账。
function setTabs(wc, noteIds) {
if (!isNoteSender(wc)) return false;
const claimed = new Set();
for (const item of Array.isArray(noteIds) ? noteIds : []) {
const id = keyOf(item && item.noteId != null ? item.noteId : item);
if (id) claimed.add(id);
}
let changed = false;
for (const key of [...openNotes.keys()]) {
if (claimed.has(key)) continue;
openNotes.delete(key);
changed = true;
}
if (changed) notifyChanged();
return true;
}
function sendToWindow(channel, payload) {
const target = get();
if (!target) return false;
target.webContents.send(channel, payload);
return true;
}
function create(rootDir, uiTheme) {
closeAllowed = false;
closePending = false;
win = new BrowserWindow({
width: 1080,
height: 820,
minWidth: 720,
minHeight: 520,
frame: false,
backgroundColor: '#141414',
icon: path.join(
rootDir,
'icons',
'dist',
uiTheme === 'light' ? 'book-ai-light.ico' : 'book-ai-dark.ico'
),
title: 'PeopleLib',
webPreferences: {
preload: path.join(rootDir, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
spellcheck: false
}
});
const created = win;
created.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));
created.webContents.on('will-navigate', (event, url) => {
if (!String(url).startsWith(pageUrl(rootDir))) event.preventDefault();
});
// 未保存的编辑要在窗口消失之前问用户,所以必须先拦下 close 交给渲染层。
// 渲染层卡住时靠看门狗兜底,否则窗口永远关不掉。
created.on('close', (event) => {
if (closeAllowed || !alive(created)) return;
event.preventDefault();
if (closePending) return;
closePending = true;
created.webContents.send('notes:prepareClose', null);
closeTimer = setTimeout(() => {
if (alive(created)) {
closeAllowed = true;
created.destroy();
}
}, CLOSE_TIMEOUT);
});
created.on('closed', () => {
if (win === created) win = null;
openNotes.clear();
closeAllowed = false;
closePending = false;
if (closeTimer) clearTimeout(closeTimer);
closeTimer = null;
notifyChanged();
});
return created;
}
function open(entryId, noteId, rootDir, uiTheme = 'dark') {
const key = keyOf(noteId);
const entry = String(entryId);
const existing = get();
if (existing) {
if (existing.isMinimized()) existing.restore();
existing.focus();
// 已经开着的标签由渲染层激活,不新建第二个编辑器
existing.webContents.send('notes:openTab', { entryId: entry, noteId: key });
if (!openNotes.has(key)) {
openNotes.set(key, entry);
notifyChanged();
}
return existing;
}
const created = create(rootDir, uiTheme);
openNotes.set(key, entry);
created.loadFile(path.join(rootDir, 'src', 'ui', 'note.html'), {
query: { entryId: entry, noteId: key }
});
notifyChanged();
return created;
}
// 笔记在别处被删除后标签必须自己退场,否则它下一次保存会把已删条目整条写回去
function closeFor(noteId) {
const key = keyOf(noteId);
if (!get() || !openNotes.has(key)) return false;
sendToWindow('notes:closeTab', { noteIds: [key] });
openNotes.delete(key);
notifyChanged();
return true;
}
function closeMany(noteIds) {
const keys = (Array.isArray(noteIds) ? noteIds : [])
.map((id) => keyOf(id))
.filter((id) => openNotes.has(id));
if (!get() || !keys.length) return 0;
sendToWindow('notes:closeTab', { noteIds: keys });
for (const key of keys) openNotes.delete(key);
notifyChanged();
return keys.length;
}
function closeForEntries(entryIds) {
const targets = new Set((Array.isArray(entryIds) ? entryIds : []).map((id) => String(id)));
if (!get() || !targets.size) return 0;
const keys = [];
for (const [key, entryId] of openNotes) {
if (targets.has(entryId)) keys.push(key);
}
if (!keys.length) return 0;
sendToWindow('notes:closeTab', { noteIds: keys });
for (const key of keys) openNotes.delete(key);
notifyChanged();
return keys.length;
}
// 渲染层处理完未保存提示后才真正放行关闭
function shutdownReady(wc) {
const target = get();
if (!target || !isNoteSender(wc) || !closePending) return false;
if (closeTimer) clearTimeout(closeTimer);
closeTimer = null;
closeAllowed = true;
closePending = false;
target.close();
return true;
}
// 用户在未保存提示里选了取消。必须复位 closePending,否则下一次点关闭会被
// 「已在处理中」挡掉,窗口再也关不上;也必须撤掉看门狗,否则十秒后它会把
// 带着未保存内容的窗口直接销毁。
function cancelClose(wc) {
if (!isNoteSender(wc) || !closePending) return false;
if (closeTimer) clearTimeout(closeTimer);
closeTimer = null;
closePending = false;
return true;
}
function all() {
const target = get();
return target ? [target] : [];
}
module.exports = {
open, get, all, openIds, closeFor, closeMany, closeForEntries,
fromWebContents, ownsNote, setTabs, shutdownReady, cancelClose, setChangeListener
};
+43 -19
View File
@@ -4,6 +4,7 @@
const fs = require('fs');
const path = require('path');
const atomic = require('../atomic-file');
const VERSION = 6;
const STANDALONE_ENTRY_ID = 'system:standalone-notes';
@@ -792,24 +793,7 @@ function migrate(raw) {
}
function save() {
const dest = getFilePath();
const temp = `${dest}.tmp`;
const backup = `${dest}.bak`;
let backedUp = false;
fs.mkdirSync(path.dirname(dest), { recursive: true });
try {
fs.writeFileSync(temp, JSON.stringify(cache, null, 2), 'utf8');
if (fs.existsSync(backup)) fs.unlinkSync(backup);
if (fs.existsSync(dest)) { fs.renameSync(dest, backup); backedUp = true; }
fs.renameSync(temp, dest);
if (backedUp) { try { fs.unlinkSync(backup); } catch (e) { /* ignore */ } }
} catch (e) {
try { if (fs.existsSync(temp)) fs.unlinkSync(temp); } catch (cleanup) { /* ignore */ }
try {
if (backedUp && !fs.existsSync(dest) && fs.existsSync(backup)) fs.renameSync(backup, dest);
} catch (rollback) { /* 下次 load 时恢复 */ }
throw e;
}
atomic.writeJson(getFilePath(), cache);
}
function load() {
@@ -1345,6 +1329,46 @@ function removeCollection(collectionId) {
});
}
// 与书库对账:列出书库里已不存在的条目。这些阅读资料在「我的笔记」里仍然可见,
// 属于有意保留,因此只报告不自动删除,由用户显式决定。
function orphanReport(knownIds) {
const known = new Set((Array.isArray(knownIds) ? knownIds : []).map((id) => String(id)));
const c = load();
const orphans = [];
for (const [entryId, entry] of Object.entries(c.entries)) {
if (entryId === STANDALONE_ENTRY_ID) continue;
if (known.has(entryId)) continue;
const notes = Array.isArray(entry.notes) ? entry.notes.length : 0;
const bookmarks = Array.isArray(entry.bookmarks) ? entry.bookmarks.length : 0;
const snapshot = entry.book || {};
orphans.push({
entryId,
title: snapshot.title || '',
notes,
bookmarks,
hasProgress: !!entry.progress
});
}
orphans.sort((a, b) => b.notes - a.notes || a.entryId.localeCompare(b.entryId));
return orphans;
}
function forgetMany(entryIds) {
const ids = (Array.isArray(entryIds) ? entryIds : []).map((id) => safeId(id, '条目 ID'));
if (!ids.length) return 0;
let removed = 0;
mutateCache((current) => {
for (const id of ids) {
if (id === STANDALONE_ENTRY_ID) continue;
if (!Object.prototype.hasOwnProperty.call(current.entries, id)) continue;
delete current.entries[id];
removed++;
}
return removed > 0;
});
return removed;
}
// forget 是显式删除:只清掉指定条目的阅读数据,不影响其它条目或笔记本。
function forget(value) {
const id = safeId(value, '条目 ID');
@@ -1363,5 +1387,5 @@ module.exports = {
addNote, addStandaloneNote, updateNote, removeNote, listNotes, getNoteCounts,
noteAssetIds,
listCollections, addCollection, updateCollection, removeCollection,
forget
orphanReport, forgetMany, forget
};
+2 -23
View File
@@ -2,6 +2,7 @@
const fs = require('fs');
const path = require('path');
const atomic = require('./atomic-file');
let filePath = null;
let cache = null;
@@ -29,29 +30,7 @@ function load() {
}
function save() {
const dest = getFilePath();
const temp = `${dest}.tmp`;
const backup = `${dest}.bak`;
let backedUp = false;
fs.mkdirSync(path.dirname(dest), { recursive: true });
try {
fs.writeFileSync(temp, JSON.stringify(cache, null, 2), 'utf8');
if (fs.existsSync(backup)) fs.unlinkSync(backup);
if (fs.existsSync(dest)) {
fs.renameSync(dest, backup);
backedUp = true;
}
fs.renameSync(temp, dest);
if (backedUp) {
try { fs.unlinkSync(backup); } catch (e) { /* 保留备份不影响提交 */ }
}
} catch (e) {
try { if (fs.existsSync(temp)) fs.unlinkSync(temp); } catch (e) { /* ignore */ }
try {
if (backedUp && !fs.existsSync(dest) && fs.existsSync(backup)) fs.renameSync(backup, dest);
} catch (rollbackError) { /* 下次加载时会恢复 */ }
throw e;
}
atomic.writeJson(getFilePath(), cache);
}
function get(key, def) {
+71
View File
@@ -219,6 +219,77 @@ $('semanticKeyClearBtn').onclick = async () => {
refreshSemanticKeyStatus();
let orphanFound = null;
function describeOrphans(data) {
const notes = data.notes || [];
const annotations = data.annotations || [];
if (!notes.length && !annotations.length) return '没有残留数据';
const parts = [];
if (notes.length) {
const noteTotal = notes.reduce((sum, item) => sum + item.notes, 0);
parts.push(`${notes.length} 本已移除书籍留有阅读资料(含 ${noteTotal} 条笔记)`);
}
if (annotations.length) {
const mb = (data.totalBytes || 0) / 1024 / 1024;
const size = mb >= 0.1 ? `${mb.toFixed(1)} MB` : `${Math.round((data.totalBytes || 0) / 1024)} KB`;
parts.push(`${annotations.length} 份孤立批注(约 ${size}`);
}
return parts.join('');
}
$('orphanScanBtn').onclick = async () => {
const btn = $('orphanScanBtn');
btn.disabled = true;
btn.textContent = '检查中...';
const r = await window.api.reader.orphanReport();
btn.disabled = false;
btn.textContent = '检查';
if (!r || !r.ok) {
$('orphanStatus').textContent = `检查失败:${(r && r.error) || '未知错误'}`;
return;
}
orphanFound = r.data;
$('orphanStatus').textContent = describeOrphans(r.data);
const hasAny = (r.data.notes || []).length > 0 || (r.data.annotations || []).length > 0;
$('orphanPurgeBtn').classList.toggle('hidden', !hasAny);
};
$('orphanPurgeBtn').onclick = async () => {
if (!orphanFound) return;
const notes = orphanFound.notes || [];
const annotations = orphanFound.annotations || [];
// 笔记是用户创作,删除不可撤销,必须让用户单独确认这一项
const lines = [];
if (annotations.length) lines.push(`<li>${annotations.length} 份孤立批注</li>`);
if (notes.length) {
const noteTotal = notes.reduce((sum, item) => sum + item.notes, 0);
lines.push(`<li>${notes.length} 本已移除书籍的阅读资料,含 <b>${noteTotal} 条笔记</b></li>`);
}
const choice = await openModal('清理残留阅读资料', `
<p>检查到:</p>
<ul class="library-bulk-preview">${lines.join('')}</ul>
<p style="margin-top:8px"><label><input type="checkbox" id="orphanDelAnnotations" checked /> 清理孤立批注</label></p>
${notes.length ? `<p style="margin-top:6px"><label><input type="checkbox" id="orphanDelNotes" /> 同时删除笔记、书签与进度</label></p>
<p class="muted" style="margin-top:6px">这些笔记目前仍可在「我的笔记」中查看,删除后无法恢复。</p>` : ''}
`, () => ({
annotations: !!(document.getElementById('orphanDelAnnotations') || {}).checked,
notes: !!(document.getElementById('orphanDelNotes') || {}).checked
}));
if (!choice) return;
if (!choice.annotations && !choice.notes) return;
const r = await window.api.reader.purgeOrphans(choice);
if (!r || !r.ok) {
await confirmModal('清理失败', (r && r.error) || '未知错误');
return;
}
const done = r.data || {};
$('orphanStatus').textContent =
`已清理 ${done.annotationsRemoved || 0} 份批注、${done.notesRemoved || 0} 本阅读资料`;
$('orphanPurgeBtn').classList.add('hidden');
orphanFound = null;
};
const AI_PROTOCOL_INFO = {
anthropic: {
label: 'Anthropic',
+13 -2
View File
@@ -214,6 +214,17 @@
<button id="proxySaveBtn" class="tb-btn">保存</button>
</div>
</div>
<div class="settings-group">
<div class="settings-item">
<div class="settings-item-info">
<div class="settings-item-label">残留阅读资料</div>
<div class="settings-item-desc">书籍移除时默认保留笔记、批注与进度。笔记仍可在「我的笔记」中查看,批注则没有查看入口。</div>
<div class="settings-path" id="orphanStatus">未检查</div>
</div>
<button id="orphanScanBtn" class="tb-btn">检查</button>
<button id="orphanPurgeBtn" class="tb-btn danger hidden">清理</button>
</div>
</div>
<div class="settings-group">
<div class="settings-item">
<div class="settings-item-info">
@@ -302,11 +313,11 @@
<div class="about-formats">
<div>
<span class="about-format-label">内置阅读</span>
<span>PDF、EPUB、MOBI、AZW、AZW3</span>
<span>PDF、EPUB、MOBI、AZW、AZW3、TXT、MD</span>
</div>
<div>
<span class="about-format-label">书库导入与管理</span>
<span>PDF、EPUB、MOBI、AZW、AZW3、TXT、DJVU、FB2、CBZ、CBR</span>
<span>PDF、EPUB、MOBI、AZW、AZW3、TXT、MD、DJVU、FB2、CBZ、CBR</span>
</div>
<div class="settings-item-desc">
MOBI、AZW 与 AZW3 由 Foliate 解析,支持无 DRM 的 MOBI/KF7/KF8 内容;DRM、KFX 与损坏文件可改用系统应用打开。
+265
View File
@@ -0,0 +1,265 @@
/* 笔记独立窗口。整窗给编辑区,画布不再受模态尺寸限制。 */
.note-window-body {
display: flex;
height: 100vh;
flex-direction: column;
overflow: hidden;
}
/* 标签条与阅读器同构。note.html 不加载 reader.css,所以这里要自带一份,
class 名保持一致,改样式时两处要一起改。 */
.doctabs {
height: 36px;
display: flex;
align-items: stretch;
flex-shrink: 0;
padding: 0 6px;
background: var(--bg-soft);
border-bottom: 1px solid var(--line);
}
.doctabs-list {
display: flex;
align-items: stretch;
flex: 1;
gap: 4px;
overflow-x: auto;
overflow-y: hidden;
}
.doctabs-list::-webkit-scrollbar { height: 0; }
.doctab {
display: flex;
align-items: center;
max-width: 220px;
flex-shrink: 0;
margin: 4px 0;
padding: 0 8px 0 14px;
gap: 8px;
background: transparent;
border: 1px solid transparent;
border-radius: 8px;
color: var(--text-dim);
font-size: 13px;
cursor: pointer;
}
/* --hover-bg-soft 只在 reader.css 里定义,这里必须用 style.css 有的变量,
否则 hover 背景静默失效 */
.doctab:hover { background: var(--hover-bg); color: var(--text); }
.doctab.active { background: var(--bg-card); border-color: var(--line); color: var(--text); }
.doctab-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.doctab.active .doctab-name { color: var(--accent-bright); font-weight: 600; }
.doctab-fmt {
padding: 0 5px;
flex-shrink: 0;
border: 1px solid var(--line);
border-radius: 5px;
color: var(--text-dim);
font-size: 10px;
}
/* 未保存标记:标签上必须看得见,否则关窗时才发现有改动 */
.doctab-dirty {
width: 7px;
height: 7px;
flex-shrink: 0;
border-radius: 50%;
background: var(--accent-bright);
}
.doctab-close {
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
background: transparent;
border: none;
border-radius: 5px;
color: var(--text-dim);
font-size: 11px;
cursor: pointer;
}
.doctab-close:hover { background: var(--danger); color: #fff; }
/* 每个标签一个视图,非活跃的整块隐藏。用 display:none 而不是移出 DOM
编辑器实例要留着,切回来不必重建,也不会丢未保存内容。 */
.note-tab-views {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
}
.note-tab-view {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
}
.note-tab-view.inactive { display: none; }
.note-dirty-box { width: 420px; max-width: 92vw; }
.note-dirty-notice {
margin: 0 0 4px;
color: var(--text-dim);
font-size: 13px;
line-height: 1.6;
}
.note-window {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
padding: 14px 18px 16px;
gap: 10px;
}
.note-window-loading,
.note-window-error {
padding: 24px;
color: var(--text-dim);
text-align: center;
}
.note-window-error {
color: var(--danger);
}
.note-window-form {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
gap: 10px;
}
.note-window-head {
display: flex;
align-items: center;
flex: 0 0 auto;
gap: 10px;
}
.note-window-title {
height: 34px;
padding: 0 10px;
flex: 1;
min-width: 0;
background: var(--input-bg);
color: var(--text);
border: 1px solid var(--line);
border-radius: 9px;
font-size: 14px;
}
.note-window-title:focus {
border-color: var(--accent);
outline: none;
}
.note-window-badge {
padding: 4px 10px;
flex: 0 0 auto;
background: var(--hover-strong);
color: var(--text-dim);
border-radius: 999px;
font-size: 12px;
}
/* 编辑区吃满剩余空间。min-height:0 缺一层,flex 子项就会被内容顶高、画布溢出窗口 */
.note-window-editor {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
}
.note-window-editor > .mixed-note-editor,
.note-window-editor .mixed-note-canvas,
.note-window-editor .mixed-note-text,
.note-window-editor .rich-note-editor {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
}
.note-window-editor .ql-container {
min-height: 0;
flex: 1;
}
.note-window-quote {
max-height: 88px;
margin: 0;
padding: 8px 10px;
flex: 0 0 auto;
overflow-y: auto;
background: var(--hover);
color: var(--text-dim);
border-left: 3px solid var(--accent);
border-radius: 0 8px 8px 0;
font-size: 12px;
}
.note-window-meta {
display: flex;
align-items: center;
flex: 0 0 auto;
flex-wrap: wrap;
gap: 12px;
}
.note-window-meta label {
display: flex;
align-items: center;
min-width: 0;
color: var(--text-dim);
font-size: 12px;
gap: 6px;
}
/* 笔记本名过长会把整行顶宽,须同时限宽并允许收缩到 0 */
.note-window-meta select,
.note-window-meta input[type="text"] {
height: 28px;
max-width: 260px;
min-width: 0;
text-overflow: ellipsis;
padding: 0 8px;
background: var(--input-bg);
color: var(--text);
border: 1px solid var(--line);
border-radius: 8px;
font-size: 12px;
}
.note-window-meta select:focus,
.note-window-meta input[type="text"]:focus {
border-color: var(--accent);
outline: none;
}
.note-window-tags input { width: 220px; }
.note-window-pin { cursor: pointer; }
.note-window-actions {
display: flex;
align-items: center;
flex: 0 0 auto;
gap: 12px;
}
.note-window-status {
flex: 1;
color: var(--text-dim);
font-size: 12px;
}
.note-window-status.error { color: var(--danger); }
@media (max-width: 720px) {
.note-window { padding: 10px 12px 12px; }
.note-window-tags input { width: 140px; }
}
+105
View File
@@ -0,0 +1,105 @@
<!DOCTYPE html>
<html lang="zh-CN" data-ui-theme="dark">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; worker-src 'self' blob:; script-src 'self'" />
<title>PeopleLib</title>
<link rel="stylesheet" href="style.css" />
<link rel="stylesheet" href="vendor/quill/quill.snow.css" />
<link rel="stylesheet" href="rich-note.css" />
<link rel="stylesheet" href="note-window.css" />
</head>
<body class="note-window-body">
<div class="titlebar">
<div class="titlebar-left">
<span class="brand">
<img class="brand-logo brand-logo-dark" src="../../icons/dist/dark/icon-32.png" alt="" />
<img class="brand-logo brand-logo-light" src="../../icons/dist/light/icon-32.png" alt="" />
<span>笔记</span>
</span>
</div>
<div class="titlebar-spacer"></div>
<div class="titlebar-controls">
<button id="uiThemeBtn" class="win-btn ui-theme-btn" title="切换到明亮主题" aria-label="切换到明亮主题">
<svg class="titlebar-icon ui-theme-sun" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>
</svg>
<svg class="titlebar-icon ui-theme-moon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M20 15.5A8.5 8.5 0 0 1 8.5 4 8.5 8.5 0 1 0 20 15.5Z"/>
</svg>
</button>
<button id="minBtn" class="win-btn" title="最小化">&#9472;</button>
<button id="maxBtn" class="win-btn" title="最大化">&#9633;</button>
<button id="closeBtn" class="win-btn win-close" title="关闭">&#10005;</button>
</div>
</div>
<div class="doctabs">
<div id="noteTabsList" class="doctabs-list" role="tablist"></div>
</div>
<main class="note-window">
<div id="noteWindowLoading" class="note-window-loading">正在加载笔记…</div>
<div id="noteWindowError" class="note-window-error hidden" role="alert"></div>
<div id="noteWindowEmpty" class="note-window-loading hidden">没有打开的笔记</div>
<div id="noteTabViews" class="note-tab-views"></div>
</main>
<template id="noteTabTemplate">
<form class="note-window-form" autocomplete="off">
<div class="note-window-head">
<input class="note-window-title" type="text" maxlength="300" placeholder="标题(可选)" />
<span class="note-window-badge"></span>
</div>
<div class="note-window-editor"></div>
<blockquote class="note-window-quote hidden"></blockquote>
<div class="note-window-meta">
<label>
<span>笔记本</span>
<select class="note-window-collection">
<option value="">未分类</option>
</select>
</label>
<label class="note-window-tags">
<span>标签</span>
<input class="note-window-tags-input" type="text" placeholder="用逗号分隔" />
</label>
<label class="note-window-pin">
<input class="note-window-pinned" type="checkbox" /> 置顶
</label>
</div>
<div class="note-window-actions">
<span class="note-window-status" role="status"></span>
<button class="tb-btn note-window-save" type="submit">保存笔记</button>
</div>
</form>
</template>
<div id="noteDirtyModal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="noteDirtyTitle">
<div class="modal-box note-dirty-box">
<div id="noteDirtyTitle" class="modal-title">这条笔记还没保存?</div>
<p id="noteDirtyNotice" class="note-dirty-notice">关闭后未保存的修改会丢失。</p>
<div class="modal-actions">
<button id="noteDirtyCancelBtn" class="tb-btn ghost" type="button">取消</button>
<button id="noteDirtyDiscardBtn" class="tb-btn danger" type="button">放弃修改</button>
<button id="noteDirtySaveBtn" class="tb-btn" type="button">保存并关闭</button>
</div>
</div>
</div>
<script src="vendor/jszip.min.js"></script>
<script src="vendor/quill/quill.js"></script>
<script src="vendor/jspdf.umd.min.js"></script>
<script src="vendor/purify.min.js"></script>
<script src="rich-note.js"></script>
<script src="mixed-note.js"></script>
<script type="module" src="views/note-shell.js"></script>
</body>
</html>
+34
View File
@@ -468,6 +468,15 @@ input[type="color"],
padding: 6px 8px; word-break: break-word; flex-shrink: 0;
}
.ai-status.warn { color: var(--warn-text); border-color: var(--warn-line); }
.ai-sessions { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
.ai-session-label { font-size: 11px; color: var(--text-dim); flex: none; }
.ai-session-select {
flex: 1; min-width: 0;
background: var(--bg-card); border: 1px solid var(--line); color: var(--text);
border-radius: 6px; padding: 3px 6px; font-size: 11px;
}
.ai-session-actions { display: flex; flex-wrap: wrap; gap: 5px; flex-shrink: 0; }
.ai-session-actions .tb-btn { flex: 1 1 auto; }
.ai-scope { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
.ai-scope-label { font-size: 11px; color: var(--text-dim); flex: none; }
.ai-scope-select {
@@ -511,6 +520,31 @@ input[type="color"],
.ai-output.streaming { border-color: var(--accent); }
.ai-output > :first-child { margin-top: 0; }
.ai-output > :last-child { margin-bottom: 0; }
.ai-thread { display: flex; flex-direction: column; gap: 10px; }
.ai-thread-notice {
padding: 5px 8px; border: 1px dashed var(--line); border-radius: 7px;
color: var(--text-dim); font-size: 11px; line-height: 1.6;
}
.ai-msg { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.ai-msg-meta {
display: flex; align-items: baseline; gap: 6px; flex-wrap: wrap;
color: var(--text-dim); font-size: 11px; line-height: 1.5;
}
.ai-msg-context { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ai-msg-body {
padding: 7px 10px; border: 1px solid var(--line); border-radius: 9px;
background: var(--input-bg); overflow-wrap: anywhere;
}
.ai-msg-body > :first-child { margin-top: 0; }
.ai-msg-body > :last-child { margin-bottom: 0; }
.ai-msg-body.ai-output-plain { white-space: pre-wrap; }
.ai-msg-user .ai-msg-body {
border-left: 2px solid var(--accent); background: var(--accent-faint); white-space: pre-wrap;
}
.ai-msg-assistant.ai-msg-streaming .ai-msg-body { border-color: var(--accent); }
.ai-msg-error .ai-msg-body { border-color: var(--danger); color: var(--error-text); }
.ai-msg-flags { color: var(--text-dim); font-size: 11px; line-height: 1.6; }
.ai-msg-actions { display: flex; flex-wrap: wrap; gap: 5px; }
.ai-output p,
.ai-output ul,
.ai-output ol,
+54 -3
View File
@@ -142,6 +142,17 @@
<div id="pane-ai" class="pane-body hidden ai-pane">
<div id="aiStatus" class="ai-status">正在读取模型配置…</div>
<div class="ai-sessions">
<span class="ai-session-label">会话</span>
<select id="aiSessionSelect" class="ai-session-select" title="切换当前书籍的对话会话"></select>
</div>
<div class="ai-session-actions">
<button id="aiSessionNewBtn" class="tb-btn ghost sm" title="新建一个会话,发送首个问题后才会创建">新建</button>
<button id="aiSessionRenameBtn" class="tb-btn ghost sm" title="重命名当前会话">重命名</button>
<button id="aiSessionPinBtn" class="tb-btn ghost sm" title="置顶当前会话">置顶</button>
<button id="aiSessionClearBtn" class="tb-btn ghost sm" title="清空当前会话的全部消息">清空</button>
<button id="aiSessionDeleteBtn" class="tb-btn danger sm" title="删除当前会话">删除</button>
</div>
<div class="ai-scope">
<span class="ai-scope-label">上下文</span>
<select id="aiScope" class="ai-scope-select" title="决定每次提问发送多少正文,范围越大消耗越多">
@@ -170,12 +181,12 @@
<button class="tb-btn ghost sm" data-ai-task="summarize">总结</button>
</div>
<div id="aiQuote" class="ai-quote hidden"></div>
<div id="aiOutput" class="ai-output" aria-live="polite"></div>
<div id="aiOutput" class="ai-output ai-thread" aria-live="polite"></div>
<div id="aiError" class="ai-error hidden"></div>
<div class="ai-out-actions">
<button id="aiStopBtn" class="tb-btn danger sm hidden">停止生成</button>
<button id="aiSaveBtn" class="tb-btn sm hidden">保存为笔记</button>
<button id="aiCopyBtn" class="tb-btn ghost sm hidden">复制</button>
<button id="aiSaveBtn" class="tb-btn sm hidden" title="把最后一条回答保存为笔记">保存为笔记</button>
<button id="aiCopyBtn" class="tb-btn ghost sm hidden" title="复制最后一条回答">复制</button>
</div>
<div class="ai-input">
<textarea id="aiQuestion" rows="3" maxlength="4000" placeholder="基于当前章节内容提问…"></textarea>
@@ -217,6 +228,13 @@
<option value="auto">自动</option>
</select>
</label>
<label><span>画质</span>
<select id="pdfRenderQuality" class="mini-select" title="PDF 渲染画质,越高越清晰也越占显存">
<option value="1">标准</option>
<option value="2">清晰</option>
<option value="3">极清</option>
</select>
</label>
</div>
<select id="themeSelect" class="mini-select" title="阅读主题">
<option value="light">浅色</option>
@@ -271,6 +289,39 @@
</div>
</div>
<div id="aiSessionRenameModal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="aiSessionRenameTitle">
<div class="modal-box confirm-box">
<div id="aiSessionRenameTitle" class="modal-title">重命名会话</div>
<input id="aiSessionTitleInput" class="note-editor-input" type="text" maxlength="120" placeholder="会话标题" />
<div class="modal-actions">
<button id="aiSessionRenameCancelBtn" class="tb-btn ghost">取消</button>
<button id="aiSessionRenameSaveBtn" class="tb-btn">保存标题</button>
</div>
</div>
</div>
<div id="aiSessionDeleteModal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="aiSessionDeleteTitle">
<div class="modal-box confirm-box">
<div id="aiSessionDeleteTitle" class="modal-title">删除这个会话?</div>
<p id="aiSessionDeleteNotice" class="ai-confirm-notice">删除后这个会话里的全部对话记录会从本地移除,无法恢复。</p>
<div class="modal-actions">
<button id="aiSessionDeleteCancelBtn" class="tb-btn ghost">取消</button>
<button id="aiSessionDeleteConfirmBtn" class="tb-btn danger">删除会话</button>
</div>
</div>
</div>
<div id="aiSessionClearModal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="aiSessionClearTitle">
<div class="modal-box confirm-box">
<div id="aiSessionClearTitle" class="modal-title">清空这个会话的消息?</div>
<p class="ai-confirm-notice">会话本身会保留,但里面的全部对话记录会从本地移除,无法恢复。</p>
<div class="modal-actions">
<button id="aiSessionClearCancelBtn" class="tb-btn ghost">取消</button>
<button id="aiSessionClearConfirmBtn" class="tb-btn danger">清空消息</button>
</div>
</div>
</div>
<div id="annotationClearModal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="annotationClearTitle">
<div class="modal-box confirm-box">
<div id="annotationClearTitle" class="modal-title">清空当前页批注?</div>
+73 -7
View File
@@ -20,6 +20,38 @@ const PDF_ASSET_OPTIONS = Object.freeze({
wasmUrl: new URL('../vendor/pdfjs/wasm/', import.meta.url).href
});
const MAX_CANVAS_SIDE = 16384;
const MAX_CANVAS_AREA = 268435456;
export function clampCanvasSize(width, height, quality) {
const cssWidth = Number.isFinite(Number(width)) && Number(width) > 0 ? Number(width) : 1;
const cssHeight = Number.isFinite(Number(height)) && Number(height) > 0 ? Number(height) : 1;
const wanted = Number(quality);
const want = Number.isFinite(wanted) && wanted > 0 ? wanted : 1;
const bySide = MAX_CANVAS_SIDE / Math.max(cssWidth, cssHeight);
const byArea = Math.sqrt(MAX_CANVAS_AREA / (cssWidth * cssHeight));
const applied = Math.min(want, bySide, byArea);
const canvasWidth = Math.max(1, Math.floor(cssWidth * applied));
const canvasHeight = Math.max(1, Math.floor(cssHeight * applied));
// 超出浏览器画布上限的尺寸会静默产出不可用画布(整页空白且不报错),
// 只能宁可降清晰度也要把尺寸压回上限内
return {
width: canvasWidth,
height: canvasHeight,
quality: applied,
// 取整后 backing 与 CSS 盒子的比例不再等于名义倍率,render 的 transform 必须用这两个实际比例
scaleX: canvasWidth / cssWidth,
scaleY: canvasHeight / cssHeight,
clamped: applied < want
};
}
export function clampRenderQuality(value) {
const n = Number(value);
if (!Number.isFinite(n)) return 1;
return Math.max(1, Math.min(4, n));
}
const CSS = `
.pdfx-scroller{--pdfx-page-gap:16px;--pdfx-page-padding:16px;position:absolute;inset:0;overflow:auto;background:#f3f3f3}
.pdfx-pages{display:grid;grid-template-columns:max-content;grid-auto-flow:row;grid-auto-columns:max-content;align-items:start;justify-content:safe center;gap:var(--pdfx-page-gap);min-width:100%;padding:var(--pdfx-page-padding) 0;box-sizing:border-box}
@@ -206,6 +238,8 @@ export function createPdfAdapter() {
const annotationHistory = new Map();
let scale = 1.2;
let renderQuality = 1;
let lastRenderStats = null;
let theme = 'light';
let viewMode = 'continuous';
let pageLayout = 'single';
@@ -628,16 +662,20 @@ export function createPdfAdapter() {
setBox(p, vp.width, vp.height);
const dpr = window.devicePixelRatio || 1;
p.canvas.width = Math.max(1, Math.floor(vp.width * dpr));
p.canvas.height = Math.max(1, Math.floor(vp.height * dpr));
const wanted = Math.max(dpr, clampRenderQuality(renderQuality));
const fit = clampCanvasSize(vp.width, vp.height, wanted);
p.canvas.width = fit.width;
p.canvas.height = fit.height;
lastRenderStats = { dpr, wanted, fit };
const ctx = p.canvas.getContext('2d', { alpha: false });
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, p.canvas.width, p.canvas.height);
const identity = fit.scaleX === 1 && fit.scaleY === 1;
const task = pdfPage.render({
canvasContext: ctx,
viewport: vp,
transform: dpr === 1 ? null : [dpr, 0, 0, dpr, 0, 0]
transform: identity ? null : [fit.scaleX, 0, 0, fit.scaleY, 0, 0]
});
p.task = task;
try {
@@ -1028,16 +1066,23 @@ export function createPdfAdapter() {
if (!container) throw new Error('缺少渲染容器');
const nextScale = clampScale(opts && opts.scale);
const nextTheme = (opts && opts.theme) || theme;
const nextQuality = opts && opts.renderQuality !== undefined
? clampRenderQuality(opts.renderQuality)
: renderQuality;
if (host !== container || !scroller || !scroller.isConnected) {
teardownView();
scale = nextScale;
renderQuality = nextQuality;
mount(container);
applyTheme(nextTheme);
} else {
if (nextScale !== scale) {
// 只改新渲染的页会让同屏出现清晰度不一致,倍率变化必须整篇重建
if (nextScale !== scale || nextQuality !== renderQuality) {
epoch++;
scale = nextScale;
renderQuality = nextQuality;
lastRenderStats = null;
for (const p of pages) {
recycle(p);
applyPlaceholder(p);
@@ -1196,11 +1241,14 @@ export function createPdfAdapter() {
const baseViewport = pdfPage.getViewport({ scale: 1 });
const area = normalizeCrop(crop, baseViewport.width, baseViewport.height);
// 直接渲染到最终发送尺寸:先渲染到 2048 再压回上限会多做一次重采样,反而把文字磨糊
const renderScale = Math.max(1, Math.min(4, MAX_CAPTURE_DIMENSION / Math.max(area.width, area.height)));
const wantScale = Math.max(1, Math.min(4, MAX_CAPTURE_DIMENSION / Math.max(area.width, area.height)));
// wantScale 下限是 1,大幅面页的截图画布就等于页面点尺寸,MediaBox 异常的文件仍会顶到画布上限
const fit = clampCanvasSize(area.width, area.height, wantScale);
const renderScale = fit.quality;
const viewport = pdfPage.getViewport({ scale: renderScale });
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(area.width * renderScale));
canvas.height = Math.max(1, Math.round(area.height * renderScale));
canvas.width = fit.width;
canvas.height = fit.height;
const context = canvas.getContext('2d', { alpha: false });
context.fillStyle = '#ffffff';
context.fillRect(0, 0, canvas.width, canvas.height);
@@ -1304,6 +1352,7 @@ export function createPdfAdapter() {
}
for (const p of pages) recycle(p);
pages.length = 0;
lastRenderStats = null;
if (scroller && scroller.parentNode) scroller.parentNode.removeChild(scroller);
if (host) host.textContent = '';
scroller = null;
@@ -1369,6 +1418,23 @@ export function createPdfAdapter() {
return pageLayout;
},
fitWidthScale,
renderStats() {
const dpr = (typeof window !== 'undefined' && window.devicePixelRatio) || 1;
const wanted = lastRenderStats ? lastRenderStats.wanted : Math.max(dpr, clampRenderQuality(renderQuality));
const fit = lastRenderStats
? lastRenderStats.fit
: clampCanvasSize(baseSize.width * scale, baseSize.height * scale, wanted);
return {
renderQuality: clampRenderQuality(renderQuality),
dpr: lastRenderStats ? lastRenderStats.dpr : dpr,
requestedScale: wanted,
effectiveScale: fit.quality,
clamped: fit.clamped,
canvasWidth: fit.width,
canvasHeight: fit.height,
pages: pageCount
};
},
setAnnotations,
setAnnotationTool,
setAnnotationStyle,
+804 -71
View File
File diff suppressed because it is too large Load Diff
+754
View File
@@ -0,0 +1,754 @@
// TXT / Markdown 阅读适配器:解码 → 切章 → 净化 → 打包成内存 EPUB,渲染交给 epub 适配器。
// 自己渲染要重写选区、字符偏移定位、滚动进度、主题注入一整套逻辑,mobi 适配器已经证明
// 转 EPUB 复用是更小的面;顺带把「整份文本塞进 DOM」变成按章加载,几十 MB 的 txt 也不会卡死。
import { createEpubAdapter } from './epub-adapter.mjs';
const XHTML_NS = 'http://www.w3.org/1999/xhtml';
export const MAX_TEXT_BYTES = 64 * 1024 * 1024;
export const MARKDOWN_MAX_CHARS = 4 * 1024 * 1024;
const CHAPTER_TARGET_CHARS = 6000;
const CHAPTER_MAX_CHARS = 24000;
const MAX_CHAPTERS = 4000;
const PARA_MAX_CHARS = 800;
const MIN_DETECTED_HEADINGS = 3;
const HEADING_LINE_MAX = 60;
const MD_SPLIT_LEVEL = 2;
const MAX_TOC_ANCHORS = 2000;
const LABEL_MAX = 80;
const NAMED_HEADING = /^(?:序章|序言|自序|序|楔子|前言|引言|导言|導言|后记|後記|后序|尾声|尾聲|结语|結語|结尾|附录|附錄|番外|外传|外傳|致谢|致謝|目录|目錄)(?:[\s::、.-].{0,40})?$/;
const NUMBERED_HEADING = /^第\s*[0-90-9零〇一二三四五六七八九十百千万两]{1,12}\s*[章節节回卷篇部集话話幕折](?:[\s::、.-].{0,40})?$/;
const LATIN_HEADING = /^(?:chapter|part|book|section|act|episode)\s+(?:\d{1,4}|[ivxlcdm]{1,8})(?:[\s:.-].{0,40})?$/i;
const PLAIN_CSS = 'p.txtx-para{margin:0;white-space:pre-wrap;text-align:justify}'
+ 'p.txtx-head{font-weight:700}';
const MARKDOWN_CSS = 'h1,h2,h3,h4,h5,h6{margin:1.2em 0 .6em;line-height:1.35}'
+ 'h1{font-size:1.6em}h2{font-size:1.35em}h3{font-size:1.18em}'
+ 'p{margin:0 0 .8em}'
+ 'ul,ol{margin:0 0 .8em;padding-inline-start:1.6em}'
+ 'li{margin:.2em 0}'
+ 'blockquote{margin:0 0 .8em;padding-inline-start:.9em;border-inline-start:3px solid currentColor;opacity:.85}'
+ 'pre{margin:0 0 .8em;padding:.6em .8em;border:1px solid currentColor;border-radius:4px;white-space:pre-wrap}'
+ 'pre,code,kbd,samp{font-family:Consolas,"Courier New","Sarasa Mono SC",monospace}'
+ 'code{font-size:.92em}'
+ 'table{border-collapse:collapse;margin:0 0 .8em}'
+ 'th,td{border:1px solid currentColor;padding:.3em .6em}'
+ 'hr{border:0;border-top:1px solid currentColor;opacity:.5;margin:1.4em 0}'
+ '.txtx-md-image{opacity:.7;font-style:italic}';
const ALLOWED_TAGS = Object.freeze([
'p', 'br', 'hr', 'strong', 'em', 's', 'del', 'ins', 'mark', 'sub', 'sup',
'blockquote', 'pre', 'code', 'kbd', 'samp',
'ul', 'ol', 'li', 'dl', 'dt', 'dd',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td',
'span'
]);
// 放行名单里没有 a/img,也没有 href/src:正文 iframe 是空 sandbox 的不透明来源,
// 任何导航只会把正文冲掉,外链必须经主进程;所以链接降级成纯文字、图片换成占位符。
const ALLOWED_ATTR = Object.freeze(['class', 'title', 'colspan', 'rowspan', 'start']);
function clamp(n, lo, hi) {
const v = Number(n);
if (!Number.isFinite(v)) return lo;
return Math.min(hi, Math.max(lo, v));
}
function xmlText(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function xmlAttr(value) {
return xmlText(value).replace(/"/g, '&quot;').replace(/'/g, '&apos;');
}
function label(value, fallback) {
const text = String(value == null ? '' : value).replace(/\s+/g, ' ').trim();
if (!text) return fallback;
return text.length > LABEL_MAX ? `${text.slice(0, LABEL_MAX)}` : text;
}
export function bytesView(bytes) {
if (bytes instanceof ArrayBuffer) return new Uint8Array(bytes);
if (ArrayBuffer.isView(bytes)) return new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
if (Array.isArray(bytes)) return new Uint8Array(bytes);
throw new Error('文本文件字节无效');
}
// XML 1.0 不接受 C0 控制符。留着会让生成的 XHTML 解析失败,正文随即退回容错的
// HTML 解析路径,行为难以预测;这些字符本来也不可见。U+FFFD 是合法 XML 字符且是
// 解码失败的唯一可见线索,必须留着。
function stripUnprintable(value) {
return String(value)
.replace(/\r\n?/g, '\n')
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f\ufffe\uffff]/g, '');
}
function decodeWith(encoding, data, fatal) {
try {
return new TextDecoder(encoding, { fatal: !!fatal }).decode(data);
} catch (e) {
return null;
}
}
// 只给「一定是正文」的字符加分,只给「一定不是正文」的字符扣分,其余非 ASCII 记零。
// 拉丁扩展区与通用标点必须记零:UTF-8 中文按 windows-1252 解出的乱码全落在那里,
// 一个汉字摊成三个乱码字符,给正分的话乱码反而赢过正确解码。
export function textScore(value) {
let good = 0;
let bad = 0;
const sample = String(value == null ? '' : value);
const capped = sample.length > 8192 ? sample.slice(0, 8192) : sample;
for (const ch of capped) {
const c = ch.codePointAt(0);
if (c === 0xfffd) { bad += 6; continue; }
if (c < 0x20) { if (c === 9 || c === 10) good += 1; else bad += 6; continue; }
if (c < 0x7f) { good += 1; continue; }
if (c <= 0x9f) { bad += 6; continue; }
if (c >= 0x4e00 && c <= 0x9fff) { good += 2; continue; }
if (c >= 0x3000 && c <= 0x303f) { good += 2; continue; }
if (c >= 0xff00 && c <= 0xffef) { good += 2; continue; }
if (c >= 0x3040 && c <= 0x30ff) { good += 2; continue; }
if (c >= 0xac00 && c <= 0xd7a3) { good += 2; continue; }
}
return good - bad;
}
// 无 BOM 的 UTF-16 在 Chromium 里没有内建嗅探,只能自己看 NUL 分布:
// UTF-16LE 存 ASCII 时高位字节恒为 0,落在奇数下标。
function guessUtf16(data) {
const limit = Math.min(data.byteLength - (data.byteLength % 2), 4096);
if (limit < 16) return '';
let odd = 0;
let even = 0;
for (let i = 0; i < limit; i++) {
if (data[i] !== 0) continue;
if (i % 2) odd++; else even++;
}
if ((odd + even) * 4 < limit) return '';
if (odd > even * 3) return 'utf-16le';
if (even > odd * 3) return 'utf-16be';
return '';
}
export function decodeTextBytes(bytes) {
const data = bytesView(bytes);
if (data.byteLength > MAX_TEXT_BYTES) {
throw new Error('该文本文件超过 64 MB,暂不支持在内置阅读器中打开,请使用外部应用');
}
const finish = (text, encoding, confident) => ({
text: stripUnprintable(String(text).replace(/^\ufeff+/, '')),
encoding,
confident
});
if (!data.byteLength) return finish('', 'utf-8', true);
if (data[0] === 0xff && data[1] === 0xfe) {
const text = decodeWith('utf-16le', data.subarray(2), false);
if (text != null) return finish(text, 'utf-16le', true);
}
if (data[0] === 0xfe && data[1] === 0xff) {
const text = decodeWith('utf-16be', data.subarray(2), false);
if (text != null) return finish(text, 'utf-16be', true);
}
if (data[0] === 0xef && data[1] === 0xbb && data[2] === 0xbf) {
const text = decodeWith('utf-8', data.subarray(3), false);
if (text != null) return finish(text, 'utf-8', true);
}
// UTF-16 嗅探必须排在 UTF-8 之前:NUL 是合法 UTF-8 字节,无 BOM 的 UTF-16
// 能被严格 UTF-8 解码器全盘接受,正文会变成夹满空洞的乱码。
const utf16 = guessUtf16(data);
if (utf16) {
const text = decodeWith(utf16, data, false);
if (text != null) return finish(text, utf16, true);
}
// 严格 UTF-8 通过就直接采信:多字节序列的自校验很强,GBK/Big5 正文几乎不可能
// 恰好构成合法 UTF-8,再拿去和其他候选比分反而会被乱码的字符数优势翻盘。
const strict = decodeWith('utf-8', data, true);
if (strict != null) return finish(strict, 'utf-8', true);
// 同分时靠顺序决定,简体中文是本应用的主场,gb18030 排最前。
let best = null;
for (const encoding of ['gb18030', 'big5', 'euc-jp', 'euc-kr', 'windows-1252']) {
const text = decodeWith(encoding, data, false);
if (text == null) continue;
const score = textScore(text);
if (!best || score > best.score) best = { text, encoding, score };
}
if (best && best.score > 0) return finish(best.text, best.encoding, true);
// 所有候选都打不出正分:按 UTF-8 宽松解码保住可读部分,confident=false 交给上层提示,
// 不让用户对着乱码以为是文件坏了。
const loose = decodeWith('utf-8', data, false);
return finish(loose == null ? '' : loose, best ? best.encoding : 'utf-8', false);
}
export function lineChunks(value) {
const text = String(value == null ? '' : value);
const out = [];
let start = 0;
for (let i = 0; i < text.length; i++) {
if (text[i] === '\n') { out.push(text.slice(start, i + 1)); start = i + 1; }
}
if (start < text.length) out.push(text.slice(start));
return out;
}
function lineOffsets(text) {
const starts = [0];
for (let i = 0; i < text.length; i++) {
if (text[i] === '\n') starts.push(i + 1);
}
return starts;
}
function headingLabel(line) {
const trimmed = line.replace(/[\s\u3000]+/g, ' ').trim();
if (!trimmed || trimmed.length > HEADING_LINE_MAX) return '';
if (NUMBERED_HEADING.test(trimmed) || NAMED_HEADING.test(trimmed) || LATIN_HEADING.test(trimmed)) return trimmed;
return '';
}
// 切出来的片段必须能原样拼回整章,textOf('document') 的「不缺不重」全靠这条。
export function splitParagraphs(value, limit) {
const lines = lineChunks(value);
const cap = Math.max(200, Number(limit) || PARA_MAX_CHARS);
const out = [];
let buffer = '';
for (let i = 0; i < lines.length; i++) {
buffer += lines[i];
const blank = !lines[i].trim();
const nextBlank = i + 1 < lines.length ? !lines[i + 1].trim() : true;
if (buffer.length >= cap || (blank && !nextBlank)) { out.push(buffer); buffer = ''; }
}
if (buffer) out.push(buffer);
return out;
}
function cutLongSpan(text, start, end, cap, sink, base) {
let from = start;
while (end - from > cap) {
let cut = text.lastIndexOf('\n', from + cap);
if (cut <= from) cut = from + cap - 1;
sink.push({ start: from, end: cut + 1, label: base, continued: from !== start });
from = cut + 1;
}
sink.push({ start: from, end, label: base, continued: from !== start });
}
function thinCuts(cuts, maxChapters) {
if (cuts.length <= maxChapters) return cuts;
const step = Math.ceil(cuts.length / maxChapters);
return cuts.filter((_, index) => index % step === 0);
}
function piecesToChapters(text, cuts, cap, maxChapters) {
const kept = thinCuts(cuts, maxChapters);
const pieces = [];
for (let i = 0; i < kept.length; i++) {
const end = i + 1 < kept.length ? kept[i + 1].start : text.length;
cutLongSpan(text, kept[i].start, end, cap, pieces, kept[i].label);
}
return pieces.map((piece) => ({
label: piece.continued ? `${piece.label}(续)` : piece.label,
text: text.slice(piece.start, piece.end),
start: piece.start,
end: piece.end,
continued: piece.continued
}));
}
export function splitPlainText(text, limits = {}) {
const source = String(text == null ? '' : text);
const maxChapters = Math.max(1, Number(limits.maxChapters) || MAX_CHAPTERS);
if (!source) {
return {
chapters: [{ label: '正文', text: '', start: 0, end: 0, continued: false }],
headings: [],
title: ''
};
}
const target = Math.max(
Number(limits.target) || CHAPTER_TARGET_CHARS,
Math.ceil(source.length / maxChapters)
);
const cap = Math.max(Number(limits.max) || CHAPTER_MAX_CHARS, target * 2);
const starts = lineOffsets(source);
const headings = [];
for (let i = 0; i < starts.length; i++) {
const end = i + 1 < starts.length ? starts[i + 1] : source.length;
const text2 = headingLabel(source.slice(starts[i], end));
if (text2) headings.push({ level: 1, label: text2, offset: starts[i] });
}
const cuts = [];
const anchored = headings.length >= MIN_DETECTED_HEADINGS;
if (anchored) {
if (headings[0].offset > 0) cuts.push({ start: 0, label: '开头' });
for (const heading of headings) cuts.push({ start: heading.offset, label: heading.label });
} else {
let index = 0;
let from = 0;
while (from < source.length) {
let cut = source.indexOf('\n', from + target);
if (cut < 0 || cut + 1 >= source.length) cut = source.length - 1;
cuts.push({ start: from, label: `${++index}` });
from = cut + 1;
}
if (!cuts.length) cuts.push({ start: 0, label: '第 1 节' });
}
// 很多 txt 小说首行是书名,可以当标题;但首行本身就是章节标题时不能拿来当书名。
const firstLine = lineChunks(source).find((line) => line.trim()) || '';
const usable = !headingLabel(firstLine) && firstLine.trim().length <= HEADING_LINE_MAX;
const titleLine = usable ? firstLine : '';
return {
chapters: piecesToChapters(source, cuts, cap, maxChapters),
headings: anchored ? headings : [],
title: label(titleLine, '')
};
}
export function markdownHeadings(source, md) {
const text = String(source == null ? '' : source);
if (!text.trim() || !md || typeof md.parse !== 'function') return [];
const starts = lineOffsets(text);
const tokens = md.parse(text, {});
const out = [];
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
if (token.type !== 'heading_open') continue;
const level = clamp(Number(String(token.tag || 'h1').slice(1)), 1, 6);
const line = token.map && Number.isInteger(token.map[0]) ? token.map[0] : 0;
const inline = tokens[i + 1];
out.push({
level,
label: label(inline && inline.content, `标题 ${out.length + 1}`),
offset: starts[clamp(line, 0, starts.length - 1)]
});
}
return out;
}
export function splitMarkdown(source, md, limits = {}) {
const text = String(source == null ? '' : source);
const maxChapters = Math.max(1, Number(limits.maxChapters) || MAX_CHAPTERS);
const headings = markdownHeadings(text, md);
const splitLevel = clamp(limits.splitLevel == null ? MD_SPLIT_LEVEL : limits.splitLevel, 1, 6);
const tops = headings.filter((h) => h.level <= splitLevel);
if (!tops.length) {
const plain = splitPlainText(text, limits);
return {
chapters: plain.chapters,
headings,
title: label(headings.length ? headings[0].label : plain.title, ''),
splitLevel
};
}
const target = Math.max(
Number(limits.target) || CHAPTER_TARGET_CHARS,
Math.ceil(text.length / maxChapters)
);
const cap = Math.max(Number(limits.max) || CHAPTER_MAX_CHARS, target * 2);
const cuts = [];
if (tops[0].offset > 0) cuts.push({ start: 0, label: '开头' });
for (const top of tops) cuts.push({ start: top.offset, label: top.label });
const topTitle = headings.find((h) => h.level === 1) || tops[0];
return {
chapters: piecesToChapters(text, cuts, cap, maxChapters),
headings,
title: label(topTitle.label, ''),
splitLevel
};
}
// 返回目录条目,同时给出每章按文档顺序排列的锚点 id,供渲染时打到对应标题元素上。
export function buildTocEntries(chapters, headings, splitLevel) {
const list = Array.isArray(headings) ? headings : [];
const anchorsByChapter = new Map();
if (!list.length) {
return {
entries: chapters.map((chapter, index) => ({
label: chapter.label,
depth: chapter.continued ? 1 : 0,
chapter: index,
anchor: ''
})),
anchorsByChapter
};
}
const entries = [];
let anchors = 0;
for (let i = 0; i < list.length; i++) {
const heading = list[i];
let owner = chapters.findIndex((c) => heading.offset >= c.start && heading.offset < c.end);
if (owner < 0) owner = chapters.length - 1;
if (owner < 0) continue;
const leading = heading.offset === chapters[owner].start;
const anchor = !leading && anchors < MAX_TOC_ANCHORS ? `txtx-h-${i}` : '';
if (anchor) anchors++;
if (!anchorsByChapter.has(owner)) anchorsByChapter.set(owner, []);
anchorsByChapter.get(owner).push(anchor);
entries.push({
label: heading.label,
depth: Math.max(0, heading.level - 1),
chapter: owner,
anchor
});
}
const covered = new Set(entries.map((entry) => entry.chapter));
const extra = [];
chapters.forEach((chapter, index) => {
if (covered.has(index)) return;
extra.push({
label: chapter.label,
depth: chapter.continued ? Number(splitLevel) || 1 : 0,
chapter: index,
anchor: ''
});
});
return {
entries: entries.concat(extra).sort((a, b) => a.chapter - b.chapter),
anchorsByChapter
};
}
export function navMarkup(entries) {
const list = Array.isArray(entries) ? entries : [];
let out = '<ol>';
let depth = 0;
let hasItem = false;
for (const entry of list) {
let want = Math.max(0, Math.min(Number(entry.depth) || 0, depth + 1));
if (want > depth && !hasItem) want = depth;
while (depth > want) { out += '</li></ol>'; depth--; hasItem = true; }
if (want > depth) { out += '<ol>'; depth++; hasItem = false; }
else if (hasItem) { out += '</li>'; hasItem = false; }
out += `<li><a href="${xmlAttr(entry.href)}">${xmlText(entry.label)}</a>`;
hasItem = true;
}
while (depth > 0) { out += '</li></ol>'; depth--; hasItem = true; }
if (hasItem) out += '</li>';
return `${out}</ol>`;
}
export function plainChapterXhtml(chapter) {
const value = chapter && typeof chapter === 'object' ? chapter : {};
const text = String(value.text == null ? '' : value.text);
const heading = !value.continued && headingLabel(lineChunks(text)[0] || '');
let head = '';
let rest = text;
if (heading) {
const first = lineChunks(text)[0] || '';
head = `<p class="txtx-para txtx-head">${xmlText(first)}</p>`;
rest = text.slice(first.length);
}
const body = splitParagraphs(rest, PARA_MAX_CHARS)
.map((piece) => `<p class="txtx-para">${xmlText(piece)}</p>`)
.join('');
return `<html xmlns="${XHTML_NS}"><head><title>${xmlText(value.label || '正文')}</title></head>`
+ `<body><style>${PLAIN_CSS}</style>${head}${body}</body></html>`;
}
export function createMarkdownRenderer(markdownit) {
if (typeof markdownit !== 'function') throw new Error('缺少 markdown-it 依赖,无法渲染 Markdown');
const md = markdownit({
// html:false 是第一道防线,正文里的裸 HTML 直接转义成文字;DOMPurify 是第二道。
// 两道都必须在,动任何一道之前先确认另一道单独够用。
html: false,
xhtmlOut: true,
breaks: false,
linkify: false,
typographer: false
});
md.renderer.rules.image = (tokens, index) => {
const alt = String(tokens[index].content || '').replace(/\s+/g, ' ').trim();
return `<span class="txtx-md-image">[${xmlText(alt ? `图片:${alt}` : '图片已省略')}]</span>`;
};
return md;
}
export function sanitizeMarkdownFragment(html, services) {
const purifier = services && services.purifier;
if (!purifier || typeof purifier.sanitize !== 'function') {
throw new Error('缺少 DOMPurify 依赖,无法安全渲染 Markdown');
}
return purifier.sanitize(String(html == null ? '' : html), {
ALLOWED_TAGS: [...ALLOWED_TAGS],
ALLOWED_ATTR: [...ALLOWED_ATTR],
ALLOW_DATA_ATTR: false,
ALLOW_ARIA_ATTR: false,
RETURN_DOM_FRAGMENT: true
});
}
export function markdownChapterXhtml(chapter, services, md, anchorIds) {
const value = chapter && typeof chapter === 'object' ? chapter : {};
const doc = services.document.implementation.createDocument(XHTML_NS, 'html', null);
const head = doc.createElementNS(XHTML_NS, 'head');
const titleEl = doc.createElementNS(XHTML_NS, 'title');
titleEl.appendChild(doc.createTextNode(String(value.label || '正文')));
head.appendChild(titleEl);
const body = doc.createElementNS(XHTML_NS, 'body');
doc.documentElement.appendChild(head);
doc.documentElement.appendChild(body);
const styleEl = doc.createElementNS(XHTML_NS, 'style');
styleEl.appendChild(doc.createTextNode(MARKDOWN_CSS));
body.appendChild(styleEl);
body.appendChild(doc.importNode(
sanitizeMarkdownFragment(md.render(String(value.text == null ? '' : value.text)), services),
true
));
const headings = Array.from(body.querySelectorAll('h1,h2,h3,h4,h5,h6'));
const ids = Array.isArray(anchorIds) ? anchorIds : [];
// 数量对不上说明章节边界切进了代码块之类的结构,此时按序号打 id 会指到错误的标题,
// 宁可放弃锚点(目录退化为跳到章首)。
if (ids.length === headings.length) {
headings.forEach((el, index) => { if (ids[index]) el.setAttribute('id', ids[index]); });
}
return new services.XMLSerializer().serializeToString(doc);
}
export function resolveServices() {
const scope = typeof window === 'undefined' ? {} : window;
return {
markdownit: scope.markdownit,
purifier: scope.DOMPurify,
document: scope.document,
XMLSerializer: scope.XMLSerializer,
JSZip: scope.JSZip
};
}
export async function buildTextEpub(bytes, options = {}) {
const services = resolveServices();
if (!services.JSZip) throw new Error('缺少 jszip 依赖,无法准备文本内容');
const report = typeof options.onProgress === 'function'
? (value) => { try { options.onProgress(clamp(value, 0, 1)); } catch (e) { /* ignore */ } }
: () => {};
const decoded = decodeTextBytes(bytes);
report(0.05);
const wantMarkdown = normalizeTextFormat(options.format) === 'md';
// 超大 Markdown 单文件降级为纯文本:markdown-it 是整篇一次性解析,
// 几 MB 以上的解析开销与内存都不可控,而这种体量的 .md 基本是日志或导出的数据。
const markdown = wantMarkdown && decoded.text.length <= MARKDOWN_MAX_CHARS;
if (markdown && (!services.document || !services.XMLSerializer)) {
throw new Error('渲染环境不完整,无法渲染 Markdown');
}
const md = markdown ? createMarkdownRenderer(services.markdownit) : null;
const split = markdown
? splitMarkdown(decoded.text, md, options.limits)
: splitPlainText(decoded.text, options.limits);
const chapters = split.chapters;
const { entries, anchorsByChapter } = buildTocEntries(
chapters,
split.headings,
markdown ? split.splitLevel : 1
);
report(0.1);
const zip = new services.JSZip();
for (let index = 0; index < chapters.length; index++) {
const xhtml = markdown
? markdownChapterXhtml(chapters[index], services, md, anchorsByChapter.get(index) || [])
: plainChapterXhtml(chapters[index]);
zip.file(`text/chapter-${index}.xhtml`, xhtml);
if (index % 64 === 0) report(0.1 + 0.75 * ((index + 1) / chapters.length));
}
report(0.85);
const title = label(options.title || split.title, wantMarkdown ? '未命名文档' : '未命名文本');
const manifest = chapters
.map((_, index) => `<item id="chapter-${index}" href="text/chapter-${index}.xhtml" media-type="application/xhtml+xml"/>`)
.concat('<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>')
.join('');
const spine = chapters.map((_, index) => `<itemref idref="chapter-${index}"/>`).join('');
const nav = navMarkup(entries.map((entry) => ({
label: entry.label,
depth: entry.depth,
href: `text/chapter-${entry.chapter}.xhtml${entry.anchor ? `#${entry.anchor}` : ''}`
})));
zip.file('mimetype', 'application/epub+zip', { compression: 'STORE' });
zip.file('META-INF/container.xml',
'<?xml version="1.0" encoding="UTF-8"?><container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="content.opf" media-type="application/oebps-package+xml"/></rootfiles></container>');
zip.file('content.opf',
`<?xml version="1.0" encoding="UTF-8"?><package version="3.0" unique-identifier="book-id" xmlns="http://www.idpf.org/2007/opf"><metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:identifier id="book-id">peoplelib-text-${Date.now()}</dc:identifier><dc:title>${xmlText(title)}</dc:title><dc:language>zh-CN</dc:language></metadata><manifest>${manifest}</manifest><spine>${spine}</spine></package>`);
zip.file('nav.xhtml',
`<html xmlns="${XHTML_NS}"><head><title>${xmlText(title)}</title></head><body><nav epub:type="toc" xmlns:epub="http://www.idpf.org/2007/ops">${nav}</nav></body></html>`);
// 纯文本压缩收益抵不上 CPU:32 MB 正文实测 STORE 打包约 340 ms
// 而这个 zip 只在内存里转手给 epub 适配器,不落盘。
const packed = await zip.generateAsync({ type: 'uint8array', compression: 'STORE' });
report(1);
return {
bytes: packed,
title,
chapterCount: chapters.length,
encoding: decoded.encoding,
confident: decoded.confident,
mode: markdown ? 'markdown' : 'plain',
charCount: decoded.text.length
};
}
export function normalizeTextFormat(format) {
const value = String(format == null ? '' : format).toLowerCase().replace(/^\./, '');
return ['md', 'markdown', 'mdown', 'mkd', 'mkdn'].includes(value) ? 'md' : 'txt';
}
export function createTextAdapter(format = 'txt') {
const inner = createEpubAdapter();
const sourceFormat = normalizeTextFormat(format);
let info = null;
function toInner(locator) {
const value = locator && typeof locator === 'object' ? locator : {};
return { kind: 'epub', chapter: value.chapter, offset: value.offset };
}
function fromInner(locator) {
const value = locator && typeof locator === 'object' ? locator : {};
return { kind: sourceFormat, chapter: value.chapter || 0, offset: value.offset || 0 };
}
async function load(bytes, options = {}) {
info = null;
const report = typeof options.onProgress === 'function'
? (value) => { try { options.onProgress(clamp(value, 0, 1)); } catch (e) { /* ignore */ } }
: null;
const built = await buildTextEpub(bytes, {
format: sourceFormat,
title: options.title,
limits: options.limits,
onProgress: report ? (value) => report(value * 0.7) : null
});
const result = await inner.load(built.bytes, {
...options,
onProgress: report ? (value) => report(0.7 + value * 0.3) : null
});
info = {
chapterCount: built.chapterCount,
title: built.title || result.title,
format: sourceFormat,
encoding: built.encoding,
confident: built.confident,
mode: built.mode,
charCount: built.charCount
};
return { ...result, ...info };
}
function renderTo(container, locator, options) {
return inner.renderTo(container, toInner(locator), options)
.then((result) => ({ ...result, locator: fromInner(result.locator) }));
}
async function toc() {
return (await inner.toc()).map((entry) => ({ ...entry, locator: fromInner(entry.locator) }));
}
function getSelection() {
const selection = inner.getSelection();
return selection ? { ...selection, locator: fromInner(selection.locator) } : null;
}
function textOf(locator, span) {
return inner.textOf(toInner(locator), span);
}
function visualViewportRect() {
const value = inner.visualViewportRect();
return value ? { ...value, locator: fromInner(value.locator) } : null;
}
function locatorLabel(locator) {
return inner.locatorLabel(toInner(locator));
}
function nextLocator(locator) {
const next = inner.nextLocator(toInner(locator));
return next ? fromInner(next) : null;
}
function prevLocator(locator) {
const previous = inner.prevLocator(toInner(locator));
return previous ? fromInner(previous) : null;
}
function percentOf(locator) {
return inner.percentOf(toInner(locator));
}
function locatorFromPercent(percent) {
return fromInner(inner.locatorFromPercent(percent));
}
function capturePinchAnchor(x, y) {
const anchor = inner.capturePinchAnchor(x, y);
return anchor ? { ...anchor, kind: sourceFormat } : null;
}
function restorePinchAnchor(anchor) {
inner.restorePinchAnchor(anchor);
}
function setLocatorChangeHandler(handler) {
inner.setLocatorChangeHandler(typeof handler === 'function'
? (locator, percent) => handler(fromInner(locator), percent)
: null);
}
function setTouchGestureHandler(handler) {
inner.setTouchGestureHandler(handler);
}
function documentInfo() {
return info ? { ...info } : null;
}
function destroy() {
inner.destroy();
info = null;
}
return {
load,
renderTo,
toc,
getSelection,
textOf,
visualViewportRect,
locatorLabel,
nextLocator,
prevLocator,
percentOf,
locatorFromPercent,
capturePinchAnchor,
restorePinchAnchor,
setLocatorChangeHandler,
setTouchGestureHandler,
documentInfo,
destroy
};
}
+97 -22
View File
@@ -9,6 +9,7 @@
--text-dim: #8b94a3;
--green: #3fb96f;
--danger: #d9534f;
--amber: #d69e2e;
--titlebar-start: #171b26;
--titlebar-end: #12141c;
--active-text: #0d1420;
@@ -35,6 +36,7 @@
--text-dim: #64748b;
--green: #35ad69;
--danger: #c93f3a;
--amber: #a96c0c;
--titlebar-start: #ffffff;
--titlebar-end: #f2f5fa;
--active-text: #ffffff;
@@ -184,14 +186,43 @@ body {
gap: 16px;
}
.card { cursor: pointer; }
/* 封面比例五花八门(生成的是 4:5,书源的常见 0.65~0.75,还有方图和横图)。
用 cover 铺满会按各自比例裁掉不同的边,同一套封面看上去缩放程度不一。
改成 contain 让整张封面完整显示:模糊放大的同一张图由 ::before 垫在**底层**
填掉留白。注意 ::before 会盖在父元素自己的背景之上,所以清晰的那层必须画在
::after 里,只靠 z-index 调不动父元素背景的层级。 */
.card-cover {
position: relative;
width: 100%; aspect-ratio: 3/4;
background: var(--bg-card) center/cover no-repeat;
background: var(--bg-card) center/contain no-repeat;
border: 1px solid var(--line); border-radius: 10px;
display: flex; align-items: center; justify-content: center;
padding: 10px; text-align: center;
overflow: hidden;
transition: transform 0.15s, border-color 0.15s;
}
.card-cover[data-cover-state="ready"]::before,
.card-cover[data-cover-state="ready"]::after {
content: "";
position: absolute;
inset: 0;
background-image: inherit;
background-repeat: no-repeat;
background-position: center;
pointer-events: none;
}
.card-cover[data-cover-state="ready"]::before {
z-index: 0;
background-size: cover;
filter: blur(12px) brightness(0.5);
transform: scale(1.15);
}
.card-cover[data-cover-state="ready"]::after {
z-index: 1;
background-size: contain;
}
/* 占位文字与角标必须浮在两层背景之上 */
.card-cover > * { position: relative; z-index: 2; }
.card-cover.readable { cursor: pointer; }
.card-cover.readable:focus-visible {
outline: 2px solid var(--accent);
@@ -209,12 +240,26 @@ body {
}
.card-sub { margin-top: 2px; font-size: 11px; color: var(--text-dim); display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; }
.card-date { margin-top: 2px; font-size: 11px; color: var(--text-dim); }
.card-badge {
display: inline-block; margin-top: 4px; padding: 1px 8px;
font-size: 11px; border-radius: 10px;
background: rgba(63,185,111,0.15); color: var(--green);
/* 状态与笔记标识叠在封面右下角,不再占用封面下方的一行。
封面图案深浅不可控,故用不透明衬底而非半透明色块保证可读性 */
.card-cover-badges {
position: absolute;
bottom: 6px;
display: flex; gap: 4px;
max-width: calc(50% - 8px);
pointer-events: none;
}
.card-badge.miss { background: rgba(217,83,79,0.15); color: var(--danger); }
.card-cover-badges.start { left: 6px; }
.card-cover-badges.end { right: 6px; justify-content: flex-end; }
.card-badge {
padding: 1px 7px;
font-size: 10px; line-height: 1.6; border-radius: 10px;
white-space: nowrap;
background: var(--bg-card); color: var(--green);
border: 1px solid rgba(63,185,111,0.45);
box-shadow: 0 1px 3px rgba(0,0,0,0.35);
}
.card-badge.miss { color: var(--danger); border-color: rgba(217,83,79,0.5); }
.empty { grid-column: 1 / -1; text-align: center; color: var(--text-dim); padding: 60px 0; }
@@ -393,9 +438,12 @@ body {
.lib-card-actions .open-btn:hover { background: var(--accent-bright); }
.lib-card-actions .open-btn:disabled { background: var(--disabled-bg); color: var(--text-dim); cursor: not-allowed; }
.card-badge.note-count {
margin-left: 5px;
background: rgba(110,168,254,0.14);
color: var(--accent-bright);
border-color: rgba(110,168,254,0.5);
}
.card-badge.annotation-count {
color: var(--amber);
border-color: rgba(214,158,46,0.55);
}
/* 书库多选 */
@@ -418,19 +466,27 @@ body {
cursor: pointer;
}
.tb-btn.ghost.active { color: var(--accent-bright); border-color: var(--accent); }
#libraryTab.select-mode .card { position: relative; }
/* 定位上下文不能只在 select-mode 下建立:退出多选是同步移除类名,
而重绘要等 IPC 返回,其间残留的复选框会失去定位祖先,
直接按视口坐标飞到左上角标题上闪一下 */
.card { position: relative; }
.card-select {
position: absolute;
top: 6px;
left: 6px;
z-index: 2;
display: flex;
padding: 4px;
background: rgba(20,20,20,0.62);
border-radius: 6px;
cursor: pointer;
}
.card-select input { margin: 0; cursor: pointer; }
/* 用投影而不是衬底色块保证在浅色封面上也看得见:
加 padding + 背景板会让复选框看起来套了一圈很粗的边框 */
.card-select input {
margin: 0;
cursor: pointer;
filter: drop-shadow(0 0 1px rgba(0,0,0,0.9)) drop-shadow(0 1px 2px rgba(0,0,0,0.55));
}
/* 退出多选后重绘要等 IPC 返回,这期间立刻藏掉残留的复选框 */
#libraryTab:not(.select-mode) .card-select { display: none; }
#libraryTab.select-mode .card-cover { transition: none; }
#libraryTab.select-mode .card:hover .card-cover { transform: none; }
#libraryTab.select-mode .card.selected .card-cover { border-color: var(--accent); }
@@ -549,23 +605,30 @@ body {
outline: none;
}
.library-search input:focus { border-color: var(--accent); }
/* 标签叠在封面顶部右侧:左上角要留给多选复选框 */
.library-card-tags {
position: absolute;
top: 6px; right: 6px;
display: flex;
justify-content: flex-end;
gap: 4px;
margin-top: 5px;
min-height: 18px;
max-width: calc(100% - 42px);
overflow: hidden;
pointer-events: none;
}
.library-card-tag {
max-width: 90px;
min-width: 0;
padding: 1px 6px;
border-radius: 8px;
background: rgba(110,168,254,0.1);
background: var(--bg-card);
border: 1px solid rgba(110,168,254,0.45);
color: var(--accent-bright);
font-size: 10px;
line-height: 1.6;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
box-shadow: 0 1px 3px rgba(0,0,0,0.35);
}
.library-organize-form { display: flex; flex-direction: column; gap: 12px; }
.library-organize-form label { color: var(--text-dim); font-size: 12px; }
@@ -968,6 +1031,7 @@ body {
}
.note-action:hover { border-color: var(--line); color: var(--text); }
.note-action.open { color: var(--accent-bright); }
.note-action.window { color: var(--accent-bright); }
.note-action.delete:hover { color: var(--danger); }
.note-edit-form { display: flex; flex-direction: column; gap: 10px; }
.note-type-choice-grid {
@@ -1022,9 +1086,12 @@ body {
.note-canvas-preview.template-pdf {
background: linear-gradient(145deg, #fff 0 68%, #f1f3f6 68% 100%);
}
.note-edit-form label { color: var(--text-dim); font-size: 12px; }
.note-edit-form textarea,
.note-edit-form select {
.note-edit-form label { color: var(--text-dim); font-size: 12px; min-width: 0; }
/* 必须排除 .canvas-note-root 与 .ql-toolbar 内部:画布工具栏和富文本工具栏都是
.note-edit-form 的后代,不加 :not() 的话工具栏里的下拉会被当成表单控件,吃到
margin-top 与 width:100%,表现为工具栏凭空高出 5px、各分组高度对不齐。 */
.note-edit-form textarea:not(.canvas-note-root textarea):not(.ql-toolbar textarea),
.note-edit-form select:not(.canvas-note-root select):not(.ql-toolbar select) {
width: 100%;
margin-top: 5px;
padding: 8px 10px;
@@ -1037,8 +1104,16 @@ body {
outline: none;
resize: vertical;
}
.note-edit-form textarea:focus,
.note-edit-form select:focus { border-color: var(--accent); }
/* 关联书籍 / 笔记本:书名可以很长,下拉框跟着长到占满整行,与右侧「标题」输入框
宽度悬殊。限宽后仍需 min-width:0,否则 select 的 min-content 以最长选项为准,
照样把整行顶宽。 */
.note-edit-form select:not(.canvas-note-root select):not(.ql-toolbar select) {
max-width: 320px;
min-width: 0;
text-overflow: ellipsis;
}
.note-edit-form textarea:not(.canvas-note-root textarea):focus,
.note-edit-form select:not(.canvas-note-root select):focus { border-color: var(--accent); }
.modal-box:has(.quill-note-editor),
.modal-box:has(.canvas-note-root) {
display: flex;
+36 -29
View File
@@ -12,6 +12,12 @@ const Library = (() => {
const selectedIds = new Set();
let visibleIds = [];
// 必须与 main.js 的 READABLE_EXT 保持一致。漏掉格式不会报错,
// 只是「阅读」按钮和封面点击静默消失,看上去像阅读器打不开这类文件
const READABLE_RE = /\.(pdf|epub|mobi|azw|azw3|txt|md)$/i;
const isReadableFile = (file) => !!file && file.exists
&& READABLE_RE.test(file.path || file.name || '');
const SORTERS = {
recent: (a, b) => (
(b.lastReadAt || 0) - (a.lastReadAt || 0)
@@ -209,11 +215,9 @@ const Library = (() => {
return items.filter((item) => selectedIds.has(String(item.id)));
}
function cardHtml(it, noteCounts) {
function cardHtml(it, noteCounts, annotationCounts) {
const openable = (it.files || []).some((file) => file.exists);
const readable = (it.files || []).some((file) => (
file.exists && /\.(pdf|epub|mobi|azw|azw3)$/i.test(file.path || file.name || '')
));
const readable = (it.files || []).some(isReadableFile);
const badge = openable
? '<span class="card-badge">已下载</span>'
: ((it.files || []).length
@@ -223,8 +227,12 @@ const Library = (() => {
const noteBadge = noteCount > 0
? `<span class="card-badge note-count">笔记 ${noteCount}</span>`
: '';
const annotationCount = annotationCounts.get(String(it.id)) || 0;
const annotationBadge = annotationCount > 0
? `<span class="card-badge annotation-count">批注 ${annotationCount}</span>`
: '';
const tagBadges = (it.tags || []).slice(0, 3)
.map((tag) => `<span class="library-card-tag">${escapeHtml(tag)}</span>`)
.map((tag) => `<span class="library-card-tag" title="${escapeHtml(tag)}">${escapeHtml(tag)}</span>`)
.join('');
const selectBox = selectMode
? `<label class="card-select" title="选择"><input type="checkbox" aria-label="选择${escapeHtml(it.title)}" /></label>`
@@ -236,12 +244,13 @@ const Library = (() => {
${selectBox}
<div class="card-cover${readable ? ' readable' : ''}" style="${coverStyle(it.cover)}"
data-cover-state="${it.cover ? 'ready' : 'pending'}"
${coverActs ? 'data-act="read" role="button" tabindex="0" title="使用内置阅读器打开"' : ''}>${it.cover ? '' : `<div class="ph">${escapeHtml(it.title)}</div>`}</div>
${coverActs ? 'data-act="read" role="button" tabindex="0" title="使用内置阅读器打开"' : ''}>${it.cover ? '' : `<div class="ph">${escapeHtml(it.title)}</div>`}
${tagBadges ? `<div class="library-card-tags">${tagBadges}</div>` : ''}
<div class="card-cover-badges start">${badge}</div>
<div class="card-cover-badges end">${annotationBadge}${noteBadge}</div>
</div>
<div class="card-title" title="${escapeHtml(it.title)}">${escapeHtml(it.title)}</div>
${(it.authors && it.authors.length) ? `<div class="card-sub">${escapeHtml(it.authors.slice(0, 2).join(', '))}</div>` : ''}
${badge}
${noteBadge}
${tagBadges ? `<div class="library-card-tags">${tagBadges}</div>` : ''}
${selectMode ? '' : `<div class="lib-card-actions">
${readable ? cardAction('read', '阅读', true) : ''}
${cardAction('open', readable ? '外部打开' : '打开', !readable, !openable)}
@@ -281,14 +290,14 @@ const Library = (() => {
};
}
function reconcileCards(items, noteCounts) {
function reconcileCards(items, noteCounts, annotationCounts) {
const existing = new Map(
Array.from(grid.querySelectorAll(':scope > .card')).map((card) => [card.dataset.id, card])
);
const keep = new Set();
items.forEach((item, index) => {
const id = String(item.id);
const markup = cardHtml(item, noteCounts).trim();
const markup = cardHtml(item, noteCounts, annotationCounts).trim();
let card = existing.get(id);
if (!card || card.__peoplelibMarkup !== markup) {
const template = document.createElement('template');
@@ -314,9 +323,10 @@ const Library = (() => {
async function refresh(force) {
if (!force && !dirty) return;
const currentRefresh = ++refreshSeq;
const [res, noteCountResult, shelfResult, tagResult] = await Promise.all([
const [res, noteCountResult, annotationCountResult, shelfResult, tagResult] = await Promise.all([
window.api.library.list(),
window.api.reader.getNoteCounts().catch(() => null),
window.api.reader.getAnnotationCounts().catch(() => null),
window.api.library.listShelves(),
window.api.library.listTags()
]);
@@ -334,6 +344,7 @@ const Library = (() => {
if (selectedTag && !libraryTags.some((tag) => tag.name === selectedTag)) selectedTag = '';
renderOrganizationSidebar();
const noteCounts = noteCountsOf(noteCountResult);
const annotationCounts = noteCountsOf(annotationCountResult);
const allItems = res.data.slice();
const scopedItems = allItems.filter((item) => {
if (selectedShelf === '__uncategorized__' && item.shelfId) return false;
@@ -365,7 +376,7 @@ const Library = (() => {
syncSelectionUi();
return;
}
reconcileCards(items, noteCounts);
reconcileCards(items, noteCounts, annotationCounts);
syncSelectionUi();
}
@@ -659,8 +670,8 @@ const Library = (() => {
});
const shelfValue = $('libraryBulkShelf').value;
const errorEl = $('libraryBulkError');
const failures = [];
for (const item of targets) {
// 一次提交:逐条 update 会把整个书库索引重写 N 遍
const patches = targets.map((item) => {
const patch = {};
if (shelfValue !== '__keep__') patch.shelfId = shelfValue || null;
const kept = (item.tags || []).filter((tag) => !strip.includes(String(tag).toLocaleLowerCase()));
@@ -671,11 +682,11 @@ const Library = (() => {
}
});
patch.tags = merged;
const response = await window.api.library.update(item.id, patch);
if (!response || !response.ok) failures.push(item.title);
}
if (failures.length) {
errorEl.textContent = `${failures.length} 本未能保存:${failures.slice(0, 3).join('、')}`;
return { id: item.id, patch };
});
const response = await window.api.library.updateMany(patches);
if (!response || !response.ok) {
errorEl.textContent = (response && response.error) || '保存失败';
return false;
}
return true;
@@ -709,17 +720,13 @@ const Library = (() => {
}));
if (!choice) return;
const failures = [];
for (const item of targets) {
const removed = await window.api.library.remove(item.id, choice);
if (!removed || !removed.ok) failures.push(item.title);
}
const removed = await window.api.library.removeMany(targets.map((item) => item.id), choice);
setSelectMode(false);
if (failures.length) {
await confirmModal('部分移除失败', `${failures.length} 本未能移除:${failures.slice(0, 3).join('、')}`);
if (!removed || !removed.ok) {
await confirmModal('移除失败', (removed && removed.error) || '未知错误');
return;
}
statusEl.textContent = `已移除 ${targets.length}`;
statusEl.textContent = `已移除 ${(removed.data && removed.data.removed) || targets.length}`;
}
async function organizeBook(item) {
@@ -772,7 +779,7 @@ const Library = (() => {
const it = res.data;
if (act === 'read') {
const files = it.files || [];
const idx = files.findIndex((x) => x.exists && /\.(pdf|epub|mobi|azw|azw3)$/i.test(x.path || x.name || ''));
const idx = files.findIndex(isReadableFile);
const r = await window.api.reader.open(id, idx >= 0 ? idx : undefined);
if (!r.ok) await confirmModal('无法阅读', r.error || '打开阅读器失败');
} else if (act === 'open') {
+610
View File
@@ -0,0 +1,610 @@
// 笔记独立窗口:单窗口多标签,形态与阅读器一致。
// 编辑器复用 MixedNote,读书/画布两类共用同一挂载入口。
//
// 「一标签一条」是数据安全约束:`reader:updateNote` 整条覆盖且无版本校验,
// 同一条笔记两个编辑器时后保存者会把前者内容整块吃掉。openTab 必须先查重。
//
// 笔记没有自动保存,切换标签只留在内存、不落盘,关闭标签或窗口才提示。
const api = window.api;
const $ = (id) => document.getElementById(id);
// 画布编辑器(fabric)吃内存,照阅读器的做法限制存活数并 LRU 回收。
// 回收前必须把未保存内容序列化进 pendingContent,否则回收即丢改动。
const MAX_LIVE_EDITORS = 3;
const el = {
uiThemeBtn: $('uiThemeBtn'),
minBtn: $('minBtn'),
maxBtn: $('maxBtn'),
closeBtn: $('closeBtn'),
loading: $('noteWindowLoading'),
error: $('noteWindowError'),
empty: $('noteWindowEmpty'),
tabsList: $('noteTabsList'),
tabViews: $('noteTabViews'),
tabTemplate: $('noteTabTemplate'),
dirtyModal: $('noteDirtyModal'),
dirtyNotice: $('noteDirtyNotice'),
dirtyCancelBtn: $('noteDirtyCancelBtn'),
dirtyDiscardBtn: $('noteDirtyDiscardBtn'),
dirtySaveBtn: $('noteDirtySaveBtn')
};
const params = new URLSearchParams(location.search);
const tabs = [];
let activeId = '';
let tabSeq = 0;
let touchSeq = 0;
let uiTheme = 'dark';
let collectionOptions = [];
let dirtyResolve = null;
let closing = false;
function errText(res, fallback) {
if (res && typeof res.error === 'string' && res.error) return res.error;
if (res instanceof Error && res.message) return res.message;
return fallback;
}
function showError(message) {
el.error.textContent = message;
el.error.classList.remove('hidden');
}
function clearError() {
el.error.textContent = '';
el.error.classList.add('hidden');
}
function activeTab() {
return tabs.find((tab) => tab.noteId === activeId) || null;
}
function tabOf(noteId) {
return tabs.find((tab) => tab.noteId === String(noteId)) || null;
}
function setStatus(tab, message, isError = false) {
if (!tab) return;
tab.dom.status.textContent = message;
tab.dom.status.classList.toggle('error', !!isError);
if (tab.statusTimer) clearTimeout(tab.statusTimer);
if (message) {
tab.statusTimer = setTimeout(() => { tab.dom.status.textContent = ''; }, 4000);
}
}
function applyUiTheme(next) {
uiTheme = next === 'light' ? 'light' : 'dark';
document.documentElement.dataset.uiTheme = uiTheme;
const label = uiTheme === 'light' ? '切换到暗色主题' : '切换到明亮主题';
el.uiThemeBtn.title = label;
el.uiThemeBtn.setAttribute('aria-label', label);
}
function tagsFromInput(value) {
return String(value || '')
.split(/[,]/)
.map((tag) => tag.trim())
.filter(Boolean);
}
function noteTypeOf(value) {
if (!value) return 'reading';
return value.noteType || (value.canvasContent ? 'canvas' : 'reading');
}
function noteLabel(note) {
const title = String((note && note.title) || '').trim();
if (title) return title;
const text = String((note && note.text) || '').replace(/\s+/g, ' ').trim();
if (text) return text.slice(0, 24);
return '未命名笔记';
}
/* --- 标签集上报 --- */
function reportTabs() {
if (!api.notes || !api.notes.tabsChanged) return;
const payload = tabs.map((tab) => ({ noteId: tab.noteId, entryId: tab.entryId }));
Promise.resolve(api.notes.tabsChanged(payload)).catch(() => { /* 下次变更时重试 */ });
}
/* --- 标签条 --- */
function renderTabs() {
el.tabsList.textContent = '';
for (const tab of tabs) {
const item = document.createElement('div');
item.className = 'doctab' + (tab.noteId === activeId ? ' active' : '');
item.dataset.noteId = tab.noteId;
item.setAttribute('role', 'tab');
item.title = tab.dirty ? `${noteLabel(tab.note)}(未保存)` : noteLabel(tab.note);
const name = document.createElement('span');
name.className = 'doctab-name';
name.textContent = noteLabel(tab.note);
item.appendChild(name);
const fmt = document.createElement('span');
fmt.className = 'doctab-fmt';
fmt.textContent = tab.noteType === 'canvas' ? '画布' : '读书';
item.appendChild(fmt);
if (tab.dirty) {
const dot = document.createElement('span');
dot.className = 'doctab-dirty';
dot.title = '有未保存的修改';
item.appendChild(dot);
}
const close = document.createElement('button');
close.className = 'doctab-close';
close.type = 'button';
close.title = '关闭';
close.textContent = '\u2715';
close.addEventListener('click', (event) => {
event.stopPropagation();
closeTab(tab.noteId);
});
item.appendChild(close);
item.addEventListener('click', () => { activate(tab.noteId); });
el.tabsList.appendChild(item);
}
el.empty.classList.toggle('hidden', tabs.length > 0);
}
/* --- 笔记本下拉 --- */
async function refreshCollections() {
let res;
try {
res = await api.reader.listCollections();
} catch (e) {
return;
}
if (!res || !res.ok || !Array.isArray(res.data)) return;
collectionOptions = res.data.map((item) => ({
id: String(item.id),
name: item.name || '未命名笔记本'
}));
for (const tab of tabs) fillCollections(tab);
}
function fillCollections(tab) {
const select = tab.dom.collection;
// 重建选项后要还原用户当前的选择。首次填充才回落到笔记自身的笔记本,
// 否则用户刚改成「未分类」会被下一次刷新弹回原值。
const current = tab.collectionFilled
? select.value
: (tab.note.collectionId == null ? '' : String(tab.note.collectionId));
select.textContent = '';
const none = document.createElement('option');
none.value = '';
none.textContent = '未分类';
select.appendChild(none);
for (const item of collectionOptions) {
const option = document.createElement('option');
option.value = item.id;
option.textContent = item.name;
select.appendChild(option);
}
select.value = current;
tab.collectionFilled = true;
}
/* --- 标签生命周期 --- */
function buildView(tab) {
const view = document.createElement('div');
view.className = 'note-tab-view inactive';
view.dataset.noteId = tab.noteId;
const form = el.tabTemplate.content.firstElementChild.cloneNode(true);
view.appendChild(form);
el.tabViews.appendChild(view);
tab.view = view;
tab.dom = {
form,
title: form.querySelector('.note-window-title'),
badge: form.querySelector('.note-window-badge'),
editorHost: form.querySelector('.note-window-editor'),
quote: form.querySelector('.note-window-quote'),
collection: form.querySelector('.note-window-collection'),
tags: form.querySelector('.note-window-tags-input'),
pinned: form.querySelector('.note-window-pinned'),
status: form.querySelector('.note-window-status'),
saveBtn: form.querySelector('.note-window-save')
};
form.addEventListener('submit', (event) => {
event.preventDefault();
save(tab);
});
for (const node of [tab.dom.title, tab.dom.tags]) {
node.addEventListener('input', () => markDirty(tab));
}
for (const node of [tab.dom.collection, tab.dom.pinned]) {
node.addEventListener('change', () => markDirty(tab));
}
}
function markDirty(tab) {
if (tab.dirty) return;
tab.dirty = true;
renderTabs();
}
function fillFields(tab) {
const note = tab.note;
tab.dom.badge.textContent = tab.noteType === 'canvas' ? '画布笔记' : '读书笔记';
tab.dom.title.value = String(note.title || '');
tab.dom.tags.value = Array.isArray(note.tags) ? note.tags.join(', ') : '';
tab.dom.pinned.checked = !!note.pinned;
tab.dom.quote.textContent = String(note.quote || '');
tab.dom.quote.classList.toggle('hidden', !String(note.quote || '').trim());
fillCollections(tab);
}
function mountEditor(tab) {
if (tab.editor) tab.editor.destroy();
// 恢复顺序不能反:pendingContent 是回收前序列化的未保存内容,
// 优先它才能让「回收后切回来」看到用户改过的样子而不是磁盘上的旧值。
const rich = tab.noteType === 'reading'
? (tab.pendingContent && tab.pendingContent.richContent)
|| tab.note.richContent
|| window.RichNote.fromText(tab.note.text)
: null;
const canvas = tab.noteType === 'canvas'
? (tab.pendingContent && tab.pendingContent.canvasContent) || tab.note.canvasContent || null
: null;
tab.editor = window.MixedNote.mount(tab.dom.editorHost, rich, canvas, {
noteType: tab.noteType,
onError: (message) => setStatus(tab, message, true)
});
tab.loaded = true;
tab.pendingContent = null;
// 基线取挂载后的序列化结果,而不是磁盘原值:画布会做 version 1→2 迁移等
// 规范化,拿磁盘值当基线会让刚打开的标签就被判定为"已修改"。
tab.baselineKey = null;
Promise.resolve(tab.editor.ready())
.then(() => { if (tab.editor && tab.baselineKey == null) tab.baselineKey = contentKey(tab); })
.catch(() => { /* 画布没起来时不做脏检查 */ });
watchEditorChanges(tab);
}
// MixedNote 没有 change 回调。用事件当"可能改过"的触发器,再比对序列化内容
// 才置 dirty:画布上单纯点一下工具或按方向键也会冒泡出 pointerdown/keydown
// 只按事件置 dirty 会让没动过内容的标签在关窗时弹出无意义的未保存提示。
function watchEditorChanges(tab) {
const host = tab.dom.editorHost;
unwatchEditorChanges(tab);
const handler = () => scheduleDirtyCheck(tab);
const types = ['input', 'pointerup', 'keyup'];
tab.editorWatcher = types.map((type) => [type, handler]);
for (const type of types) host.addEventListener(type, handler);
}
function unwatchEditorChanges(tab) {
if (!tab.editorWatcher) return;
for (const [type, handler] of tab.editorWatcher) {
tab.dom.editorHost.removeEventListener(type, handler);
}
tab.editorWatcher = null;
}
function contentKey(tab) {
if (!tab.editor) return tab.baselineKey;
return tab.noteType === 'canvas'
? JSON.stringify(tab.editor.canvasContent())
: JSON.stringify(tab.editor.richContent());
}
function scheduleDirtyCheck(tab) {
if (tab.dirty || tab.dirtyTimer || tab.baselineKey == null) return;
// 画布 fabric 的对象要等一帧才落到 content(),立刻比对会读到旧值
tab.dirtyTimer = setTimeout(() => {
tab.dirtyTimer = 0;
if (tab.dirty || !tab.editor || tab.baselineKey == null) return;
if (contentKey(tab) !== tab.baselineKey) markDirty(tab);
}, 250);
}
function touch(tab) {
tab.touch = ++touchSeq;
}
// 回收非活跃标签的编辑器。未保存内容先序列化,绝不能直接 destroy。
async function evictExcept(keep) {
const live = tabs.filter((tab) => tab.editor);
if (live.length <= MAX_LIVE_EDITORS) return;
const victims = live
.filter((tab) => tab !== keep && tab.noteId !== activeId)
.sort((a, b) => a.touch - b.touch);
let count = live.length;
while (count > MAX_LIVE_EDITORS && victims.length) {
const victim = victims.shift();
await release(victim);
count -= 1;
}
}
async function release(tab) {
if (!tab.editor) return;
try {
await tab.editor.ready();
} catch (e) { /* 画布没加载成功也要继续回收 */ }
tab.pendingContent = {
richContent: tab.noteType === 'reading' ? tab.editor.richContent() : null,
canvasContent: tab.noteType === 'canvas' ? tab.editor.canvasContent() : null,
text: tab.noteType === 'reading' ? tab.editor.text() : ''
};
unwatchEditorChanges(tab);
if (tab.dirtyTimer) { clearTimeout(tab.dirtyTimer); tab.dirtyTimer = 0; }
try { tab.editor.destroy(); } catch (e) { /* ignore */ }
tab.editor = null;
tab.loaded = false;
}
async function openTab(entryId, noteId) {
const key = String(noteId || '');
if (!key) return;
const exist = tabOf(key);
if (exist) {
await activate(key);
return;
}
let res;
try {
res = await api.notes.getOne(entryId, key);
} catch (e) {
res = { ok: false, error: (e && e.message) || String(e) };
}
if (!res || !res.ok || !res.data) {
showError(`无法打开这条笔记:${errText(res, '笔记不存在或已被删除')}`);
el.loading.classList.add('hidden');
return;
}
clearError();
const note = res.data;
const tab = {
id: ++tabSeq,
noteId: String(note.id),
entryId: String(note.entryId),
note,
noteType: noteTypeOf(note),
view: null,
dom: null,
editor: null,
editorWatcher: null,
pendingContent: null,
baselineKey: null,
collectionFilled: false,
loaded: false,
dirty: false,
saving: false,
statusTimer: 0,
dirtyTimer: 0,
touch: 0
};
buildView(tab);
fillFields(tab);
tabs.push(tab);
el.loading.classList.add('hidden');
reportTabs();
await activate(tab.noteId);
}
async function activate(noteId) {
const tab = tabOf(noteId);
if (!tab) return;
const prev = activeTab();
if (prev && prev !== tab) prev.view.classList.add('inactive');
activeId = tab.noteId;
tab.view.classList.remove('inactive');
touch(tab);
if (!tab.loaded) {
mountEditor(tab);
await evictExcept(tab);
}
renderTabs();
document.title = `${noteLabel(tab.note)} - PeopleLib`;
if (tab.dom) tab.dom.title.focus();
}
// 未保存时的三选一。取消返回 null,让调用方原样放弃关闭动作。
function confirmDirty(tab) {
if (dirtyResolve) return Promise.resolve(null);
el.dirtyNotice.textContent = `${noteLabel(tab.note)}」有未保存的修改,关闭后会丢失。`;
el.dirtyModal.classList.remove('hidden');
requestAnimationFrame(() => el.dirtyCancelBtn.focus());
return new Promise((resolve) => { dirtyResolve = resolve; });
}
function settleDirty(choice) {
if (!dirtyResolve) return;
const resolve = dirtyResolve;
dirtyResolve = null;
el.dirtyModal.classList.add('hidden');
resolve(choice);
}
// force 用于笔记已在别处被删除:此时不能再提示保存,
// 保存会把已删的笔记整条写回去。
async function closeTab(noteId, force = false) {
const tab = tabOf(noteId);
if (!tab) return true;
if (!force && tab.dirty) {
await activate(tab.noteId);
const choice = await confirmDirty(tab);
if (choice === 'cancel' || choice == null) return false;
if (choice === 'save') {
const ok = await save(tab);
if (!ok) return false;
}
}
await release(tab);
tab.view.remove();
if (tab.statusTimer) clearTimeout(tab.statusTimer);
const index = tabs.indexOf(tab);
if (index >= 0) tabs.splice(index, 1);
if (activeId === tab.noteId) {
activeId = '';
const next = tabs[Math.min(index, tabs.length - 1)];
if (next) await activate(next.noteId);
}
renderTabs();
reportTabs();
if (!tabs.length && !closing) api.close();
return true;
}
async function save(tab) {
if (!tab || tab.saving) return false;
if (tab.editor) await tab.editor.ready();
const content = tab.editor
? {
richContent: tab.noteType === 'reading' ? tab.editor.richContent() : null,
canvasContent: tab.noteType === 'canvas' ? tab.editor.canvasContent() : null,
text: tab.noteType === 'reading' ? tab.editor.text() : '',
hasContent: tab.editor.hasContent()
}
: {
richContent: tab.pendingContent ? tab.pendingContent.richContent : null,
canvasContent: tab.pendingContent ? tab.pendingContent.canvasContent : null,
text: tab.pendingContent ? tab.pendingContent.text : '',
hasContent: !!(tab.pendingContent
&& (tab.pendingContent.richContent || tab.pendingContent.canvasContent))
};
if (!content.hasContent && !String(tab.note.quote || '').trim()) {
setStatus(tab, '请输入笔记内容', true);
return false;
}
// 只提交本窗口负责的字段。locator / documentKey / quote 等定位信息保持原样,
// 独立窗口没有正文上下文,跟着一起写回会把摘录的定位覆盖成空。
const patch = {
noteType: tab.noteType,
title: tab.dom.title.value.trim(),
...(tab.noteType === 'canvas'
? { canvasContent: content.canvasContent }
: { text: String(content.text || '').trim(), richContent: content.richContent }),
collectionId: tab.dom.collection.value || null,
tags: tagsFromInput(tab.dom.tags.value),
pinned: tab.dom.pinned.checked
};
tab.saving = true;
tab.dom.saveBtn.disabled = true;
let res;
try {
res = await api.reader.updateNote(tab.entryId, tab.noteId, patch);
} catch (e) {
res = { ok: false, error: (e && e.message) || String(e) };
} finally {
tab.saving = false;
tab.dom.saveBtn.disabled = false;
}
if (!res || !res.ok) {
setStatus(tab, `保存失败:${errText(res, '未知错误')}`, true);
return false;
}
if (res.data) tab.note = { ...tab.note, ...res.data };
tab.dirty = false;
// 基线要跟着落盘内容前移,否则保存完立刻又被判定为已修改
if (tab.editor) tab.baselineKey = contentKey(tab);
renderTabs();
setStatus(tab, '已保存');
return true;
}
// 关窗前逐个处理未保存标签。任一取消就中止关闭,
// 处理完才允许主进程真正销毁窗口。
async function prepareClose() {
if (closing) return;
closing = true;
for (const tab of tabs.slice()) {
if (!tab.dirty) continue;
await activate(tab.noteId);
const choice = await confirmDirty(tab);
if (choice === 'cancel' || choice == null) {
await abortClose();
return;
}
if (choice === 'save') {
const ok = await save(tab);
if (!ok) { await abortClose(); return; }
} else {
tab.dirty = false;
}
}
try {
await api.notes.shutdownReady();
} catch (e) {
await abortClose();
}
}
// 取消关闭必须通知主进程复位:否则主进程的 closePending 一直为真,
// 下次点关闭会被当成"正在处理"直接忽略,而看门狗仍会在十秒后销毁窗口。
async function abortClose() {
closing = false;
if (!api.notes.cancelClose) return;
try {
await api.notes.cancelClose();
} catch (e) { /* 主进程已退出时无所谓 */ }
}
function bind() {
el.minBtn.addEventListener('click', () => api.minimize());
el.maxBtn.addEventListener('click', () => api.maximize());
el.closeBtn.addEventListener('click', () => api.close());
el.uiThemeBtn.addEventListener('click', () => {
applyUiTheme(uiTheme === 'dark' ? 'light' : 'dark');
Promise.resolve(api.ui.setTheme(uiTheme)).catch(() => { /* 主题偏好丢失不影响编辑 */ });
});
el.dirtyCancelBtn.addEventListener('click', () => settleDirty('cancel'));
el.dirtyDiscardBtn.addEventListener('click', () => settleDirty('discard'));
el.dirtySaveBtn.addEventListener('click', () => settleDirty('save'));
window.addEventListener('keydown', (event) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
event.preventDefault();
save(activeTab());
return;
}
if (event.key === 'Escape' && dirtyResolve) settleDirty('cancel');
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'w') {
event.preventDefault();
if (activeId) closeTab(activeId);
}
});
if (api.notes.onOpenTab) {
api.notes.onOpenTab((data) => {
if (!data || !data.noteId) return;
openTab(data.entryId, data.noteId);
});
}
if (api.notes.onCloseTab) {
api.notes.onCloseTab(async (data) => {
const ids = data && Array.isArray(data.noteIds) ? data.noteIds : [];
// 笔记已被删除,force 关闭:再提示保存会把已删条目写回去
for (const id of ids) await closeTab(String(id), true);
});
}
if (api.notes.onPrepareClose) api.notes.onPrepareClose(() => { prepareClose(); });
api.reader.onNotesChanged(() => { refreshCollections(); });
}
(async function start() {
bind();
try {
const saved = await api.ui.getTheme();
applyUiTheme(saved && saved.ok ? saved.data : 'dark');
} catch (e) {
applyUiTheme('dark');
}
api.ui.onThemeChanged((next) => applyUiTheme(next));
await refreshCollections();
await openTab(params.get('entryId') || '', params.get('noteId') || '');
})();
+44 -2
View File
@@ -21,6 +21,7 @@ const Notes = (() => {
let selectedTag = '';
let selectedNoteType = '';
let searchText = '';
let openWindowIds = new Set();
let listEl;
let statusEl;
let collectionListEl;
@@ -92,6 +93,27 @@ const Notes = (() => {
render();
};
});
if (window.api.notes && window.api.notes.onWindowsChanged) {
window.api.notes.onWindowsChanged((ids) => {
openWindowIds = new Set((Array.isArray(ids) ? ids : []).map((id) => String(id)));
render();
});
refreshOpenWindows();
}
}
async function refreshOpenWindows() {
if (!window.api.notes || !window.api.notes.openWindows) return;
let res;
try {
res = await window.api.notes.openWindows();
} catch (error) {
return;
}
if (!res || !res.ok || !Array.isArray(res.data)) return;
openWindowIds = new Set(res.data.map((id) => String(id)));
render();
}
function markDirty() {
@@ -327,6 +349,7 @@ const Notes = (() => {
function renderNote(note, collectionNames) {
const card = document.createElement('article');
card.className = 'note-card';
card.dataset.noteId = String(note.id);
card.dataset.noteType = note.noteType || (note.canvasContent ? 'canvas' : 'reading');
if (note.pinned) card.classList.add('pinned');
@@ -445,8 +468,11 @@ const Notes = (() => {
const actions = document.createElement('div');
actions.className = 'note-actions';
const editButton = actionButton('编辑', 'edit');
editButton.onclick = () => editNote(note);
// 独立窗口是单窗口多标签,这里的"已开"指这条笔记已占了一个标签
const windowOpen = openWindowIds.has(String(note.id));
// 已开标签时不再开模态:同一条笔记两处编辑,后保存者会整条覆盖前者
const editButton = actionButton(windowOpen ? '在窗口中编辑' : '编辑', 'edit');
editButton.onclick = () => (windowOpen ? openNoteWindow(note) : editNote(note));
const deleteButton = actionButton('删除', 'delete');
deleteButton.onclick = () => deleteNote(note);
if (note.associated !== false) {
@@ -454,6 +480,9 @@ const Notes = (() => {
openButton.onclick = () => openNote(note);
actions.appendChild(openButton);
}
const windowButton = actionButton(windowOpen ? '切到窗口' : '独立窗口', 'window');
windowButton.onclick = () => openNoteWindow(note);
actions.appendChild(windowButton);
actions.append(editButton, deleteButton);
footer.append(meta, actions);
card.appendChild(footer);
@@ -481,6 +510,19 @@ const Notes = (() => {
});
}
async function openNoteWindow(note) {
let result;
try {
result = await window.api.notes.openWindow(note.entryId, note.id);
} catch (error) {
await confirmModal('无法打开', errorText(error, '打开笔记窗口失败'));
return;
}
if (!result || !result.ok) {
await confirmModal('无法打开', errorText(result && result.error, '打开笔记窗口失败'));
}
}
async function openNote(note) {
if (note.associated === false || !note.entryId) {
await confirmModal('无法打开', '这条笔记缺少书籍定位信息。');