diff --git a/package-lock.json b/package-lock.json index ce34126..a12c408 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "peoplelib", - "version": "2.1.6", + "version": "2.1.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "peoplelib", - "version": "2.1.6", + "version": "2.1.7", "license": "MIT", "dependencies": { "foliate-js": "1.0.1", diff --git a/package.json b/package.json index 57a7550..1e184fd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "peoplelib", - "version": "2.1.6", + "version": "2.1.7", "description": "多源开放文献、电子书与本地书库客户端", "main": "main.js", "author": "peoplelib", diff --git a/src/_test/ai.test.js b/src/_test/ai.test.js index 6b32e6a..94b9c03 100644 --- a/src/_test/ai.test.js +++ b/src/_test/ai.test.js @@ -193,7 +193,7 @@ test('Anthropic 接口使用原生 Messages 图像 source 和流式事件', asyn assert.strictEqual(seen.options.headers['anthropic-version'], '2023-06-01'); assert.ok(!seen.options.headers.Authorization); assert.strictEqual(seen.body.system.includes('文档页面图像'), true); - assert.strictEqual(seen.body.messages.length, 1); + assert.strictEqual(seen.body.messages.length, 3); assert.strictEqual(seen.body.messages[0].content[0].type, 'text'); const image = seen.body.messages[0].content[1]; assert.strictEqual(image.type, 'image'); @@ -201,6 +201,12 @@ test('Anthropic 接口使用原生 Messages 图像 source 和流式事件', asyn assert.strictEqual(image.source.type, 'base64'); assert.strictEqual(image.source.media_type, 'image/png'); assert.ok(image.source.data.length > 0); + assert.deepStrictEqual( + seen.body.messages[0].content.at(-1).cache_control, + { type: 'ephemeral' }, + '稳定资料前缀末尾必须声明 Anthropic 缓存断点' + ); + assert.strictEqual(seen.body.messages[2].content, '问题:图中是什么?'); }); test('OpenAI Responses 接口使用 input_image 和响应增量事件', async () => { @@ -387,9 +393,9 @@ test('不支持的任务类型被拒绝', () => { test('ask 任务把问题与片段一起送出', () => { const ai = setup(); const msgs = ai.buildMessages('ask', '文档内容', '这讲了什么'); - assert.strictEqual(msgs.length, 2); + assert.strictEqual(msgs.length, 4); assert.ok(msgs[1].content.includes('文档内容')); - assert.ok(msgs[1].content.includes('这讲了什么')); + assert.ok(msgs[3].content.includes('这讲了什么')); assert.ok(/编造|没有提到/.test(msgs[0].content), '缺少防幻觉约束'); }); @@ -429,19 +435,23 @@ async function captureBody(protocol, args, options) { // history 缺省时请求体必须与单轮时代逐字节一致,否则等于悄悄改了单轮行为 test('未传 history 时三种协议请求体与单轮完全一致', async () => { - // 写死单轮的 user 正文,只比对"传与不传 history"两次结果会同时被同一个 bug 污染 - const expectedUser = '文档片段:\n"""\n正文\n"""\n\n问题:问题'; + // 写死资料与当前问题的分层,只比对"传与不传 history"会同时被同一个 bug 污染 + const expectedContext = '文档片段:\n"""\n正文\n"""'; + const expectedQuestion = '问题:问题'; for (const protocol of Object.keys(PROTOCOL_STREAMS)) { const base = await captureBody(protocol, { task: 'ask', text: '正文', question: '问题' }); if (protocol === 'chat-completions') { - assert.strictEqual(base.messages.length, 2, '单轮只该有 system + user'); - assert.strictEqual(base.messages[1].content, expectedUser); + assert.deepStrictEqual(base.messages.map((m) => m.role), ['system', 'user', 'assistant', 'user']); + assert.strictEqual(base.messages[1].content, expectedContext); + assert.strictEqual(base.messages[3].content, expectedQuestion); } else if (protocol === 'anthropic') { - assert.strictEqual(base.messages.length, 1, '单轮只该有一条 user'); - assert.strictEqual(base.messages[0].content, expectedUser); + assert.deepStrictEqual(base.messages.map((m) => m.role), ['user', 'assistant', 'user']); + assert.strictEqual(base.messages[0].content[0].text, expectedContext); + assert.strictEqual(base.messages[2].content, expectedQuestion); } else { - assert.strictEqual(base.input.length, 1, '单轮只该有一条 user'); - assert.deepStrictEqual(base.input[0].content, [{ type: 'input_text', text: expectedUser }]); + assert.deepStrictEqual(base.input.map((m) => m.role), ['user', 'assistant', 'user']); + assert.deepStrictEqual(base.input[0].content, [{ type: 'input_text', text: expectedContext }]); + assert.deepStrictEqual(base.input[2].content, [{ type: 'input_text', text: expectedQuestion }]); } for (const history of [undefined, null, [], 'not-an-array', {}]) { const withArg = await captureBody( @@ -471,15 +481,16 @@ test('chat-completions 把历史插在 system 之后、当前轮之前', async ( }); assert.deepStrictEqual( body.messages.map((m) => m.role), - ['system', 'user', 'assistant', 'user', 'assistant', 'user'], - '历史必须在 messages 里,且当前轮排最后' + ['system', 'user', 'assistant', 'user', 'assistant', 'user', 'assistant', 'user'], + '资料前缀必须在历史之前,当前轮排最后' ); - assert.strictEqual(body.messages[1].content, '第一个问题'); - assert.strictEqual(body.messages[2].content, '第一个回答'); - assert.strictEqual(body.messages[3].content, '第二个问题'); - assert.strictEqual(body.messages[4].content, '第二个回答'); - assert.match(body.messages[5].content, /第三个问题/, '当前轮问题丢失'); - assert.match(body.messages[5].content, /正文/, '当前轮正文丢失'); + assert.match(body.messages[1].content, /正文/, '稳定资料前缀丢失'); + assert.strictEqual(body.messages[3].content, '第一个问题'); + assert.strictEqual(body.messages[4].content, '第一个回答'); + assert.strictEqual(body.messages[5].content, '第二个问题'); + assert.strictEqual(body.messages[6].content, '第二个回答'); + assert.match(body.messages[7].content, /第三个问题/, '当前轮问题丢失'); + assert.doesNotMatch(body.messages[7].content, /正文/, '正文不应跟着每轮问题放到变化的尾部'); assert.ok( !/第一个问题/.test(body.messages[0].content), '历史不该被塞进 system,模型会把它当成指令' @@ -498,10 +509,14 @@ test('anthropic 历史进 messages,system 仍在顶层', async () => { }); assert.strictEqual(typeof body.system, 'string'); assert.ok(!/旧问题|旧回答/.test(body.system), 'Anthropic 的 system 是顶层字段,历史不该混进去'); - assert.deepStrictEqual(body.messages.map((m) => m.role), ['user', 'assistant', 'user']); - assert.strictEqual(body.messages[0].content, '旧问题'); - assert.strictEqual(body.messages[1].content, '旧回答'); - assert.match(body.messages[2].content, /新问题/); + assert.deepStrictEqual( + body.messages.map((m) => m.role), + ['user', 'assistant', 'user', 'assistant', 'user'] + ); + assert.match(body.messages[0].content[0].text, /正文/); + assert.strictEqual(body.messages[2].content, '旧问题'); + assert.strictEqual(body.messages[3].content, '旧回答'); + assert.match(body.messages[4].content, /新问题/); assert.ok(!body.messages.some((m) => m.role === 'system'), 'system 不能出现在 messages 里'); }); @@ -519,13 +534,100 @@ test('openai-responses 历史进 input,instructions 与 store:false 保持', a assert.ok(!/旧问题|旧回答/.test(body.instructions), 'instructions 是顶层字段,历史不该混进去'); assert.strictEqual(body.store, false, 'store 必须保持 false,服务端不留存对话'); assert.ok(!('previous_response_id' in body), '多轮不能靠服务端留存,与 store:false 冲突'); - assert.deepStrictEqual(body.input.map((m) => m.role), ['user', 'assistant', 'user']); - // 纯字符串是 Responses 输入消息的合法简写,避开 input_text 不接受 assistant 的限制 - assert.strictEqual(body.input[0].content, '旧问题'); - assert.strictEqual(body.input[1].content, '旧回答'); - assert.ok(Array.isArray(body.input[2].content), '当前轮仍用结构化 content'); - assert.strictEqual(body.input[2].content[0].type, 'input_text'); - assert.match(body.input[2].content[0].text, /新问题/); + assert.deepStrictEqual( + body.input.map((m) => m.role), + ['user', 'assistant', 'user', 'assistant', 'user'] + ); + assert.match(body.input[0].content[0].text, /正文/); + assert.strictEqual(body.input[2].content, '旧问题'); + assert.strictEqual(body.input[3].content, '旧回答'); + assert.ok(Array.isArray(body.input[4].content), '当前轮仍用结构化 content'); + assert.strictEqual(body.input[4].content[0].type, 'input_text'); + assert.match(body.input[4].content[0].text, /新问题/); +}); + +test('三种协议都把未变化资料放在历史之前形成稳定缓存前缀', async () => { + for (const protocol of Object.keys(PROTOCOL_STREAMS)) { + const first = await captureBody(protocol, { + task: 'ask', + text: '固定全文资料', + question: '问题一', + history: [] + }); + const second = await captureBody(protocol, { + task: 'ask', + text: '固定全文资料', + question: '问题二', + history: [ + { role: 'user', text: '问题一' }, + { role: 'assistant', text: '回答一' } + ] + }); + if (protocol === 'chat-completions') { + assert.deepStrictEqual(first.messages.slice(0, 3), second.messages.slice(0, 3)); + assert.strictEqual(second.messages.at(-1).content, '问题:问题二'); + } else if (protocol === 'anthropic') { + assert.deepStrictEqual(first.messages.slice(0, 2), second.messages.slice(0, 2)); + assert.strictEqual(second.messages.at(-1).content, '问题:问题二'); + } else { + assert.deepStrictEqual(first.input.slice(0, 2), second.input.slice(0, 2)); + assert.strictEqual(second.input.at(-1).content[0].text, '问题:问题二'); + } + } +}); + +test('OpenAI 官方 GPT-5.6 请求使用稳定缓存键和显式资料断点', async () => { + for (const protocol of ['chat-completions', 'openai-responses']) { + const options = { + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-5.6' + }; + const first = await captureBody(protocol, { + task: 'ask', + text: '固定全文资料', + question: '问题一' + }, options); + const second = await captureBody(protocol, { + task: 'ask', + text: '固定全文资料', + question: '问题二', + history: [ + { role: 'user', text: '问题一' }, + { role: 'assistant', text: '回答一' } + ] + }, options); + const changed = await captureBody(protocol, { + task: 'ask', + text: '另一份全文资料', + question: '问题三' + }, options); + + assert.match(first.prompt_cache_key, /^peoplelib-[0-9a-f]{48}$/); + assert.strictEqual(first.prompt_cache_key, second.prompt_cache_key); + assert.notStrictEqual(first.prompt_cache_key, changed.prompt_cache_key); + assert.deepStrictEqual(first.prompt_cache_options, { mode: 'explicit' }); + const firstItem = protocol === 'chat-completions' ? first.messages[1] : first.input[0]; + assert.deepStrictEqual( + firstItem.content.at(-1).prompt_cache_breakpoint, + { mode: 'explicit' } + ); + } +}); + +test('OpenAI 兼容接口不接收官方专用缓存字段', async () => { + for (const protocol of ['chat-completions', 'openai-responses']) { + const body = await captureBody(protocol, { + task: 'ask', + text: '固定全文资料', + question: '问题' + }, { + baseUrl: 'https://compatible.example.com/v1', + model: 'gpt-5.6' + }); + assert.ok(!('prompt_cache_key' in body)); + assert.ok(!('prompt_cache_options' in body)); + assert.doesNotMatch(JSON.stringify(body), /prompt_cache_breakpoint/); + } }); test('历史里的图像不被重发,只发当前轮的图', async () => { @@ -580,8 +682,11 @@ test('anthropic 历史领头是 assistant 时首条仍是 user', async () => { }); // Anthropic 的 /messages 直接 400 拒绝领头 assistant assert.strictEqual(body.messages[0].role, 'user', '首条必须是 user,否则 Anthropic 直接 400'); - assert.deepStrictEqual(body.messages.map((m) => m.role), ['user', 'assistant', 'user']); - assert.strictEqual(body.messages[0].content, '真正的第一问'); + assert.deepStrictEqual( + body.messages.map((m) => m.role), + ['user', 'assistant', 'user', 'assistant', 'user'] + ); + assert.strictEqual(body.messages[2].content, '真正的第一问'); }); test('历史末条是 user 时与当前轮合并,两段文本都保留', async () => { @@ -624,7 +729,7 @@ test('历史内部相邻同角色被合并而不是丢弃', async () => { const roles = items.map((m) => m.role).filter((r) => r !== 'system'); assert.deepStrictEqual( roles, - ['user', 'assistant', 'user'], + ['user', 'assistant', 'user', 'assistant', 'user'], `${protocol} 未合并相邻同角色,Anthropic 会直接 400` ); const flat = JSON.stringify(items); @@ -657,7 +762,11 @@ test('历史含空文本时不产生空 content', async () => { assert.ok(text && text.trim(), `${protocol} 出现空 content,Anthropic 不接受空字符串`); } const roles = items.map((m) => m.role).filter((r) => r !== 'system'); - assert.deepStrictEqual(roles, ['user', 'assistant', 'user'], `${protocol} 空消息未被过滤干净`); + assert.deepStrictEqual( + roles, + ['user', 'assistant', 'user', 'assistant', 'user'], + `${protocol} 空消息未被过滤干净` + ); } }); @@ -692,7 +801,11 @@ test('buildMessages 也接受历史参数', () => { { role: 'user', text: '旧问题' }, { role: 'assistant', text: '旧回答' } ]); - assert.deepStrictEqual(msgs.map((m) => m.role), ['system', 'user', 'assistant', 'user']); - assert.strictEqual(msgs[1].content, '旧问题'); - assert.match(msgs[3].content, /新问题/); + assert.deepStrictEqual( + msgs.map((m) => m.role), + ['system', 'user', 'assistant', 'user', 'assistant', 'user'] + ); + assert.match(msgs[1].content, /正文/); + assert.strictEqual(msgs[3].content, '旧问题'); + assert.match(msgs[5].content, /新问题/); }); diff --git a/src/_test/electron/ai-scope.integration.js b/src/_test/electron/ai-scope.integration.js index c7c5c35..07022d3 100644 --- a/src/_test/electron/ai-scope.integration.js +++ b/src/_test/electron/ai-scope.integration.js @@ -252,8 +252,9 @@ app.whenReady().then(async () => { })()`)); await js("document.getElementById('aiConfirmSendBtn').click()"); await new Promise((r) => setTimeout(r, 4000)); - // 多轮之后本轮提问固定在末尾,历史占据中间位置,因此不能按固定下标取 - const pageContent = received[1] && received[1].messages && received[1].messages.at(-1).content; + // 图像资料固定放在历史之前,当前问题留在末尾,后续相同资料才能命中前缀缓存 + const pageContent = received[1] && received[1].messages + && received[1].messages.find((message) => Array.isArray(message.content))?.content; const pageImage = Array.isArray(pageContent) ? pageContent.find((part) => part && part.type === 'image_url') : null; @@ -336,7 +337,8 @@ app.whenReady().then(async () => { await new Promise((r) => setTimeout(r, 600)); await js("document.getElementById('aiConfirmSendBtn').click()"); await new Promise((r) => setTimeout(r, 4000)); - const regionContent = received[2] && received[2].messages && received[2].messages.at(-1).content; + const regionContent = received[2] && received[2].messages + && received[2].messages.find((message) => Array.isArray(message.content))?.content; const regionImage = Array.isArray(regionContent) ? regionContent.find((part) => part && part.type === 'image_url') : null; @@ -352,7 +354,10 @@ app.whenReady().then(async () => { && 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.slice(3, -1).every((m) => typeof m.content === 'string') + && regionMessages.flatMap( + (m) => Array.isArray(m.content) ? m.content.filter((part) => part.type === 'image_url') : [] + ).length === 1, regionMessages.map((m) => (typeof m.content === 'string' ? 'str' : 'arr')).join(',')); // 会话必须落盘:关掉窗口再开回来,历史消息与会话列表都应原样恢复 @@ -510,6 +515,20 @@ app.whenReady().then(async () => { `${migratedValue}/${migratedSaved}`); migrationWin.destroy(); + // 第一轮已经确认过同一本书的全文。同一会话继续使用未变化全文时应直接发送, + // 改变正文或新建会话后才需要再次确认。 + await js("var s=document.getElementById('aiScope'); s.value='document'; s.dispatchEvent(new Event('change'));"); + await new Promise((r) => setTimeout(r, 8000)); + const requestsBeforeRepeatedDocument = received.length; + await js("document.getElementById('aiQuestion').value='基于同一全文继续回答';document.getElementById('aiSendBtn').click();"); + await new Promise((r) => setTimeout(r, 600)); + chk('同一会话重复使用相同全文不再弹确认框', + (await js("document.getElementById('aiConfirmModal').classList.contains('hidden')")) === true); + await new Promise((r) => setTimeout(r, 3500)); + chk('跳过重复确认后仍只发送一次请求', + received.length === requestsBeforeRepeatedDocument + 1, + `${requestsBeforeRepeatedDocument} -> ${received.length}`); + const regionDataUrl = regionImage?.image_url?.url || ''; const encoded = regionDataUrl.slice(regionDataUrl.indexOf(',') + 1); const imageBytes = Buffer.from(encoded, 'base64'); diff --git a/src/_test/electron/library-notes.integration.js b/src/_test/electron/library-notes.integration.js index 885a23f..aa04d01 100644 --- a/src/_test/electron/library-notes.integration.js +++ b/src/_test/electron/library-notes.integration.js @@ -431,6 +431,14 @@ app.whenReady().then(async () => { (await js("document.getElementById('libStatus').textContent")) === '搜索“Rsrch”显示 1 条,当前分类 3 条' ); + check( + '书库搜索结果提示独占工具栏下一行', + await js(`(() => { + const search = document.querySelector('.library-toolbar').getBoundingClientRect(); + const status = document.getElementById('libStatus').getBoundingClientRect(); + return status.top >= search.bottom && status.left === search.left; + })()`) + ); await js(`(() => { document.getElementById('librarySearchInput').value = 'bob auth'; document.getElementById('librarySearchBtn').click(); diff --git a/src/_test/ui.test.js b/src/_test/ui.test.js index 0e322c2..9b25611 100644 --- a/src/_test/ui.test.js +++ b/src/_test/ui.test.js @@ -134,6 +134,14 @@ test('书库支持标题作者模糊搜索、侧栏滚动和稳定封面占位 assert.match(html, /id="librarySearchInput"[^>]+搜索标题或作者/); assert.match(html, /id="librarySearchBtn"/); assert.match(html, /id="libraryClearSearchBtn"/); + assert.match( + html, + /id="addLocalBtn"[^>]*>[^<]*添加本地<\/button>\s*<\/div>\s*