feat: 内置阅读器、批注笔记与 AI 助手,发布 1.3.0

新增 PDF/EPUB/MOBI/AZW 内置阅读器,PDF 分段读取支持超大文件,
批注、读书与画布笔记、封面生成与本地导入。AI 助手支持三种协议、
图像上下文与安全 Markdown 渲染,上下文范围改为 选中/当前页/全文,
页面与全文无需选中文本即可发送,全文会提示可能超出模型限制。

便携版输出目录固定为 PeopleLib-windows-x64,不再随版本号变化,
避免升级后 data/ 被遗留在旧目录。

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
lofyer
2026-08-03 12:13:02 +08:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent b8c8d24107
commit 3ccd044527
307 changed files with 98477 additions and 1148 deletions
+436
View File
@@ -0,0 +1,436 @@
// 验证 AI 上下文范围由用户选择,且大上下文必须确认后才外发。
// 用真实的本地 OpenAI 兼容服务接收请求,断言"实际离开进程的内容",而不是 stub 渲染层。
const { app, BrowserWindow, clipboard, shell, nativeImage } = require('electron');
const path = require('path');
const fs = require('fs');
const os = require('os');
const http = require('http');
const ROOT = path.resolve(__dirname, '..', '..', '..');
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'plscope-'));
app.setPath('userData', TMP);
app.setPath('appData', TMP);
const results = [];
function chk(name, cond, extra = '') { results.push([cond ? 'OK' : 'FAIL', name, extra]); }
const openedExternal = [];
const openExternalStub = async (url) => { openedExternal.push(url); };
shell.openExternal = openExternalStub;
if (shell.openExternal !== openExternalStub) throw new Error('无法隔离外部链接测试');
const AI_MARKDOWN = [
'# 回答\n\n',
'1. **第一项**\n2. 第二项\n\n',
'```js\nconsole.log("safe")\n```\n\n',
'| 项目 | 结论 |\n| --- | --- |\n| A | 可用 |\n\n',
'[安全链接](https://example.com/path)\n\n',
'[危险链接](javascript:alert(1))\n\n',
'<img src=x onerror="window.__aiXss=true">\n\n',
'![远程图片](https://untrusted.example/tracker.png)'
].join('');
// 真实的本地模型服务:记录每次收到的 body
const received = [];
const requests = [];
const server = http.createServer((req, res) => {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
let parsed;
try { parsed = JSON.parse(body); } catch (e) { parsed = { parseError: body.slice(0, 80) }; }
received.push(parsed);
requests.push({ url: req.url, headers: req.headers, body: parsed });
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
if (req.url.endsWith('/messages')) {
res.write(`event: content_block_delta\ndata: ${JSON.stringify({ type: 'content_block_delta', delta: { type: 'text_delta', text: 'Anthropic 正常' } })}\n\n`);
res.write(`event: message_stop\ndata: ${JSON.stringify({ type: 'message_stop' })}\n\n`);
res.end();
return;
}
if (req.url.endsWith('/responses')) {
res.write(`event: response.output_text.delta\ndata: ${JSON.stringify({ type: 'response.output_text.delta', delta: 'Responses 正常' })}\n\n`);
res.write(`event: response.completed\ndata: ${JSON.stringify({ type: 'response.completed', response: { status: 'completed' } })}\n\n`);
res.end();
return;
}
res.write(`data: ${JSON.stringify({ choices: [{ delta: { content: AI_MARKDOWN.slice(0, 80) } }] })}\n\n`);
setTimeout(() => {
res.write(`data: ${JSON.stringify({ choices: [{ delta: { content: AI_MARKDOWN.slice(80) } }] })}\n\n`);
res.write('data: [DONE]\n\n');
res.end();
}, 300);
});
});
function charsOf(request) {
const msgs = (request && request.messages) || [];
return msgs.reduce((k, m) => k + (typeof (m && m.content) === 'string' ? m.content.length : 0), 0);
}
app.whenReady().then(async () => {
await new Promise((r) => server.listen(0, '127.0.0.1', r));
const port = server.address().port;
const epubPath = path.join(os.tmpdir(), 'plscope-cache', 's.epub');
fs.mkdirSync(path.dirname(epubPath), { recursive: true });
if (!fs.existsSync(epubPath)) {
const { fetch: uf, ProxyAgent } = require('undici');
const r = await uf('https://www.gutenberg.org/ebooks/11.epub.noimages', {
dispatcher: new ProxyAgent({ uri: 'http://127.0.0.1:7890', connectTimeout: 30000 })
});
fs.writeFileSync(epubPath, Buffer.from(await r.arrayBuffer()));
}
require(path.join(ROOT, 'main.js'));
const settings = require(path.join(ROOT, 'src', 'settings'));
const readerStore = require(path.join(ROOT, 'src', 'reader', 'store'));
const aiConfig = require(path.join(ROOT, 'src', 'reader', 'ai-config'));
const library = require(path.join(ROOT, 'src', 'library', 'store'));
settings.init(TMP);
readerStore.init(TMP);
aiConfig.init(TMP, require('electron').safeStorage);
library.init(path.join(TMP, 'library'));
require(path.join(ROOT, 'src', 'sources', 'http')).setProxy('');
aiConfig.save({
protocol: 'chat-completions',
baseUrl: `http://127.0.0.1:${port}/v1`,
model: 'test-model',
apiKey: '',
vision: true
});
const e = library.add({ title: 'Alice', authors: [], files: [{ path: epubPath, name: 's.epub', format: 'EPUB' }] });
await new Promise((r) => setTimeout(r, 2500));
for (const w of BrowserWindow.getAllWindows()) w.hide();
const win = new BrowserWindow({
show: false, width: 1200, height: 860,
webPreferences: { preload: path.join(ROOT, 'preload.js'), contextIsolation: true, nodeIntegration: false }
});
const errs = [];
win.webContents.on('console-message', (event) => {
const { level, message } = event;
if (level >= 2 && !/Autofill|Indexing all PDF objects/.test(message)) {
errs.push(message.slice(0, 120));
}
});
await win.loadFile(path.join(ROOT, 'src', 'ui', 'reader.html'), { query: { entryId: e.id } });
await new Promise((r) => setTimeout(r, 9000));
const js = async (code) => {
try { return await win.webContents.executeJavaScript(code); }
catch (err) { return 'ERR ' + err.message.slice(0, 90); }
};
await js("(function(){var n=document.querySelectorAll('#tocList [data-idx], #tocList .toc-item, #tocList button');if(n[3])n[3].click();return n.length})()");
await new Promise((r) => setTimeout(r, 3000));
chk('上下文选择器存在', (await js("!!document.getElementById('aiScope')")) === true);
chk('默认范围是"仅选中文本"', (await js("document.getElementById('aiScope').value")) === 'selection');
chk('文本和图像五个范围选项齐全',
(await js("Array.from(document.getElementById('aiScope').options).map(o=>o.value).join(',')")) === 'selection,page,document,page-image,region-image');
await js("document.querySelector('[data-pane=\"ai\"]').click()");
await new Promise((r) => setTimeout(r, 800));
chk('未选中文本时给出提示', String(await js("document.getElementById('aiCost').textContent")).includes('未选中'));
// 打开阅读器并静置:不应有任何请求发往模型
await new Promise((r) => setTimeout(r, 3000));
chk('空闲时不会自动调用模型', received.length === 0, '请求数=' + received.length);
// 页面/全文范围不依赖选中文本:此时正文里没有任何选区
chk('切换范围前确实没有选中文本',
(await js("String(window.getSelection() ? window.getSelection().toString() : '').trim().length")) === 0);
await js("var s=document.getElementById('aiScope'); s.value='page'; s.dispatchEvent(new Event('change'));");
await new Promise((r) => setTimeout(r, 1500));
const pageCostText = String(await js("document.getElementById('aiCost').textContent"));
chk('未选中文本时当前页范围仍可估算', /字.*tokens/.test(pageCostText), pageCostText);
const pageChars = Number((/([\d,]+)\s*字/.exec(pageCostText) || [0, '0'])[1].replace(/,/g, ''));
chk('当前页范围估算出非空正文', pageChars > 0, '字数=' + pageChars);
await js("var s=document.getElementById('aiScope'); s.value='document'; s.dispatchEvent(new Event('change'));");
await new Promise((r) => setTimeout(r, 8000));
const costText = String(await js("document.getElementById('aiCost').textContent"));
chk('全文范围显示字数与 token 估算', /字.*tokens/.test(costText), costText);
chk('全文范围提示可能超过模型限制', /可能超过模型限制/.test(costText), costText);
const docChars = Number((/([\d,]+)\s*字/.exec(costText) || [0, '0'])[1].replace(/,/g, ''));
chk('全文范围覆盖整本而不仅当前页', docChars > pageChars * 5, `全文=${docChars} 当前页=${pageChars}`);
// 全文提问 + 用户拒绝 => 一个字都不该发出去
await js("document.getElementById('aiQuestion').value='这章讲了什么';document.getElementById('aiSendBtn').click();");
await new Promise((r) => setTimeout(r, 2500));
chk('大上下文会显示应用内确认框',
(await js("!document.getElementById('aiConfirmModal').classList.contains('hidden')")) === true);
const summary = String(await js(
"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('确认框使用应用按钮而非原生弹窗',
(await js("document.getElementById('aiConfirmSendBtn').textContent.trim()")) === '继续发送');
const captureDir = process.env.PEOPLELIB_CAPTURE_DIR || TMP;
fs.writeFileSync(path.join(captureDir, 'ai-send-confirmation.png'), (await win.webContents.capturePage()).toPNG());
await js("document.getElementById('aiConfirmCancelBtn').click()");
await new Promise((r) => setTimeout(r, 500));
chk('用户拒绝后没有任何外发请求', received.length === 0, '请求数=' + received.length);
chk('提问框内容在取消后保留', (await js("document.getElementById('aiQuestion').value")) === '这章讲了什么');
// 用户同意 => 才真正发送全文
await js("document.getElementById('aiSendBtn').click();");
await new Promise((r) => setTimeout(r, 1200));
await js("document.getElementById('aiConfirmSendBtn').click()");
await new Promise((r) => setTimeout(r, 180));
chk('流式传输过程中稳定渲染不完整 Markdown', await js(`(() => {
const output = document.getElementById('aiOutput');
return output.classList.contains('streaming')
&& output.querySelector('h1')?.textContent === '回答'
&& output.textContent.length > 0;
})()`));
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]));
chk('AI 回答使用成熟 Markdown 结构渲染', await js(`(() => {
const output = document.getElementById('aiOutput');
return output.querySelector('h1')?.textContent === '回答'
&& output.querySelector('strong')?.textContent === '第一项'
&& output.querySelectorAll('ol > li').length === 2
&& output.querySelector('pre code')?.textContent.includes('console.log')
&& output.querySelectorAll('table th').length === 2;
})()`));
chk('Markdown 链接和图片执行安全策略', await js(`(() => {
const output = document.getElementById('aiOutput');
const safe = output.querySelector('a[data-external-url]');
return safe?.dataset.externalUrl === 'https://example.com/path'
&& safe.getAttribute('href') === '#'
&& !output.querySelector('a[href^="javascript:"], img, script, iframe, object')
&& !!output.querySelector('.ai-md-image-placeholder')
&& window.__aiXss !== true;
})()`));
await js("document.querySelector('#aiOutput a[data-external-url]').click()");
await new Promise((r) => setTimeout(r, 200));
chk('安全链接通过主进程校验后打开', openedExternal.join(',') === 'https://example.com/path');
await js("document.getElementById('aiCopyBtn').click()");
await new Promise((r) => setTimeout(r, 200));
chk('复制 AI 回答保留原始 Markdown', clipboard.readText() === AI_MARKDOWN);
await js("var s=document.getElementById('aiScope'); s.value='page-image'; s.dispatchEvent(new Event('change'));");
await new Promise((r) => setTimeout(r, 2000));
const pageVisual = await js(`(() => {
const card = document.getElementById('aiVisualCard');
const image = document.getElementById('aiVisualPreview');
return {
visible: !card.classList.contains('hidden'),
source: image.getAttribute('src') || '',
meta: document.getElementById('aiVisualMeta').textContent,
ocrDisabled: document.getElementById('aiOcrBtn').disabled
};
})()`);
chk('当前页面图像生成内存预览并保留 OCR 入口',
pageVisual.visible
&& pageVisual.source.startsWith('data:image/jpeg;base64,')
&& /\d+ × \d+/.test(pageVisual.meta)
&& pageVisual.ocrDisabled);
await js("document.getElementById('aiQuestion').value='这张页面图像讲了什么';document.getElementById('aiSendBtn').click();");
await new Promise((r) => setTimeout(r, 600));
chk('发送图像前明确显示上传尺寸和数量', await js(`(() => {
const modal = document.getElementById('aiConfirmModal');
return !modal.classList.contains('hidden')
&& document.getElementById('aiConfirmScope').textContent.includes('图像')
&& document.getElementById('aiConfirmCost').textContent.includes('1 张图像');
})()`));
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 pageImage = Array.isArray(pageContent)
? pageContent.find((part) => part && part.type === 'image_url')
: null;
const pageImageUrl = pageImage && pageImage.image_url && pageImage.image_url.url;
const pageImageBytes = typeof pageImageUrl === 'string'
? Buffer.from(pageImageUrl.slice(pageImageUrl.indexOf(',') + 1), 'base64')
: Buffer.alloc(0);
chk('当前页面仅以内嵌受限图像发送给视觉模型',
received.length === 2
&& /^data:image\/jpeg;base64,/.test(pageImageUrl || '')
&& pageImageBytes.length > 100
&& pageImageBytes.length <= 3 * 1024 * 1024);
const pageImageSize = nativeImage.createFromBuffer(pageImageBytes).getSize();
chk('页面图像压到目标体积以内并限制在 1600px',
pageImageBytes.length <= 400 * 1024
&& Math.max(pageImageSize.width, pageImageSize.height) <= 1600,
`${pageImageSize.width}x${pageImageSize.height} ${Math.round(pageImageBytes.length / 1024)}KB`);
await js("var s=document.getElementById('aiScope'); s.value='region-image'; s.dispatchEvent(new Event('change'));");
await new Promise((r) => setTimeout(r, 500));
const selectionReady = await js(`(() => {
const overlay = document.querySelector('.visual-select-overlay');
const viewport = document.querySelector('.epub-scroll').getBoundingClientRect();
if (!overlay) return false;
const x1 = viewport.left + 80;
const y1 = viewport.top + 100;
const x2 = Math.min(viewport.right - 40, x1 + 300);
const y2 = Math.min(viewport.bottom - 40, y1 + 220);
overlay.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerId: 41, button: 0, buttons: 1, clientX: x1, clientY: y1 }));
overlay.dispatchEvent(new PointerEvent('pointermove', { bubbles: true, pointerId: 41, buttons: 1, clientX: x2, clientY: y2 }));
overlay.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, pointerId: 41, button: 0, clientX: x2, clientY: y2 }));
const box = overlay.querySelector('.visual-select-box');
const initial = box.getBoundingClientRect();
box.dispatchEvent(new PointerEvent('pointerdown', {
bubbles: true, pointerId: 42, button: 0, buttons: 1,
clientX: initial.left + initial.width / 2, clientY: initial.top + initial.height / 2
}));
overlay.dispatchEvent(new PointerEvent('pointermove', {
bubbles: true, pointerId: 42, buttons: 1,
clientX: initial.left + initial.width / 2 + 16, clientY: initial.top + initial.height / 2 + 12
}));
overlay.dispatchEvent(new PointerEvent('pointerup', {
bubbles: true, pointerId: 42, button: 0,
clientX: initial.left + initial.width / 2 + 16, clientY: initial.top + initial.height / 2 + 12
}));
const moved = box.getBoundingClientRect();
const handle = box.querySelector('.handle-se');
handle.dispatchEvent(new PointerEvent('pointerdown', {
bubbles: true, pointerId: 43, button: 0, buttons: 1,
clientX: moved.right, clientY: moved.bottom
}));
overlay.dispatchEvent(new PointerEvent('pointermove', {
bubbles: true, pointerId: 43, buttons: 1,
clientX: moved.right + 20, clientY: moved.bottom + 16
}));
overlay.dispatchEvent(new PointerEvent('pointerup', {
bubbles: true, pointerId: 43, button: 0,
clientX: moved.right + 20, clientY: moved.bottom + 16
}));
const resized = box.getBoundingClientRect();
return !overlay.querySelector('.visual-select-actions').classList.contains('hidden')
&& overlay.querySelectorAll('.visual-select-handle').length === 4
&& moved.left > initial.left
&& moved.top > initial.top
&& resized.width > moved.width
&& resized.height > moved.height;
})()`);
chk('框选区域支持创建、移动及四角调整', selectionReady);
await js("document.querySelector('.visual-select-actions .tb-btn').click()");
await new Promise((r) => setTimeout(r, 2000));
const regionVisual = await js(`(() => ({
visible: !document.getElementById('aiVisualCard').classList.contains('hidden'),
label: document.getElementById('aiVisualLabel').textContent,
meta: document.getElementById('aiVisualMeta').textContent,
overlayGone: !document.querySelector('.visual-select-overlay')
}))()`);
chk('确认框选后恢复 AI 面板并显示区域预览',
regionVisual.visible && regionVisual.label === '框选区域' && regionVisual.overlayGone);
await js("document.getElementById('aiQuestion').value='这个框选区域是什么';document.getElementById('aiSendBtn').click();");
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 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 || ''));
chk('超大回答降级为纯文本以限制解析开销', await js(`(() => {
const output = document.getElementById('aiOutput');
const text = 'x'.repeat(256 * 1024 + 1);
window.AiMarkdown.mount(output, text);
return output.classList.contains('ai-output-plain')
&& output.textContent.length === text.length
&& output.children.length === 0;
})()`));
const saved = await js("window.api.settings.get('reader.aiScope','selection').then(r=>r.data)");
chk('范围选择已持久化', saved === 'region-image', String(saved));
// 旧版本存过 chapter,升级后必须迁移到 document,而不是回落成 selection
await js("window.api.settings.set('reader.aiScope','chapter')");
const migrationWin = new BrowserWindow({
show: false, width: 1200, height: 860,
webPreferences: { preload: path.join(ROOT, 'preload.js'), contextIsolation: true, nodeIntegration: false }
});
await migrationWin.loadFile(path.join(ROOT, 'src', 'ui', 'reader.html'), { query: { entryId: e.id } });
await new Promise((r) => setTimeout(r, 9000));
const migratedValue = await migrationWin.webContents.executeJavaScript("document.getElementById('aiScope').value");
const migratedSaved = await migrationWin.webContents.executeJavaScript(
"window.api.settings.get('reader.aiScope','selection').then(r=>r.data)"
);
chk('旧 chapter 设置迁移为全文', migratedValue === 'document' && migratedSaved === 'document',
`${migratedValue}/${migratedSaved}`);
migrationWin.destroy();
const regionDataUrl = regionImage?.image_url?.url || '';
const encoded = regionDataUrl.slice(regionDataUrl.indexOf(',') + 1);
const imageBytes = Buffer.from(encoded, 'base64');
const imageSize = nativeImage.createFromBuffer(imageBytes).getSize();
const visualContext = {
kind: 'region',
includeImage: true,
image: {
mimeType: 'image/jpeg',
base64: encoded,
width: imageSize.width,
height: imageSize.height,
bytes: imageBytes.length
},
ocr: { status: 'idle', text: '', include: false }
};
const aiClient = require(path.join(ROOT, 'src', 'reader', 'ai-client'));
aiConfig.save({
protocol: 'anthropic',
baseUrl: `http://127.0.0.1:${port}/v1`,
model: 'claude-fixture',
apiKey: '',
vision: true
});
const anthropicText = await aiClient.stream({
task: 'ask',
text: '',
question: '测试 Anthropic 图片',
visualContexts: [visualContext]
});
const anthropicRequest = requests.at(-1);
const anthropicImage = anthropicRequest?.body?.messages?.[0]?.content?.[1];
chk('Anthropic Messages API 真实请求使用 base64 source',
anthropicText === 'Anthropic 正常'
&& anthropicRequest?.url === '/v1/messages'
&& anthropicRequest?.headers?.['anthropic-version'] === '2023-06-01'
&& anthropicImage?.type === 'image'
&& anthropicImage?.source?.type === 'base64'
&& anthropicImage?.source?.media_type === 'image/jpeg');
aiConfig.save({
protocol: 'openai-responses',
baseUrl: `http://127.0.0.1:${port}/v1`,
model: 'responses-fixture',
apiKey: '',
vision: true
});
const responsesText = await aiClient.stream({
task: 'ask',
text: '',
question: '测试 Responses 图片',
visualContexts: [visualContext]
});
const responsesRequest = requests.at(-1);
const responsesImage = responsesRequest?.body?.input?.[0]?.content?.[1];
chk('OpenAI Responses API 真实请求使用 input_image',
responsesText === 'Responses 正常'
&& responsesRequest?.url === '/v1/responses'
&& responsesImage?.type === 'input_image'
&& /^data:image\/jpeg;base64,/.test(responsesImage?.image_url || ''));
chk('无渲染层报错', errs.length === 0, errs.slice(0, 2).join(' | '));
console.log('\n========== AI 上下文控制验证 ==========');
for (const [s, n, x] of results) console.log(`${s.padEnd(5)} ${n}${x ? ' [' + x + ']' : ''}`);
const bad = results.filter((r) => r[0] === 'FAIL').length;
console.log(`\n通过 ${results.length - bad}/${results.length}`);
server.close();
app.exit(bad ? 1 : 0);
}).catch((e) => { console.error('异常:', e); app.exit(1); });