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
+338 -1
View File
@@ -326,7 +326,7 @@ test('OCR-only 契约不要求视觉模型且不会发送图像', async () => {
assert.doesNotMatch(body.messages[1].content, /data:image/);
});
test('超长上下文被截断且保留首尾', () => {
test('显式调用 clipContext 时中间挖空并保留首尾', () => {
const ai = setup();
const long = 'A'.repeat(5000) + 'MIDDLE' + 'B'.repeat(5000) + 'TAIL_MARK';
const clipped = ai.clipContext(long, 2000);
@@ -336,6 +336,49 @@ test('超长上下文被截断且保留首尾', () => {
assert.ok(clipped.includes('省略'), '未标注截断');
});
test('正文完整外发,不再按 MAX_CHARS 静默截断', () => {
const ai = setup();
// 40 页文档实测:旧行为只发出 8 页,其余 32 页静默丢失,而界面仍显示全文字数
const body = 'X'.repeat(ai.MAX_CHARS * 4) + 'TAIL_MARK';
const msgs = ai.buildMessages('ask', body, '这讲了什么');
assert.ok(msgs[1].content.includes(body), '正文被截断了');
assert.ok(!msgs[1].content.includes('中间省略'), '不应再自动挖空正文');
assert.ok(msgs[1].content.includes('TAIL_MARK'));
});
test('接口报的上下文超限被转成可操作的中文提示', async () => {
const ai = setup();
for (const [status, raw] of [
[400, "This model's maximum context length is 8192 tokens, however you requested 90000 tokens"],
[400, 'prompt is too long: 250000 tokens > 200000 maximum'],
[413, 'Payload Too Large']
]) {
h.setHandler(() => streamResponse(JSON.stringify({ error: { message: raw } }), { status }));
await assert.rejects(
() => ai.stream({ task: 'ask', text: '正文', question: '问题' }),
(err) => {
assert.match(err.message, /上下文超出模型窗口/);
assert.match(err.message, /范围改小|更大窗口/);
return true;
},
`HTTP ${status} 未被识别为上下文超限`
);
}
// 普通错误不应被误判成超限
h.setHandler(() => streamResponse(
JSON.stringify({ error: { message: 'invalid temperature value' } }),
{ status: 400 }
));
await assert.rejects(
() => ai.stream({ task: 'ask', text: '正文', question: '问题' }),
(err) => {
assert.doesNotMatch(err.message, /上下文超出模型窗口/);
return true;
}
);
});
test('不支持的任务类型被拒绝', () => {
const ai = setup();
assert.throws(() => ai.buildMessages('hack', 'x'), /不支持的任务/);
@@ -359,3 +402,297 @@ test('取消请求时抛出 AbortError 而不是静默返回', async () => {
(e) => e.name === 'AbortError'
);
});
// 多轮对话历史
const PROTOCOL_STREAMS = {
'chat-completions': () => streamResponse(sseBody(['答'])),
anthropic: () => streamResponse(
`data: ${JSON.stringify({ type: 'content_block_delta', delta: { type: 'text_delta', text: '答' } })}\n\n`
+ `data: ${JSON.stringify({ type: 'message_stop' })}\n\n`
),
'openai-responses': () => streamResponse(
`data: ${JSON.stringify({ type: 'response.output_text.delta', delta: '答' })}\n\n`
+ `data: ${JSON.stringify({ type: 'response.completed' })}\n\n`
)
};
async function captureBody(protocol, args, options) {
const ai = setup({ protocol, ...options });
let body = null;
h.setHandler((_url, opts) => {
body = JSON.parse(opts.body);
return PROTOCOL_STREAMS[protocol]();
});
await ai.stream(args);
return body;
}
// history 缺省时请求体必须与单轮时代逐字节一致,否则等于悄悄改了单轮行为
test('未传 history 时三种协议请求体与单轮完全一致', async () => {
// 写死单轮的 user 正文,只比对"传与不传 history"两次结果会同时被同一个 bug 污染
const expectedUser = '文档片段:\n"""\n正文\n"""\n\n问题:问题';
for (const protocol of Object.keys(PROTOCOL_STREAMS)) {
const base = await captureBody(protocol, { task: 'ask', text: '正文', question: '问题' });
if (protocol === 'chat-completions') {
assert.strictEqual(base.messages.length, 2, '单轮只该有 system + user');
assert.strictEqual(base.messages[1].content, expectedUser);
} else if (protocol === 'anthropic') {
assert.strictEqual(base.messages.length, 1, '单轮只该有一条 user');
assert.strictEqual(base.messages[0].content, expectedUser);
} else {
assert.strictEqual(base.input.length, 1, '单轮只该有一条 user');
assert.deepStrictEqual(base.input[0].content, [{ type: 'input_text', text: expectedUser }]);
}
for (const history of [undefined, null, [], 'not-an-array', {}]) {
const withArg = await captureBody(
protocol,
{ task: 'ask', text: '正文', question: '问题', history }
);
assert.strictEqual(
JSON.stringify(withArg),
JSON.stringify(base),
`${protocol} 的空 history 改变了请求体(history=${JSON.stringify(history)}`
);
}
}
});
test('chat-completions 把历史插在 system 之后、当前轮之前', async () => {
const body = await captureBody('chat-completions', {
task: 'ask',
text: '正文',
question: '第三个问题',
history: [
{ id: 'a', role: 'user', text: '第一个问题' },
{ id: 'b', role: 'assistant', text: '第一个回答' },
{ id: 'c', role: 'user', text: '第二个问题' },
{ id: 'd', role: 'assistant', text: '第二个回答' }
]
});
assert.deepStrictEqual(
body.messages.map((m) => m.role),
['system', 'user', 'assistant', 'user', 'assistant', 'user'],
'历史必须在 messages 里,且当前轮排最后'
);
assert.strictEqual(body.messages[1].content, '第一个问题');
assert.strictEqual(body.messages[2].content, '第一个回答');
assert.strictEqual(body.messages[3].content, '第二个问题');
assert.strictEqual(body.messages[4].content, '第二个回答');
assert.match(body.messages[5].content, /第三个问题/, '当前轮问题丢失');
assert.match(body.messages[5].content, /正文/, '当前轮正文丢失');
assert.ok(
!/第一个问题/.test(body.messages[0].content),
'历史不该被塞进 system,模型会把它当成指令'
);
});
test('anthropic 历史进 messagessystem 仍在顶层', async () => {
const body = await captureBody('anthropic', {
task: 'ask',
text: '正文',
question: '新问题',
history: [
{ id: 'a', role: 'user', text: '旧问题' },
{ id: 'b', role: 'assistant', text: '旧回答' }
]
});
assert.strictEqual(typeof body.system, 'string');
assert.ok(!/旧问题|旧回答/.test(body.system), 'Anthropic 的 system 是顶层字段,历史不该混进去');
assert.deepStrictEqual(body.messages.map((m) => m.role), ['user', 'assistant', 'user']);
assert.strictEqual(body.messages[0].content, '旧问题');
assert.strictEqual(body.messages[1].content, '旧回答');
assert.match(body.messages[2].content, /新问题/);
assert.ok(!body.messages.some((m) => m.role === 'system'), 'system 不能出现在 messages 里');
});
test('openai-responses 历史进 inputinstructions 与 store:false 保持', async () => {
const body = await captureBody('openai-responses', {
task: 'ask',
text: '正文',
question: '新问题',
history: [
{ id: 'a', role: 'user', text: '旧问题' },
{ id: 'b', role: 'assistant', text: '旧回答' }
]
});
assert.strictEqual(typeof body.instructions, 'string');
assert.ok(!/旧问题|旧回答/.test(body.instructions), 'instructions 是顶层字段,历史不该混进去');
assert.strictEqual(body.store, false, 'store 必须保持 false,服务端不留存对话');
assert.ok(!('previous_response_id' in body), '多轮不能靠服务端留存,与 store:false 冲突');
assert.deepStrictEqual(body.input.map((m) => m.role), ['user', 'assistant', 'user']);
// 纯字符串是 Responses 输入消息的合法简写,避开 input_text 不接受 assistant 的限制
assert.strictEqual(body.input[0].content, '旧问题');
assert.strictEqual(body.input[1].content, '旧回答');
assert.ok(Array.isArray(body.input[2].content), '当前轮仍用结构化 content');
assert.strictEqual(body.input[2].content[0].type, 'input_text');
assert.match(body.input[2].content[0].text, /新问题/);
});
test('历史里的图像不被重发,只发当前轮的图', async () => {
const image = visualContext().image;
const history = [
{ id: 'a', role: 'user', text: '看这张图', images: [image, image] },
{ id: 'b', role: 'assistant', text: '看到了' }
];
const args = {
task: 'ask',
text: '',
question: '这张呢',
visualContexts: [visualContext()],
history
};
const chat = await captureBody('chat-completions', args, { vision: true });
const chatImages = chat.messages.flatMap(
(m) => (Array.isArray(m.content) ? m.content.filter((c) => c.type === 'image_url') : [])
);
assert.strictEqual(chatImages.length, 1, '历史图像被重发了,长会话费用会随轮数累积');
const anthropic = await captureBody('anthropic', args, { vision: true });
const anthropicImages = anthropic.messages.flatMap(
(m) => (Array.isArray(m.content) ? m.content.filter((c) => c.type === 'image') : [])
);
assert.strictEqual(anthropicImages.length, 1, '历史图像被重发了');
const responses = await captureBody('openai-responses', args, { vision: true });
const responseImages = responses.input.flatMap(
(m) => (Array.isArray(m.content) ? m.content.filter((c) => c.type === 'input_image') : [])
);
assert.strictEqual(responseImages.length, 1, '历史图像被重发了');
const historyJson = JSON.stringify(history);
assert.strictEqual(historyJson, JSON.stringify([
{ id: 'a', role: 'user', text: '看这张图', images: [image, image] },
{ id: 'b', role: 'assistant', text: '看到了' }
]), '不该原地改写调用方传进来的历史数组');
});
test('anthropic 历史领头是 assistant 时首条仍是 user', async () => {
const body = await captureBody('anthropic', {
task: 'ask',
text: '正文',
question: '新问题',
history: [
{ id: 'a', role: 'assistant', text: '孤立的开场回答' },
{ id: 'b', role: 'user', text: '真正的第一问' },
{ id: 'c', role: 'assistant', text: '第一答' }
]
});
// Anthropic 的 /messages 直接 400 拒绝领头 assistant
assert.strictEqual(body.messages[0].role, 'user', '首条必须是 user,否则 Anthropic 直接 400');
assert.deepStrictEqual(body.messages.map((m) => m.role), ['user', 'assistant', 'user']);
assert.strictEqual(body.messages[0].content, '真正的第一问');
});
test('历史末条是 user 时与当前轮合并,两段文本都保留', async () => {
for (const protocol of Object.keys(PROTOCOL_STREAMS)) {
const body = await captureBody(protocol, {
task: 'ask',
text: '正文',
question: '当前问题',
history: [
{ id: 'a', role: 'user', text: '上一问' },
{ id: 'b', role: 'assistant', text: '上一答' },
{ id: 'c', role: 'user', text: '没等到回答的追问' }
]
});
const items = protocol === 'openai-responses' ? body.input : body.messages;
const roles = items.map((m) => m.role).filter((r) => r !== 'system');
for (let i = 1; i < roles.length; i++) {
assert.notStrictEqual(roles[i], roles[i - 1], `${protocol} 出现相邻同角色,Anthropic 会直接 400`);
}
const flat = JSON.stringify(items);
assert.match(flat, /没等到回答的追问/, `${protocol} 静默丢弃了用户内容`);
assert.match(flat, /当前问题/, `${protocol} 当前轮问题丢失`);
}
});
test('历史内部相邻同角色被合并而不是丢弃', async () => {
for (const protocol of Object.keys(PROTOCOL_STREAMS)) {
const body = await captureBody(protocol, {
task: 'ask',
text: '正文',
question: '当前问题',
history: [
{ id: 'a', role: 'user', text: '连问一' },
{ id: 'b', role: 'user', text: '连问二' },
{ id: 'c', role: 'assistant', text: '连答一' },
{ id: 'd', role: 'assistant', text: '连答二' }
]
});
const items = protocol === 'openai-responses' ? body.input : body.messages;
const roles = items.map((m) => m.role).filter((r) => r !== 'system');
assert.deepStrictEqual(
roles,
['user', 'assistant', 'user'],
`${protocol} 未合并相邻同角色,Anthropic 会直接 400`
);
const flat = JSON.stringify(items);
for (const mark of ['连问一', '连问二', '连答一', '连答二', '当前问题']) {
assert.match(flat, new RegExp(mark), `${protocol} 静默丢弃了 ${mark}`);
}
}
});
test('历史含空文本时不产生空 content', async () => {
for (const protocol of Object.keys(PROTOCOL_STREAMS)) {
const body = await captureBody(protocol, {
task: 'ask',
text: '正文',
question: '当前问题',
history: [
{ id: 'a', role: 'user', text: '有效提问' },
{ id: 'b', role: 'assistant', text: ' ' },
{ id: 'c', role: 'assistant', text: '' },
{ id: 'd', role: 'user', text: null },
{ id: 'e', role: 'assistant', text: '有效回答' },
null
]
});
const items = protocol === 'openai-responses' ? body.input : body.messages;
for (const item of items) {
const text = typeof item.content === 'string'
? item.content
: JSON.stringify(item.content);
assert.ok(text && text.trim(), `${protocol} 出现空 contentAnthropic 不接受空字符串`);
}
const roles = items.map((m) => m.role).filter((r) => r !== 'system');
assert.deepStrictEqual(roles, ['user', 'assistant', 'user'], `${protocol} 空消息未被过滤干净`);
}
});
test('历史按旧到新排列,当前轮在最后', async () => {
const history = [];
for (let i = 1; i <= 3; i++) {
history.push({ id: `u${i}`, role: 'user', text: `问题${i}` });
history.push({ id: `a${i}`, role: 'assistant', text: `回答${i}` });
}
for (const protocol of Object.keys(PROTOCOL_STREAMS)) {
const body = await captureBody(protocol, {
task: 'ask',
text: '正文',
question: '问题4',
history
});
const items = protocol === 'openai-responses' ? body.input : body.messages;
const flat = items.map((m) => (typeof m.content === 'string' ? m.content : JSON.stringify(m.content)));
const order = ['问题1', '回答1', '问题2', '回答2', '问题3', '回答3', '问题4']
.map((mark) => flat.findIndex((s) => s.includes(mark)));
assert.ok(order.every((i) => i >= 0), `${protocol} 有历史轮次丢失`);
for (let i = 1; i < order.length; i++) {
assert.ok(order[i] > order[i - 1], `${protocol} 历史顺序颠倒,模型会读到倒序对话`);
}
assert.strictEqual(order[order.length - 1], flat.length - 1, `${protocol} 当前轮不在最后`);
}
});
test('buildMessages 也接受历史参数', () => {
const ai = setup();
const msgs = ai.buildMessages('ask', '正文', '新问题', [], [
{ role: 'user', text: '旧问题' },
{ role: 'assistant', text: '旧回答' }
]);
assert.deepStrictEqual(msgs.map((m) => m.role), ['system', 'user', 'assistant', 'user']);
assert.strictEqual(msgs[1].content, '旧问题');
assert.match(msgs[3].content, /新问题/);
});