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', () => {
+5 -4
View File
@@ -14,7 +14,6 @@ const GLOBAL_ENTRY_ID = 'system:global-chat';
const LIMITS = {
sessionId: 160,
title: 200,
messageText: 20000,
question: 4000,
contextText: 12000,
contextHash: 32,
@@ -252,7 +251,9 @@ function buildMessage(raw, role, lenient) {
return {
id: newId('msg'),
role,
text: limitedString(value.text, role === 'user' ? LIMITS.question : LIMITS.messageText),
text: role === 'user'
? limitedString(value.text, LIMITS.question)
: String(value.text == null ? '' : value.text),
task: normalizeTask(value.task, lenient),
contextRef: role === 'user' ? normalizeContextRef(value.contextRef, lenient) : null,
images: normalizeImages(value.images, lenient),
@@ -807,7 +808,7 @@ function finishAssistant(sessionId, messageId, patch) {
? held.message
: doc.messages.find((item) => item.id === msgId && item.role === 'assistant');
if (!message) throw new Error('会话消息不存在');
message.text = limitedString(value.text == null ? message.text : value.text, LIMITS.messageText);
message.text = String(value.text == null ? message.text : value.text);
if (value.task !== undefined) message.task = normalizeTask(value.task, false);
if (value.images !== undefined) message.images = normalizeImages(value.images, false);
if (value.tokensEstimate !== undefined) message.tokensEstimate = count(value.tokensEstimate, 1e9);
@@ -843,7 +844,7 @@ function normalizeBudget(budget) {
return {
maxChars: clampInt(value.maxChars, 50, 4000000, LIMITS.contextText),
maxMessages: clampInt(value.maxMessages, 1, LIMITS.messagesPerSession, 20),
maxMessageChars: clampInt(value.maxMessageChars, 50, LIMITS.messageText, LIMITS.question)
maxMessageChars: clampInt(value.maxMessageChars, 50, 4000000, LIMITS.question)
};
}
+13 -17
View File
@@ -11,7 +11,7 @@ const STANDALONE_ENTRY_ID = 'system:standalone-notes';
const LIMITS = {
id: 160,
title: 500,
text: 20000,
canvasText: 20000,
quote: 10000,
context: 20000,
aiTask: 500,
@@ -24,7 +24,7 @@ const LIMITS = {
locatorJson: 50000,
richBlocks: 500,
richOps: 5000,
richJson: 12 * 1024 * 1024,
canvasRichJson: 12 * 1024 * 1024,
richImages: 12,
richImageBytes: 2 * 1024 * 1024,
richImageTotalBytes: 8 * 1024 * 1024,
@@ -316,7 +316,6 @@ function normalizeRichContent(input) {
}
if (value.ops.length > LIMITS.richOps) throw new Error('富文本笔记内容过多');
const result = { version: 2, ops: [] };
let textLength = 0;
let imageCount = 0;
let imageBytes = 0;
for (const op of value.ops) {
@@ -325,8 +324,6 @@ function normalizeRichContent(input) {
}
const attributes = normalizeRichAttributes(op.attributes);
if (typeof op.insert === 'string') {
textLength += op.insert.length;
if (textLength > LIMITS.text) throw new Error('富文本笔记文字过多');
if (op.insert) result.ops.push({
insert: op.insert,
...(attributes ? { attributes } : {})
@@ -349,7 +346,6 @@ function normalizeRichContent(input) {
}
result.ops.push({ insert: { image: image.dataUrl } });
}
if (JSON.stringify(result).length > LIMITS.richJson) throw new Error('富文本笔记过大');
return hasRichContent(result) ? result : null;
}
@@ -359,8 +355,7 @@ function richPlainText(content) {
.filter((op) => typeof op.insert === 'string')
.map((op) => op.insert)
.join('')
.replace(/\n$/, '')
.slice(0, LIMITS.text);
.replace(/\n$/, '');
}
function hasRichContent(content) {
@@ -431,7 +426,7 @@ function normalizeCanvasObject(value, imageTotals) {
throw new Error('画布对象线宽无效');
}
if (value.canvasKind === 'text'
&& (typeof value.text !== 'string' || value.text.length > LIMITS.text)) {
&& (typeof value.text !== 'string' || value.text.length > LIMITS.canvasText)) {
throw new Error('画布文字无效');
}
if ((value.canvasKind === 'pen' || value.canvasKind === 'highlight')
@@ -475,7 +470,7 @@ function normalizeCanvasFlow(value, pageIds, firstPageId) {
}
if (typeof op.insert === 'string') {
textLength += op.insert.length;
if (textLength > LIMITS.text) throw new Error('画布全局文本文字过多');
if (textLength > LIMITS.canvasText) throw new Error('画布全局文本文字过多');
const attributes = normalizeRichAttributes(op.attributes);
if (op.insert) result.ops.push({
insert: op.insert,
@@ -499,7 +494,7 @@ function normalizeCanvasFlow(value, pageIds, firstPageId) {
breakIds.add(pageId);
result.ops.push({ insert: { canvasPageBreak: pageId } });
}
if (JSON.stringify(result).length > LIMITS.richJson) throw new Error('画布全局文本过大');
if (JSON.stringify(result).length > LIMITS.canvasRichJson) throw new Error('画布全局文本过大');
const meaningful = result.ops.some((op) => (
typeof op.insert === 'string' ? op.insert.trim() : !!op.insert.canvasPageBreak
));
@@ -593,14 +588,15 @@ function canvasPlainText(content) {
return [flowText, objectText]
.filter((text) => text.trim())
.join('\n')
.slice(0, LIMITS.text);
.slice(0, LIMITS.canvasText);
}
function notePlainText(richContent, canvasContent, fallback) {
return [
richContent ? richPlainText(richContent) : String(fallback || ''),
const text = [
richContent ? richPlainText(richContent) : String(fallback == null ? '' : fallback),
canvasPlainText(canvasContent)
].filter((value) => value.trim()).join('\n').slice(0, LIMITS.text);
].filter((value) => value.trim()).join('\n');
return canvasContent ? text.slice(0, LIMITS.canvasText) : text;
}
function hasCanvasContent(content) {
@@ -1023,7 +1019,7 @@ function noteInput(note, c) {
if (noteType === 'canvas' && !hasCanvasContent(canvasContent)) {
throw new Error('画布笔记内容为空');
}
const text = notePlainText(richContent, canvasContent, limitedString(note.text, LIMITS.text));
const text = notePlainText(richContent, canvasContent, note.text);
const quote = limitedString(note.quote, LIMITS.quote);
if (!text.trim() && !quote.trim()
&& !hasRichContent(richContent) && !hasCanvasContent(canvasContent)) {
@@ -1104,7 +1100,7 @@ function updateNote(id, noteId, patch) {
else delete note.canvasContent;
}
if (Object.prototype.hasOwnProperty.call(patch, 'text')) {
fallbackText = limitedString(patch.text, LIMITS.text);
fallbackText = String(patch.text == null ? '' : patch.text);
if (note.noteType !== 'canvas'
&& !Object.prototype.hasOwnProperty.call(patch, 'richContent')) {
delete note.richContent;
+19
View File
@@ -737,6 +737,25 @@ input[type="color"],
.ai-confirm-notice {
color: var(--text-dim); font-size: 12px; line-height: 1.7;
}
.ai-session-save-box { width: 500px; }
.ai-session-save-options {
display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px;
}
.ai-session-save-option {
display: flex; align-items: center; gap: 7px;
min-height: 30px; padding: 7px 9px;
border: 1px solid var(--line); border-radius: 8px;
background: var(--input-bg); color: var(--text); font-size: 13px;
}
.ai-session-save-option:has(input[type="radio"]:checked) { border-color: var(--accent); }
.ai-session-save-rounds {
width: 64px; min-width: 0; padding: 4px 6px;
border: 1px solid var(--line); border-radius: 6px;
background: var(--bg-card); color: var(--text); font: inherit;
}
.ai-session-save-rounds:focus { border-color: var(--accent); outline: none; }
.ai-session-save-summary { margin-top: 4px; }
.ai-session-save-box .ai-confirm-notice { min-height: 1.7em; margin-bottom: 0; }
.pick-list { max-height: 48vh; overflow-y: auto; display: flex; flex-direction: column; gap: 4px; }
.pick-item {
display: flex; align-items: center; gap: 8px;
+34 -1
View File
@@ -185,7 +185,7 @@
<div id="aiError" class="ai-error hidden"></div>
<div class="ai-out-actions">
<button id="aiStopBtn" class="tb-btn danger sm hidden">停止生成</button>
<button id="aiSaveBtn" class="tb-btn sm hidden" title="把最后一条回答保存为笔记">保存为笔记</button>
<button id="aiSaveBtn" class="tb-btn sm hidden" title="把当前会话保存为一条笔记">保存会话…</button>
<button id="aiCopyBtn" class="tb-btn ghost sm hidden" title="复制最后一条回答">复制</button>
</div>
<div class="ai-input">
@@ -322,6 +322,39 @@
</div>
</div>
<div id="aiSessionSaveModal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="aiSessionSaveTitle">
<div class="modal-box ai-session-save-box">
<div id="aiSessionSaveTitle" class="modal-title">保存会话为笔记</div>
<div class="ai-session-save-options">
<label class="ai-session-save-option">
<input id="aiSessionSaveRecent" type="radio" name="aiSessionSaveRange" value="recent" />
<span>最近</span>
<input id="aiSessionSaveRounds" class="ai-session-save-rounds" type="number" min="1" max="100" step="1" value="5" aria-label="最近轮数" />
<span></span>
</label>
<label class="ai-session-save-option">
<input id="aiSessionSaveAll" type="radio" name="aiSessionSaveRange" value="all" />
<span>当前保留会话全部</span>
</label>
</div>
<div class="ai-confirm-summary ai-session-save-summary">
<div>
<span class="ai-confirm-label">将保存</span>
<strong id="aiSessionSaveRoundCount"></strong>
</div>
<div>
<span class="ai-confirm-label">正文字符数</span>
<strong id="aiSessionSaveCharCount"></strong>
</div>
</div>
<p id="aiSessionSaveNotice" class="ai-confirm-notice"></p>
<div class="modal-actions">
<button id="aiSessionSaveCancelBtn" class="tb-btn ghost">取消</button>
<button id="aiSessionSaveConfirmBtn" class="tb-btn">保存为一条笔记</button>
</div>
</div>
</div>
<div id="annotationClearModal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="annotationClearTitle">
<div class="modal-box confirm-box">
<div id="annotationClearTitle" class="modal-title">清空当前页批注?</div>
+214 -4
View File
@@ -65,6 +65,15 @@ const el = {
aiSessionClearModal: $('aiSessionClearModal'),
aiSessionClearCancelBtn: $('aiSessionClearCancelBtn'),
aiSessionClearConfirmBtn: $('aiSessionClearConfirmBtn'),
aiSessionSaveModal: $('aiSessionSaveModal'),
aiSessionSaveRecent: $('aiSessionSaveRecent'),
aiSessionSaveAll: $('aiSessionSaveAll'),
aiSessionSaveRounds: $('aiSessionSaveRounds'),
aiSessionSaveRoundCount: $('aiSessionSaveRoundCount'),
aiSessionSaveCharCount: $('aiSessionSaveCharCount'),
aiSessionSaveNotice: $('aiSessionSaveNotice'),
aiSessionSaveCancelBtn: $('aiSessionSaveCancelBtn'),
aiSessionSaveConfirmBtn: $('aiSessionSaveConfirmBtn'),
aiQuestion: $('aiQuestion'),
aiSendBtn: $('aiSendBtn'),
aiScope: $('aiScope'),
@@ -160,6 +169,9 @@ let aiThreadHasMore = false;
let aiSessionRenameResolve = null;
let aiSessionDeleteResolve = null;
let aiSessionClearResolve = null;
let aiSessionSaveState = null;
let aiSessionSaveLoading = false;
let aiSessionSavePreference = { mode: 'recent', rounds: 5 };
let annotationClearResolve = null;
let annotationOpen = false;
let annotationTool = 'pan';
@@ -2256,6 +2268,9 @@ let aiRenderTimer = 0;
let aiStreamNode = null;
const AI_THREAD_LIMIT = 60;
const AI_SESSION_SAVE_MESSAGE_LIMIT = 200;
const AI_SESSION_SAVE_ROUNDS_MAX = AI_SESSION_SAVE_MESSAGE_LIMIT / 2;
const AI_SESSION_SAVE_SETTING = 'reader.aiSessionSave';
const AI_TASK_LABELS = {
summarize: '总结当前上下文',
@@ -2388,7 +2403,7 @@ function attachAssistantActions(box, message) {
actions.className = 'ai-msg-actions';
const save = document.createElement('button');
save.className = 'tb-btn sm ai-msg-save';
save.textContent = '保存为笔记';
save.textContent = '保存此回答';
save.title = '把这条回答保存为笔记';
save.addEventListener('click', () => saveAiNote(aiResultOf(box.dataset.messageId)));
const copy = document.createElement('button');
@@ -2456,7 +2471,7 @@ function renderAiThread() {
const notice = document.createElement('div');
notice.className = 'ai-thread-notice';
notice.textContent = dropped > 0
? `较早的 ${dropped.toLocaleString()} 轮对话已省略`
? `较早的 ${dropped.toLocaleString()} 条消息已不再保留`
: '只显示最近的对话,更早的记录未载入';
el.aiOutput.appendChild(notice);
}
@@ -2521,7 +2536,6 @@ function syncLastAiResult() {
}
lastAiResult = latest ? aiResultOf(latest.id) : null;
const has = !!(lastAiResult && lastAiResult.text.trim());
el.aiSaveBtn.classList.toggle('hidden', !has);
el.aiCopyBtn.classList.toggle('hidden', !has);
}
@@ -2536,12 +2550,15 @@ function syncAiSessionControls() {
const busy = !!aiRun;
const hasEntry = !!aiThreadEntryId;
const hasSession = !!aiSessionId;
const hasConversation = hasSession && Number(aiSessionMeta && aiSessionMeta.messageCount) >= 2;
el.aiSessionSelect.disabled = busy || !hasEntry;
el.aiSessionNewBtn.disabled = busy || !hasEntry;
el.aiSessionRenameBtn.disabled = busy || !hasSession;
el.aiSessionPinBtn.disabled = busy || !hasSession;
el.aiSessionClearBtn.disabled = busy || !hasSession;
el.aiSessionDeleteBtn.disabled = busy || !hasSession;
el.aiSaveBtn.classList.toggle('hidden', !hasConversation);
el.aiSaveBtn.disabled = busy || aiSessionSaveLoading || !hasConversation;
const pinned = !!(aiSessionMeta && aiSessionMeta.pinned);
el.aiSessionPinBtn.textContent = pinned ? '取消置顶' : '置顶';
el.aiSessionPinBtn.title = pinned ? '取消置顶当前会话' : '置顶当前会话';
@@ -2743,6 +2760,181 @@ function confirmAiSessionClear() {
return new Promise((resolve) => { aiSessionClearResolve = resolve; });
}
function normalizeAiSessionSavePreference(value) {
const raw = value && typeof value === 'object' ? value : {};
const mode = raw.mode === 'all' ? 'all' : 'recent';
const rounds = Math.min(
AI_SESSION_SAVE_ROUNDS_MAX,
Math.max(1, Math.floor(Number(raw.rounds) || 5))
);
return { mode, rounds };
}
function aiConversationRounds(messages) {
const rounds = [];
let user = null;
for (const message of Array.isArray(messages) ? messages : []) {
if (message && message.role === 'user') {
user = message;
} else if (message && message.role === 'assistant' && user) {
rounds.push({ user, assistant: message });
user = null;
}
}
return rounds;
}
function aiSessionSaveSelection() {
if (!aiSessionSaveState) return [];
const all = aiSessionSaveState.rounds;
if (el.aiSessionSaveAll.checked) return all.slice();
const count = Math.min(
AI_SESSION_SAVE_ROUNDS_MAX,
Math.max(1, Math.floor(Number(el.aiSessionSaveRounds.value) || aiSessionSavePreference.rounds))
);
el.aiSessionSaveRounds.value = String(count);
return all.slice(-count);
}
function aiSessionAnswerText(message) {
const text = String((message && message.text) || '');
const parts = text.trim() ? [text] : [];
const flags = [];
if (message && message.truncated) flags.push('内容已裁剪');
if (message && message.cancelled) flags.push('已停止生成');
if (message && String(message.error || '').trim()) flags.push(`错误:${String(message.error).trim()}`);
if (flags.length) parts.push(`> 状态:${flags.join('')}`);
return parts.filter(Boolean).join('\n\n') || '(无回答内容)';
}
function aiSessionNoteText(rounds, droppedMessages, omittedRounds) {
const parts = [];
const dropped = Math.max(0, Number(droppedMessages) || 0);
if (dropped > 0) {
parts.push(`> 说明:更早的 ${dropped.toLocaleString()} 条消息已不在当前保留会话中,因此未保存。`);
}
if (omittedRounds > 0) {
parts.push(`> 说明:本笔记仅保存最近 ${rounds.length.toLocaleString()} 轮,另有 ${omittedRounds.toLocaleString()} 轮当前保留会话未包含在本笔记中。`);
}
rounds.forEach((round, index) => {
const rawQuestion = String((round.user && round.user.text) || '');
const question = rawQuestion.trim() ? rawQuestion : '(无问题内容)';
const answer = aiSessionAnswerText(round.assistant);
parts.push(`## 第 ${index + 1}\n\n### 问题\n\n${question}\n\n### 回答\n\n${answer}`);
});
return parts.join('\n\n---\n\n');
}
function updateAiSessionSaveSummary() {
if (!aiSessionSaveState) return;
const selected = aiSessionSaveSelection();
const omitted = Math.max(0, aiSessionSaveState.rounds.length - selected.length);
const text = aiSessionNoteText(selected, aiSessionSaveState.droppedMessages, omitted);
el.aiSessionSaveRounds.disabled = el.aiSessionSaveAll.checked;
el.aiSessionSaveRoundCount.textContent = `${selected.length.toLocaleString()}`;
el.aiSessionSaveCharCount.textContent = `${text.length.toLocaleString()}`;
el.aiSessionSaveConfirmBtn.disabled = !selected.length;
const dropped = aiSessionSaveState.droppedMessages;
el.aiSessionSaveNotice.textContent = dropped > 0
? `更早的 ${dropped.toLocaleString()} 条消息已被会话存储上限淘汰,不会出现在笔记中。`
: '会话正文将完整写入一条笔记,不会拆分,也不会在此处截断。';
}
function closeAiSessionSave() {
aiSessionSaveState = null;
el.aiSessionSaveModal.classList.add('hidden');
}
async function openAiSessionSave() {
const sessions = aiSessionsApi();
if (!sessions || !aiSessionId || aiSessionSaveLoading) return;
const wanted = aiSessionId;
const entryId = aiThreadEntryId;
aiSessionSaveLoading = true;
syncAiSessionControls();
let res;
try {
res = await sessions.messages(wanted, { limit: AI_SESSION_SAVE_MESSAGE_LIMIT });
} catch (e) {
res = { ok: false, error: (e && e.message) || String(e) };
} finally {
aiSessionSaveLoading = false;
syncAiSessionControls();
}
if (aiSessionId !== wanted || aiThreadEntryId !== entryId) return;
if (!res || !res.ok || !res.data) {
toast(`读取会话失败:${errText(res, '未知错误')}`, true);
return;
}
const rounds = aiConversationRounds(res.data.messages);
if (!rounds.length) {
toast('当前会话没有可保存的完整问答轮次', true);
return;
}
const meta = res.data.meta || aiSessionMeta || {};
aiSessionSaveState = {
sessionId: wanted,
entryId,
title: aiSessionTitle(meta),
documentKey: meta.documentKey || null,
droppedMessages: Math.max(0, Number(meta.droppedMessages) || 0),
rounds
};
el.aiSessionSaveRecent.checked = aiSessionSavePreference.mode === 'recent';
el.aiSessionSaveAll.checked = aiSessionSavePreference.mode === 'all';
el.aiSessionSaveRounds.value = String(aiSessionSavePreference.rounds);
updateAiSessionSaveSummary();
el.aiSessionSaveModal.classList.remove('hidden');
requestAnimationFrame(() => (
el.aiSessionSaveRecent.checked ? el.aiSessionSaveRounds.focus() : el.aiSessionSaveConfirmBtn.focus()
));
}
async function saveAiSessionNote() {
if (!aiSessionSaveState) return;
const state = aiSessionSaveState;
const selected = aiSessionSaveSelection();
if (!selected.length) return;
const mode = el.aiSessionSaveAll.checked ? 'all' : 'recent';
const preference = normalizeAiSessionSavePreference({
mode,
rounds: el.aiSessionSaveRounds.value
});
aiSessionSavePreference = preference;
try { await api.settings.set(AI_SESSION_SAVE_SETTING, preference); } catch (e) { /* 保存笔记不受偏好写入失败影响 */ }
const omitted = Math.max(0, state.rounds.length - selected.length);
const text = aiSessionNoteText(selected, state.droppedMessages, omitted);
el.aiSessionSaveConfirmBtn.disabled = true;
let res;
try {
res = await api.reader.addNote(state.entryId, {
noteType: 'reading',
title: state.title,
text,
quote: '',
source: 'ai',
aiTask: null,
locator: null,
documentKey: state.documentKey,
fileIndex: null
});
} catch (e) {
res = { ok: false, error: (e && e.message) || String(e) };
}
if (!res || !res.ok) {
el.aiSessionSaveConfirmBtn.disabled = false;
toast(`保存会话失败:${errText(res, '未知错误')}`, true);
return;
}
const tab = tabs.find((item) => item.entryId === state.entryId);
if (tab) {
upsertTabNote(tab, res.data);
if (tab === activeTab()) renderNotes();
}
closeAiSessionSave();
toast(`已将 ${selected.length.toLocaleString()} 轮会话(${text.length.toLocaleString()} 字)保存为一条笔记`);
}
async function renameAiSession() {
const sessions = aiSessionsApi();
if (!sessions || !aiSessionId) return;
@@ -3552,7 +3744,7 @@ function bind() {
el.aiStopBtn.addEventListener('click', () => {
if (aiRun) api.ai.cancel(aiRun.runId);
});
el.aiSaveBtn.addEventListener('click', () => saveAiNote(lastAiResult));
el.aiSaveBtn.addEventListener('click', openAiSessionSave);
el.aiCopyBtn.addEventListener('click', async () => {
if (!lastAiResult) return;
try { await api.copy(lastAiResult.text); toast('已复制'); } catch (e) { toast('复制失败', true); }
@@ -3584,6 +3776,17 @@ function bind() {
el.aiSessionClearModal.addEventListener('click', (e) => {
if (e.target === el.aiSessionClearModal) closeAiSessionClear(false);
});
el.aiSessionSaveRecent.addEventListener('change', updateAiSessionSaveSummary);
el.aiSessionSaveAll.addEventListener('change', updateAiSessionSaveSummary);
el.aiSessionSaveRounds.addEventListener('input', () => {
el.aiSessionSaveRecent.checked = true;
updateAiSessionSaveSummary();
});
el.aiSessionSaveCancelBtn.addEventListener('click', closeAiSessionSave);
el.aiSessionSaveConfirmBtn.addEventListener('click', saveAiSessionNote);
el.aiSessionSaveModal.addEventListener('click', (e) => {
if (e.target === el.aiSessionSaveModal) closeAiSessionSave();
});
const activateAiLink = async (event) => {
if (event.type === 'auxclick' && event.button !== 1) return;
const link = event.target && typeof event.target.closest === 'function'
@@ -3667,6 +3870,13 @@ async function start() {
resetAiThread();
await refreshAiStatus();
try {
const saved = await api.settings.get(AI_SESSION_SAVE_SETTING, aiSessionSavePreference);
aiSessionSavePreference = normalizeAiSessionSavePreference(
saved && saved.ok ? saved.data : aiSessionSavePreference
);
} catch (e) { /* 使用默认保存范围 */ }
try {
const savedScope = await api.settings.get('reader.aiScope', 'selection');
const storedScope = savedScope && savedScope.ok ? savedScope.data : 'selection';