const { app, BrowserWindow, ipcMain, clipboard, dialog, shell, session, safeStorage, nativeImage } = require('electron'); const path = require('path'); const fs = require('fs'); const crypto = require('crypto'); const { pathToFileURL } = require('url'); const { Readable, Transform } = require('stream'); const { pipeline } = require('stream/promises'); const DL_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36'; const RELEASES_API = 'https://api.github.com/repos/lofyer/peoplelib/releases/latest'; const RELEASES_PAGE = 'https://github.com/lofyer/peoplelib/releases'; function parseVersion(v) { const s = String(v || '').replace(/^v/i, '').trim(); const [core, pre = ''] = s.split(/[-+]/); return { nums: core.split('.').map((n) => parseInt(n, 10) || 0), // 有预发布标记的版本低于同号正式版:1.1.0-beta < 1.1.0 pre: pre.toLowerCase() }; } function compareVersion(a, b) { const pa = parseVersion(a); const pb = parseVersion(b); const len = Math.max(pa.nums.length, pb.nums.length); for (let i = 0; i < len; i++) { const x = pa.nums[i] || 0; const y = pb.nums[i] || 0; if (x !== y) return x > y ? 1 : -1; } if (pa.pre === pb.pre) return 0; if (!pa.pre) return 1; if (!pb.pre) return -1; return pa.pre > pb.pre ? 1 : -1; } async function checkUpdate() { const current = app.getVersion(); const ac = new AbortController(); const timer = setTimeout(() => ac.abort(), 15000); let res; try { res = await fetchWithProxy(RELEASES_API, { headers: { 'User-Agent': DL_UA, 'Accept': 'application/vnd.github+json' }, signal: ac.signal }); } catch (e) { if (e && e.name === 'AbortError') throw new Error('检查更新超时,请检查网络或代理设置'); throw e; } finally { clearTimeout(timer); } if (!res.ok) throw new Error(`检查更新失败: ${res.status}`); const json = await res.json(); const latest = json.tag_name || json.name || ''; return { current, latest: String(latest).replace(/^v/i, ''), hasUpdate: compareVersion(latest, current) > 0, url: json.html_url || RELEASES_PAGE, notes: json.body || '' }; } function filenameFromResponse(res, fallback) { const cd = res.headers.get('content-disposition') || ''; let m = cd.match(/filename\*=(?:UTF-8'')?([^;]+)/i) || cd.match(/filename="?([^";]+)"?/i); if (m) { try { return decodeURIComponent(m[1].trim()); } catch (e) { return m[1].trim(); } } try { const u = new URL(res.url); const base = path.basename(u.pathname); if (base && /\.[a-z0-9]{2,5}$/i.test(base)) return decodeURIComponent(base); } catch (e) { /* ignore */ } return fallback || 'download.bin'; } app.setName('PeopleLib'); app.setAppUserModelId('com.peoplelib.client'); const APP_ICON_DIR = path.join(__dirname, 'icons', 'dist'); function iconForTheme(theme) { const name = theme === 'light' ? 'light' : 'dark'; // .ico 只有 Windows 认;macOS/Linux 用 PNG,窗口图标不需要 icns return process.platform === 'win32' ? path.join(APP_ICON_DIR, `book-ai-${name}.ico`) : path.join(APP_ICON_DIR, name, 'icon-256.png'); } // macOS 的 .app 内部不可写(DMG 只读,且升级覆盖会连用户数据一起删), // 只有 Windows 便携版才把 data/ 放在可执行文件旁边。 function resolveUserDataDir() { if (!app.isPackaged) return path.join(app.getPath('appData'), 'PeopleLib'); if (process.platform === 'win32') return path.join(path.dirname(app.getPath('exe')), 'data'); return path.join(app.getPath('appData'), 'PeopleLib'); } const userDataDir = resolveUserDataDir(); app.setPath('userData', userDataDir); const sources = require('./src/sources'); const library = require('./src/library/store'); const localImport = require('./src/library/local-import'); const coverGenerator = require('./src/library/cover-generator'); const zlibAuth = require('./src/sources/zlib-auth'); const semanticKey = require('./src/sources/semantic-key'); const settings = require('./src/settings'); 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); semanticKey.init(userDataDir, safeStorage); settings.init(userDataDir); let currentUiTheme = settings.get( 'ui.theme', settings.get('reader.uiTheme', 'dark') ) === 'light' ? 'light' : 'dark'; readerStore.init(userDataDir); annotations.init(userDataDir); noteAssets.init(userDataDir); aiConfig.init(userDataDir, safeStorage); aiSessions.init(userDataDir); aiImages.init(userDataDir); // 启动时从持久化设置恢复代理 try { setProxy(settings.get('proxy', '')); } catch (e) { console.warn('代理配置无效,已改为直连:', e.message); setProxy(''); } // 书库目录:默认 /library,用户可在设置中更改 const DEFAULT_LIBRARY_DIR = path.join(userDataDir, 'library'); try { library.init(settings.get('libraryDir', '') || DEFAULT_LIBRARY_DIR); } catch (e) { console.warn('自定义书库目录不可用,已回退到默认目录:', e.message); library.init(DEFAULT_LIBRARY_DIR); } coverGenerator.init(__dirname, library); const legacyImportPending = !settings.get('legacyImported', false); let mainWindow; let activeDownloads = 0; let readerPurgeSeq = 0; let startupMaintenanceStarted = false; const readerPurgeWaiters = new Map(); const purgedReaderEntries = new Set(); const pendingLocalImports = new Map(); function requestReaderPurge(entryId) { const requestId = `purge_${Date.now().toString(36)}_${(++readerPurgeSeq).toString(36)}`; if (!readerWindow.get()) return Promise.resolve(); return new Promise((resolve) => { const timer = setTimeout(() => { readerPurgeWaiters.delete(requestId); resolve(); }, 5000); readerPurgeWaiters.set(requestId, () => { clearTimeout(timer); readerPurgeWaiters.delete(requestId); resolve(); }); readerWindow.purgeFor(entryId, requestId); }); } function ensureReaderWritable(entryId) { if (purgedReaderEntries.has(String(entryId))) throw new Error('该条目的阅读资料已删除'); } function createWindow() { mainWindow = new BrowserWindow({ width: 1240, height: 840, minWidth: 940, minHeight: 620, frame: false, backgroundColor: '#141414', icon: iconForTheme(currentUiTheme), title: 'PeopleLib', webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false } }); mainWindow.loadFile(path.join(__dirname, 'src', 'ui', 'index.html')); mainWindow.webContents.once('did-finish-load', () => { setTimeout(runStartupMaintenance, 1500); }); } function runStartupMaintenance() { if (startupMaintenanceStarted) return; startupMaintenanceStarted = true; try { if (legacyImportPending) { library.importLegacy(userDataDir); settings.set('legacyImported', true); } library.scan(); for (const job of coverGenerator.ensureAll()) job.catch(() => {}); } catch (e) { console.warn('启动维护任务失败:', e.message); } } function notifyLibraryChanged() { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('library:changed'); } } function notifyNotesChanged(data) { const payload = data && typeof data === 'object' ? data : {}; 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(), ...noteWindow.all()]; for (const win of windows) { if (!win || win.isDestroyed()) continue; try { win.setIcon(icon); } catch (e) { /* 平台不支持动态图标时保留创建时图标 */ } } } function notifyUiThemeChanged() { const windows = [mainWindow, ...readerWindow.all(), ...noteWindow.all()]; for (const win of windows) { if (win && !win.isDestroyed()) { win.webContents.send('ui:themeChanged', currentUiTheme); } } } library.setChangeListener(notifyLibraryChanged); app.whenReady().then(() => { // 应用代理到 Chromium defaultSession(影响 net.fetch、窗口加载、所有请求) const p = getProxy(); if (p) { session.defaultSession.setProxy({ proxyRules: p }).catch(() => {}); } createWindow(); setTimeout(cleanupNoteAssets, 0); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); }); }); app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); }); app.on('before-quit', () => { coverGenerator.close(); rangeSessions.closeAll().catch(() => {}); }); // fn 同步抛出时也必须变成 { ok:false },否则 invoke 直接 reject, // 渲染层的 await 没有 catch,界面会永远停在"加载中"。 function wrap(fn) { return Promise.resolve() .then(typeof fn === 'function' ? fn : () => fn) .then((data) => ({ ok: true, data })) .catch((err) => ({ ok: false, error: (err && err.message) || String(err) })); } // 数据源 ipcMain.handle('sources:list', () => wrap(() => sources.listSources())); ipcMain.handle('source:list', (_e, sourceId, page) => wrap(() => sources.getSource(sourceId).list(page))); ipcMain.handle('source:search', (_e, sourceId, keyword, page) => wrap(() => sources.getSource(sourceId).search(keyword, page))); ipcMain.handle('source:detail', (_e, sourceId, postId) => wrap(() => sources.getSource(sourceId).detail(postId))); ipcMain.handle('source:download', (_e, sourceId, postId) => wrap(() => sources.getSource(sourceId).download(postId))); // 代理配置:全局生效,影响所有数据源的 HTTP 请求与文件下载 ipcMain.handle('proxy:get', () => wrap(() => getProxy())); ipcMain.handle('proxy:set', (_e, url) => { const previous = getProxy(); try { const u = String(url || '').trim(); setProxy(u); settings.set('proxy', u); session.defaultSession.setProxy({ proxyRules: u || 'direct://' }).catch(() => {}); return { ok: true }; } catch (e) { try { setProxy(previous); } catch (rollbackError) { /* ignore */ } return { ok: false, error: e.message || String(e) }; } }); function zlibOrigin(value) { let url; try { url = new URL(String(value || '')); } catch (e) { throw new Error('Z-Library 镜像地址无效'); } if (url.protocol !== 'https:' || url.username || url.password) { throw new Error('Z-Library 镜像必须使用 HTTPS'); } return url.origin; } async function waitForZlibPage(win, origin) { const deadline = Date.now() + 30000; while (Date.now() < deadline) { if (win.isDestroyed()) throw new Error('Z-Library 登录页面已关闭'); const current = win.webContents.getURL(); const title = win.getTitle(); if (current.startsWith(`${origin}/`) && title && !/checking your browser/i.test(title)) return; await new Promise((resolve) => setTimeout(resolve, 400)); } throw new Error('Z-Library 浏览器验证超时'); } async function browserZlibLogin(mirror, email, password) { const origin = zlibOrigin(mirror); const win = new BrowserWindow({ show: false, width: 900, height: 700, webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true, webSecurity: true, backgroundThrottling: false } }); win.webContents.setWindowOpenHandler(() => ({ action: 'deny' })); win.webContents.on('will-attach-webview', (event) => event.preventDefault()); win.webContents.on('will-navigate', (event, target) => { try { if (new URL(target).origin !== origin) event.preventDefault(); } catch (e) { event.preventDefault(); } }); try { win.loadURL(`${origin}/`).catch(() => {}); await waitForZlibPage(win, origin); const body = new URLSearchParams({ isModal: 'true', email: String(email), password: String(password), site_mode: 'books', action: 'login', isSingleLogin: '1', redirectUrl: '', gg_json_mode: '1' }).toString(); const code = `fetch(${JSON.stringify(`${origin}/rpc.php`)}, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Requested-With': 'XMLHttpRequest' }, body: ${JSON.stringify(body)} }).then(async (response) => ({ status: response.status, contentType: response.headers.get('content-type') || '', text: await response.text() }))`; const result = await win.webContents.executeJavaScriptInIsolatedWorld( 1001, [{ code }], true ); let data = null; try { data = JSON.parse(result && result.text); } catch (e) { /* 非 JSON */ } if (!data) { if (/checking your browser|diamwall|cloudflare/i.test(String(result && result.text || ''))) { throw new Error('登录镜像触发了浏览器验证'); } throw new Error(`登录镜像未返回 JSON(HTTP ${Number(result && result.status) || 0})`); } const response = data.response && typeof data.response === 'object' ? data.response : {}; if (response.validationError || response.error) { return { error: String(response.message || response.error || '登录失败') }; } const cookies = await session.defaultSession.cookies.get({ url: `${origin}/` }); const userId = cookies.find((cookie) => cookie.name === 'remix_userid'); const userKey = cookies.find((cookie) => cookie.name === 'remix_userkey'); if (!userId || !userKey || !userId.value || !userKey.value) { throw new Error('登录响应缺少会话信息'); } return { userId: userId.value, userKey: userKey.value }; } finally { if (!win.isDestroyed()) win.destroy(); } } sources.getSource('zlib').setLoginTransport(browserZlibLogin); // Z-Library 凭据 ipcMain.handle('zlib:hasCreds', () => wrap(() => zlibAuth.hasCreds())); ipcMain.handle('zlib:login', (_e, email, password) => wrap(() => sources.getSource('zlib').login(email, password))); ipcMain.handle('zlib:logout', () => wrap(() => sources.getSource('zlib').logout())); // Semantic Scholar API Key ipcMain.handle('semanticScholar:keyStatus', () => wrap(() => semanticKey.status())); ipcMain.handle('semanticScholar:setKey', (_e, key) => wrap(() => semanticKey.write(key))); ipcMain.handle('semanticScholar:clearKey', () => wrap(() => semanticKey.clear())); // 书库目录管理 ipcMain.handle('library:getDir', () => wrap(() => ({ dir: library.getRoot(), isDefault: library.getRoot() === DEFAULT_LIBRARY_DIR }))); ipcMain.handle('library:pickDir', async () => { const r = await dialog.showOpenDialog(liveWindow(), { title: '选择书库目录', defaultPath: library.getRoot(), properties: ['openDirectory', 'createDirectory'] }); if (r.canceled || !r.filePaths.length) return { ok: true, data: null }; return { ok: true, data: r.filePaths[0] }; }); // migrate=true 时把现有数据搬到新目录,否则只切换(旧目录原样保留) ipcMain.handle('library:setDir', (_e, dir, migrate) => wrap(() => { const dest = String(dir || '').trim(); if (!dest) throw new Error('目录不能为空'); if (activeDownloads) throw new Error('请等待当前下载完成后再切换书库目录'); const previousSetting = settings.get('libraryDir', ''); const previousRoot = library.getRoot(); try { if (migrate) { library.migrateTo(dest); } else { library.init(dest); } const r = library.scan(); settings.set('libraryDir', dest); if (migrate) library.finalizeMigration(); notifyLibraryChanged(); for (const job of coverGenerator.ensureAll()) job.catch(() => {}); return { dir: library.getRoot(), ...r }; } catch (e) { if (migrate) { try { library.rollbackMigration(); } catch (rollbackError) { /* ignore */ } } else { try { library.init(previousRoot); } catch (rollbackError) { /* ignore */ } } try { settings.set('libraryDir', previousSetting); } catch (rollbackError) { /* ignore */ } throw e; } })); ipcMain.handle('library:scan', () => wrap(() => { const r = library.scan(); notifyLibraryChanged(); for (const job of coverGenerator.ensureAll()) job.catch(() => {}); return r; })); // 本地书库 ipcMain.handle('library:list', () => wrap(() => library.list().map((item) => ({ ...item, lastReadAt: readerStore.getLastReadAt(item.id) })))); ipcMain.handle('library:get', (_e, id) => wrap(() => library.get(id))); ipcMain.handle('library:listShelves', () => wrap(() => library.listShelves())); ipcMain.handle('library:listTags', () => wrap(() => library.listTags())); ipcMain.handle('library:addShelf', (_e, input) => wrap(() => library.addShelf(input))); ipcMain.handle('library:updateShelf', (_e, shelfId, patch) => wrap(() => library.updateShelf(shelfId, patch))); ipcMain.handle('library:removeShelf', (_e, shelfId) => wrap(() => library.removeShelf(shelfId))); ipcMain.handle('library:addTag', (_e, input) => wrap(() => library.addTag(input))); ipcMain.handle('library:updateTag', (_e, tagId, patch) => wrap(() => library.updateTag(tagId, patch))); ipcMain.handle('library:removeTag', (_e, tagId) => wrap(() => library.removeTag(tagId))); ipcMain.handle('library:findBySource', (_e, sourceId, postId) => wrap(() => library.findBySource(sourceId, postId))); ipcMain.handle('library:add', (_e, item) => wrap(() => { const entry = library.add(item); coverGenerator.ensure(entry.id).catch(() => {}); return entry; })); ipcMain.handle('library:update', (_e, id, patch) => wrap(() => { const entry = library.update(id, patch); coverGenerator.ensure(entry.id).catch(() => {}); return entry; })); ipcMain.handle('library:remove', (_e, id, options) => wrap(() => { const deleteFiles = options && typeof options === 'object' ? options.deleteFiles === true : options === true; const deleteReadingData = !!( options && typeof options === 'object' && options.deleteReadingData === true ); return (async () => { await requestReaderPurge(id); if (deleteReadingData) { const key = String(id); purgedReaderEntries.add(key); try { readerStore.forget(id); annotations.forget(id); cleanupNoteAssets(); } catch (e) { purgedReaderEntries.delete(key); throw e; } notifyNotesChanged({ entryId: String(id), type: 'forget' }); } try { const removed = library.remove(id, deleteFiles); if (!removed && deleteReadingData) purgedReaderEntries.delete(String(id)); return removed; } catch (e) { if (deleteReadingData) purgedReaderEntries.delete(String(id)); throw e; } })(); })); // 批量整理走单次索引写入。逐条 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 用于文件不属于任何已有条目时自动建条目,避免"下载了但书库不知道"。 ipcMain.handle('download:file', async (event, url, suggestName, entryId, extraHeaders, meta, requestId) => { activeDownloads++; let partial = ''; let res = null; let bodyHandled = false; const progressId = typeof requestId === 'string' ? requestId.slice(0, 100) : ''; const sendProgress = (data) => { if (!progressId || event.sender.isDestroyed()) return; event.sender.send('download:progress', { requestId: progressId, ...data }); }; try { const parsedUrl = new URL(String(url || '')); if (!/^https?:$/.test(parsedUrl.protocol)) throw new Error('仅支持 HTTP 或 HTTPS 下载链接'); const askSavePath = settings.get('askSavePath', false); const suggested = library.sanitize(suggestName || 'download.bin'); let target; if (askSavePath) { const save = await dialog.showSaveDialog(liveWindow(), { title: '保存文件', defaultPath: path.join(library.filesDir(), suggested) }); if (save.canceled || !save.filePath) return { ok: true, data: { canceled: true } }; target = save.filePath; } const headers = { 'User-Agent': DL_UA, ...(extraHeaders || {}) }; headers['Referer'] = parsedUrl.origin + '/'; const ac = new AbortController(); const timer = setTimeout(() => ac.abort(), 30000); try { res = await fetchWithProxy(parsedUrl.toString(), { redirect: 'follow', headers, signal: ac.signal }); } finally { clearTimeout(timer); } if (!res.ok) throw new Error(`下载失败: ${res.status}`); const declaredSize = Number(res.headers.get('content-length')); const totalBytes = Number.isFinite(declaredSize) && declaredSize > 0 ? declaredSize : null; let receivedBytes = 0; let lastProgressAt = 0; sendProgress({ receivedBytes, totalBytes, percent: totalBytes ? 0 : null }); const respName = filenameFromResponse(res, suggestName); const hasExt = suggestName && /\.[a-z0-9]{2,5}$/i.test(suggestName); const defaultName = library.sanitize(hasExt ? suggestName : respName); const contentType = res.headers.get('content-type') || ''; const expectedExt = path.extname(target || defaultName).toLowerCase(); if (/text\/html|application\/json/i.test(contentType) && !['.html', '.htm', '.json', '.txt'].includes(expectedExt)) { throw new Error('下载地址返回了网页而不是文献文件'); } if (!target) target = library.allocFilePath(defaultName); fs.mkdirSync(path.dirname(target), { recursive: true }); partial = path.join( path.dirname(target), `.${path.basename(target)}.${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.part` ); if (!res.body) throw new Error('下载响应没有文件内容'); let transferTimer; const refreshTransferTimer = () => { clearTimeout(transferTimer); transferTimer = setTimeout(() => ac.abort(), 30000); }; const activity = new Transform({ transform(chunk, encoding, callback) { refreshTransferTimer(); receivedBytes += chunk.length; const now = Date.now(); if (now - lastProgressAt >= 100 || (totalBytes && receivedBytes >= totalBytes)) { lastProgressAt = now; sendProgress({ receivedBytes, totalBytes, percent: totalBytes ? Math.min(1, receivedBytes / totalBytes) : null }); } callback(null, chunk); } }); refreshTransferTimer(); bodyHandled = true; try { await pipeline(Readable.fromWeb(res.body), activity, fs.createWriteStream(partial, { flags: 'wx' })); } finally { clearTimeout(transferTimer); } if (askSavePath) { const backup = `${target}.${process.pid}-${Date.now()}.bak`; let backedUp = false; try { if (fs.existsSync(target)) { fs.renameSync(target, backup); backedUp = true; } fs.renameSync(partial, target); partial = ''; if (backedUp) { try { fs.unlinkSync(backup); } catch (e) { /* 保留备份不影响下载 */ } } } catch (e) { try { if (backedUp && !fs.existsSync(target) && fs.existsSync(backup)) fs.renameSync(backup, target); } catch (rollbackError) { /* ignore */ } throw e; } } else { for (;;) { try { fs.linkSync(partial, target); break; } catch (e) { if (e.code !== 'EEXIST') throw e; target = library.allocFilePath(defaultName); } } try { fs.unlinkSync(partial); } catch (e) { /* 保留硬链接副本不影响文件 */ } partial = ''; } sendProgress({ receivedBytes, totalBytes, percent: 1, complete: true }); // 落库:优先挂到已有条目,否则用 meta 新建 let id = entryId; if (!id && meta) { const existing = meta.sourceId && meta.sourcePostId ? library.findBySource(meta.sourceId, meta.sourcePostId) : null; id = existing ? existing.id : library.add(meta).id; } const entry = id ? library.attachFile(id, target) : null; if (entry) coverGenerator.ensure(entry.id).catch(() => {}); return { ok: true, data: { path: target, name: path.basename(target), entryId: id || null, entry } }; } catch (e) { if (res && res.body && !bodyHandled) { try { await res.body.cancel(); } catch (cancelError) { /* ignore */ } } if (partial) { try { fs.unlinkSync(partial); } catch (cleanupError) { /* ignore */ } } if (e && (e.name === 'AbortError' || /aborted/i.test(e.message || ''))) { return { ok: false, error: '下载超时,请检查网络或代理设置' }; } return { ok: false, error: e.message || String(e) }; } finally { activeDownloads--; } }); // 打开文件。没有关联程序时(例如未装 epub 阅读器)退而求其次, // 在资源管理器里定位该文件,而不是静默失败。 ipcMain.handle('shell:openPath', async (_e, p) => { const target = String(p || ''); if (!target) return { ok: false, error: '路径为空' }; if (!fs.existsSync(target)) return { ok: false, error: '文件不存在,可能已被移动或删除' }; const err = await shell.openPath(target); if (!err) return { ok: true }; shell.showItemInFolder(target); return { ok: true, data: { revealed: true, reason: err } }; }); ipcMain.handle('shell:showItem', (_e, p) => { shell.showItemInFolder(p || ''); return { ok: true }; }); ipcMain.handle('shell:openExternal', async (_e, url) => { try { const target = new URL(String(url || '')); if (!/^https?:$/.test(target.protocol)) throw new Error('仅允许打开 HTTP 或 HTTPS 链接'); await shell.openExternal(target.toString()); return { ok: true }; } catch (e) { return { ok: false, error: e.message || String(e) }; } }); ipcMain.handle('dialog:pickLocal', (event, kind) => wrap(async () => { const sourceKind = kind === 'folder' ? 'folder' : 'files'; const r = await dialog.showOpenDialog(liveWindow(), { title: sourceKind === 'folder' ? '选择本地图书文件夹' : '选择本地图书文件', properties: sourceKind === 'folder' ? ['openDirectory'] : ['openFile', 'multiSelections'], filters: sourceKind === 'folder' ? undefined : [{ 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); if (!records.length) { throw new Error('所选位置中没有支持的图书文件'); } const now = Date.now(); for (const [id, pending] of pendingLocalImports) { if (now - pending.createdAt > 10 * 60 * 1000) pendingLocalImports.delete(id); } const selectionId = crypto.randomUUID(); pendingLocalImports.set(selectionId, { senderId: event.sender.id, records, kind: sourceKind, createdAt: now }); return { selectionId, kind: sourceKind, paths: r.filePaths.map((file) => path.resolve(file)), count: records.length, sample: records.slice(0, 5) }; })); ipcMain.handle('library:importLocal', (event, selectionId, options) => wrap(async () => { const id = String(selectionId || ''); const pending = pendingLocalImports.get(id); if (!pending) { throw new Error('本地导入选择已失效,请重新选择'); } if (pending.senderId !== event.sender.id) throw new Error('无权使用该本地导入选择'); pendingLocalImports.delete(id); if (Date.now() - pending.createdAt > 10 * 60 * 1000) { throw new Error('本地导入选择已过期,请重新选择'); } const organization = options && ['none', 'shelf', 'tag'].includes(options.organization) ? options.organization : 'none'; const records = pending.records.map((record) => ({ ...record })); if (records.length === 1 && options && typeof options === 'object') { records[0].title = String(options.title || '').trim(); const author = String(options.author || '').trim(); records[0].authors = author ? [author] : []; } const result = library.importLocal(records, organization); for (const item of result.items) coverGenerator.ensure(item.id).catch(() => {}); return { ...result, discovered: records.length }; })); // --- 阅读器 --- 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) || String(webContents.getURL() || '').startsWith(expected); } 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); if (!win || win.isDestroyed()) throw new Error('阅读器窗口不可用'); const value = rect && typeof rect === 'object' ? rect : {}; const area = { x: Math.floor(Number(value.x)), y: Math.floor(Number(value.y)), width: Math.floor(Number(value.width)), height: Math.floor(Number(value.height)) }; const [contentWidth, contentHeight] = win.getContentSize(); if ( !Object.values(area).every(Number.isFinite) || area.x < 0 || area.y < 0 || area.width < 2 || area.height < 2 || area.width > 4096 || area.height > 4096 || area.x + area.width > contentWidth || area.y + area.height > contentHeight ) { throw new Error('截图区域无效或超出阅读器窗口'); } let image = await event.sender.capturePage(area); if (image.isEmpty()) throw new Error('没有截取到文档图像'); let size = image.getSize(); const longest = Math.max(size.width, size.height); if (longest > 2048) { const ratio = 2048 / longest; image = image.resize({ width: Math.max(1, Math.round(size.width * ratio)), height: Math.max(1, Math.round(size.height * ratio)), quality: 'best' }); size = image.getSize(); } let data = null; for (const quality of [90, 82, 72, 62]) { const candidate = image.toJPEG(quality); if (candidate.length <= 3 * 1024 * 1024) { data = candidate; break; } } if (!data) throw new Error('截图数据超过 3 MB'); return { mimeType: 'image/jpeg', base64: data.toString('base64'), width: size.width, height: size.height, bytes: data.length }; })); ipcMain.on('reader:purgeReady', (event, requestId) => { if (!readerWindow.fromWebContents(event.sender)) return; const resolve = readerPurgeWaiters.get(String(requestId)); if (resolve) resolve(); }); ipcMain.on('reader:shutdownReady', (event) => { readerWindow.shutdownReady(event.sender); }); // 只允许读取书库中真实登记过的文件,杜绝渲染层传任意路径读盘 function resolveReadable(entryId, fileIndex, documentKey) { const item = library.get(entryId); if (!item) throw new Error('条目不存在'); const files = (item.files || []).filter((f) => f && f.path); if (!files.length) throw new Error('该条目还没有可阅读的文件'); let idx = Number.isInteger(fileIndex) ? fileIndex : files.findIndex((f) => READABLE_EXT.has(path.extname(f.path).toLowerCase())); const expectedKey = /^[a-f0-9]{64}$/.test(String(documentKey || '')) ? String(documentKey) : ''; if (expectedKey) { const matched = files.findIndex((candidate) => { if (!candidate || !candidate.path || !READABLE_EXT.has(path.extname(candidate.path).toLowerCase())) return false; if (!fs.existsSync(candidate.path)) return false; try { return annotations.documentKey(candidate.path) === expectedKey; } catch (e) { return false; } }); if (matched < 0) throw new Error('笔记关联的原始文件已变更或不存在'); idx = matched; } const resolvedIndex = idx >= 0 ? idx : 0; const file = files[resolvedIndex]; if (!file) throw new Error('找不到指定文件'); const abs = path.resolve(file.path); const ext = path.extname(abs).toLowerCase(); if (!READABLE_EXT.has(ext)) throw new Error(`暂不支持在阅读器中打开 ${ext || '该格式'} 文件`); if (!fs.existsSync(abs)) throw new Error('文件不存在,可能已被移动或删除'); return { item, file, abs, format: ext.slice(1), fileIndex: resolvedIndex }; } rangeSessions.init(resolveReadable); const rangeSessionSenders = new Set(); function trackRangeSessionSender(webContents) { const senderId = webContents.id; if (rangeSessionSenders.has(senderId)) return; rangeSessionSenders.add(senderId); webContents.once('destroyed', () => { rangeSessionSenders.delete(senderId); rangeSessions.closeSender(senderId).catch(() => {}); }); } ipcMain.handle('reader:open', (_e, entryId, fileIndex) => wrap(() => { const { item, abs, format, fileIndex: resolvedIndex } = resolveReadable(entryId, fileIndex); readerWindow.open(entryId, __dirname, resolvedIndex, null, currentUiTheme); return { entryId, title: item.title, format, path: abs, fileIndex: resolvedIndex }; })); ipcMain.handle('reader:openAt', (_e, entryId, fileIndex, documentKey, locator) => wrap(() => { const resolved = resolveReadable(entryId, fileIndex, documentKey); const target = locator && typeof locator === 'object' ? locator : null; readerWindow.open(entryId, __dirname, resolved.fileIndex, target, currentUiTheme); return { entryId, title: resolved.item.title, format: resolved.format, path: resolved.abs, fileIndex: resolved.fileIndex, locator: target }; })); ipcMain.handle('reader:meta', (_e, entryId, fileIndex) => wrap(() => { const { item, abs, format, fileIndex: resolvedIndex } = resolveReadable(entryId, fileIndex); const fileSize = fs.statSync(abs).size; const documentKey = annotations.documentKey(abs); readerStore.setBookSnapshot(String(entryId), { title: item.title || '', authors: item.authors || [] }); readerStore.bindDocument(String(entryId), documentKey); const files = (item.files || []).filter((f) => f && f.path).map((f, i) => ({ index: i, name: f.name || path.basename(f.path), format: path.extname(f.path).toLowerCase().slice(1), readable: READABLE_EXT.has(path.extname(f.path).toLowerCase()) })); return { entryId, title: item.title, authors: item.authors || [], format, documentKey, fileIndex: resolvedIndex, size: fileSize, files, state: readerStore.getState(entryId, documentKey) }; })); const MAX_BUFFERED_READER_BYTES = 256 * 1024 * 1024; function readBoundedFile(abs, maxBytes) { const fd = fs.openSync(abs, 'r'); try { const stat = fs.fstatSync(fd); if (!stat.isFile() || !Number.isSafeInteger(stat.size) || stat.size > maxBytes) { throw new Error('该电子书超过 256 MB,暂不支持在内置阅读器中打开,请使用外部应用'); } const buffer = Buffer.allocUnsafe(stat.size); let offset = 0; while (offset < buffer.length) { const bytesRead = fs.readSync(fd, buffer, offset, buffer.length - offset, offset); if (!bytesRead) break; offset += bytesRead; } const after = fs.fstatSync(fd); if (offset !== buffer.length || after.size !== stat.size || after.mtimeMs !== stat.mtimeMs || after.ctimeMs !== stat.ctimeMs) { throw new Error('电子书文件在读取期间发生变化,请重试'); } return buffer; } finally { fs.closeSync(fd); } } ipcMain.handle('reader:rangeOpen', (event, entryId, fileIndex) => wrap(async () => { if (!isReaderSender(event.sender)) throw new Error('只有阅读器可以创建 PDF 分段读取会话'); trackRangeSessionSender(event.sender); return rangeSessions.open(event.sender.id, entryId, fileIndex); })); ipcMain.handle('reader:rangeRead', (event, sessionId, begin, end) => wrap(() => { if (!isReaderSender(event.sender)) throw new Error('只有阅读器可以读取 PDF 分段数据'); return rangeSessions.read(event.sender.id, sessionId, begin, end); })); ipcMain.handle('reader:rangeClose', (event, sessionId) => wrap(() => { if (!isReaderSender(event.sender)) throw new Error('只有阅读器可以关闭 PDF 分段读取会话'); return rangeSessions.close(event.sender.id, sessionId); })); ipcMain.handle('reader:bytes', (_e, entryId, fileIndex) => wrap(() => { const { abs, format } = resolveReadable(entryId, fileIndex); if (format === 'pdf') throw new Error('PDF 必须使用分段读取'); return readBoundedFile(abs, MAX_BUFFERED_READER_BYTES); })); ipcMain.handle('reader:openExternal', (_e, entryId, fileIndex) => wrap(async () => { const { abs } = resolveReadable(entryId, fileIndex); const error = await shell.openPath(abs); if (error) throw new Error(error); return true; })); ipcMain.handle('reader:getState', (_e, entryId, documentKey) => wrap(() => ( readerStore.getState(String(entryId), documentKey) ))); ipcMain.handle('reader:setProgress', (_e, entryId, documentKey, locator, percent) => wrap(() => { ensureReaderWritable(entryId); return readerStore.setProgress(String(entryId), documentKey, locator, percent); })); ipcMain.handle('reader:addBookmark', (_e, entryId, mark) => wrap(() => { ensureReaderWritable(entryId); return readerStore.addBookmark(String(entryId), mark); })); ipcMain.handle('reader:removeBookmark', (_e, entryId, markId) => wrap(() => { ensureReaderWritable(entryId); return readerStore.removeBookmark(String(entryId), markId); })); function notePayloadWithAssets(event, value) { const note = value && typeof value === 'object' ? { ...value } : value; if (!note || typeof note !== 'object' || !Object.prototype.hasOwnProperty.call(note, 'canvasContent')) { return { note, tokens: [] }; } const resolved = noteAssets.resolveDrafts(note.canvasContent, event.sender.id); note.canvasContent = resolved.content; return { note, tokens: resolved.tokens }; } function cleanupNoteAssets() { try { noteAssets.cleanup(readerStore.noteAssetIds()); } catch (error) { /* 后续保存时重试 */ } } ipcMain.handle('reader:addNote', (event, entryId, note) => wrap(() => { const id = String(entryId); ensureReaderWritable(id); const item = library.get(id); if (item) readerStore.setBookSnapshot(id, { title: item.title || '', authors: item.authors || [] }); const prepared = notePayloadWithAssets(event, note); const result = readerStore.addNote(id, prepared.note); noteAssets.commitTokens(prepared.tokens); cleanupNoteAssets(); notifyNotesChanged({ entryId: id, noteId: result.id, type: 'add' }); return result; })); ipcMain.handle('reader:addStandaloneNote', (event, note) => wrap(() => { const prepared = notePayloadWithAssets(event, note); const result = readerStore.addStandaloneNote(prepared.note); noteAssets.commitTokens(prepared.tokens); cleanupNoteAssets(); notifyNotesChanged({ entryId: readerStore.STANDALONE_ENTRY_ID, noteId: result.id, type: 'add' }); return result; })); ipcMain.handle('reader:updateNote', (event, entryId, noteId, patch) => wrap(() => { const id = String(entryId); ensureReaderWritable(id); const prepared = notePayloadWithAssets(event, patch); const result = readerStore.updateNote(id, noteId, prepared.note); if (result) { noteAssets.commitTokens(prepared.tokens); cleanupNoteAssets(); notifyNotesChanged({ entryId: id, noteId: result.id, type: 'update' }); } return result; })); ipcMain.handle('reader:removeNote', (_e, entryId, noteId) => wrap(() => { const id = String(entryId); 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); notifyNotesChanged({ collectionId: result.id, type: 'collection-add' }); return result; })); ipcMain.handle('reader:updateCollection', (_e, collectionId, patch) => wrap(() => { const result = readerStore.updateCollection(collectionId, patch); if (result) notifyNotesChanged({ collectionId: result.id, type: 'collection-update' }); return result; })); ipcMain.handle('reader:removeCollection', (_e, collectionId) => wrap(() => { const result = readerStore.removeCollection(collectionId); if (result) notifyNotesChanged({ collectionId: String(collectionId), type: 'collection-remove' }); return result; })); ipcMain.handle('reader:pickNotePdf', (event) => wrap(async () => { const result = await dialog.showOpenDialog(senderWindow(event), { title: '选择 PDF 笔记底版', properties: ['openFile'], filters: [{ name: 'PDF 文档', extensions: ['pdf'] }] }); if (result.canceled || !result.filePaths.length) return null; return noteAssets.stagePdf(result.filePaths[0], event.sender.id); })); ipcMain.handle('reader:notePdfBytes', (event, ref) => wrap(() => { const value = ref && typeof ref === 'object' ? ref : {}; if (value.draftToken) return noteAssets.readDraft(value.draftToken, event.sender.id); const assetId = noteAssets.safeAssetId(value.assetId); if (!readerStore.noteAssetIds().includes(assetId)) throw new Error('PDF 笔记底版不存在'); return noteAssets.readAsset(assetId); })); ipcMain.handle('reader:saveNotePdf', (event, bytes, suggestedName) => wrap(async () => { const data = Buffer.isBuffer(bytes) ? Buffer.from(bytes) : ArrayBuffer.isView(bytes) ? Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength) : bytes instanceof ArrayBuffer ? Buffer.from(bytes) : null; if (!data || !data.length || data.length > 100 * 1024 * 1024 || data.subarray(0, 5).toString('ascii') !== '%PDF-') { throw new Error('导出的 PDF 数据无效或超过 100 MB'); } const base = String(suggestedName || 'PeopleLib-笔记.pdf') .replace(/[<>:"/\\|?*\u0000-\u001f]/g, '_') .slice(0, 180); const result = await dialog.showSaveDialog(senderWindow(event), { title: '导出画布笔记', defaultPath: base.toLowerCase().endsWith('.pdf') ? base : `${base}.pdf`, filters: [{ name: 'PDF 文档', extensions: ['pdf'] }] }); if (result.canceled || !result.filePath) return { canceled: true }; fs.writeFileSync(result.filePath, data); return { canceled: false }; })); ipcMain.handle('reader:getAnnotations', (_e, entryId, fileIndex) => wrap(() => { const resolved = resolveReadable(entryId, fileIndex); if (resolved.format !== 'pdf') throw new Error('只有 PDF 支持页面批注'); return annotations.get(String(entryId), annotations.documentKey(resolved.abs)); })); ipcMain.handle('reader:setAnnotationPage', (_e, entryId, fileIndex, page, data) => wrap(() => { ensureReaderWritable(entryId); const resolved = resolveReadable(entryId, fileIndex); if (resolved.format !== 'pdf') throw new Error('只有 PDF 支持页面批注'); return annotations.setPage(String(entryId), annotations.documentKey(resolved.abs), page, data); })); // --- AI --- function notifyAiChanged(status) { for (const win of BrowserWindow.getAllWindows()) { if (!win.isDestroyed()) win.webContents.send('ai:changed', status); } } ipcMain.handle('ai:status', () => wrap(() => aiConfig.status())); ipcMain.handle('ai:save', (_e, cfg) => wrap(() => { const status = aiConfig.save(cfg || {}); notifyAiChanged(status); return status; })); ipcMain.handle('ai:clear', () => wrap(() => { const status = aiConfig.clear(); notifyAiChanged(status); 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) { return `${senderId}:${runId}`; } function canonicalVisualContexts(raw) { return normalizeVisualContexts(raw).map((context) => { if (!context.includeImage || !context.image) return context; const source = Buffer.from(context.image.base64, 'base64'); const decoded = nativeImage.createFromBuffer(source); if (decoded.isEmpty()) throw new Error('无法解码上下文图像'); const size = decoded.getSize(); if (size.width !== context.image.width || size.height !== context.image.height) { throw new Error('图像解码尺寸不匹配'); } const data = decoded.toJPEG(85); if (!data.length || data.length > 3 * 1024 * 1024) throw new Error('图像编码后超过 3 MB'); return { ...context, image: { mimeType: 'image/jpeg', base64: data.toString('base64'), width: size.width, height: size.height, bytes: data.length } }; }); } ipcMain.handle('ai:cancel', (event, runId) => wrap(() => { if (!isReaderSender(event.sender)) return false; const run = aiRuns.get(aiRunKey(event.sender.id, String(runId))); if (!run) return false; run.controller.abort(); return true; })); // 流式:增量通过 ai:delta 事件推给发起窗口,最终结果由 invoke 返回 ipcMain.handle('ai:run', async (e, 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, 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) => { streamed += piece; if (!wc.isDestroyed()) { wc.send('ai:delta', { runId: id, delta: piece, sessionId: chatId, messageId: assistantMessageId }); } } }); if (chatId) settleAiAssistant(chatId, assistantMessageId, { text: full }); return { ok: true, data: { text: full, sessionId: chatId, userMessageId, assistantMessageId } }; } catch (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); })); ipcMain.handle('ui:getTheme', () => wrap(() => currentUiTheme)); ipcMain.handle('ui:setTheme', (_e, value) => wrap(() => { const theme = value === 'light' ? 'light' : 'dark'; settings.set('ui.theme', theme); settings.set('reader.uiTheme', theme); applyWindowIcons(theme); notifyUiThemeChanged(); return theme; })); ipcMain.handle('app:version', () => wrap(() => app.getVersion())); ipcMain.handle('app:checkUpdate', () => wrap(checkUpdate)); ipcMain.handle('copy', (_e, text) => { clipboard.writeText(String(text || '')); return { ok: true }; }); function liveWindow() { return mainWindow && !mainWindow.isDestroyed() ? mainWindow : null; } // 窗口按钮要作用于发出请求的那个窗口,否则阅读器窗口的最小化/关闭会误操作主窗口 function senderWindow(e) { const w = BrowserWindow.fromWebContents(e.sender); return w && !w.isDestroyed() ? w : liveWindow(); } ipcMain.on('win:minimize', (e) => { const w = senderWindow(e); if (w) w.minimize(); }); ipcMain.on('win:maximize', (e) => { const w = senderWindow(e); if (!w) return; if (w.isMaximized()) w.unmaximize(); else w.maximize(); }); ipcMain.on('win:close', (e) => { const w = senderWindow(e); if (w) w.close(); });