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
+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);
});