构建与发布 / 单测与集成测试 (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
588 lines
30 KiB
JavaScript
588 lines
30 KiB
JavaScript
// 验证 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',
|
||
''
|
||
].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);
|
||
});
|