feat: AI 会话支持按轮保存完整笔记

This commit is contained in:
lofyer
2026-08-04 20:50:31 +08:00
parent 0fd7c59e08
commit f72c26642f
9 changed files with 471 additions and 33 deletions
+19 -3
View File
@@ -180,7 +180,7 @@ test('messages 支持 limit 与 before 游标翻页', () => {
// --- 字段限长 ---
test('字段限长与 store.limitedString 一致:超长截断而不是抛错', () => {
test('受限字段超长截断而不是抛错', () => {
const { sessions } = fresh('limits');
const L = sessions.LIMITS;
const meta = sessions.create({ title: '标'.repeat(L.title + 50) });
@@ -189,15 +189,31 @@ test('字段限长与 store.limitedString 一致:超长截断而不是抛错',
assert.strictEqual(user.text.length, L.question, `user 文本应截到 question=${L.question}`);
const p = sessions.appendAssistant(meta.id, {});
const done = sessions.finishAssistant(meta.id, p.id, {
text: '答'.repeat(L.messageText + 100),
text: '答',
error: '错'.repeat(L.errorText + 100)
});
assert.strictEqual(done.text.length, L.messageText, `assistant 文本应截到 messageText=${L.messageText}`);
assert.strictEqual(done.text, '回答');
assert.strictEqual(done.error.length, L.errorText, `error 应截到 ${L.errorText}`);
const renamed = sessions.rename(meta.id, '新'.repeat(L.title + 10));
assert.strictEqual(renamed.title.length, L.title);
});
test('超过 20000 字的 assistant 正文经 append、finish 与磁盘恢复后保持完整', () => {
const { sessions, dir } = fresh('long-assistant');
const meta = sessions.create({});
sessions.appendUser(meta.id, { text: '请生成长回答' });
const initial = `占位开头${'初'.repeat(21000)}占位结尾`;
const placeholder = sessions.appendAssistant(meta.id, { text: initial });
assert.strictEqual(placeholder.text, initial, 'appendAssistant 不应静默截断 assistant 正文');
const answer = `回答开头${'答'.repeat(25000)}回答结尾`;
const done = sessions.finishAssistant(meta.id, placeholder.id, { text: answer });
assert.strictEqual(done.text, answer, 'finishAssistant 不应静默截断 assistant 正文');
const raw = JSON.parse(fs.readFileSync(sessionFile(dir, meta.id), 'utf8'));
assert.strictEqual(raw.messages[1].text, answer, '超过 20000 字的回答必须完整落盘');
const restored = at(dir).sessions.messages(meta.id);
assert.strictEqual(restored.messages[1].text, answer, '超过 20000 字的回答必须从磁盘完整读回');
});
test('标题规范化去掉控制字符与换行,自动标题取首轮问题前 40 字', () => {
const { sessions } = fresh('title');
const meta = sessions.create({ title: ' 带\n换行\u0007和控制符 ' });
@@ -377,6 +377,86 @@ app.whenReady().then(async () => {
)),
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 }
+39 -2
View File
@@ -651,6 +651,41 @@ test('笔记可更新且必需保留正文或引用', () => {
assert.strictEqual(s.updateNote('e1', 'nt_missing', { title: 'x' }), null);
});
test('普通读书笔记新增与更新均完整保留超长正文', () => {
const d = tmp();
let s = storeAt(d);
const addedText = '新增正文'.repeat(6000);
const updatedText = '更新正文'.repeat(6500);
const note = s.addNote('e1', { text: addedText, source: 'manual' });
assert.strictEqual(note.text, addedText);
assert.strictEqual(s.getState('e1').notes[0].text, addedText);
s = storeAt(d);
assert.strictEqual(s.getState('e1').notes[0].text, addedText);
const updated = s.updateNote('e1', note.id, { text: updatedText });
assert.strictEqual(updated.text, updatedText);
s = storeAt(d);
assert.strictEqual(s.getState('e1').notes[0].text, updatedText);
});
test('富文本派生的普通笔记正文不截断', () => {
const s = freshStore();
const addedText = '富文本新增'.repeat(5000);
const updatedText = '富文本更新'.repeat(5500);
const note = s.addNote('e1', {
richContent: { version: 2, ops: [{ insert: `${addedText}\n` }] }
});
assert.strictEqual(note.text, addedText);
assert.strictEqual(note.richContent.ops[0].insert, `${addedText}\n`);
const updated = s.updateNote('e1', note.id, {
richContent: { version: 2, ops: [{ insert: `${updatedText}\n` }] }
});
assert.strictEqual(updated.text, updatedText);
assert.strictEqual(updated.richContent.ops[0].insert, `${updatedText}\n`);
});
test('笔记本名称不区分大小写去重,删除后笔记移入未分类', () => {
const s = freshStore();
const collection = s.addCollection({ name: 'Research' });
@@ -726,6 +761,7 @@ test('无关联笔记独立持久化且不计入书库卡片笔记数', () => {
test('旧版 reader.json 安全迁移为 v6 并保留阅读数据', () => {
const d = tmp();
const file = path.join(d, 'reader.json');
const legacyText = '旧版超长正文'.repeat(4000);
const legacy = {
entries: {
e1: {
@@ -733,7 +769,7 @@ test('旧版 reader.json 安全迁移为 v6 并保留阅读数据', () => {
bookmarks: [{ id: 'bm_old', locator: { page: 8 }, at: 11 }],
notes: [{
id: 'nt_old',
text: '旧笔记',
text: legacyText,
quote: '旧引用',
kind: 'ai',
at: 123,
@@ -748,6 +784,7 @@ test('旧版 reader.json 安全迁移为 v6 并保留阅读数据', () => {
assert.deepStrictEqual(state.progress, legacy.entries.e1.progress);
assert.deepStrictEqual(state.bookmarks, legacy.entries.e1.bookmarks);
assert.strictEqual(state.notes[0].id, 'nt_old');
assert.strictEqual(state.notes[0].text, legacyText);
assert.strictEqual(state.notes[0].source, 'ai');
assert.strictEqual(state.notes[0].createdAt, 123);
assert.strictEqual(state.notes[0].updatedAt, 123);
@@ -778,7 +815,7 @@ test('字段限制、来源校验和安全 ID 校验生效', () => {
text: 'x'.repeat(25000),
tags: Array.from({ length: 40 }, (_, i) => `tag-${i}`)
});
assert.strictEqual(note.text.length, 20000);
assert.strictEqual(note.text.length, 25000);
assert.strictEqual(note.tags.length, 30);
});
+48 -2
View File
@@ -871,9 +871,10 @@ test('AI 会话调用 api.ai.sessions 契约并带上 sessionId 发送', () => {
assert.match(shell, /res\.data\.assistantMessageId|ids\.assistantMessageId/);
});
test('AI 每条回答各自提供保存为笔记与复制', () => {
test('AI 每条回答各自提供保存此回答与复制', () => {
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
assert.match(shell, /save\.className = 'tb-btn sm ai-msg-save'/);
assert.match(shell, /save\.textContent = '保存此回答'/);
assert.match(shell, /copy\.className = 'tb-btn ghost sm ai-msg-copy'/);
assert.match(shell, /saveAiNote\(aiResultOf\(box\.dataset\.messageId\)\)/);
assert.match(shell, /copyAiMessage\(box\.dataset\.messageId\)/);
@@ -884,6 +885,51 @@ test('AI 每条回答各自提供保存为笔记与复制', () => {
assert.match(shell, /async function saveAiNote\(target\)/);
});
test('AI 会话可按最近轮数或全部保留消息保存为一条笔记', () => {
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.html'), 'utf8');
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
assert.match(html, /id="aiSaveBtn"[^>]+title="把当前会话保存为一条笔记"[^>]*>保存会话…</);
assert.match(html, /id="aiSessionSaveModal"[\s\S]*id="aiSessionSaveRecent"/);
assert.match(html, /id="aiSessionSaveRounds"[^>]+type="number"[^>]+min="1"[^>]+max="100"/);
assert.match(html, /id="aiSessionSaveAll"[\s\S]*当前保留会话全部/);
assert.match(html, /id="aiSessionSaveRoundCount"[\s\S]*id="aiSessionSaveCharCount"/);
// 保存时必须另取后端当前保留的全部 200 条,不能复用界面当前 60 条
assert.match(shell, /const AI_THREAD_LIMIT = 60/);
assert.match(shell, /const AI_SESSION_SAVE_MESSAGE_LIMIT = 200/);
assert.match(shell, /sessions\.messages\(wanted, \{ limit: AI_SESSION_SAVE_MESSAGE_LIMIT \}\)/);
assert.match(shell, /function aiConversationRounds\(messages\)/);
assert.match(shell, /message\.role === 'user'[\s\S]{0,220}message\.role === 'assistant' && user/);
assert.match(shell, /return all\.slice\(-count\)/);
// 范围和 N 记住上次选择,确认前展示真实轮数和最终正文字符数
assert.match(shell, /const AI_SESSION_SAVE_SETTING = 'reader\.aiSessionSave'/);
assert.match(shell, /api\.settings\.get\(AI_SESSION_SAVE_SETTING, aiSessionSavePreference\)/);
assert.match(shell, /api\.settings\.set\(AI_SESSION_SAVE_SETTING, preference\)/);
assert.match(shell, /aiSessionSaveRoundCount\.textContent = `\$\{selected\.length\.toLocaleString\(\)\} 轮`/);
assert.match(shell, /aiSessionSaveCharCount\.textContent = `\$\{text\.length\.toLocaleString\(\)\} 字`/);
const saveStart = shell.indexOf('async function saveAiSessionNote()');
const saveEnd = shell.indexOf('\nasync function renameAiSession()', saveStart);
assert.ok(saveStart >= 0 && saveEnd > saveStart, '缺少保存会话实现');
const saveBlock = shell.slice(saveStart, saveEnd);
assert.strictEqual((saveBlock.match(/api\.reader\.addNote\(/g) || []).length, 1, '一次会话保存只能调用一次 addNote');
assert.match(saveBlock, /title: state\.title/);
assert.match(saveBlock, /\n\s+text,/);
assert.match(saveBlock, /quote: ''/);
assert.match(saveBlock, /locator: null/);
assert.match(saveBlock, /已将 \$\{selected\.length\.toLocaleString\(\)\} 轮会话(\$\{text\.length\.toLocaleString\(\)\} 字)保存为一条笔记/);
});
test('AI 会话笔记正文标明问答结构与未保留的较早消息', () => {
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
assert.match(shell, /`## 第 \$\{index \+ 1\} 轮\\n\\n### 问题\\n\\n\$\{question\}\\n\\n### 回答\\n\\n\$\{answer\}`/);
assert.match(shell, /更早的 \$\{dropped\.toLocaleString\(\)\} 条消息已不在当前保留会话中,因此未保存/);
assert.match(shell, /更早的 \$\{dropped\.toLocaleString\(\)\} 条消息已被会话存储上限淘汰,不会出现在笔记中/);
assert.match(shell, /会话正文将完整写入一条笔记,不会拆分,也不会在此处截断/);
});
test('AI 气泡标注上下文范围、停止、裁剪与省略轮次', () => {
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
assert.match(shell, /const parts = \[scopeName\(ref\.scope\)\]/);
@@ -892,7 +938,7 @@ test('AI 气泡标注上下文范围、停止、裁剪与省略轮次', () => {
assert.match(shell, /if \(message\.truncated\) flags\.push\('内容已裁剪'\)/);
assert.match(shell, /if \(message\.cancelled\) flags\.push\('已停止生成'\)/);
assert.match(shell, /box\.classList\.add\('ai-msg-error'\)/);
assert.match(shell, /较早的 \$\{dropped\.toLocaleString\(\)\} 轮对话已省略/);
assert.match(shell, /较早的 \$\{dropped\.toLocaleString\(\)\} 条消息已不再保留/);
});
test('AI 线程里的模型输出全部经 AiMarkdown 渲染,不直接写 innerHTML', () => {