feat: AI 会话支持按轮保存完整笔记
This commit is contained in:
@@ -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 }
|
||||
|
||||
@@ -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
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user