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:
+93
-25
@@ -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 不接受 assistant,output_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) {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user