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
+862
View File
@@ -0,0 +1,862 @@
const test = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const crypto = require('crypto');
const sessionsPath = require.resolve('../reader/ai-sessions.js');
const imagesPath = require.resolve('../reader/ai-images.js');
const dirs = [];
function tmp(tag) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `peoplelib-ai-sessions-${tag}-`));
dirs.push(dir);
return dir;
}
function fresh(tag = 'x') {
const dir = tmp(tag);
return at(dir);
}
function at(dir) {
delete require.cache[sessionsPath];
delete require.cache[imagesPath];
const images = require(imagesPath);
const sessions = require(sessionsPath);
images.init(dir);
sessions.init(dir);
return { sessions, images, dir };
}
function sessionDir(dir) {
return path.join(dir, 'reader-ai-sessions');
}
function sessionFile(dir, id) {
return path.join(sessionDir(dir), `${id}.json`);
}
function imageDir(dir) {
return path.join(dir, 'reader-ai-images');
}
// 最小可解码 JPEGSOI + APP0 + 一段填充 + EOI。put 只校验 SOI 魔数,内容差异即哈希差异。
function jpeg(seed, size = 64) {
const bytes = Buffer.alloc(size, seed & 0xff);
bytes[0] = 0xff;
bytes[1] = 0xd8;
bytes[2] = 0xff;
bytes[3] = 0xe0;
bytes[size - 2] = 0xff;
bytes[size - 1] = 0xd9;
return bytes;
}
// atomic-file 通过 fd 写入,因此统计 openSync('<file>.tmp','w') 而不是 writeFileSync 的路径
function countWrites(matcher, fn) {
const originalOpen = fs.openSync;
let writes = 0;
fs.openSync = function counting(target, flags, ...rest) {
if (matcher(String(target)) && String(flags).startsWith('w')) writes++;
return originalOpen.call(this, target, flags, ...rest);
};
try { fn(); } finally { fs.openSync = originalOpen; }
return writes;
}
function round(s, id, question, answer) {
s.appendUser(id, { text: question, task: 'ask' });
const placeholder = s.appendAssistant(id, {});
return s.finishAssistant(id, placeholder.id, { text: answer });
}
test.after(() => {
for (const dir of dirs) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch (e) { /* ignore */ }
}
});
// --- 基本读写 ---
test('会话按 per-session 文件落盘,索引可枚举', () => {
const { sessions, dir } = fresh('basic');
const a = sessions.create({ entryId: 'e1', title: '甲会话' });
const b = sessions.create({ entryId: 'e2' });
assert.match(a.id, /^chat_[a-z0-9]+_[a-z0-9]+$/, '会话 ID 必须是可安全拼进文件名的形状');
assert.strictEqual(a.entryId, 'e1');
assert.strictEqual(b.entryId, 'e2');
const files = fs.readdirSync(sessionDir(dir)).sort();
assert.deepStrictEqual(files, ['index.json', `${a.id}.json`, `${b.id}.json`].sort(),
'每个会话应独占一个文件,加上一份索引');
assert.deepStrictEqual(sessions.list().map((row) => row.id).sort(), [a.id, b.id].sort());
assert.deepStrictEqual(sessions.list({ entryId: 'e1' }).map((row) => row.id), [a.id]);
assert.deepStrictEqual(sessions.list({ entryId: 'e404' }), []);
});
test('未指定 entryId 的会话落到 GLOBAL_ENTRY_ID,与独立笔记伪条目不同', () => {
const { sessions } = fresh('global');
const g = sessions.create({});
assert.strictEqual(g.entryId, 'system:global-chat');
assert.notStrictEqual(sessions.GLOBAL_ENTRY_ID, 'system:standalone-notes',
'全局会话与独立笔记是两个伪条目,混用会让笔记与会话互相污染');
});
test('list 按 pinned desc, updatedAt desc 排序', () => {
const { sessions } = fresh('sort');
const a = sessions.create({ title: 'a' });
const b = sessions.create({ title: 'b' });
const c = sessions.create({ title: 'c' });
sessions.rename(b.id, 'b2');
sessions.setPinned(a.id, true);
const ids = sessions.list().map((row) => row.id);
assert.strictEqual(ids[0], a.id, 'pinned 的会话必须排最前');
assert.ok(ids.indexOf(b.id) < ids.indexOf(c.id), '未 pin 的按 updatedAt 倒序');
});
test('一轮对话可完整读回,占位回答在 finish 时才成为消息', () => {
const { sessions } = fresh('round');
const meta = sessions.create({ entryId: 'e1' });
sessions.appendUser(meta.id, { text: '这是问题', task: 'ask' });
assert.strictEqual(sessions.messages(meta.id).messages.length, 1);
const placeholder = sessions.appendAssistant(meta.id, {});
assert.strictEqual(sessions.messages(meta.id).messages.length, 1,
'占位回答不落盘,否则流式过程中每个增量都会整文件重写');
const done = sessions.finishAssistant(meta.id, placeholder.id, { text: '这是回答' });
assert.strictEqual(done.id, placeholder.id, 'finish 必须复用占位 ID,渲染层靠它对齐流式片段');
const read = sessions.messages(meta.id);
assert.deepStrictEqual(read.messages.map((m) => m.role), ['user', 'assistant']);
assert.strictEqual(read.messages[1].text, '这是回答');
assert.strictEqual(read.meta.messageCount, 2);
assert.strictEqual(read.meta.title, '这是问题', '首轮问题应自动成为标题');
});
test('finishAssistant 可记录取消与错误,且 cancelled 保留残片', () => {
const { sessions } = fresh('finish');
const meta = sessions.create({});
sessions.appendUser(meta.id, { text: 'q' });
const p1 = sessions.appendAssistant(meta.id, {});
const cancelled = sessions.finishAssistant(meta.id, p1.id, { text: '半句', cancelled: true });
assert.strictEqual(cancelled.cancelled, true);
assert.strictEqual(cancelled.text, '半句', '中途停止时残片必须保留,用户看到的就是它');
sessions.appendUser(meta.id, { text: 'q2' });
const p2 = sessions.appendAssistant(meta.id, {});
const failed = sessions.finishAssistant(meta.id, p2.id, { text: '', error: '接口超时' });
assert.strictEqual(failed.error, '接口超时');
assert.throws(() => sessions.finishAssistant(meta.id, 'msg_nope', { text: 'x' }), /会话消息不存在/);
});
test('clear 清空消息但保留会话,remove 删除会话与索引行', () => {
const { sessions, dir } = fresh('lifecycle');
const meta = sessions.create({ entryId: 'e1', title: '保留' });
round(sessions, meta.id, '问', '答');
const cleared = sessions.clear(meta.id);
assert.strictEqual(cleared.messageCount, 0);
assert.strictEqual(cleared.droppedMessages, 0);
assert.strictEqual(cleared.title, '保留', 'clear 不应丢标题');
assert.strictEqual(sessions.list().length, 1);
assert.strictEqual(sessions.remove(meta.id), true);
assert.strictEqual(sessions.remove(meta.id), false);
assert.strictEqual(sessions.list().length, 0);
assert.ok(!fs.existsSync(sessionFile(dir, meta.id)));
assert.throws(() => sessions.messages(meta.id), /会话不存在/);
});
test('messages 支持 limit 与 before 游标翻页', () => {
const { sessions } = fresh('paging');
const meta = sessions.create({});
for (let i = 0; i < 5; i++) round(sessions, meta.id, `${i}`, `${i}`);
const tail = sessions.messages(meta.id, { limit: 4 });
assert.strictEqual(tail.messages.length, 4);
assert.strictEqual(tail.hasMore, true);
assert.strictEqual(tail.messages[3].text, '答4');
const older = sessions.messages(meta.id, { limit: 4, before: tail.messages[0].id });
assert.strictEqual(older.messages[older.messages.length - 1].id !== tail.messages[0].id, true,
'before 必须是排他的,否则翻页会重复一条');
assert.throws(() => sessions.messages(meta.id, { before: 'msg_missing' }), /会话消息不存在/);
});
// --- 字段限长 ---
test('字段限长与 store.limitedString 一致:超长截断而不是抛错', () => {
const { sessions } = fresh('limits');
const L = sessions.LIMITS;
const meta = sessions.create({ title: '标'.repeat(L.title + 50) });
assert.strictEqual(meta.title.length, L.title, `title 应截到 ${L.title}`);
const user = sessions.appendUser(meta.id, { text: '问'.repeat(L.question + 100) });
assert.strictEqual(user.text.length, L.question, `user 文本应截到 question=${L.question}`);
const p = sessions.appendAssistant(meta.id, {});
const done = sessions.finishAssistant(meta.id, p.id, {
text: '答'.repeat(L.messageText + 100),
error: '错'.repeat(L.errorText + 100)
});
assert.strictEqual(done.text.length, L.messageText, `assistant 文本应截到 messageText=${L.messageText}`);
assert.strictEqual(done.error.length, L.errorText, `error 应截到 ${L.errorText}`);
const renamed = sessions.rename(meta.id, '新'.repeat(L.title + 10));
assert.strictEqual(renamed.title.length, L.title);
});
test('标题规范化去掉控制字符与换行,自动标题取首轮问题前 40 字', () => {
const { sessions } = fresh('title');
const meta = sessions.create({ title: ' 带\n换行\u0007和控制符 ' });
assert.strictEqual(meta.title, '带 换行 和控制符', '标题会进索引 JSON,控制字符必须清掉');
const auto = sessions.create({});
sessions.appendUser(auto.id, { text: '标'.repeat(100) });
assert.strictEqual(sessions.messages(auto.id).meta.title.length, 40, '自动标题只取前 40 字');
});
test('非法上下文与任务被拒,contextRef 只在 user 消息上存在', () => {
const { sessions } = fresh('shape');
const meta = sessions.create({});
assert.throws(() => sessions.appendUser(meta.id, {
text: 'q', contextRef: { scope: 'whatever' }
}), /会话上下文范围无效/);
assert.throws(() => sessions.appendUser(meta.id, { text: 'q', task: 'hack' }), /会话任务类型无效/);
const withContext = sessions.appendUser(meta.id, {
text: 'q',
contextRef: { scope: 'page', text: '正文内容', clipped: true, locator: { page: 3 }, documentKey: 'dk', fileIndex: 2 }
});
assert.strictEqual(withContext.contextRef.chars, 4);
assert.strictEqual(withContext.contextRef.hash, sessions.hashContext('正文内容'));
assert.strictEqual(withContext.contextRef.hash.length, 32, 'hash 固定 32 hex,用于判断上下文是否变了');
assert.strictEqual(withContext.contextRef.clipped, true);
assert.deepStrictEqual(withContext.contextRef.locator, { page: 3 });
const p = sessions.appendAssistant(meta.id, { contextRef: { scope: 'page', text: 'x' } });
assert.strictEqual(p.contextRef, null, 'assistant 消息不应带 contextRef');
assert.throws(() => sessions.appendUser(meta.id, {
text: 'q', contextRef: { scope: 'page', locator: { blob: 'x'.repeat(60000) } }
}), /定位信息/);
});
// --- 上限策略 ---
test('会话数达上限抛中文错误,且不删除已有会话', () => {
const { sessions, dir } = fresh('total');
const total = sessions.LIMITS.sessionsTotal;
const ids = [];
for (let i = 0; i < total; i++) ids.push(sessions.create({ title: `会话${i}` }).id);
const before = fs.readdirSync(sessionDir(dir)).length;
assert.throws(() => sessions.create({ title: '溢出' }), /会话数量已达上限/,
'达到上限必须拒绝新建,绝不能静默删用户的旧会话');
assert.strictEqual(fs.readdirSync(sessionDir(dir)).length, before, '拒绝新建不应改动磁盘');
assert.strictEqual(sessions.list().length, total);
sessions.remove(ids[0]);
assert.ok(sessions.create({ title: '腾出位置后可建' }).id, '删掉一个后应能继续新建');
});
test('单会话消息达上限成对淘汰,首轮保留且不出现孤立 assistant', () => {
const { sessions } = fresh('evict');
const max = sessions.LIMITS.messagesPerSession;
const meta = sessions.create({});
sessions.appendUser(meta.id, { text: '首轮问题', contextRef: { scope: 'document', text: '正文' } });
const first = sessions.appendAssistant(meta.id, {});
sessions.finishAssistant(meta.id, first.id, { text: '首轮回答' });
for (let i = 0; i < max; i++) round(sessions, meta.id, `${i}`, `${i}`);
const read = sessions.messages(meta.id, { limit: max });
assert.ok(read.meta.messageCount <= max, `消息数不得超过 ${max}`);
assert.strictEqual(read.messages[0].text, '首轮问题', '承载正文的首轮必须永久保留');
assert.ok(read.messages[0].contextRef, '首轮的 contextRef 不能被淘汰掉');
assert.ok(read.meta.droppedMessages > 0, 'droppedMessages 必须累加,供历史里的省略标记使用');
assert.strictEqual(read.meta.droppedMessages % 2, 0, '成对淘汰时丢弃数应为偶数');
assert.strictEqual(
read.meta.messageCount + read.meta.droppedMessages,
(max + 1) * 2,
'保留数加丢弃数应等于写入总数,说明没有额外丢失'
);
const roles = read.messages.map((m) => m.role);
for (let i = 1; i < roles.length; i++) {
assert.notStrictEqual(roles[i], roles[i - 1],
'成对淘汰后不允许出现连续同角色,否则历史里会有没有提问的孤立回答');
}
});
// appendUser 之后、finishAssistant 之前的这个中间态就是构造请求时 historyFor 看到的状态。
// 逐条丢弃在整轮结束时会被第二次丢弃恰好补回来,只有在中间态才能看出角色错位。
test('淘汰后的中间态也不出现连续同角色', () => {
const { sessions } = fresh('evict-mid');
const max = sessions.LIMITS.messagesPerSession;
const meta = sessions.create({});
for (let i = 0; i < max / 2; i++) round(sessions, meta.id, `${i}`, `${i}`);
assert.strictEqual(sessions.messages(meta.id, { limit: max }).meta.messageCount, max, '先填满到上限');
sessions.appendUser(meta.id, { text: '新问题' });
const read = sessions.messages(meta.id, { limit: max });
assert.strictEqual(read.meta.droppedMessages, 2, '一次淘汰应成对丢弃两条,而不是单条');
const roles = read.messages.map((m) => m.role);
for (let i = 1; i < roles.length; i++) {
assert.notStrictEqual(roles[i], roles[i - 1],
'中间态出现连续同角色,说明淘汰是逐条而非成对,这会让历史里出现两条相邻的回答');
}
assert.strictEqual(roles[0], 'user');
assert.strictEqual(read.messages[0].text, '问0', '首轮提问必须还在');
assert.strictEqual(roles[roles.length - 1], 'user');
const out = sessions.historyFor(meta.id, { maxChars: 100000, maxMessages: max, maxMessageChars: 2000 });
assert.strictEqual(
out.messages.length,
read.messages.length,
'中间态角色已经交替,historyFor 不该再需要合并任何消息;需要合并说明淘汰留下了错位'
);
});
test('图像数达上限时丢弃最早消息的图像引用但保留文本', () => {
const { sessions, images } = fresh('img-evict');
const max = sessions.LIMITS.imagesPerSession;
const meta = sessions.create({});
const ids = [];
for (let i = 0; i <= max; i++) {
const put = images.put(jpeg(i + 1), 'image/jpeg');
ids.push(put.imageId);
sessions.appendUser(meta.id, {
text: `${i}`,
images: [{ imageId: put.imageId, mimeType: 'image/jpeg', width: 10, height: 10, bytes: put.bytes }]
});
const p = sessions.appendAssistant(meta.id, {});
sessions.finishAssistant(meta.id, p.id, { text: `${i}` });
}
const read = sessions.messages(meta.id, { limit: 500 });
const totalImages = read.messages.reduce((n, m) => n + m.images.length, 0);
assert.ok(totalImages <= max, `会话内图像引用不得超过 ${max}`);
assert.strictEqual(read.messages[0].text, '第 0 张', '丢图像引用不应丢文本');
assert.strictEqual(read.messages[0].images.length, 0, '最早的图像引用先被丢弃');
assert.ok(!sessions.imageIds().includes(ids[0]), '被丢弃的引用不应再出现在 GC 白名单里');
});
// --- 文件隔离与写放大 ---
test('追加消息只重写自己那个会话文件', () => {
const { sessions, dir } = fresh('isolate');
const a = sessions.create({ title: 'a' });
const b = sessions.create({ title: 'b' });
const bFile = sessionFile(dir, b.id);
const before = crypto.createHash('sha256').update(fs.readFileSync(bFile)).digest('hex');
round(sessions, a.id, '问', '答');
const after = crypto.createHash('sha256').update(fs.readFileSync(bFile)).digest('hex');
assert.strictEqual(after, before, '写 a 不应碰 b 的字节,否则 100 个会话就是 100 倍写放大');
const writesToB = countWrites((t) => t === `${bFile}.tmp`, () => {
round(sessions, a.id, '问2', '答2');
});
assert.strictEqual(writesToB, 0, '其他会话文件的写入次数必须是 0');
});
test('一轮对话对会话文件只写两次,索引写入不随消息数增长', () => {
const { sessions, dir } = fresh('writes');
const meta = sessions.create({});
const file = sessionFile(dir, meta.id);
const isSession = (t) => t === `${file}.tmp`;
const oneRound = countWrites(isSession, () => {
sessions.appendUser(meta.id, { text: '问' });
const p = sessions.appendAssistant(meta.id, {});
for (let i = 0; i < 50; i++) sessions.appendAssistant; // 流式增量不经过存储层
sessions.finishAssistant(meta.id, p.id, { text: '答'.repeat(500) });
});
assert.strictEqual(oneRound, 2, '一轮对话最多两次会话文件写入:appendUser 一次,finishAssistant 一次');
// 对照:两轮就是四次,证明计数器没失灵
const twoRounds = countWrites(isSession, () => {
round(sessions, meta.id, '问2', '答2');
round(sessions, meta.id, '问3', '答3');
});
assert.strictEqual(twoRounds, 4, '两轮应写四次,用于对照说明上面的 2 不是计数器失灵');
const idxFile = path.join(sessionDir(dir), 'index.json');
const idxWrites = countWrites((t) => t === `${idxFile}.tmp`, () => {
round(sessions, meta.id, '问4', '答4');
});
assert.strictEqual(idxWrites, 2, '索引跟着会话文件一起更新,一轮两次');
});
test('会话文件解析结果按内容缓存,同 mtime 同体积但内容不同也不会读到旧值', () => {
const { sessions, dir } = fresh('cache');
const meta = sessions.create({});
round(sessions, meta.id, '原始问题', '原始回答');
const file = sessionFile(dir, meta.id);
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
// utimesSync 会丢掉 mtime 的小数毫秒,先钉成整秒再取基准,否则构造本身对不上
const fixed = new Date(1700000000000);
fs.utimesSync(file, fixed, fixed);
const stat = fs.statSync(file);
assert.strictEqual(sessions.messages(meta.id).messages[0].text, '原始问题');
// 等长替换 + 还原 mtime:按 mtime+size 判定的缓存在这里会返回旧内容
raw.messages[0].text = '篡改问题';
const rewritten = JSON.stringify(raw, null, 2);
assert.strictEqual(Buffer.byteLength(rewritten, 'utf8'), stat.size, '构造用例要求体积不变');
fs.writeFileSync(file, rewritten);
fs.utimesSync(file, fixed, fixed);
assert.strictEqual(fs.statSync(file).mtimeMs, stat.mtimeMs, '构造用例要求 mtime 不变');
assert.strictEqual(fs.statSync(file).size, stat.size, '构造用例要求体积不变');
assert.strictEqual(sessions.messages(meta.id).messages[0].text, '篡改问题',
'缓存必须按内容哈希失效,按 mtime+size 会漏掉同毫秒内的改写');
});
// --- 损坏自愈 ---
test('主文件损坏时从 .bak 恢复', () => {
const { sessions, dir } = fresh('bak');
const meta = sessions.create({ entryId: 'e1' });
round(sessions, meta.id, '问', '答');
const file = sessionFile(dir, meta.id);
fs.copyFileSync(file, `${file}.bak`);
fs.writeFileSync(file, '{ 这不是 JSON');
const read = sessions.messages(meta.id);
assert.strictEqual(read.messages.length, 2, '.bak 完好时必须恢复出完整对话');
assert.strictEqual(read.messages[0].text, '问');
const corrupt = fs.readdirSync(sessionDir(dir)).filter((n) => n.includes('.corrupt-'));
assert.strictEqual(corrupt.length, 1, '损坏的主文件应被隔离留档而不是直接删除');
});
test('主文件与 .bak 都损坏时隔离成 .corrupt- 并返回空会话', () => {
const { sessions, dir } = fresh('corrupt');
const meta = sessions.create({ entryId: 'e1', title: '标题' });
round(sessions, meta.id, '问', '答');
const file = sessionFile(dir, meta.id);
fs.writeFileSync(file, 'broken');
fs.writeFileSync(`${file}.bak`, 'also broken');
const read = sessions.messages(meta.id);
assert.deepStrictEqual(read.messages, [], '两份都坏只能返回空,不能抛错让界面卡死');
assert.strictEqual(read.meta.entryId, 'e1', '空会话应从索引行恢复出归属,否则会变成孤立会话');
const corrupt = fs.readdirSync(sessionDir(dir)).filter((n) => n.includes('.corrupt-'));
assert.strictEqual(corrupt.length, 1);
round(sessions, meta.id, '新问', '新答');
assert.strictEqual(sessions.messages(meta.id).messages.length, 2, '自愈后应能继续写入');
});
test('index.json 丢失后扫目录重建,list 结果正确', () => {
const { sessions, dir } = fresh('reindex');
const a = sessions.create({ entryId: 'e1', title: '甲' });
const b = sessions.create({ entryId: 'e2', title: '乙' });
round(sessions, a.id, '问', '答');
const idxFile = path.join(sessionDir(dir), 'index.json');
fs.unlinkSync(idxFile);
const reloaded = at(dir).sessions;
const rows = reloaded.list();
assert.strictEqual(rows.length, 2, '索引是派生缓存,丢失后必须能从会话文件重建');
const rowA = rows.find((r) => r.id === a.id);
assert.strictEqual(rowA.title, '甲');
assert.strictEqual(rowA.entryId, 'e1');
assert.strictEqual(rowA.messageCount, 2);
assert.ok(rowA.bytes > 0, '重建的索引行应带上真实体积');
assert.strictEqual(rows.find((r) => r.id === b.id).messageCount, 0);
assert.ok(fs.existsSync(idxFile), '重建结果应回写磁盘');
});
test('index.json 损坏时重建,且损坏的索引不会让会话消失', () => {
const { sessions, dir } = fresh('reindex2');
const a = sessions.create({ title: '甲' });
fs.writeFileSync(path.join(sessionDir(dir), 'index.json'), '[[[not json');
const reloaded = at(dir).sessions;
assert.deepStrictEqual(reloaded.list().map((r) => r.id), [a.id]);
});
test('索引里混入非法行时被丢弃,真实会话仍在', () => {
const { sessions, dir } = fresh('reindex3');
const a = sessions.create({ title: '甲' });
const idxFile = path.join(sessionDir(dir), 'index.json');
const idx = JSON.parse(fs.readFileSync(idxFile, 'utf8'));
idx.sessions.push({ id: '../escape', title: '恶意' });
idx.sessions.push({ id: '__proto__', title: '污染' });
fs.writeFileSync(idxFile, JSON.stringify(idx, null, 2));
const rows = at(dir).sessions.list();
assert.deepStrictEqual(rows.map((r) => r.id), [a.id], '索引里的非法 ID 必须被丢弃');
});
test('会话文件里的坏消息被逐条丢弃,其余消息仍可读', () => {
const { sessions, dir } = fresh('bad-msg');
const meta = sessions.create({});
round(sessions, meta.id, '好问题', '好回答');
const file = sessionFile(dir, meta.id);
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
raw.messages.splice(1, 0, { role: 'nobody', text: '坏消息' }, null, 'not an object');
raw.messages.push({ role: 'assistant', text: '尾部回答', images: 'not an array', task: '非法' });
fs.writeFileSync(file, JSON.stringify(raw, null, 2));
const read = at(dir).sessions.messages(meta.id);
assert.deepStrictEqual(read.messages.map((m) => m.text), ['好问题', '好回答', '尾部回答'],
'会话内容部分来自模型输出,坏条目要丢弃而不是让整个会话读不出来');
assert.deepStrictEqual(read.messages[2].images, []);
assert.strictEqual(read.messages[2].task, null);
});
// --- ID 校验 ---
test('非法会话 ID 被拒,不产生任何文件', () => {
const { sessions, dir } = fresh('ids');
const bad = ['', '../escape', 'a/b', 'a\\b', '__proto__', 'prototype', 'constructor',
'.', '..', '-lead', 'x'.repeat(200), 'a.b', 'a:b', null, undefined, 'chat_ok\u0000'];
for (const id of bad) {
assert.throws(() => sessions.messages(id), /会话 ID 无效/, `会话 ID ${JSON.stringify(id)} 必须被拒`);
assert.throws(() => sessions.remove(id), /会话 ID 无效/);
assert.throws(() => sessions.appendUser(id, { text: 'x' }), /会话 ID 无效/);
}
assert.ok(!fs.existsSync(sessionDir(dir)) || !fs.readdirSync(sessionDir(dir)).some((n) => n !== 'index.json'),
'被拒的 ID 不应在磁盘上留下文件');
});
test('非法 entryId 与 messageId 被拒', () => {
const { sessions } = fresh('ids2');
for (const entryId of ['../x', 'a/b', '__proto__', 'x'.repeat(200), '.', '..']) {
assert.throws(() => sessions.create({ entryId }), /会话条目 ID 无效/);
}
const meta = sessions.create({ entryId: 'e1' });
for (const msgId of ['', '../x', 'a/b', '__proto__', 'x'.repeat(200)]) {
assert.throws(() => sessions.finishAssistant(meta.id, msgId, { text: 'x' }), /会话消息 ID 无效/);
}
for (const msgId of ['../x', 'a/b', '__proto__', 'x'.repeat(200)]) {
assert.throws(() => sessions.messages(meta.id, { before: msgId }), /会话消息 ID 无效/);
}
assert.doesNotThrow(() => sessions.messages(meta.id, { before: '' }), '空游标等价于不传,取最新一页');
});
test('finishAssistant 不能跨会话认领占位消息', () => {
const { sessions } = fresh('cross');
const a = sessions.create({});
const b = sessions.create({});
sessions.appendUser(a.id, { text: 'q' });
const p = sessions.appendAssistant(a.id, {});
assert.throws(() => sessions.finishAssistant(b.id, p.id, { text: '越界' }), /会话消息不存在/,
'占位消息必须绑定会话,否则渲染层可以把回答写进别的会话');
});
// --- historyFor ---
function history(sessions, id, budget) {
return sessions.historyFor(id, budget);
}
test('historyFor 始终 pin 首条 user 消息', () => {
const { sessions } = fresh('hist-pin');
const meta = sessions.create({});
sessions.appendUser(meta.id, { text: `正文${'甲'.repeat(300)}`, contextRef: { scope: 'document', text: 'x' } });
const p = sessions.appendAssistant(meta.id, {});
sessions.finishAssistant(meta.id, p.id, { text: '首答' });
for (let i = 0; i < 20; i++) round(sessions, meta.id, `${i}`.repeat(20), `${i}`.repeat(20));
const out = history(sessions, meta.id, { maxChars: 600, maxMessages: 6, maxMessageChars: 400 });
assert.strictEqual(out.messages[0].role, 'user');
assert.ok(out.messages[0].text.includes('正文'),
'承载正文的首条 user 必须永远在历史里,否则后续追问会失去参照');
assert.ok(out.messages.length < 42, '预算内应确实丢掉了中间轮次');
assert.ok(out.dropped > 0);
});
test('historyFor 的省略标记折进现有消息而不是新增消息', () => {
const { sessions } = fresh('hist-mark');
const meta = sessions.create({});
for (let i = 0; i < 12; i++) round(sessions, meta.id, `问题${i}`.repeat(20), `回答${i}`.repeat(20));
const out = history(sessions, meta.id, { maxChars: 500, maxMessages: 5, maxMessageChars: 400 });
const marked = out.messages.filter((m) => /已省略较早的 \d+ 轮对话/.test(m.text));
assert.strictEqual(marked.length, 1, '省略标记只应出现一次');
assert.strictEqual(marked[0], out.messages[0], '标记必须折进最旧那条保留消息');
assert.ok(marked[0].text.length > '[……已省略较早的 1 轮对话……]\n'.length,
'标记是折进去的,所以这条消息里还应有原本的正文,而不是一条只有标记的空消息');
assert.ok(out.messages.every((m) => m.role === 'user' || m.role === 'assistant'),
'不允许为了放标记而造出 system 之类的新角色');
});
test('historyFor 总字符不超过 maxChars', () => {
const { sessions } = fresh('hist-total');
const meta = sessions.create({});
for (let i = 0; i < 15; i++) round(sessions, meta.id, `${i}`.repeat(50), `${i}`.repeat(50));
for (const maxChars of [200, 500, 1200, 3000]) {
const out = history(sessions, meta.id, { maxChars, maxMessages: 30, maxMessageChars: 2000 });
const total = out.messages.reduce((n, m) => n + m.text.length, 0);
assert.ok(total <= maxChars, `maxChars=${maxChars} 时实际 ${total} 字符,超预算会直接被接口拒绝`);
}
});
test('historyFor 单条超限中间挖空且保留尾部', () => {
const { sessions } = fresh('hist-clip');
const meta = sessions.create({});
const head = '开头标记';
const tail = '结尾标记';
sessions.appendUser(meta.id, { text: `${head}${'填'.repeat(3000)}${tail}` });
const p = sessions.appendAssistant(meta.id, {});
sessions.finishAssistant(meta.id, p.id, { text: '答' });
const out = history(sessions, meta.id, { maxChars: 4000, maxMessages: 10, maxMessageChars: 600 });
const first = out.messages[0];
assert.ok(first.text.length <= 600, '单条应被压到 maxMessageChars 以内');
assert.ok(first.text.startsWith(head), '挖空必须保留头部');
assert.ok(first.text.endsWith(tail), '挖空必须保留尾部,尾部往往是真正的提问');
assert.ok(first.text.includes('中间内容已省略'), '挖空处要有可见标记');
assert.strictEqual(first.truncated, true, 'truncated 标记供界面提示用户');
});
test('historyFor 裁剪后若首条是 assistant 则丢掉它', () => {
const { sessions, dir } = fresh('hist-lead');
const meta = sessions.create({});
round(sessions, meta.id, '问', '答');
// 恶性输入:直接把会话文件改成 assistant 领头,模拟历史数据或模型侧写坏
const file = sessionFile(dir, meta.id);
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
raw.messages = [
{ id: 'msg_a1', role: 'assistant', text: '孤立回答', createdAt: 1 },
{ id: 'msg_u1', role: 'user', text: '真正的提问', createdAt: 2 },
{ id: 'msg_a2', role: 'assistant', text: '真正的回答', createdAt: 3 }
];
fs.writeFileSync(file, JSON.stringify(raw, null, 2));
const out = at(dir).sessions.historyFor(meta.id, { maxChars: 4000, maxMessages: 10, maxMessageChars: 400 });
assert.strictEqual(out.messages[0].role, 'user',
'Anthropic 的 /messages 要求 messages[0].role === "user",领头的 assistant 必须丢掉');
assert.ok(!out.messages.some((m) => m.text.includes('孤立回答')));
});
test('historyFor 不产生连续同角色消息', () => {
const { sessions, dir } = fresh('hist-roles');
const meta = sessions.create({});
round(sessions, meta.id, '问', '答');
const file = sessionFile(dir, meta.id);
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
raw.messages = [
{ id: 'msg_u1', role: 'user', text: '问一', createdAt: 1 },
{ id: 'msg_u2', role: 'user', text: '问二', createdAt: 2 },
{ id: 'msg_u3', role: 'user', text: '问三', createdAt: 3 },
{ id: 'msg_a1', role: 'assistant', text: '答一', createdAt: 4 },
{ id: 'msg_a2', role: 'assistant', text: '答二', createdAt: 5 },
{ id: 'msg_u4', role: 'user', text: '问四', createdAt: 6 }
];
fs.writeFileSync(file, JSON.stringify(raw, null, 2));
const reloaded = at(dir).sessions;
for (const budget of [
{ maxChars: 4000, maxMessages: 10, maxMessageChars: 400 },
{ maxChars: 20, maxMessages: 3, maxMessageChars: 50 },
{ maxChars: 4000, maxMessages: 2, maxMessageChars: 400 }
]) {
const out = reloaded.historyFor(meta.id, budget);
const roles = out.messages.map((m) => m.role);
for (let i = 1; i < roles.length; i++) {
assert.notStrictEqual(roles[i], roles[i - 1],
`预算 ${JSON.stringify(budget)} 下出现了连续同角色,Anthropic 会直接 400`);
}
if (roles.length) assert.strictEqual(roles[0], 'user');
assert.ok(out.messages.find((m) => m.text.includes('问一')), '首条 user 仍应被 pin');
}
});
test('historyFor 把已被物理丢弃的轮次计入省略标记', () => {
const { sessions } = fresh('hist-dropped');
const max = sessions.LIMITS.messagesPerSession;
const meta = sessions.create({});
for (let i = 0; i < max; i++) round(sessions, meta.id, `${i}`, `${i}`);
const read = sessions.messages(meta.id, { limit: max });
assert.ok(read.meta.droppedMessages > 0);
const out = history(sessions, meta.id, { maxChars: 100000, maxMessages: max, maxMessageChars: 2000 });
assert.ok(out.dropped >= read.meta.droppedMessages,
'磁盘上已丢的轮次也要计入 dropped,否则模型会以为它看到了完整对话');
assert.match(out.messages[0].text, /已省略较早的 \d+ 轮对话/);
});
test('historyFor 在空会话与单条会话上不炸', () => {
const { sessions } = fresh('hist-edge');
const meta = sessions.create({});
assert.deepStrictEqual(sessions.historyFor(meta.id, {}), { messages: [], dropped: 0 });
sessions.appendUser(meta.id, { text: '只有一条' });
const out = sessions.historyFor(meta.id, { maxChars: 5, maxMessages: 1, maxMessageChars: 50 });
assert.strictEqual(out.messages.length, 1, '首条 user 即使超预算也要留下,否则请求没有内容可发');
assert.strictEqual(out.messages[0].role, 'user');
});
// --- imageIds 与 GC ---
test('imageIds 遍历全部会话文件,索引缺行也能找到引用', () => {
const { sessions, images, dir } = fresh('gc-scan');
const a = sessions.create({ title: '甲' });
const b = sessions.create({ title: '乙' });
const imgA = images.put(jpeg(11), 'image/jpeg');
const imgB = images.put(jpeg(22), 'image/jpeg');
sessions.appendUser(a.id, { text: '带图甲', images: [{ imageId: imgA.imageId, bytes: imgA.bytes }] });
sessions.appendUser(b.id, { text: '带图乙', images: [{ imageId: imgB.imageId, bytes: imgB.bytes }] });
// 故意让索引缺掉 b:只读索引的 GC 会把 b 引用的图当垃圾删掉
const idxFile = path.join(sessionDir(dir), 'index.json');
const idx = JSON.parse(fs.readFileSync(idxFile, 'utf8'));
idx.sessions = idx.sessions.filter((row) => row.id !== b.id);
fs.writeFileSync(idxFile, JSON.stringify(idx, null, 2));
const reloaded = at(dir);
assert.ok(!reloaded.sessions.list().some((row) => row.id === b.id) || true);
const ids = reloaded.sessions.imageIds();
assert.ok(ids.includes(imgB.imageId),
'imageIds 必须扫目录而不是读索引,索引损坏时只读索引会静默删掉仍被引用的图');
assert.ok(ids.includes(imgA.imageId));
const removed = reloaded.images.cleanup(ids, { graceMs: 0 });
assert.strictEqual(removed, 0, '全部图仍被引用时不该删任何东西');
assert.ok(fs.existsSync(path.join(imageDir(dir), `${imgB.imageId}.jpg`)));
});
test('imageIds 覆盖 .bak 里的引用与内存中的占位消息', () => {
const { sessions, images, dir } = fresh('gc-bak');
const meta = sessions.create({});
const img = images.put(jpeg(33), 'image/jpeg');
sessions.appendUser(meta.id, { text: '带图', images: [{ imageId: img.imageId, bytes: img.bytes }] });
const file = sessionFile(dir, meta.id);
fs.copyFileSync(file, `${file}.bak`);
fs.writeFileSync(file, 'broken');
const reloaded = at(dir);
assert.ok(reloaded.sessions.imageIds().includes(img.imageId),
'主文件坏掉但 .bak 还引用着这张图,GC 不能删它');
const pendingImg = images.put(jpeg(44), 'image/jpeg');
const s2 = fresh('gc-pending');
const m2 = s2.sessions.create({});
s2.sessions.appendUser(m2.id, { text: 'q' });
s2.sessions.appendAssistant(m2.id, { images: [{ imageId: pendingImg.imageId, bytes: pendingImg.bytes }] });
assert.ok(s2.sessions.imageIds().includes(pendingImg.imageId),
'未落盘的占位消息引用的图也要在白名单里,否则流式期间的 GC 会删掉它');
});
test('会话删除后其图像被 cleanup 回收', () => {
const { sessions, images, dir } = fresh('gc-remove');
const keep = sessions.create({ title: '留' });
const drop = sessions.create({ title: '删' });
const imgKeep = images.put(jpeg(55), 'image/jpeg');
const imgDrop = images.put(jpeg(66), 'image/jpeg');
sessions.appendUser(keep.id, { text: 'k', images: [{ imageId: imgKeep.imageId, bytes: imgKeep.bytes }] });
sessions.appendUser(drop.id, { text: 'd', images: [{ imageId: imgDrop.imageId, bytes: imgDrop.bytes }] });
sessions.remove(drop.id);
const removed = images.cleanup(sessions.imageIds(), { graceMs: 0 });
assert.strictEqual(removed, 1);
assert.ok(fs.existsSync(path.join(imageDir(dir), `${imgKeep.imageId}.jpg`)));
assert.ok(!fs.existsSync(path.join(imageDir(dir), `${imgDrop.imageId}.jpg`)));
});
// --- ai-images ---
test('ai-images 只收 JPEG,魔数不对直接拒', () => {
const { images } = fresh('img-mime');
const png = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex');
assert.throws(() => images.put(png, 'image/png'), /仅支持 JPEG/);
assert.throws(() => images.put(png, 'image/jpeg'), /不是有效的 JPEG/,
'声明 JPEG 但内容是 PNG 必须被拒,否则视觉接口会收到无法解码的负载');
assert.throws(() => images.put(Buffer.alloc(0), 'image/jpeg'), /数据为空/);
assert.throws(() => images.put(Buffer.alloc(64, 0), 'image/jpeg'), /不是有效的 JPEG/);
assert.throws(() => images.put('not a buffer', 'image/jpeg'), /数据为空/);
assert.throws(() => images.put(jpeg(1, 4 * 1024 * 1024), 'image/jpeg'), /超过 3 MB/);
});
test('ai-images 内容寻址去重,同图两次 put 只占一份磁盘', () => {
const { images, dir } = fresh('img-dedup');
const bytes = jpeg(77, 1024);
const first = images.put(bytes, 'image/jpeg');
const second = images.put(Buffer.from(bytes), 'image/jpeg');
assert.strictEqual(second.imageId, first.imageId, '同样的字节必须得到同样的 imageId');
assert.strictEqual(fs.readdirSync(imageDir(dir)).length, 1, '去重后磁盘上只应有一份');
assert.strictEqual(images.totalBytes(), 1024);
assert.match(first.imageId, /^img_[a-f0-9]{64}$/);
const other = images.put(jpeg(78, 1024), 'image/jpeg');
assert.notStrictEqual(other.imageId, first.imageId, '不同字节必须得到不同 imageId');
assert.strictEqual(images.totalBytes(), 2048);
});
test('ai-images 读回与 dataUrl 一致,非法 imageId 被拒', () => {
const { images } = fresh('img-read');
const bytes = jpeg(88, 256);
const { imageId } = images.put(bytes, 'image/jpeg');
assert.ok(images.read(imageId).equals(bytes));
assert.strictEqual(images.dataUrl(imageId), `data:image/jpeg;base64,${bytes.toString('base64')}`);
for (const bad of ['', 'img_short', '../escape', 'img_' + 'g'.repeat(64), '__proto__',
`img_${'a'.repeat(64)}/../x`, null, 'pdf_' + 'a'.repeat(64)]) {
assert.throws(() => images.read(bad), /会话图像标识无效/, `imageId ${JSON.stringify(bad)} 必须被拒`);
assert.throws(() => images.dataUrl(bad), /会话图像标识无效/);
assert.throws(() => images.safeImageId(bad), /会话图像标识无效/);
}
assert.throws(() => images.read(`img_${'a'.repeat(64)}`), /会话图像不存在/);
});
test('ai-images cleanup 只删未引用的,宽限期内的新文件不删', () => {
const { images, dir } = fresh('img-cleanup');
const a = images.put(jpeg(1, 128), 'image/jpeg');
const b = images.put(jpeg(2, 128), 'image/jpeg');
const c = images.put(jpeg(3, 128), 'image/jpeg');
assert.strictEqual(images.cleanup([a.imageId]), 0,
'刚落盘的图还没被会话引用,宽限期内删掉就是删正在提交的数据');
assert.strictEqual(images.cleanup([a.imageId], { graceMs: 0 }), 2);
assert.deepStrictEqual(fs.readdirSync(imageDir(dir)), [`${a.imageId}.jpg`]);
assert.ok(images.read(a.imageId));
assert.strictEqual(images.cleanup([], { graceMs: 0 }), 1);
assert.strictEqual(images.totalBytes(), 0);
assert.strictEqual(images.cleanup([], { graceMs: 0 }), 0, '目录空了也不该报错');
// 白名单里的垃圾值不应意外保住任何文件
images.put(jpeg(4, 128), 'image/jpeg');
assert.strictEqual(images.cleanup(['../escape', null, '__proto__'], { graceMs: 0 }), 1);
});
test('ai-images 目录不存在时 totalBytes 与 cleanup 返回零值', () => {
const { images } = fresh('img-empty');
assert.strictEqual(images.totalBytes(), 0);
assert.strictEqual(images.cleanup([]), 0);
});
test('会话里的图像元数据被规范化,非法引用被拒', () => {
const { sessions, images } = fresh('img-meta');
const meta = sessions.create({});
const img = images.put(jpeg(99, 512), 'image/jpeg');
assert.throws(() => sessions.appendUser(meta.id, {
text: 'q', images: [{ imageId: '../escape' }]
}), /会话图像标识无效/);
assert.throws(() => sessions.appendUser(meta.id, {
text: 'q', images: [{ imageId: img.imageId, mimeType: 'image/png' }]
}), /仅支持 JPEG/);
const stored = sessions.appendUser(meta.id, {
text: 'q',
images: [
{ imageId: img.imageId, width: 100, height: 200, bytes: 512, ocrIncluded: true },
{ imageId: img.imageId, width: 100, height: 200, bytes: 512 }
]
});
assert.strictEqual(stored.images.length, 1, '同一张图重复引用应折成一条');
assert.deepStrictEqual(stored.images[0], {
imageId: img.imageId,
mimeType: 'image/jpeg',
width: 100,
height: 200,
bytes: 512,
ocrIncluded: true
});
});
// --- 对账 ---
test('orphanReport 只报书库里已不存在的条目,GLOBAL_ENTRY_ID 永不判为孤立', () => {
const { sessions } = fresh('orphan');
const inLib = sessions.create({ entryId: 'e1', title: '在库' });
const gone = sessions.create({ entryId: 'e404', title: '已删' });
const global = sessions.create({ entryId: sessions.GLOBAL_ENTRY_ID, title: '全局' });
round(sessions, gone.id, '问', '答');
const report = sessions.orphanReport(['e1']);
assert.deepStrictEqual(report.map((r) => r.sessionId), [gone.id]);
assert.strictEqual(report[0].entryId, 'e404');
assert.strictEqual(report[0].title, '已删', '报告要带标题,否则用户无法判断是否回收');
assert.strictEqual(report[0].messageCount, 2);
assert.ok(report[0].bytes > 0);
assert.ok(!report.some((r) => r.sessionId === global.id), '全局会话不绑书籍,永远不是孤立数据');
assert.ok(!report.some((r) => r.sessionId === inLib.id));
});
test('forgetMany 尊重白名单,且显式点名 GLOBAL_ENTRY_ID 也不删', () => {
const { sessions } = fresh('forget');
const keep = sessions.create({ entryId: 'e1', title: '在库' });
const dropA = sessions.create({ entryId: 'e404', title: '已删甲' });
const dropB = sessions.create({ entryId: 'e404', title: '已删乙' });
const global = sessions.create({ entryId: sessions.GLOBAL_ENTRY_ID, title: '全局' });
assert.strictEqual(sessions.forgetMany([]), 0);
assert.strictEqual(sessions.forgetMany(['e404', sessions.GLOBAL_ENTRY_ID]), 2);
const remaining = sessions.list().map((row) => row.id).sort();
assert.deepStrictEqual(remaining, [keep.id, global.id].sort(),
'在库条目与全局会话都不该被回收,误删的是用户无法找回的对话');
assert.throws(() => sessions.forgetMany(['../escape']), /会话条目 ID 无效/);
assert.strictEqual(sessions.list().length, 2, '非法输入不应造成部分删除');
});
test('rebuildIndex 返回索引形状并与 list 一致', () => {
const { sessions } = fresh('rebuild');
const a = sessions.create({ entryId: 'e1', title: '甲' });
round(sessions, a.id, '问', '答');
const idx = sessions.rebuildIndex();
assert.strictEqual(idx.version, 1);
assert.strictEqual(idx.sessions.length, 1);
assert.deepStrictEqual(Object.keys(idx.sessions[0]).sort(),
['bytes', 'entryId', 'id', 'messageCount', 'pinned', 'title', 'updatedAt'].sort());
assert.deepStrictEqual(sessions.list()[0], idx.sessions[0]);
});
test('list 返回的是副本,改动不会污染内存索引', () => {
const { sessions } = fresh('clone');
const meta = sessions.create({ title: '原标题' });
const rows = sessions.list();
rows[0].title = '被改坏';
assert.strictEqual(sessions.list()[0].title, '原标题');
assert.strictEqual(sessions.messages(meta.id).meta.title, '原标题');
});