Files
peoplelib/src/_test/electron/ai-scope.integration.js
T
lofyer 3f8ae81a14
构建与发布 / 单测与集成测试 (push) Waiting to run
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
构建与发布 / 发布 GitHub Release (push) Blocked by required conditions
test: 集成夹具的代理改为读 HTTPS_PROXY,不再写死本机端口
2026-08-05 20:01:33 +08:00

588 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 验证 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');
// 代理只在设了 HTTPS_PROXY 时用,写死本机代理会让 CI 直接 ECONNREFUSED
const proxy = process.env.HTTPS_PROXY || process.env.https_proxy || '';
const r = await uf('https://www.gutenberg.org/ebooks/11.epub.noimages', {
dispatcher: proxy ? new ProxyAgent({ uri: proxy, connectTimeout: 30000 }) : undefined
});
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]));
// 界面告知的字数必须等于真正离开进程的字数。旧行为在这里砍掉 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 === '回答'
&& 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.at(-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.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)))));
async function waitForNoteCount(count) {
const deadline = Date.now() + 5000;
let notes = [];
while (Date.now() < deadline) {
notes = readerStore.listNotes({ entryId: e.id });
if (notes.length >= count) return notes;
await new Promise((r) => setTimeout(r, 100));
}
return notes;
}
const waitForSaveModal = () => js(`new Promise((resolve) => {
const deadline = Date.now() + 3000;
const check = () => {
if (!document.getElementById('aiSessionSaveModal').classList.contains('hidden')) {
resolve(true);
} else if (Date.now() >= deadline) {
resolve(false);
} else {
setTimeout(check, 50);
}
};
check();
})`);
const notesBeforeSave = readerStore.listNotes({ entryId: e.id });
const noteIdsBeforeSave = new Set(notesBeforeSave.map((note) => note.id));
await js("document.getElementById('aiSaveBtn').click()");
await waitForSaveModal();
await js(`(() => {
const recent = document.getElementById('aiSessionSaveRecent');
const rounds = document.getElementById('aiSessionSaveRounds');
recent.click();
rounds.value = '2';
rounds.dispatchEvent(new Event('input', { bubbles: true }));
document.getElementById('aiSessionSaveConfirmBtn').click();
})()`);
const notesAfterRecent = await waitForNoteCount(notesBeforeSave.length + 1);
const recentNote = notesAfterRecent.find((note) => !noteIdsBeforeSave.has(note.id));
const recentText = String(recentNote?.text || '');
const recentSecondQuestion = recentText.indexOf('这张页面图像讲了什么');
const recentSecondAnswer = recentText.indexOf(AI_MARKDOWN, recentSecondQuestion);
const recentThirdQuestion = recentText.indexOf('这个框选区域是什么', recentSecondAnswer);
const recentThirdAnswer = recentText.indexOf(AI_MARKDOWN, recentThirdQuestion);
chk('保存最近两轮只新增一条 AI 会话笔记',
notesAfterRecent.length === notesBeforeSave.length + 1
&& recentNote?.source === 'ai'
&& recentNote?.title === storedList[0].title,
`新增=${notesAfterRecent.length - notesBeforeSave.length} 来源=${recentNote?.source} 标题=${recentNote?.title}`);
chk('最近两轮笔记正文排除第一轮并保持问答顺序',
!recentText.includes('这章讲了什么')
&& recentSecondQuestion >= 0
&& recentSecondAnswer > recentSecondQuestion
&& recentThirdQuestion > recentSecondAnswer
&& recentThirdAnswer > recentThirdQuestion,
recentText.slice(0, 160));
const recentPreference = settings.get('reader.aiSessionSave', null);
chk('保存最近两轮偏好已持久化',
recentPreference?.mode === 'recent' && recentPreference?.rounds === 2,
JSON.stringify(recentPreference));
const recentNoteIds = new Set(notesAfterRecent.map((note) => note.id));
await js("document.getElementById('aiSaveBtn').click()");
await waitForSaveModal();
await js(`(() => {
document.getElementById('aiSessionSaveAll').click();
document.getElementById('aiSessionSaveConfirmBtn').click();
})()`);
const notesAfterAll = await waitForNoteCount(notesAfterRecent.length + 1);
const allNote = notesAfterAll.find((note) => !recentNoteIds.has(note.id));
const allText = String(allNote?.text || '');
chk('保存全部三轮再次只新增一条会话笔记',
notesAfterAll.length === notesAfterRecent.length + 1
&& allNote?.source === 'ai'
&& allNote?.title === storedList[0].title
&& allText.includes('这章讲了什么')
&& allText.includes('这张页面图像讲了什么')
&& allText.includes('这个框选区域是什么'),
`新增=${notesAfterAll.length - notesAfterRecent.length} 标题=${allNote?.title}`);
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);
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);
for (const [s, n, x] of results) console.log(`${s.padEnd(5)} ${n}${x ? ' [' + x + ']' : ''}`);
app.exit(1);
});