diff --git a/src/_test/ai-sessions.test.js b/src/_test/ai-sessions.test.js index 990e61b..f63214e 100644 --- a/src/_test/ai-sessions.test.js +++ b/src/_test/ai-sessions.test.js @@ -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和控制符 ' }); diff --git a/src/_test/electron/ai-scope.integration.js b/src/_test/electron/ai-scope.integration.js index 7311d9a..9306553 100644 --- a/src/_test/electron/ai-scope.integration.js +++ b/src/_test/electron/ai-scope.integration.js @@ -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 } diff --git a/src/_test/reader.test.js b/src/_test/reader.test.js index 22ff500..02acf6f 100644 --- a/src/_test/reader.test.js +++ b/src/_test/reader.test.js @@ -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); }); diff --git a/src/_test/ui.test.js b/src/_test/ui.test.js index f60ffe6..457c9b9 100644 --- a/src/_test/ui.test.js +++ b/src/_test/ui.test.js @@ -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="把当前会话保存为一条笔记"[^>]*>保存会话…]+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', () => { diff --git a/src/reader/ai-sessions.js b/src/reader/ai-sessions.js index a375050..534c5b2 100644 --- a/src/reader/ai-sessions.js +++ b/src/reader/ai-sessions.js @@ -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) }; } diff --git a/src/reader/store.js b/src/reader/store.js index 192a750..b64ef60 100644 --- a/src/reader/store.js +++ b/src/reader/store.js @@ -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; diff --git a/src/ui/reader.css b/src/ui/reader.css index 28ad4f9..c063fcb 100644 --- a/src/ui/reader.css +++ b/src/ui/reader.css @@ -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; diff --git a/src/ui/reader.html b/src/ui/reader.html index 98277f0..1479903 100644 --- a/src/ui/reader.html +++ b/src/ui/reader.html @@ -185,7 +185,7 @@
- +
@@ -322,6 +322,39 @@
+ +