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:
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user