feat: 笔记独立窗口改为多标签,AI 多轮会话与 TXT/MD 阅读

笔记独立窗口从「一窗一条」改为单窗口多标签,与阅读器一致:
标签集在主进程侧为权威,notes:tabsChanged 只能收窄不能新增,
否则渲染层可以谎报持有某条笔记来越权读取。存活编辑器上限 3 并
LRU 回收,回收前序列化未保存内容。笔记没有自动保存,关标签与
关窗都做二次确认,取消关闭必须回报主进程复位 closePending,
否则窗口再也关不掉而看门狗仍会销毁未保存内容。

AI 助手支持多轮会话:会话独立落盘,先取历史再写提问,
历史只发文本不重发图像,失败与取消都保留已流出的残片。

新增 TXT/MD 内置阅读(转内存 EPUB 复用 epub 渲染管线),
补上渲染层遗漏的可阅读格式白名单:主进程本就放行 txt/md,
但渲染层另有两份白名单漏了,表现为卡片上没有「阅读」按钮。

书库卡片封面改用 contain 完整显示,留白由同图模糊层垫底,
修正不同比例封面被裁切程度不一导致的观感不一致;多选复选框
去掉衬底色块,恢复原生外观。

其余:PDF 画质档位与画布尺寸钳制、原子写入、笔记资源托管、
GitHub Pages 站点。
This commit is contained in:
lofyer
2026-08-04 16:19:06 +08:00
parent 522b0f74a5
commit 0fd7c59e08
68 changed files with 11721 additions and 301 deletions
+352 -11
View File
@@ -108,9 +108,12 @@ const readerStore = require('./src/reader/store');
const annotations = require('./src/reader/annotations');
const noteAssets = require('./src/reader/note-assets');
const readerWindow = require('./src/reader/window');
const noteWindow = require('./src/reader/note-window');
const rangeSessions = require('./src/reader/range-sessions');
const aiConfig = require('./src/reader/ai-config');
const aiClient = require('./src/reader/ai-client');
const aiSessions = require('./src/reader/ai-sessions');
const aiImages = require('./src/reader/ai-images');
const { normalizeVisualContexts } = require('./src/reader/visual-context');
const { setProxy, getProxy, fetchWithProxy } = require('./src/sources/http');
zlibAuth.init(userDataDir, safeStorage);
@@ -124,6 +127,8 @@ readerStore.init(userDataDir);
annotations.init(userDataDir);
noteAssets.init(userDataDir);
aiConfig.init(userDataDir, safeStorage);
aiSessions.init(userDataDir);
aiImages.init(userDataDir);
// 启动时从持久化设置恢复代理
try {
setProxy(settings.get('proxy', ''));
@@ -217,16 +222,26 @@ function notifyLibraryChanged() {
function notifyNotesChanged(data) {
const payload = data && typeof data === 'object' ? data : {};
const windows = [mainWindow, ...readerWindow.all()];
const windows = [mainWindow, ...readerWindow.all(), ...noteWindow.all()];
for (const win of windows) {
if (win && !win.isDestroyed()) win.webContents.send('reader:notesChanged', payload);
}
}
function notifyNoteWindowsChanged(noteIds) {
const payload = Array.isArray(noteIds) ? noteIds : noteWindow.openIds();
const windows = [mainWindow, ...readerWindow.all()];
for (const win of windows) {
if (win && !win.isDestroyed()) win.webContents.send('notes:windowsChanged', payload);
}
}
noteWindow.setChangeListener(notifyNoteWindowsChanged);
function applyWindowIcons(theme) {
currentUiTheme = theme === 'light' ? 'light' : 'dark';
const icon = iconForTheme(currentUiTheme);
const windows = [mainWindow, ...readerWindow.all()];
const windows = [mainWindow, ...readerWindow.all(), ...noteWindow.all()];
for (const win of windows) {
if (!win || win.isDestroyed()) continue;
try { win.setIcon(icon); } catch (e) { /* 平台不支持动态图标时保留创建时图标 */ }
@@ -234,7 +249,7 @@ function applyWindowIcons(theme) {
}
function notifyUiThemeChanged() {
const windows = [mainWindow, ...readerWindow.all()];
const windows = [mainWindow, ...readerWindow.all(), ...noteWindow.all()];
for (const win of windows) {
if (win && !win.isDestroyed()) {
win.webContents.send('ui:themeChanged', currentUiTheme);
@@ -514,6 +529,97 @@ ipcMain.handle('library:remove', (_e, id, options) => wrap(() => {
})();
}));
// 批量整理走单次索引写入。逐条 update 会把索引重写 N 遍,
// 上千条的书库里批量改动会明显卡顿。
ipcMain.handle('library:updateMany', (_e, patches) => wrap(() => {
const list = Array.isArray(patches) ? patches : [];
if (list.length > 5000) throw new Error('单次批量更新条目过多');
const result = library.updateMany(list);
for (const entry of list) {
if (entry && entry.id != null) coverGenerator.ensure(entry.id).catch(() => {});
}
return result;
}));
ipcMain.handle('library:removeMany', (_e, ids, options) => wrap(() => {
const list = (Array.isArray(ids) ? ids : []).map((id) => String(id));
if (list.length > 5000) throw new Error('单次批量移除条目过多');
const deleteFiles = !!(options && options.deleteFiles === true);
const deleteReadingData = !!(options && options.deleteReadingData === true);
return (async () => {
for (const id of list) await requestReaderPurge(id);
const purged = [];
if (deleteReadingData) {
for (const id of list) {
purgedReaderEntries.add(id);
purged.push(id);
}
try {
noteWindow.closeForEntries(list);
readerStore.forgetMany(list);
annotations.forgetMany(list);
aiSessions.forgetMany(list);
cleanupNoteAssets();
collectAiImages();
} catch (e) {
for (const id of purged) purgedReaderEntries.delete(id);
throw e;
}
for (const id of list) notifyNotesChanged({ entryId: id, type: 'forget' });
}
try {
return library.removeMany(list, deleteFiles);
} catch (e) {
for (const id of purged) purgedReaderEntries.delete(id);
throw e;
}
})();
}));
// 孤立阅读资料对账。笔记在「我的笔记」里仍可查看,属于有意保留,
// 因此只报告不自动删除;批注没有浏览入口,孤立后只会白占空间。
ipcMain.handle('reader:orphanReport', () => wrap(() => {
const knownIds = library.list().map((item) => String(item.id));
const notes = readerStore.orphanReport(knownIds);
const annotationOrphans = annotations.orphanReport(knownIds);
const chatOrphans = aiSessions.orphanReport(knownIds);
return {
notes,
annotations: annotationOrphans,
chats: chatOrphans,
totalBytes: annotationOrphans.reduce((sum, item) => sum + item.bytes, 0)
+ chatOrphans.reduce((sum, item) => sum + item.bytes, 0)
};
}));
ipcMain.handle('reader:purgeOrphans', (_e, options) => wrap(() => {
const scope = options && typeof options === 'object' ? options : {};
const knownIds = library.list().map((item) => String(item.id));
// 目标必须重新对账后确定,不接受渲染层直接传 ID,
// 否则一个过期的界面状态就能删掉仍在书库里的条目的阅读资料
const noteTargets = scope.notes === true
? readerStore.orphanReport(knownIds).map((item) => item.entryId)
: [];
const annotationTargets = scope.annotations === true
? annotations.orphanReport(knownIds).map((item) => item.entryId)
: [];
const chatTargets = scope.chats === true
? aiSessions.orphanReport(knownIds).map((item) => item.entryId)
: [];
if (noteTargets.length) noteWindow.closeForEntries(noteTargets);
const notesRemoved = noteTargets.length ? readerStore.forgetMany(noteTargets) : 0;
const annotationsRemoved = annotationTargets.length
? annotations.forgetMany(annotationTargets)
: 0;
const chatsRemoved = chatTargets.length ? aiSessions.forgetMany(chatTargets) : 0;
if (notesRemoved) {
cleanupNoteAssets();
for (const id of noteTargets) notifyNotesChanged({ entryId: id, type: 'forget' });
}
if (chatsRemoved) collectAiImages();
return { notesRemoved, annotationsRemoved, chatsRemoved };
}));
// 下载文件:默认直接存入书库目录并挂到条目上;
// 开启"下载前询问保存位置"后改为弹保存框(此时文件在书库外,记绝对路径)。
// meta 用于文件不属于任何已有条目时自动建条目,避免"下载了但书库不知道"。
@@ -700,7 +806,7 @@ ipcMain.handle('dialog:pickLocal', (event, kind) => wrap(async () => {
: ['openFile', 'multiSelections'],
filters: sourceKind === 'folder'
? undefined
: [{ name: '图书', extensions: ['pdf', 'epub', 'mobi', 'azw', 'azw3', 'txt', 'djvu', 'fb2', 'cbz', 'cbr'] }]
: [{ name: '图书', extensions: ['pdf', 'epub', 'mobi', 'azw', 'azw3', 'txt', 'md', 'djvu', 'fb2', 'cbz', 'cbr'] }]
});
if (r.canceled || !r.filePaths.length) return null;
const records = await localImport.discover(r.filePaths);
@@ -754,7 +860,7 @@ ipcMain.handle('library:importLocal', (event, selectionId, options) => wrap(asyn
// --- 阅读器 ---
const READABLE_EXT = new Set(['.pdf', '.epub', '.mobi', '.azw', '.azw3']);
const READABLE_EXT = new Set(['.pdf', '.epub', '.mobi', '.azw', '.azw3', '.txt', '.md']);
function isReaderSender(webContents) {
const expected = pathToFileURL(path.join(__dirname, 'src', 'ui', 'reader.html')).href;
return !!readerWindow.fromWebContents(webContents)
@@ -762,6 +868,13 @@ function isReaderSender(webContents) {
}
ipcMain.handle('reader:ready', (event) => wrap(() => readerWindow.markReady(event.sender)));
// 关闭书籍标签页或整个阅读窗口时,阅读进度与批注刚落盘,
// 书库卡片上的"最近阅读"排序和批注计数需要立刻跟上
ipcMain.handle('reader:entryClosed', (event) => wrap(() => {
if (!isReaderSender(event.sender)) throw new Error('只有阅读器可以上报关闭');
notifyLibraryChanged();
return true;
}));
ipcMain.handle('reader:captureRect', (event, rect) => wrap(async () => {
if (!isReaderSender(event.sender)) throw new Error('只有阅读器可以截取文档内容');
const win = BrowserWindow.fromWebContents(event.sender);
@@ -1033,13 +1146,54 @@ ipcMain.handle('reader:removeNote', (_e, entryId, noteId) => wrap(() => {
ensureReaderWritable(id);
const result = readerStore.removeNote(id, noteId);
if (result) {
// 窗口必须先退场再清理资产:留着的话它下次保存会把已删的笔记整条写回去
noteWindow.closeFor(noteId);
cleanupNoteAssets();
notifyNotesChanged({ entryId: id, noteId: String(noteId), type: 'remove' });
}
return result;
}));
// 笔记独立窗口。目标必须由主进程重新对账后确定,
// 渲染层给的 ID 只是查询条件,不能当授权凭据。
function findNote(entryId, noteId) {
const id = String(noteId == null ? '' : noteId);
if (!id) throw new Error('笔记 ID 无效');
const filters = entryId == null || entryId === '' ? {} : { entryId: String(entryId) };
const note = readerStore.listNotes(filters).find((item) => String(item.id) === id);
if (!note) throw new Error('笔记不存在或已被删除');
return note;
}
ipcMain.handle('notes:openWindow', (_e, entryId, noteId) => wrap(() => {
const note = findNote(entryId, noteId);
noteWindow.open(note.entryId, note.id, __dirname, currentUiTheme);
return { entryId: note.entryId, noteId: note.id };
}));
ipcMain.handle('notes:getOne', (event, entryId, noteId) => wrap(() => {
// 笔记窗口只能读自己已打开的标签,避免这个通道变成遍历全部笔记的后门。
// 多标签之后授权从「等于某一条」变成「在标签集内」,放宽成「是笔记窗口就给」等于取消校验。
if (noteWindow.fromWebContents(event.sender)
&& !noteWindow.ownsNote(event.sender, noteId)) {
throw new Error('无权读取其它笔记');
}
return findNote(entryId, noteId);
}));
// 标签集由渲染层上报,但只用于广播与授权范围收窄,新增标签仍要过 findNote 对账
ipcMain.handle('notes:tabsChanged', (event, tabs) => wrap(() => {
noteWindow.setTabs(event.sender, tabs);
return noteWindow.openIds();
}));
ipcMain.handle('notes:shutdownReady', (event) => wrap(() => noteWindow.shutdownReady(event.sender)));
ipcMain.handle('notes:cancelClose', (event) => wrap(() => noteWindow.cancelClose(event.sender)));
ipcMain.handle('notes:openWindows', () => wrap(() => noteWindow.openIds()));
ipcMain.handle('reader:listNotes', (_e, filters) => wrap(() => readerStore.listNotes(filters || {})));
ipcMain.handle('reader:getNoteCounts', () => wrap(() => readerStore.getNoteCounts()));
ipcMain.handle('reader:getAnnotationCounts', () => wrap(() => annotations.getCounts()));
ipcMain.handle('reader:listCollections', () => wrap(() => readerStore.listCollections()));
ipcMain.handle('reader:addCollection', (_e, input) => wrap(() => {
const result = readerStore.addCollection(input);
@@ -1128,6 +1282,101 @@ ipcMain.handle('ai:clear', () => wrap(() => {
return status;
}));
// 会话归属由主进程按 entryId 对账,渲染层给的 entryId 只作过滤条件,
// 不能当授权凭据:否则任意窗口都能读别的书的对话。
function readerOnly(event, fn) {
return wrap(() => {
if (!isReaderSender(event.sender)) throw new Error('只有阅读器可以管理 AI 会话');
return fn();
});
}
function aiSessionEntryId(value) {
const id = String(value == null ? '' : value);
if (!id || id === aiSessions.GLOBAL_ENTRY_ID) return aiSessions.GLOBAL_ENTRY_ID;
if (!library.get(id)) throw new Error('条目不存在');
return id;
}
// 会话文件可能残留已删除条目的记录,取其 entryId 时不再校验书库,
// 否则孤立会话既列不出来也删不掉。
function requireAiSession(sessionId) {
const meta = aiSessions.messages(sessionId, { limit: 1 }).meta;
return meta;
}
ipcMain.handle('ai:sessionList', (event, filters) => readerOnly(event, () => {
const raw = filters && typeof filters === 'object' ? filters : {};
const entryId = raw.entryId == null || raw.entryId === ''
? null
: aiSessionEntryId(raw.entryId);
return aiSessions.list(entryId ? { entryId } : {});
}));
ipcMain.handle('ai:sessionCreate', (event, input) => readerOnly(event, () => {
const raw = input && typeof input === 'object' ? input : {};
return aiSessions.create({
entryId: aiSessionEntryId(raw.entryId),
title: raw.title,
documentKey: raw.documentKey
});
}));
ipcMain.handle('ai:sessionRename', (event, sessionId, title) => readerOnly(event, () => {
requireAiSession(sessionId);
return aiSessions.rename(sessionId, title);
}));
ipcMain.handle('ai:sessionPin', (event, sessionId, pinned) => readerOnly(event, () => {
requireAiSession(sessionId);
return aiSessions.setPinned(sessionId, pinned === true);
}));
ipcMain.handle('ai:sessionRemove', (event, sessionId) => readerOnly(event, () => {
requireAiSession(sessionId);
const removed = aiSessions.remove(sessionId);
collectAiImages();
return removed;
}));
ipcMain.handle('ai:sessionClear', (event, sessionId) => readerOnly(event, () => {
requireAiSession(sessionId);
const meta = aiSessions.clear(sessionId);
collectAiImages();
return meta;
}));
ipcMain.handle('ai:sessionMessages', (event, sessionId, options) => readerOnly(event, () => {
const raw = options && typeof options === 'object' ? options : {};
return aiSessions.messages(sessionId, { limit: raw.limit, before: raw.before });
}));
// 图像 GC 的 keep 集合必须来自扫全部会话文件的 imageIds(),不能只读索引
function collectAiImages() {
try {
return aiImages.cleanup(aiSessions.imageIds());
} catch (error) {
return 0;
}
}
const AI_HISTORY_BUDGET = { maxChars: 12000, maxMessages: 20 };
const AI_TASK_TITLES = {
summarize: '总结当前上下文',
translate: '翻译选中文本',
explain: '解释选中文本',
ask: '提问'
};
// 存进会话的是用户看到的那句话,不是整篇正文:
// 正文另有 contextRef 记录哈希与字数,把它当消息正文会让重开后的气泡变成几千字原文
function aiTurnTitle(task, question) {
const asked = String(question == null ? '' : question).trim();
if (asked) return asked;
return AI_TASK_TITLES[task] || '提问';
}
const aiRuns = new Map();
function aiRunKey(senderId, runId) {
@@ -1169,40 +1418,132 @@ ipcMain.handle('ai:cancel', (event, runId) => wrap(() => {
// 流式:增量通过 ai:delta 事件推给发起窗口,最终结果由 invoke 返回
ipcMain.handle('ai:run', async (e, payload) => {
const { runId, task, text, question, visualContexts } = payload || {};
const { runId, sessionId, task, text, question, visualContexts, scope, locator, documentKey, fileIndex } = payload || {};
const id = String(runId || '');
if (!isReaderSender(e.sender)) return { ok: false, error: '只有阅读器可以使用 AI 助手' };
if (!/^[A-Za-z0-9_-]{1,80}$/.test(id)) return { ok: false, error: 'runId 无效' };
const key = aiRunKey(e.sender.id, id);
if (aiRuns.has(key)) return { ok: false, error: '该请求已在进行中' };
const chatId = sessionId == null || sessionId === '' ? '' : String(sessionId);
let meta = null;
if (chatId) {
try {
meta = requireAiSession(chatId);
} catch (err) {
return { ok: false, error: (err && err.message) || String(err) };
}
// 同一会话内不允许并发:两轮同时写同一个文件,后完成的那轮会覆盖前一轮的消息
for (const run of aiRuns.values()) {
if (run.sessionId && run.sessionId === chatId) {
return { ok: false, error: '该会话正在生成中,请先等待或停止' };
}
}
}
const ctl = new AbortController();
const wc = e.sender;
const abortOnDestroy = () => ctl.abort();
wc.once('destroyed', abortOnDestroy);
aiRuns.set(key, { controller: ctl, senderId: wc.id });
aiRuns.set(key, { controller: ctl, senderId: wc.id, sessionId: chatId });
const body = String(text == null ? '' : text);
let userMessageId = '';
let assistantMessageId = '';
let history = [];
let streamed = '';
try {
const visuals = canonicalVisualContexts(visualContexts);
if (chatId) {
// 历史必须在写入本轮之前取,否则当前提问会被当成自己的历史重复发一遍
history = aiSessions.historyFor(chatId, AI_HISTORY_BUDGET).messages;
const userMessage = aiSessions.appendUser(chatId, {
text: aiTurnTitle(task, question),
task,
contextRef: body || visuals.length ? {
scope,
chars: body.length,
hash: aiSessions.hashContext(body),
locator,
documentKey: documentKey || meta.documentKey,
fileIndex
} : null,
images: persistAiImages(visuals)
});
userMessageId = userMessage.id;
assistantMessageId = aiSessions.appendAssistant(chatId, { task }).id;
}
const full = await aiClient.stream({
task,
text,
question,
visualContexts: visuals,
history,
signal: ctl.signal,
onDelta: (piece) => {
if (!wc.isDestroyed()) wc.send('ai:delta', { runId: id, delta: piece });
streamed += piece;
if (!wc.isDestroyed()) {
wc.send('ai:delta', {
runId: id,
delta: piece,
sessionId: chatId,
messageId: assistantMessageId
});
}
}
});
return { ok: true, data: { text: full } };
if (chatId) settleAiAssistant(chatId, assistantMessageId, { text: full });
return { ok: true, data: { text: full, sessionId: chatId, userMessageId, assistantMessageId } };
} catch (err) {
if (err && err.name === 'AbortError') return { ok: false, error: '已取消', cancelled: true };
return { ok: false, error: (err && err.message) || String(err) };
const cancelled = !!(err && err.name === 'AbortError');
const message = cancelled ? '已取消' : ((err && err.message) || String(err));
// 失败与取消都要落盘:用户的提问已经花掉了 token,
// 已经流出来的残片也要留住,否则界面上看到的半截回答一重开就消失
if (chatId && assistantMessageId) {
settleAiAssistant(chatId, assistantMessageId, {
text: streamed,
cancelled,
error: cancelled ? null : message
});
}
if (cancelled) {
return { ok: false, error: message, cancelled: true, data: { sessionId: chatId, userMessageId, assistantMessageId } };
}
return { ok: false, error: message, data: { sessionId: chatId, userMessageId, assistantMessageId } };
} finally {
wc.removeListener('destroyed', abortOnDestroy);
aiRuns.delete(key);
}
});
// 落盘失败不能把已经拿到的回答变成请求失败,最多是这一轮没存住
function settleAiAssistant(chatId, messageId, patch) {
try {
return aiSessions.finishAssistant(chatId, messageId, patch);
} catch (error) {
return null;
}
}
function persistAiImages(visuals) {
const stored = [];
for (const context of visuals) {
if (!context.includeImage || !context.image) continue;
try {
const put = aiImages.put(Buffer.from(context.image.base64, 'base64'), context.image.mimeType);
stored.push({
imageId: put.imageId,
mimeType: 'image/jpeg',
width: context.image.width,
height: context.image.height,
bytes: put.bytes,
ocrIncluded: !!(context.ocr && context.ocr.include)
});
} catch (error) { /* 存图失败不影响本轮提问 */ }
}
return stored;
}
// 通用设置读写(目前用于"下载前询问保存位置"开关)
ipcMain.handle('settings:get', (_e, key, def) => wrap(() => settings.get(key, def)));
ipcMain.handle('settings:set', (_e, key, value) => wrap(() => { settings.set(key, value); }));