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
+93 -25
View File
@@ -10,8 +10,8 @@ const { fetchWithProxy } = require('../sources/http');
const MAX_CHARS = 12000;
const MAX_QUESTION_CHARS = 4000;
// 上下文按字符数截断。中间挖空而不是尾部截断:
// 结论性内容常在末尾,只留开头会让模型答非所问
// 中间挖空而不是尾部截断:结论性内容常在末尾,只留开头会让模型答非所问。
// 正文默认不再走这里,只在有明确预算上限的场合(如多轮历史)显式调用
function clipContext(text, limit = MAX_CHARS) {
const s = String(text || '');
if (s.length <= limit) return s;
@@ -42,7 +42,7 @@ const TASKS = {
function buildPromptFromNormalized(task, text, question, visuals) {
const t = TASKS[task];
if (!t) throw new Error('不支持的任务类型: ' + task);
let body = clipContext(text);
let body = String(text || '');
const ocr = visuals
.filter((item) => item.ocr.include)
.map((item) => item.ocr.text.trim())
@@ -58,27 +58,60 @@ function buildPromptFromNormalized(task, text, question, visuals) {
return { system, userText, images };
}
function buildMessagesFromNormalized(task, text, question, visuals) {
// 历史轮只取 role 与 text,其余字段(尤其 images)一律忽略:
// 视觉模型按图块计费,重发历史图会让长会话费用随轮数累积,用户无从预期。
function normalizeHistory(history) {
if (!Array.isArray(history)) return [];
const items = [];
for (const entry of history) {
if (!entry) continue;
const role = entry.role === 'assistant' ? 'assistant' : 'user';
const text = String(entry.text || '').trim();
if (!text) continue;
const last = items[items.length - 1];
// 相邻同角色合并而不是丢弃:Anthropic 会直接 400,但丢内容比合并更糟
if (last && last.role === role) last.text = `${last.text}\n\n${text}`;
else items.push({ role, text });
}
// Anthropic 的 /messages 要求首条必须是 user
while (items.length && items[0].role === 'assistant') items.shift();
return items;
}
// 当前轮固定是 user,历史末条若也是 user 就会相邻同角色,把它并入当前轮正文。
function mergeHistory(history, currentText) {
const items = normalizeHistory(history);
const tail = items.length && items[items.length - 1].role === 'user' ? items.pop() : null;
return {
items,
currentText: tail ? `${tail.text}\n\n${currentText}` : currentText
};
}
function buildMessagesFromNormalized(task, text, question, visuals, history) {
const { system, userText, images } = buildPromptFromNormalized(task, text, question, visuals);
const merged = mergeHistory(history, userText);
const userContent = images.length
? [
{ type: 'text', text: userText },
{ type: 'text', text: merged.currentText },
...images.map((item) => ({
type: 'image_url',
image_url: { url: imageDataUrl(item.image) }
}))
]
: userText;
: merged.currentText;
return [
{ role: 'system', content: system },
...merged.items.map((item) => ({ role: item.role, content: item.text })),
{ role: 'user', content: userContent }
];
}
function buildAnthropicPayload(cfg, prompt) {
function buildAnthropicPayload(cfg, prompt, history) {
const merged = mergeHistory(history, prompt.userText);
const content = prompt.images.length
? [
{ type: 'text', text: prompt.userText },
{ type: 'text', text: merged.currentText },
...prompt.images.map((item) => ({
type: 'image',
source: {
@@ -88,20 +121,24 @@ function buildAnthropicPayload(cfg, prompt) {
}
}))
]
: prompt.userText;
: merged.currentText;
return {
model: cfg.model,
system: prompt.system,
messages: [{ role: 'user', content }],
messages: [
...merged.items.map((item) => ({ role: item.role, content: item.text })),
{ role: 'user', content }
],
temperature: cfg.temperature,
max_tokens: cfg.maxTokens,
stream: true
};
}
function buildResponsesPayload(cfg, prompt) {
function buildResponsesPayload(cfg, prompt, history) {
const merged = mergeHistory(history, prompt.userText);
const content = [
{ type: 'input_text', text: prompt.userText },
{ type: 'input_text', text: merged.currentText },
...prompt.images.map((item) => ({
type: 'input_image',
image_url: imageDataUrl(item.image)
@@ -110,7 +147,12 @@ function buildResponsesPayload(cfg, prompt) {
return {
model: cfg.model,
instructions: prompt.system,
input: [{ role: 'user', content }],
input: [
// 纯字符串是 Responses 输入消息的合法简写,同时绕开 input_text/output_text
// 的角色约束:input_text 不接受 assistantoutput_text 只出现在带 id 的输出项里。
...merged.items.map((item) => ({ role: item.role, content: item.text })),
{ role: 'user', content }
],
temperature: cfg.temperature,
max_output_tokens: cfg.maxTokens,
stream: true,
@@ -118,8 +160,14 @@ function buildResponsesPayload(cfg, prompt) {
};
}
function buildMessages(task, text, question, visualContexts) {
return buildMessagesFromNormalized(task, text, question, normalizeVisualContexts(visualContexts));
function buildMessages(task, text, question, visualContexts, history) {
return buildMessagesFromNormalized(
task,
text,
question,
normalizeVisualContexts(visualContexts),
history
);
}
function endpointFor(baseUrl, protocol) {
@@ -144,24 +192,42 @@ function headersFor(cfg) {
return headers;
}
function payloadFor(cfg, task, text, question, visuals) {
function payloadFor(cfg, task, text, question, visuals, history) {
const prompt = buildPromptFromNormalized(task, text, question, visuals);
if (cfg.protocol === 'anthropic') return buildAnthropicPayload(cfg, prompt);
if (cfg.protocol === 'openai-responses') return buildResponsesPayload(cfg, prompt);
if (cfg.protocol === 'anthropic') return buildAnthropicPayload(cfg, prompt, history);
if (cfg.protocol === 'openai-responses') return buildResponsesPayload(cfg, prompt, history);
return {
model: cfg.model,
messages: buildMessagesFromNormalized(task, text, question, visuals),
messages: buildMessagesFromNormalized(task, text, question, visuals, history),
temperature: cfg.temperature,
max_tokens: cfg.maxTokens,
stream: true
};
}
// 正文不再由本地截断,超出模型窗口时只能由接口报错。各家措辞不同,
// 统一识别成一句可操作的中文提示,否则用户只会看到一串英文而不知道该缩小范围。
const CONTEXT_OVERFLOW_RE = /context[_\s-]?length|context window|maximum context|too many tokens|prompt is too long|reduce the length|input length|exceeds? the (?:maximum|context)/i;
function isContextOverflow(message, status) {
// 413 单看状态码就够:请求体过大只可能是上下文塞太多
if (status === 413) return true;
if (!CONTEXT_OVERFLOW_RE.test(String(message || ''))) return false;
return status === undefined || status === 400 || status === 422;
}
function overflowHint(message) {
return `上下文超出模型窗口,请把范围改小(如改用"当前页"或选中片段)或换用更大窗口的模型。接口原文:${message}`;
}
function parseErrorBody(text, status) {
try {
const j = JSON.parse(text);
const msg = (j.error && (j.error.message || j.error)) || j.message;
if (msg) return String(msg);
if (msg) {
const s = String(msg);
return isContextOverflow(s, status) ? overflowHint(s) : s;
}
} catch (e) { /* 非 JSON */ }
if (status === 401 || status === 403) return 'API Key 无效或没有权限';
if (status === 404) return '接口地址或模型名称不存在';
@@ -189,8 +255,8 @@ function streamFinished(protocol, event) {
}
// onDelta 每收到一段增量就回调一次;返回完整文本。
// signal 用于用户中途取消。
async function stream({ task, text, question, visualContexts, signal, onDelta }) {
// signal 用于用户中途取消。history 是本轮之前的历史轮次,只消费 role 与 text。
async function stream({ task, text, question, visualContexts, history, signal, onDelta }) {
const cfg = aiConfig.get();
const st = aiConfig.status();
if (!cfg.apiKey && !st.isLocal) throw new Error('尚未配置 API Key,请先在设置中填写');
@@ -203,7 +269,7 @@ async function stream({ task, text, question, visualContexts, signal, onDelta })
const res = await fetchWithProxy(endpointFor(cfg.baseUrl, cfg.protocol), {
method: 'POST',
headers: headersFor(cfg),
body: JSON.stringify(payloadFor(cfg, task, text, question, visuals)),
body: JSON.stringify(payloadFor(cfg, task, text, question, visuals, history)),
signal
});
@@ -231,11 +297,13 @@ async function stream({ task, text, question, visualContexts, signal, onDelta })
// 部分服务端把错误放在流里返回
if (j.error || j.type === 'error') {
const error = j.error || j;
throw new Error(error.message || String(error));
const message = error.message || String(error);
throw new Error(isContextOverflow(message) ? overflowHint(message) : message);
}
if (cfg.protocol === 'openai-responses' && ['response.failed', 'response.incomplete'].includes(j.type)) {
const error = j.response && (j.response.error || j.response.incomplete_details);
throw new Error((error && (error.message || error.reason)) || 'OpenAI Responses 请求未完成');
const message = (error && (error.message || error.reason)) || 'OpenAI Responses 请求未完成';
throw new Error(isContextOverflow(message) ? overflowHint(message) : message);
}
const piece = streamDelta(cfg.protocol, j);
if (piece) {
+155
View File
@@ -0,0 +1,155 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const atomic = require('../atomic-file');
const ASSET_RE = /^img_[a-f0-9]{64}$/;
const MIME_TYPE = 'image/jpeg';
const MAX_IMAGE_BYTES = 3 * 1024 * 1024;
const MAX_TOTAL_BYTES = 256 * 1024 * 1024;
const GRACE_MS = 10 * 60 * 1000;
let rootDir = null;
function init(userDataDir) {
rootDir = path.join(userDataDir, 'reader-ai-images');
}
function directory() {
if (rootDir) return rootDir;
const home = process.env.APPDATA || process.env.HOME || process.cwd();
return path.join(home, 'PeopleLib', 'reader-ai-images');
}
function safeImageId(value) {
const id = String(value == null ? '' : value);
if (!ASSET_RE.test(id)) throw new Error('会话图像标识无效');
return id;
}
function fileOf(imageId) {
return path.join(directory(), `${safeImageId(imageId)}.jpg`);
}
function verifyJpeg(bytes) {
if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8 || bytes[2] !== 0xff) {
throw new Error('会话图像不是有效的 JPEG');
}
}
// 上一次写入被中断会留下 .tmp,'wx' 会因此永久失败,这里清掉再重试一次
function writeBlob(dest, bytes) {
const temp = `${dest}.tmp`;
try {
atomic.writeBytesExclusive(dest, bytes);
} catch (error) {
if (!error || error.code !== 'EEXIST' || !fs.existsSync(temp)) throw error;
fs.unlinkSync(temp);
atomic.writeBytesExclusive(dest, bytes);
}
}
function put(buffer, mimeType) {
if (String(mimeType == null ? '' : mimeType).toLowerCase() !== MIME_TYPE) {
throw new Error('会话图像仅支持 JPEG');
}
let bytes = null;
if (Buffer.isBuffer(buffer)) bytes = buffer;
else if (buffer instanceof Uint8Array) bytes = Buffer.from(buffer);
if (!bytes || !bytes.length) throw new Error('会话图像数据为空');
if (bytes.length > MAX_IMAGE_BYTES) throw new Error('会话图像超过 3 MB');
verifyJpeg(bytes);
const imageId = `img_${crypto.createHash('sha256').update(bytes).digest('hex')}`;
const dest = fileOf(imageId);
if (!fs.existsSync(dest)) {
if (totalBytes() + bytes.length > MAX_TOTAL_BYTES) throw new Error('会话图像总量已达上限');
writeBlob(dest, bytes);
}
return { imageId, bytes: bytes.length };
}
function read(imageId) {
const file = fileOf(imageId);
let stat = null;
try {
stat = fs.statSync(file);
} catch (error) {
throw new Error('会话图像不存在');
}
if (!stat.isFile() || stat.size <= 0 || stat.size > MAX_IMAGE_BYTES) {
throw new Error('会话图像为空或超过 3 MB');
}
const bytes = fs.readFileSync(file);
verifyJpeg(bytes);
return bytes;
}
function dataUrl(imageId) {
return `data:${MIME_TYPE};base64,${read(imageId).toString('base64')}`;
}
function listBlobs() {
let names = [];
try {
names = fs.readdirSync(directory());
} catch (error) {
if (error && error.code === 'ENOENT') return [];
throw error;
}
const blobs = [];
for (const name of names) {
const match = /^(img_[a-f0-9]{64})\.jpg$/.exec(name);
if (!match) continue;
const file = path.join(directory(), name);
let stat = null;
try { stat = fs.statSync(file); } catch (error) { continue; }
if (!stat.isFile()) continue;
blobs.push({ imageId: match[1], file, size: stat.size, mtimeMs: stat.mtimeMs });
}
return blobs;
}
// 图像先落盘、再被会话引用,中间存在窗口期。
// 宽限期内的新文件一律不删,否则一次并发的清理就能抹掉正在提交的图像。
function cleanup(referencedIds, options) {
const opts = options && typeof options === 'object' ? options : {};
const graceMs = Number.isFinite(Number(opts.graceMs)) && Number(opts.graceMs) >= 0
? Number(opts.graceMs)
: GRACE_MS;
const keep = new Set();
for (const id of Array.from(referencedIds || [])) {
const text = String(id == null ? '' : id);
if (ASSET_RE.test(text)) keep.add(text);
}
const now = Date.now();
let removed = 0;
for (const blob of listBlobs()) {
if (keep.has(blob.imageId)) continue;
if (now - blob.mtimeMs < graceMs) continue;
try {
fs.unlinkSync(blob.file);
removed++;
} catch (error) { /* 单个失败不影响其余回收 */ }
}
return removed;
}
function totalBytes() {
let total = 0;
for (const blob of listBlobs()) total += blob.size;
return total;
}
module.exports = {
init,
safeImageId,
put,
read,
dataUrl,
cleanup,
totalBytes,
MIME_TYPE,
MAX_IMAGE_BYTES,
MAX_TOTAL_BYTES,
GRACE_MS
};
+999
View File
@@ -0,0 +1,999 @@
// AI 多轮对话持久化:每个会话一个文件,index.json 只是可重建的派生缓存。
//
// 不并入 reader.json:那边每次写入都要 clone 整库快照并重写整个文件,
// 逐轮追加的对话会把最贵的数据放进最热的写路径。
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const atomic = require('../atomic-file');
const images = require('./ai-images');
const VERSION = 1;
const GLOBAL_ENTRY_ID = 'system:global-chat';
const LIMITS = {
sessionId: 160,
title: 200,
messageText: 20000,
question: 4000,
contextText: 12000,
contextHash: 32,
errorText: 500,
documentKey: 500,
locatorJson: 50000,
messagesPerSession: 200,
sessionsTotal: 100,
sessionFileBytes: 4 * 1024 * 1024,
imagesPerSession: 8,
imageBytes: 3 * 1024 * 1024,
imageTotalBytes: 256 * 1024 * 1024
};
const SCOPES = new Set(['selection', 'page', 'document', 'page-image', 'region-image']);
const TASKS = new Set(['ask', 'translate', 'explain', 'summarize']);
const INDEX_NAME = 'index.json';
const DOC_CACHE_SIZE = 4;
const PENDING_LIMIT = 16;
const TITLE_CHARS = 40;
const MID_MARK = '\n[……中间内容已省略……]\n';
// 会话 ID 会被拼进文件名,所以比 store.js 的 isSafeId 更严:
// 不允许 '.' 与 ':',前者能拼出 .bak 之类的兄弟文件名,后者在 Windows 上会被当成 NTFS 数据流。
const SESSION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
let rootDir = null;
let indexCache = null;
let docCache = new Map();
let pending = new Map();
function init(userDataDir) {
rootDir = path.join(userDataDir, 'reader-ai-sessions');
indexCache = null;
docCache = new Map();
pending = new Map();
}
function directory() {
if (rootDir) return rootDir;
const home = process.env.APPDATA || process.env.HOME || process.cwd();
return path.join(home, 'PeopleLib', 'reader-ai-sessions');
}
function indexFile() {
return path.join(directory(), INDEX_NAME);
}
function isObject(value) {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function clone(value) {
return value == null ? value : JSON.parse(JSON.stringify(value));
}
function newId(prefix) {
return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
}
function isReservedKey(value) {
return value === '__proto__' || value === 'prototype' || value === 'constructor';
}
function isSafeEntryId(value) {
return typeof value === 'string'
&& value.length > 0
&& value.length <= LIMITS.sessionId
&& /^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(value)
&& value !== '.'
&& value !== '..'
&& !isReservedKey(value);
}
function normalizeEntryId(value) {
if (value == null || value === '') return GLOBAL_ENTRY_ID;
const id = String(value);
if (!isSafeEntryId(id)) throw new Error('会话条目 ID 无效');
return id;
}
function safeSessionId(value) {
const id = String(value == null ? '' : value);
if (!SESSION_ID_RE.test(id) || id.length > LIMITS.sessionId || isReservedKey(id)) {
throw new Error('会话 ID 无效');
}
return id;
}
function safeMessageId(value) {
const id = String(value == null ? '' : value);
if (!SESSION_ID_RE.test(id) || id.length > LIMITS.sessionId || isReservedKey(id)) {
throw new Error('会话消息 ID 无效');
}
return id;
}
function limitedString(value, max) {
return String(value == null ? '' : value).slice(0, max);
}
function nullableString(value, max, label) {
if (value == null || value === '') return null;
const result = String(value);
if (/[\u0000-\u001f]/.test(result)) throw new Error(`${label}无效`);
return result.slice(0, max);
}
function count(value, max) {
const n = Number(value);
if (!Number.isFinite(n) || n <= 0) return 0;
return Math.min(Math.floor(n), max);
}
function clampInt(value, min, max, fallback) {
const n = Number(value);
if (!Number.isFinite(n)) return fallback;
return Math.min(max, Math.max(min, Math.floor(n)));
}
function timestamp(value, fallback) {
const n = Number(value);
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : fallback;
}
function jsonValue(value, label) {
if (value == null) return null;
let encoded;
try {
encoded = JSON.stringify(value);
} catch (e) {
throw new Error(`${label}必须可序列化`);
}
if (encoded === undefined || encoded.length > LIMITS.locatorJson) {
throw new Error(`${label}无效或过大`);
}
return JSON.parse(encoded);
}
function normalizeDocumentKey(value) {
const key = nullableString(value, LIMITS.documentKey, '文档标识');
if (key && isReservedKey(key)) throw new Error('文档标识无效');
return key;
}
function hashContext(text) {
return crypto.createHash('sha256')
.update(String(text == null ? '' : text), 'utf8')
.digest('hex')
.slice(0, LIMITS.contextHash);
}
function normalizeHash(value) {
if (value == null || value === '') return '';
const text = String(value);
if (!/^[0-9a-fA-F]+$/.test(text)) throw new Error('会话上下文摘要无效');
return text.toLowerCase().slice(0, LIMITS.contextHash);
}
function normalizeTask(value, lenient) {
if (value == null || value === '') return null;
const task = String(value);
if (!TASKS.has(task)) {
if (lenient) return null;
throw new Error('会话任务类型无效');
}
return task;
}
function normalizeImages(raw, lenient) {
if (raw == null) return [];
if (!Array.isArray(raw)) {
if (lenient) return [];
throw new Error('会话图像列表格式无效');
}
const result = [];
const seen = new Set();
for (const item of raw) {
try {
const value = isObject(item) ? item : {};
const imageId = images.safeImageId(value.imageId);
const mimeType = String(value.mimeType == null || value.mimeType === ''
? images.MIME_TYPE
: value.mimeType).toLowerCase();
if (mimeType !== images.MIME_TYPE) throw new Error('会话图像仅支持 JPEG');
const bytes = count(value.bytes, LIMITS.imageBytes + 1);
if (bytes > LIMITS.imageBytes) throw new Error('会话图像超过 3 MB');
if (seen.has(imageId)) continue;
seen.add(imageId);
result.push({
imageId,
mimeType,
width: count(value.width, 100000),
height: count(value.height, 100000),
bytes,
ocrIncluded: value.ocrIncluded === true
});
} catch (error) {
if (!lenient) throw error;
}
}
if (result.length > LIMITS.imagesPerSession) {
if (!lenient) throw new Error('单条消息图像过多');
result.length = LIMITS.imagesPerSession;
}
return result;
}
function normalizeContextRef(raw, lenient) {
if (raw == null) return null;
if (!isObject(raw)) {
if (lenient) return null;
throw new Error('会话上下文格式无效');
}
try {
const scope = String(raw.scope == null ? '' : raw.scope);
if (!SCOPES.has(scope)) throw new Error('会话上下文范围无效');
const source = raw.hash == null && raw.text != null ? String(raw.text) : null;
return {
scope,
chars: source != null && raw.chars == null ? source.length : count(raw.chars, 1e9),
hash: source != null ? hashContext(source) : normalizeHash(raw.hash),
clipped: raw.clipped === true,
locator: jsonValue(raw.locator, '定位信息'),
documentKey: normalizeDocumentKey(raw.documentKey),
fileIndex: raw.fileIndex == null ? null : count(raw.fileIndex, 100000)
};
} catch (error) {
if (lenient) return null;
throw error;
}
}
function buildMessage(raw, role, lenient) {
const value = isObject(raw) ? raw : {};
return {
id: newId('msg'),
role,
text: limitedString(value.text, role === 'user' ? LIMITS.question : LIMITS.messageText),
task: normalizeTask(value.task, lenient),
contextRef: role === 'user' ? normalizeContextRef(value.contextRef, lenient) : null,
images: normalizeImages(value.images, lenient),
tokensEstimate: count(value.tokensEstimate, 1e9),
truncated: value.truncated === true,
cancelled: role === 'assistant' && value.cancelled === true,
error: role === 'assistant'
? (lenient
? limitedString(value.error, LIMITS.errorText) || null
: nullableString(value.error, LIMITS.errorText, '会话错误信息'))
: null,
createdAt: Date.now()
};
}
function messageFromDisk(raw) {
const value = isObject(raw) ? raw : {};
const role = value.role === 'assistant' ? 'assistant' : (value.role === 'user' ? 'user' : null);
if (!role) return null;
const message = buildMessage(value, role, true);
let id = null;
try { id = safeMessageId(value.id); } catch (error) { id = null; }
message.id = id || message.id;
message.createdAt = timestamp(value.createdAt, message.createdAt);
return message;
}
function emptySession(id, entryId) {
const now = Date.now();
return {
version: VERSION,
id,
title: '',
entryId: entryId || GLOBAL_ENTRY_ID,
documentKey: null,
pinned: false,
droppedMessages: 0,
createdAt: now,
updatedAt: now,
messages: []
};
}
// 会话文件内容部分来自模型输出,读回时一律重新过一遍规范化,坏消息直接丢弃而不是抛错
function normalizeSession(raw, id) {
if (!isObject(raw)) throw new Error('会话文件结构无效');
const doc = emptySession(id, null);
let entryId = GLOBAL_ENTRY_ID;
try { entryId = normalizeEntryId(raw.entryId); } catch (error) { entryId = GLOBAL_ENTRY_ID; }
doc.entryId = entryId;
doc.title = limitedString(raw.title, LIMITS.title);
try { doc.documentKey = normalizeDocumentKey(raw.documentKey); } catch (error) { doc.documentKey = null; }
doc.pinned = raw.pinned === true;
doc.droppedMessages = count(raw.droppedMessages, 1e9);
doc.createdAt = timestamp(raw.createdAt, doc.createdAt);
doc.updatedAt = timestamp(raw.updatedAt, doc.createdAt);
const list = Array.isArray(raw.messages) ? raw.messages : [];
for (const item of list) {
const message = messageFromDisk(item);
if (message) doc.messages.push(message);
}
return doc;
}
function fileOf(sessionId) {
return path.join(directory(), `${safeSessionId(sessionId)}.json`);
}
function fileBytes(sessionId) {
try {
return fs.statSync(fileOf(sessionId)).size;
} catch (error) {
return 0;
}
}
function cacheGet(id, hash) {
const entry = docCache.get(id);
if (!entry || entry.hash !== hash) return null;
docCache.delete(id);
docCache.set(id, entry);
return entry.doc;
}
// 缓存按内容哈希失效而不是 mtime + size:时间戳粒度粗,
// 同一毫秒内的两次改写会得到相同的 mtime 与体积,按 stat 判定就会返回旧内容
function cacheSet(id, hash, doc) {
docCache.delete(id);
docCache.set(id, { hash, doc });
while (docCache.size > DOC_CACHE_SIZE) {
docCache.delete(docCache.keys().next().value);
}
}
function hashText(text) {
return crypto.createHash('sha256').update(text, 'utf8').digest('hex');
}
function parseSessionText(text, id) {
const doc = normalizeSession(JSON.parse(text), id);
doc.id = id;
return doc;
}
function readSessionFile(file, id) {
const text = fs.readFileSync(file, 'utf8');
if (Buffer.byteLength(text, 'utf8') > LIMITS.sessionFileBytes * 2) {
throw new Error('会话文件过大');
}
const hash = hashText(text);
const cached = cacheGet(id, hash);
if (cached) return cached;
const doc = parseSessionText(text, id);
cacheSet(id, hash, doc);
return doc;
}
function readDoc(sessionId) {
const id = safeSessionId(sessionId);
const file = fileOf(id);
const backup = `${file}.bak`;
if (!fs.existsSync(file)) {
if (!fs.existsSync(backup)) return null;
try { fs.renameSync(backup, file); } catch (error) { return null; }
}
try {
return readSessionFile(file, id);
} catch (error) {
docCache.delete(id);
if (fs.existsSync(backup)) {
try {
const recovered = parseSessionText(fs.readFileSync(backup, 'utf8'), id);
try { fs.renameSync(file, `${file}.corrupt-${Date.now()}`); } catch (renameError) { /* ignore */ }
fs.copyFileSync(backup, file);
cacheSet(id, hashText(fs.readFileSync(file, 'utf8')), recovered);
return recovered;
} catch (backupError) { /* 下面隔离损坏文件 */ }
}
try { fs.renameSync(file, `${file}.corrupt-${Date.now()}`); } catch (renameError) { /* ignore */ }
const row = indexRow(id);
const doc = emptySession(id, row ? row.entryId : null);
if (row) doc.title = row.title;
writeDoc(doc);
return doc;
}
}
function requireDoc(sessionId) {
const doc = readDoc(sessionId);
if (!doc) throw new Error('会话不存在');
return doc;
}
function writeDoc(doc) {
const file = fileOf(doc.id);
let encoded = JSON.stringify(doc, null, 2);
while (Buffer.byteLength(encoded, 'utf8') > LIMITS.sessionFileBytes) {
if (!dropOldestPair(doc)) throw new Error('会话内容过大');
encoded = JSON.stringify(doc, null, 2);
}
atomic.writeJson(file, doc);
cacheSet(doc.id, hashText(encoded), doc);
putIndexRow(doc, Buffer.byteLength(encoded, 'utf8'));
}
function emptyIndex() {
return { version: VERSION, sessions: [] };
}
function normalizeIndexRow(raw) {
if (!isObject(raw)) return null;
let id;
let entryId;
try {
id = safeSessionId(raw.id);
entryId = normalizeEntryId(raw.entryId);
} catch (error) {
return null;
}
return {
id,
title: limitedString(raw.title, LIMITS.title),
entryId,
messageCount: count(raw.messageCount, LIMITS.messagesPerSession),
updatedAt: timestamp(raw.updatedAt, 0),
bytes: count(raw.bytes, Number.MAX_SAFE_INTEGER),
pinned: raw.pinned === true
};
}
function normalizeIndex(raw) {
if (!isObject(raw) || !Array.isArray(raw.sessions)) return null;
const sessions = [];
const seen = new Set();
for (const item of raw.sessions) {
const row = normalizeIndexRow(item);
if (!row || seen.has(row.id)) continue;
seen.add(row.id);
sessions.push(row);
}
return { version: VERSION, sessions };
}
function rowOf(doc, bytes) {
return {
id: doc.id,
title: doc.title,
entryId: doc.entryId,
messageCount: doc.messages.length,
updatedAt: doc.updatedAt,
bytes: bytes || 0,
pinned: !!doc.pinned
};
}
function saveIndex() {
atomic.writeJson(indexFile(), indexCache);
}
function loadIndex() {
if (indexCache) return indexCache;
const file = indexFile();
const backup = `${file}.bak`;
try {
if (!fs.existsSync(file) && fs.existsSync(backup)) fs.renameSync(backup, file);
const parsed = normalizeIndex(JSON.parse(fs.readFileSync(file, 'utf8')));
if (!parsed) throw new Error('会话索引结构无效');
indexCache = parsed;
return indexCache;
} catch (error) {
return rebuildIndex();
}
}
function indexRow(sessionId) {
const rows = loadIndex().sessions;
return rows.find((row) => row.id === sessionId) || null;
}
function putIndexRow(doc, bytes) {
const idx = loadIndex();
const row = rowOf(doc, bytes);
const at = idx.sessions.findIndex((item) => item.id === doc.id);
if (at < 0) idx.sessions.push(row);
else idx.sessions[at] = row;
saveIndex();
}
function dropIndexRow(sessionId) {
const idx = loadIndex();
const at = idx.sessions.findIndex((item) => item.id === sessionId);
if (at < 0) return false;
idx.sessions.splice(at, 1);
saveIndex();
return true;
}
function sessionFiles() {
let names = [];
try {
names = fs.readdirSync(directory());
} catch (error) {
if (error && error.code === 'ENOENT') return [];
throw error;
}
const found = [];
for (const name of names) {
if (name === INDEX_NAME || !name.endsWith('.json')) continue;
const id = name.slice(0, -5);
if (!SESSION_ID_RE.test(id) || id.length > LIMITS.sessionId || isReservedKey(id)) continue;
const file = path.join(directory(), name);
let stat = null;
try { stat = fs.statSync(file); } catch (error) { continue; }
if (!stat.isFile()) continue;
found.push({ id, file, bytes: stat.size });
}
return found;
}
// 扫目录而不是读索引:索引是派生缓存,损坏时若按索引对账就会漏掉真实存在的会话
function scanSessions(includeBackups) {
const result = [];
for (const item of sessionFiles()) {
let doc = null;
try {
doc = readSessionFile(item.file, item.id);
} catch (error) {
doc = null;
}
if (doc) {
result.push({ id: item.id, bytes: item.bytes, doc });
if (!includeBackups) continue;
}
if (!includeBackups) continue;
const backup = `${item.file}.bak`;
if (!fs.existsSync(backup)) continue;
try {
result.push({ id: item.id, bytes: item.bytes, doc: parseSessionText(fs.readFileSync(backup, 'utf8'), item.id) });
} catch (error) { /* 备份也坏了就没有更多引用可救 */ }
}
return result;
}
function rebuildIndex() {
const sessions = [];
for (const item of scanSessions(false)) sessions.push(rowOf(item.doc, item.bytes));
sessions.sort((a, b) => b.updatedAt - a.updatedAt || a.id.localeCompare(b.id));
indexCache = { version: VERSION, sessions };
if (fs.existsSync(directory())) {
try { saveIndex(); } catch (error) { /* 内存索引仍可用,下次再落盘 */ }
}
return indexCache;
}
function metaOf(doc, bytes) {
return {
id: doc.id,
title: doc.title,
entryId: doc.entryId,
documentKey: doc.documentKey,
pinned: !!doc.pinned,
droppedMessages: doc.droppedMessages,
messageCount: doc.messages.length,
bytes: bytes || 0,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt
};
}
function metaOfSession(sessionId) {
const doc = requireDoc(sessionId);
return metaOf(doc, fileBytes(doc.id));
}
function mutate(sessionId, fn) {
const id = safeSessionId(sessionId);
const doc = requireDoc(id);
let result;
try {
result = fn(doc);
} catch (error) {
docCache.delete(id);
throw error;
}
try {
writeDoc(doc);
} catch (error) {
docCache.delete(id);
throw error;
}
return result;
}
// 首轮承载正文,永不丢;其余成对丢弃,
// 否则会留下没有提问的孤立回答或连续两条 user,两者都会被 Anthropic 的 /messages 拒绝
function dropOldestPair(doc) {
const start = doc.messages[1] && doc.messages[1].role === 'assistant' ? 2 : 1;
if (doc.messages.length <= start) return false;
const n = doc.messages[start].role === 'user'
&& doc.messages[start + 1]
&& doc.messages[start + 1].role === 'assistant'
? 2
: 1;
doc.messages.splice(start, n);
doc.droppedMessages += n;
return true;
}
function countImages(doc) {
let total = 0;
for (const message of doc.messages) total += message.images.length;
return total;
}
function enforceLimits(doc) {
while (doc.messages.length > LIMITS.messagesPerSession) {
if (!dropOldestPair(doc)) break;
}
let total = countImages(doc);
while (total > LIMITS.imagesPerSession) {
const victim = doc.messages.find((message) => message.images.length > 0);
if (!victim) break;
total -= victim.images.length;
victim.images = [];
}
}
function normalizeTitle(value) {
return String(value == null ? '' : value)
.replace(/[\u0000-\u001f\s]+/g, ' ')
.trim()
.slice(0, LIMITS.title);
}
function autoTitle(text) {
return normalizeTitle(text).slice(0, TITLE_CHARS);
}
function list(filters) {
const value = isObject(filters) ? filters : {};
let rows = loadIndex().sessions;
if (value.entryId != null && value.entryId !== '') {
const entryId = normalizeEntryId(value.entryId);
rows = rows.filter((row) => row.entryId === entryId);
}
return clone(rows).sort((a, b) => (
(Number(b.pinned) - Number(a.pinned))
|| (b.updatedAt - a.updatedAt)
|| a.id.localeCompare(b.id)
));
}
function create(input) {
const value = isObject(input) ? input : {};
const entryId = normalizeEntryId(value.entryId);
const title = normalizeTitle(value.title);
const documentKey = normalizeDocumentKey(value.documentKey);
if (loadIndex().sessions.length >= LIMITS.sessionsTotal) {
throw new Error('会话数量已达上限,请先删除旧会话');
}
let id = newId('chat');
for (let attempt = 0; attempt < 5 && fs.existsSync(fileOf(id)); attempt++) id = newId('chat');
if (fs.existsSync(fileOf(id))) throw new Error('会话创建失败,请重试');
const doc = emptySession(id, entryId);
doc.title = title;
doc.documentKey = documentKey;
doc.pinned = value.pinned === true;
writeDoc(doc);
return metaOf(doc, fileBytes(id));
}
function rename(sessionId, title) {
mutate(sessionId, (doc) => {
doc.title = normalizeTitle(title);
doc.updatedAt = Date.now();
return true;
});
return metaOfSession(sessionId);
}
function setPinned(sessionId, pinned) {
mutate(sessionId, (doc) => {
doc.pinned = pinned === true;
doc.updatedAt = Date.now();
return true;
});
return metaOfSession(sessionId);
}
function remove(sessionId) {
const id = safeSessionId(sessionId);
const file = fileOf(id);
docCache.delete(id);
for (const [key, item] of Array.from(pending)) {
if (item.sessionId === id) pending.delete(key);
}
let targets = [file, `${file}.tmp`, `${file}.bak`];
try {
const prefix = `${path.basename(file)}.corrupt-`;
targets = targets.concat(
fs.readdirSync(directory())
.filter((name) => name.startsWith(prefix))
.map((name) => path.join(directory(), name))
);
} catch (error) { /* 目录尚不存在 */ }
let removed = false;
for (const target of targets) {
try {
if (fs.existsSync(target)) {
fs.unlinkSync(target);
removed = true;
}
} catch (error) {
if (target === file) throw error;
}
}
if (dropIndexRow(id)) removed = true;
return removed;
}
function clear(sessionId) {
mutate(sessionId, (doc) => {
doc.messages = [];
doc.droppedMessages = 0;
doc.updatedAt = Date.now();
return true;
});
for (const [key, item] of Array.from(pending)) {
if (item.sessionId === safeSessionId(sessionId)) pending.delete(key);
}
return metaOfSession(sessionId);
}
function messages(sessionId, options) {
const opts = isObject(options) ? options : {};
const id = safeSessionId(sessionId);
const doc = requireDoc(id);
const limit = clampInt(opts.limit, 1, LIMITS.messagesPerSession, LIMITS.messagesPerSession);
let end = doc.messages.length;
if (opts.before != null && opts.before !== '') {
const cursor = safeMessageId(opts.before);
const at = doc.messages.findIndex((message) => message.id === cursor);
if (at < 0) throw new Error('会话消息不存在');
end = at;
}
const start = Math.max(0, end - limit);
return {
meta: metaOf(doc, fileBytes(id)),
messages: clone(doc.messages.slice(start, end)),
hasMore: start > 0
};
}
function appendUser(sessionId, input) {
const value = isObject(input) ? input : {};
const message = buildMessage(value, 'user', false);
const stored = mutate(sessionId, (doc) => {
doc.messages.push(message);
if (!doc.title) doc.title = autoTitle(message.text);
if (!doc.documentKey && message.contextRef && message.contextRef.documentKey) {
doc.documentKey = message.contextRef.documentKey;
}
enforceLimits(doc);
doc.updatedAt = Date.now();
return message;
});
return clone(stored);
}
// 占位回答只留在内存里:流式过程中每个增量都落盘会把一轮对话放大成上百次整文件重写,
// 而空回答本身没有保存价值,进程意外退出丢掉它不损失用户数据。
function appendAssistant(sessionId, input) {
const id = safeSessionId(sessionId);
requireDoc(id);
const message = buildMessage(isObject(input) ? input : {}, 'assistant', false);
for (const [key, item] of Array.from(pending)) {
if (item.sessionId === id) pending.delete(key);
}
while (pending.size >= PENDING_LIMIT) pending.delete(pending.keys().next().value);
pending.set(message.id, { sessionId: id, message });
return clone(message);
}
function finishAssistant(sessionId, messageId, patch) {
const id = safeSessionId(sessionId);
const msgId = safeMessageId(messageId);
const value = isObject(patch) ? patch : {};
const held = pending.get(msgId);
if (held && held.sessionId !== id) throw new Error('会话消息不存在');
const stored = mutate(id, (doc) => {
const message = held
? 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);
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);
message.cancelled = value.cancelled === true;
message.error = nullableString(value.error, LIMITS.errorText, '会话错误信息');
if (held) doc.messages.push(message);
enforceLimits(doc);
doc.updatedAt = Date.now();
return message;
});
pending.delete(msgId);
return clone(stored);
}
function clipMiddle(text, max) {
if (text.length <= max) return text;
if (max <= 0) return '';
if (max <= MID_MARK.length + 4) return text.slice(text.length - max);
const head = Math.ceil((max - MID_MARK.length) / 2);
const tail = max - MID_MARK.length - head;
return `${text.slice(0, head)}${MID_MARK}${text.slice(text.length - tail)}`;
}
function clipMessage(message, max) {
if (message.text.length <= max) return message;
message.text = clipMiddle(message.text, max);
message.truncated = true;
return message;
}
function normalizeBudget(budget) {
const value = isObject(budget) ? 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)
};
}
// Anthropic 的 /messages 要求首条是 user 且不允许连续同角色,
// 所以合并同角色、丢掉领头的 assistant 都不是可选优化,缺一条就是 400。
function mergeSameRole(kept) {
const merged = [];
for (const message of kept) {
const last = merged[merged.length - 1];
if (!last || last.role !== message.role) {
merged.push(message);
continue;
}
last.text = last.text && message.text ? `${last.text}\n\n${message.text}` : `${last.text}${message.text}`;
last.images = last.images.concat(message.images).slice(0, LIMITS.imagesPerSession);
last.truncated = last.truncated || message.truncated;
last.cancelled = message.cancelled;
last.error = message.error || last.error;
last.tokensEstimate = last.tokensEstimate + message.tokensEstimate;
}
return merged;
}
function enforceTotal(kept, maxChars) {
let total = kept.reduce((sum, message) => sum + message.text.length, 0);
for (const message of kept) {
if (total <= maxChars) break;
if (!message.text.length) continue;
const target = Math.max(0, message.text.length - (total - maxChars));
const next = clipMiddle(message.text, target);
total -= message.text.length - next.length;
message.text = next;
message.truncated = true;
}
}
function historyFor(sessionId, budget) {
const doc = requireDoc(sessionId);
const limits = normalizeBudget(budget);
const all = clone(doc.messages);
const carried = doc.droppedMessages;
if (!all.length) return { messages: [], dropped: carried };
const keep = new Set();
let used = 0;
let slots = limits.maxMessages;
const pinIndex = all.findIndex((message) => message.role === 'user');
if (pinIndex >= 0) {
all[pinIndex] = clipMessage(all[pinIndex], limits.maxMessageChars);
keep.add(pinIndex);
used += all[pinIndex].text.length;
slots -= 1;
}
for (let i = all.length - 1; i >= 0 && slots > 0; i--) {
if (keep.has(i)) continue;
const message = clipMessage(all[i], limits.maxMessageChars);
if (used + message.text.length > limits.maxChars) break;
keep.add(i);
used += message.text.length;
slots -= 1;
}
let kept = all.filter((message, index) => keep.has(index));
let dropped = all.length - kept.length;
while (kept.length && kept[0].role === 'assistant') {
kept.shift();
dropped++;
}
kept = mergeSameRole(kept);
const total = dropped + carried;
const mark = total > 0 && kept.length
? `[……已省略较早的 ${Math.max(1, Math.ceil(total / 2))} 轮对话……]\n`
: '';
enforceTotal(kept, Math.max(0, limits.maxChars - mark.length));
if (mark) {
kept[0].text = `${mark}${kept[0].text}`;
kept[0].truncated = true;
}
return { messages: kept, dropped: total };
}
// GC 的 keep 集合来自这里,因此必须扫全部会话文件(含 .bak):
// 只读索引的话,索引损坏时正在被引用的图会被当成垃圾删掉,属于静默数据丢失。
function imageIds() {
const ids = new Set();
for (const item of scanSessions(true)) {
for (const message of item.doc.messages) {
for (const image of message.images) ids.add(image.imageId);
}
}
for (const item of pending.values()) {
for (const image of item.message.images) ids.add(image.imageId);
}
return Array.from(ids);
}
function orphanReport(knownIds) {
const known = new Set((Array.isArray(knownIds) ? knownIds : []).map((id) => String(id)));
const orphans = [];
for (const item of scanSessions(false)) {
const doc = item.doc;
if (doc.entryId === GLOBAL_ENTRY_ID) continue;
if (known.has(doc.entryId)) continue;
orphans.push({
sessionId: doc.id,
entryId: doc.entryId,
title: doc.title,
messageCount: doc.messages.length,
bytes: item.bytes
});
}
orphans.sort((a, b) => b.bytes - a.bytes || a.sessionId.localeCompare(b.sessionId));
return orphans;
}
function forgetMany(entryIds) {
const ids = new Set();
for (const value of Array.isArray(entryIds) ? entryIds : []) {
const id = normalizeEntryId(value);
if (id === GLOBAL_ENTRY_ID) continue;
ids.add(id);
}
if (!ids.size) return 0;
let removed = 0;
for (const item of scanSessions(false)) {
if (!ids.has(item.doc.entryId)) continue;
try {
if (remove(item.doc.id)) removed++;
} catch (error) { /* 单个失败不影响其余回收 */ }
}
return removed;
}
module.exports = {
init,
list,
create,
rename,
setPinned,
remove,
clear,
messages,
appendUser,
appendAssistant,
finishAssistant,
historyFor,
imageIds,
orphanReport,
forgetMany,
rebuildIndex,
hashContext,
GLOBAL_ENTRY_ID,
LIMITS,
VERSION
};
+92 -23
View File
@@ -1,6 +1,7 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const atomic = require('../atomic-file');
const MAX_PAGE_BYTES = 2 * 1024 * 1024;
const MAX_OBJECTS = 5000;
@@ -11,10 +12,12 @@ const DOCUMENT_SAMPLE_BYTES = 4 * 1024 * 1024;
let rootDir = null;
let documentKeys = new Map();
let countCache = new Map();
function init(userDataDir) {
rootDir = path.join(userDataDir, 'reader-annotations');
documentKeys = new Map();
countCache = new Map();
}
function directory() {
@@ -136,29 +139,7 @@ function read(entryId) {
}
function write(entryId, data) {
const dest = fileOf(entryId);
const temp = `${dest}.tmp`;
const backup = `${dest}.bak`;
let backedUp = false;
fs.mkdirSync(directory(), { recursive: true });
try {
fs.writeFileSync(temp, JSON.stringify(data, null, 2), 'utf8');
if (fs.existsSync(backup)) fs.unlinkSync(backup);
if (fs.existsSync(dest)) {
fs.renameSync(dest, backup);
backedUp = true;
}
fs.renameSync(temp, dest);
if (backedUp) {
try { fs.unlinkSync(backup); } catch (e) { /* 保留备份不影响使用 */ }
}
} catch (e) {
try { if (fs.existsSync(temp)) fs.unlinkSync(temp); } catch (cleanup) { /* ignore */ }
try {
if (backedUp && !fs.existsSync(dest) && fs.existsSync(backup)) fs.renameSync(backup, dest);
} catch (rollback) { /* 下次读取时恢复 */ }
throw e;
}
atomic.writeJson(fileOf(entryId), data);
}
function get(entryId, documentKey) {
@@ -205,8 +186,93 @@ function setPage(entryId, documentKey, page, pageData) {
return { page: Number(pageKey), count: clean.objects.length, updatedAt: doc.updatedAt };
}
function countObjects(data) {
let total = 0;
for (const doc of Object.values(data.documents || {})) {
if (!doc || typeof doc !== 'object' || !doc.pages || typeof doc.pages !== 'object') continue;
for (const page of Object.values(doc.pages)) {
if (page && Array.isArray(page.objects)) total += page.objects.length;
}
}
return total;
}
// 批注文件单个可达 64 MB,而书库每次刷新都要取一遍计数,
// 按 mtime + size 缓存避免重复解析未改动的文件
function getCounts() {
const counts = {};
let names = [];
try {
names = fs.readdirSync(directory()).filter((name) => name.endsWith('.json'));
} catch (e) {
return counts;
}
const seen = new Set();
for (const name of names) {
const entryId = name.slice(0, -5);
if (!/^[a-zA-Z0-9_-]{1,128}$/.test(entryId)) continue;
seen.add(entryId);
const file = path.join(directory(), name);
let stat = null;
try { stat = fs.statSync(file); } catch (e) { continue; }
const cached = countCache.get(entryId);
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
if (cached.count > 0) counts[entryId] = cached.count;
continue;
}
let count = 0;
try {
count = countObjects(parseDocument(file, entryId));
} catch (e) {
count = 0;
}
countCache.set(entryId, { mtimeMs: stat.mtimeMs, size: stat.size, count });
if (count > 0) counts[entryId] = count;
}
for (const key of Array.from(countCache.keys())) {
if (!seen.has(key)) countCache.delete(key);
}
return counts;
}
// 批注没有独立的浏览界面,条目一旦离开书库就再也看不到,
// 因此对账时连体积一起报出来,便于用户判断是否回收
function orphanReport(knownIds) {
const known = new Set((Array.isArray(knownIds) ? knownIds : []).map((id) => String(id)));
const counts = getCounts();
const orphans = [];
let names = [];
try {
names = fs.readdirSync(directory()).filter((name) => name.endsWith('.json'));
} catch (e) {
return orphans;
}
for (const name of names) {
const entryId = name.slice(0, -5);
if (!/^[a-zA-Z0-9_-]{1,128}$/.test(entryId)) continue;
if (known.has(entryId)) continue;
let size = 0;
try { size = fs.statSync(path.join(directory(), name)).size; } catch (e) { continue; }
orphans.push({ entryId, count: counts[entryId] || 0, bytes: size });
}
orphans.sort((a, b) => b.bytes - a.bytes || a.entryId.localeCompare(b.entryId));
return orphans;
}
function forgetMany(entryIds) {
const ids = Array.isArray(entryIds) ? entryIds : [];
let removed = 0;
for (const id of ids) {
try {
if (forget(id)) removed++;
} catch (e) { /* 单个失败不影响其余回收 */ }
}
return removed;
}
function forget(entryId) {
const file = fileOf(entryId);
countCache.delete(normalizeEntryId(entryId));
let removed = false;
let targets = [file, `${file}.tmp`, `${file}.bak`];
try {
@@ -236,6 +302,9 @@ module.exports = {
hashDocumentFile,
get,
setPage,
getCounts,
orphanReport,
forgetMany,
forget,
LARGE_DOCUMENT_BYTES,
DOCUMENT_SAMPLE_BYTES
+250
View File
@@ -0,0 +1,250 @@
// 笔记独立窗口的生命周期管理。
// 单窗口多标签,形态与阅读器一致:窗口只有一个,一条笔记占一个标签。
//
// 「一标签一条」是数据安全约束,不是体验优化:`reader:updateNote` 是整条覆盖、
// 无版本校验,同一条笔记开两个编辑器时后保存者会把前者的内容整块吃掉。
//
// openNotes 是主进程侧的标签集镜像,由渲染层通过 `notes:tabsChanged` 上报。
// 它同时承担授权职责(见 ownsNote),所以不能只信渲染层:新增标签一律先过
// main.js 的 findNote() 用 listNotes() 对账。
const path = require('path');
const { pathToFileURL } = require('url');
const { BrowserWindow } = require('electron');
const CLOSE_TIMEOUT = 10000;
let win = null;
// noteId -> entryId。删除对账要按 entryId 找标签,所以存的是映射不是集合。
const openNotes = new Map();
let onChanged = null;
let closeAllowed = false;
let closePending = false;
let closeTimer = null;
function alive(target) {
return !!target && !target.isDestroyed();
}
function keyOf(noteId) {
return String(noteId == null ? '' : noteId);
}
function get() {
if (alive(win)) return win;
win = null;
return null;
}
function openIds() {
if (!get()) return [];
return [...openNotes.keys()];
}
function notifyChanged() {
if (typeof onChanged === 'function') onChanged(openIds());
}
function setChangeListener(fn) {
onChanged = typeof fn === 'function' ? fn : null;
}
function pageUrl(rootDir) {
return pathToFileURL(path.join(rootDir, 'src', 'ui', 'note.html')).href;
}
function isNoteSender(wc) {
const target = get();
return !!target && !!wc && target.webContents.id === wc.id;
}
function fromWebContents(wc) {
return isNoteSender(wc) ? 'note' : null;
}
// 笔记窗口只能读自己已经打开的那些标签。放宽成「只要是笔记窗口就给」
// 会让这个通道变成遍历全部笔记的后门。
function ownsNote(wc, noteId) {
if (!isNoteSender(wc)) return false;
return openNotes.has(keyOf(noteId));
}
// 渲染层只能"收窄"标签集(上报自己关掉了哪些),不能新增。
// 允许新增等于让渲染层自己扩权:谎报持有某条笔记,随后 notes:getOne 就放行了。
// 新增只能走 open(),那条路径在 main.js 里过 findNote() 对账。
function setTabs(wc, noteIds) {
if (!isNoteSender(wc)) return false;
const claimed = new Set();
for (const item of Array.isArray(noteIds) ? noteIds : []) {
const id = keyOf(item && item.noteId != null ? item.noteId : item);
if (id) claimed.add(id);
}
let changed = false;
for (const key of [...openNotes.keys()]) {
if (claimed.has(key)) continue;
openNotes.delete(key);
changed = true;
}
if (changed) notifyChanged();
return true;
}
function sendToWindow(channel, payload) {
const target = get();
if (!target) return false;
target.webContents.send(channel, payload);
return true;
}
function create(rootDir, uiTheme) {
closeAllowed = false;
closePending = false;
win = new BrowserWindow({
width: 1080,
height: 820,
minWidth: 720,
minHeight: 520,
frame: false,
backgroundColor: '#141414',
icon: path.join(
rootDir,
'icons',
'dist',
uiTheme === 'light' ? 'book-ai-light.ico' : 'book-ai-dark.ico'
),
title: 'PeopleLib',
webPreferences: {
preload: path.join(rootDir, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
spellcheck: false
}
});
const created = win;
created.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));
created.webContents.on('will-navigate', (event, url) => {
if (!String(url).startsWith(pageUrl(rootDir))) event.preventDefault();
});
// 未保存的编辑要在窗口消失之前问用户,所以必须先拦下 close 交给渲染层。
// 渲染层卡住时靠看门狗兜底,否则窗口永远关不掉。
created.on('close', (event) => {
if (closeAllowed || !alive(created)) return;
event.preventDefault();
if (closePending) return;
closePending = true;
created.webContents.send('notes:prepareClose', null);
closeTimer = setTimeout(() => {
if (alive(created)) {
closeAllowed = true;
created.destroy();
}
}, CLOSE_TIMEOUT);
});
created.on('closed', () => {
if (win === created) win = null;
openNotes.clear();
closeAllowed = false;
closePending = false;
if (closeTimer) clearTimeout(closeTimer);
closeTimer = null;
notifyChanged();
});
return created;
}
function open(entryId, noteId, rootDir, uiTheme = 'dark') {
const key = keyOf(noteId);
const entry = String(entryId);
const existing = get();
if (existing) {
if (existing.isMinimized()) existing.restore();
existing.focus();
// 已经开着的标签由渲染层激活,不新建第二个编辑器
existing.webContents.send('notes:openTab', { entryId: entry, noteId: key });
if (!openNotes.has(key)) {
openNotes.set(key, entry);
notifyChanged();
}
return existing;
}
const created = create(rootDir, uiTheme);
openNotes.set(key, entry);
created.loadFile(path.join(rootDir, 'src', 'ui', 'note.html'), {
query: { entryId: entry, noteId: key }
});
notifyChanged();
return created;
}
// 笔记在别处被删除后标签必须自己退场,否则它下一次保存会把已删条目整条写回去
function closeFor(noteId) {
const key = keyOf(noteId);
if (!get() || !openNotes.has(key)) return false;
sendToWindow('notes:closeTab', { noteIds: [key] });
openNotes.delete(key);
notifyChanged();
return true;
}
function closeMany(noteIds) {
const keys = (Array.isArray(noteIds) ? noteIds : [])
.map((id) => keyOf(id))
.filter((id) => openNotes.has(id));
if (!get() || !keys.length) return 0;
sendToWindow('notes:closeTab', { noteIds: keys });
for (const key of keys) openNotes.delete(key);
notifyChanged();
return keys.length;
}
function closeForEntries(entryIds) {
const targets = new Set((Array.isArray(entryIds) ? entryIds : []).map((id) => String(id)));
if (!get() || !targets.size) return 0;
const keys = [];
for (const [key, entryId] of openNotes) {
if (targets.has(entryId)) keys.push(key);
}
if (!keys.length) return 0;
sendToWindow('notes:closeTab', { noteIds: keys });
for (const key of keys) openNotes.delete(key);
notifyChanged();
return keys.length;
}
// 渲染层处理完未保存提示后才真正放行关闭
function shutdownReady(wc) {
const target = get();
if (!target || !isNoteSender(wc) || !closePending) return false;
if (closeTimer) clearTimeout(closeTimer);
closeTimer = null;
closeAllowed = true;
closePending = false;
target.close();
return true;
}
// 用户在未保存提示里选了取消。必须复位 closePending,否则下一次点关闭会被
// 「已在处理中」挡掉,窗口再也关不上;也必须撤掉看门狗,否则十秒后它会把
// 带着未保存内容的窗口直接销毁。
function cancelClose(wc) {
if (!isNoteSender(wc) || !closePending) return false;
if (closeTimer) clearTimeout(closeTimer);
closeTimer = null;
closePending = false;
return true;
}
function all() {
const target = get();
return target ? [target] : [];
}
module.exports = {
open, get, all, openIds, closeFor, closeMany, closeForEntries,
fromWebContents, ownsNote, setTabs, shutdownReady, cancelClose, setChangeListener
};
+43 -19
View File
@@ -4,6 +4,7 @@
const fs = require('fs');
const path = require('path');
const atomic = require('../atomic-file');
const VERSION = 6;
const STANDALONE_ENTRY_ID = 'system:standalone-notes';
@@ -792,24 +793,7 @@ function migrate(raw) {
}
function save() {
const dest = getFilePath();
const temp = `${dest}.tmp`;
const backup = `${dest}.bak`;
let backedUp = false;
fs.mkdirSync(path.dirname(dest), { recursive: true });
try {
fs.writeFileSync(temp, JSON.stringify(cache, null, 2), 'utf8');
if (fs.existsSync(backup)) fs.unlinkSync(backup);
if (fs.existsSync(dest)) { fs.renameSync(dest, backup); backedUp = true; }
fs.renameSync(temp, dest);
if (backedUp) { try { fs.unlinkSync(backup); } catch (e) { /* ignore */ } }
} catch (e) {
try { if (fs.existsSync(temp)) fs.unlinkSync(temp); } catch (cleanup) { /* ignore */ }
try {
if (backedUp && !fs.existsSync(dest) && fs.existsSync(backup)) fs.renameSync(backup, dest);
} catch (rollback) { /* 下次 load 时恢复 */ }
throw e;
}
atomic.writeJson(getFilePath(), cache);
}
function load() {
@@ -1345,6 +1329,46 @@ function removeCollection(collectionId) {
});
}
// 与书库对账:列出书库里已不存在的条目。这些阅读资料在「我的笔记」里仍然可见,
// 属于有意保留,因此只报告不自动删除,由用户显式决定。
function orphanReport(knownIds) {
const known = new Set((Array.isArray(knownIds) ? knownIds : []).map((id) => String(id)));
const c = load();
const orphans = [];
for (const [entryId, entry] of Object.entries(c.entries)) {
if (entryId === STANDALONE_ENTRY_ID) continue;
if (known.has(entryId)) continue;
const notes = Array.isArray(entry.notes) ? entry.notes.length : 0;
const bookmarks = Array.isArray(entry.bookmarks) ? entry.bookmarks.length : 0;
const snapshot = entry.book || {};
orphans.push({
entryId,
title: snapshot.title || '',
notes,
bookmarks,
hasProgress: !!entry.progress
});
}
orphans.sort((a, b) => b.notes - a.notes || a.entryId.localeCompare(b.entryId));
return orphans;
}
function forgetMany(entryIds) {
const ids = (Array.isArray(entryIds) ? entryIds : []).map((id) => safeId(id, '条目 ID'));
if (!ids.length) return 0;
let removed = 0;
mutateCache((current) => {
for (const id of ids) {
if (id === STANDALONE_ENTRY_ID) continue;
if (!Object.prototype.hasOwnProperty.call(current.entries, id)) continue;
delete current.entries[id];
removed++;
}
return removed > 0;
});
return removed;
}
// forget 是显式删除:只清掉指定条目的阅读数据,不影响其它条目或笔记本。
function forget(value) {
const id = safeId(value, '条目 ID');
@@ -1363,5 +1387,5 @@ module.exports = {
addNote, addStandaloneNote, updateNote, removeNote, listNotes, getNoteCounts,
noteAssetIds,
listCollections, addCollection, updateCollection, removeCollection,
forget
orphanReport, forgetMany, forget
};