// 验证 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', '\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); });