feat: 内置阅读器、批注笔记与 AI 助手,发布 1.3.0

新增 PDF/EPUB/MOBI/AZW 内置阅读器,PDF 分段读取支持超大文件,
批注、读书与画布笔记、封面生成与本地导入。AI 助手支持三种协议、
图像上下文与安全 Markdown 渲染,上下文范围改为 选中/当前页/全文,
页面与全文无需选中文本即可发送,全文会提示可能超出模型限制。

便携版输出目录固定为 PeopleLib-windows-x64,不再随版本号变化,
避免升级后 data/ 被遗留在旧目录。

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
lofyer
2026-08-03 12:13:02 +08:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent b8c8d24107
commit 3ccd044527
307 changed files with 98477 additions and 1148 deletions
+361
View File
@@ -0,0 +1,361 @@
const test = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const h = require('./helpers');
h.installFetchStub();
const cfgPath = require.resolve('../reader/ai-config.js');
const clientPath = require.resolve('../reader/ai-client.js');
const dirs = [];
function tmp() {
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'peoplelib-ai-'));
dirs.push(d);
return d;
}
test.after(() => {
for (const d of dirs) {
try { fs.rmSync(d, { recursive: true, force: true }); } catch (e) { /* ignore */ }
}
});
const storage = {
isEncryptionAvailable: () => true,
encryptString: (s) => Buffer.from('E' + s),
decryptString: (b) => b.toString().slice(1)
};
function setup({
protocol = 'chat-completions',
baseUrl = 'https://api.test.com/v1',
model = 'm',
apiKey = 'sk-1',
vision = false
} = {}) {
delete require.cache[cfgPath];
delete require.cache[clientPath];
const cfg = require(cfgPath);
cfg.init(tmp(), storage);
cfg.save({ protocol, baseUrl, model, apiKey, vision });
return require(clientPath);
}
function visualContext(overrides = {}) {
const base64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=';
return {
kind: 'page',
image: {
mimeType: 'image/png',
base64,
width: 1,
height: 1,
bytes: Buffer.from(base64, 'base64').length
},
ocr: { status: 'idle', text: '', include: false },
...overrides
};
}
function sseBody(chunks, { done = true } = {}) {
const lines = chunks.map((c) => `data: ${JSON.stringify({ choices: [{ delta: { content: c } }] })}\n\n`);
if (done) lines.push('data: [DONE]\n\n');
return lines.join('');
}
// 把字符串切成多个 chunk,模拟真实网络分片(含跨 chunk 断行)
function streamResponse(text, { status = 200, pieces = 3 } = {}) {
const buf = Buffer.from(text, 'utf8');
const size = Math.ceil(buf.length / pieces);
const parts = [];
for (let i = 0; i < buf.length; i += size) parts.push(buf.subarray(i, i + size));
return {
ok: status >= 200 && status < 300,
status,
headers: { get: () => null, getSetCookie: () => [] },
text: async () => text,
json: async () => JSON.parse(text),
body: (async function* () { for (const p of parts) yield p; })()
};
}
test('流式增量按顺序回调并拼出完整文本', async () => {
const ai = setup();
h.setHandler(() => streamResponse(sseBody(['你', '好', '世界']), { pieces: 5 }));
const seen = [];
const full = await ai.stream({ task: 'translate', text: 'hello', onDelta: (d) => seen.push(d) });
assert.strictEqual(full, '你好世界');
assert.deepStrictEqual(seen, ['你', '好', '世界']);
});
test('SSE 分片跨 chunk 断开也能正确解析', async () => {
const ai = setup();
// 每个字节一个 chunk,保证 data: 行被切碎
h.setHandler(() => streamResponse(sseBody(['abc', 'def']), { pieces: 200 }));
const full = await ai.stream({ task: 'explain', text: 'x' });
assert.strictEqual(full, 'abcdef');
});
test('遇到 [DONE] 立即结束,不解析后续内容', async () => {
const ai = setup();
const body = sseBody(['一'], { done: true }) + sseBody(['不该出现'], { done: false });
h.setHandler(() => streamResponse(body));
assert.strictEqual(await ai.stream({ task: 'summarize', text: 'x' }), '一');
});
test('HTTP 错误体里的 message 会被提取为中文可读错误', async () => {
const ai = setup();
h.setHandler(() => streamResponse(JSON.stringify({ error: { message: 'model not found' } }), { status: 404 }));
await assert.rejects(() => ai.stream({ task: 'translate', text: 'x' }), /model not found/);
});
test('401 无 JSON 体时给出可读提示', async () => {
const ai = setup();
h.setHandler(() => streamResponse('Unauthorized', { status: 401 }));
await assert.rejects(() => ai.stream({ task: 'translate', text: 'x' }), /API Key 无效/);
});
test('流内返回 error 字段也会抛出', async () => {
const ai = setup();
h.setHandler(() => streamResponse('data: ' + JSON.stringify({ error: { message: '额度不足' } }) + '\n\n'));
await assert.rejects(() => ai.stream({ task: 'ask', text: 'x', question: 'q' }), /额度不足/);
});
test('未配置 Key 且非本地端点时拒绝请求', async () => {
const ai = setup({ apiKey: '' });
await assert.rejects(() => ai.stream({ task: 'translate', text: 'x' }), /API Key/);
});
test('本地端点无 Key 也允许请求,且不带 Authorization 头', async () => {
const ai = setup({ baseUrl: 'http://localhost:11434/v1', apiKey: '' });
let seenHeaders = null;
h.setHandler((_u, o) => { seenHeaders = o.headers; return streamResponse(sseBody(['ok'])); });
assert.strictEqual(await ai.stream({ task: 'translate', text: 'x' }), 'ok');
assert.ok(!seenHeaders.Authorization, '本地模型不该发送 Authorization');
});
test('请求体包含模型名与 stream 标志,且 Key 放在头里', async () => {
const ai = setup({ model: 'deepseek-chat', apiKey: 'sk-abc' });
let seen = null;
h.setHandler((u, o) => { seen = { u, o }; return streamResponse(sseBody(['x'])); });
await ai.stream({ task: 'translate', text: 'hi' });
const body = JSON.parse(seen.o.body);
assert.strictEqual(body.model, 'deepseek-chat');
assert.strictEqual(body.stream, true);
assert.strictEqual(seen.o.headers.Authorization, 'Bearer sk-abc');
assert.ok(seen.u.endsWith('/chat/completions'), '端点拼接错误: ' + seen.u);
assert.ok(!seen.u.includes('sk-abc'), 'Key 不该出现在 URL 中');
});
test('启用图像输入后使用 OpenAI 兼容的 image_url 消息', async () => {
const ai = setup({ vision: true });
let body = null;
h.setHandler((_u, options) => {
body = JSON.parse(options.body);
return streamResponse(sseBody(['看到了']));
});
const full = await ai.stream({
task: 'ask',
text: '',
question: '图中是什么?',
visualContexts: [visualContext()]
});
assert.strictEqual(full, '看到了');
assert.ok(Array.isArray(body.messages[1].content));
assert.strictEqual(body.messages[1].content[0].type, 'text');
assert.strictEqual(body.messages[1].content[1].type, 'image_url');
assert.match(body.messages[1].content[1].image_url.url, /^data:image\/png;base64,/);
assert.deepStrictEqual(Object.keys(body.messages[1].content[1].image_url), ['url']);
});
test('Anthropic 接口使用原生 Messages 图像 source 和流式事件', async () => {
const ai = setup({ protocol: 'anthropic', vision: true });
let seen = null;
h.setHandler((url, options) => {
seen = { url, options, body: JSON.parse(options.body) };
return streamResponse([
`event: content_block_delta\ndata: ${JSON.stringify({ type: 'content_block_delta', delta: { type: 'text_delta', text: '识别' } })}\n\n`,
`event: content_block_delta\ndata: ${JSON.stringify({ type: 'content_block_delta', delta: { type: 'text_delta', text: '成功' } })}\n\n`,
`event: message_stop\ndata: ${JSON.stringify({ type: 'message_stop' })}\n\n`
].join(''), { pieces: 11 });
});
const full = await ai.stream({
task: 'ask',
text: '',
question: '图中是什么?',
visualContexts: [visualContext()]
});
assert.strictEqual(full, '识别成功');
assert.ok(seen.url.endsWith('/messages'), seen.url);
assert.strictEqual(seen.options.headers['x-api-key'], 'sk-1');
assert.strictEqual(seen.options.headers['anthropic-version'], '2023-06-01');
assert.ok(!seen.options.headers.Authorization);
assert.strictEqual(seen.body.system.includes('文档页面图像'), true);
assert.strictEqual(seen.body.messages.length, 1);
assert.strictEqual(seen.body.messages[0].content[0].type, 'text');
const image = seen.body.messages[0].content[1];
assert.strictEqual(image.type, 'image');
assert.deepStrictEqual(Object.keys(image.source), ['type', 'media_type', 'data']);
assert.strictEqual(image.source.type, 'base64');
assert.strictEqual(image.source.media_type, 'image/png');
assert.ok(image.source.data.length > 0);
});
test('OpenAI Responses 接口使用 input_image 和响应增量事件', async () => {
const ai = setup({ protocol: 'openai-responses', vision: true });
let seen = null;
h.setHandler((url, options) => {
seen = { url, options, body: JSON.parse(options.body) };
return streamResponse([
`event: response.output_text.delta\ndata: ${JSON.stringify({ type: 'response.output_text.delta', delta: '看见' })}\n\n`,
`event: response.output_text.delta\ndata: ${JSON.stringify({ type: 'response.output_text.delta', delta: '图片' })}\n\n`,
`event: response.completed\ndata: ${JSON.stringify({ type: 'response.completed', response: { status: 'completed' } })}\n\n`
].join(''), { pieces: 13 });
});
const full = await ai.stream({
task: 'ask',
text: '',
question: '图中是什么?',
visualContexts: [visualContext()]
});
assert.strictEqual(full, '看见图片');
assert.ok(seen.url.endsWith('/responses'), seen.url);
assert.strictEqual(seen.options.headers.Authorization, 'Bearer sk-1');
assert.strictEqual(seen.body.instructions.includes('文档页面图像'), true);
assert.strictEqual(seen.body.max_output_tokens, 1024);
assert.strictEqual(seen.body.store, false);
assert.strictEqual(seen.body.input[0].content[0].type, 'input_text');
const image = seen.body.input[0].content[1];
assert.strictEqual(image.type, 'input_image');
assert.match(image.image_url, /^data:image\/png;base64,/);
});
test('OpenAI Responses 失败事件不会被当作空回答', async () => {
const ai = setup({ protocol: 'openai-responses' });
h.setHandler(() => streamResponse(
`event: response.failed\ndata: ${JSON.stringify({
type: 'response.failed',
response: { error: { message: 'responses failed' } }
})}\n\n`
));
await assert.rejects(
() => ai.stream({ task: 'translate', text: 'x' }),
/responses failed/
);
});
test('协议端点追加在查询参数之前并保留参数', async () => {
const ai = setup({
protocol: 'openai-responses',
baseUrl: 'https://gateway.example.com/v1?api-version=2026-01-01'
});
let requestUrl = '';
h.setHandler((url) => {
requestUrl = url;
return streamResponse(
`data: ${JSON.stringify({ type: 'response.output_text.delta', delta: 'ok' })}\n\n`
+ `data: ${JSON.stringify({ type: 'response.completed' })}\n\n`
);
});
assert.strictEqual(await ai.stream({ task: 'translate', text: 'x' }), 'ok');
const url = new URL(requestUrl);
assert.strictEqual(url.pathname, '/v1/responses');
assert.strictEqual(url.searchParams.get('api-version'), '2026-01-01');
});
test('未显式启用图像能力时拒绝发送图片', async () => {
const ai = setup({ vision: false });
await assert.rejects(
() => ai.stream({
task: 'ask',
text: '',
question: '图中是什么?',
visualContexts: [visualContext()]
}),
/未启用图像输入/
);
});
test('图像上下文拒绝伪造尺寸、远程地址和多图输入', () => {
const ai = setup({ vision: true });
const badSize = visualContext();
badSize.image.width = 2;
assert.throws(() => ai.buildMessages('ask', '', 'q', [badSize]), /声明尺寸不匹配/);
assert.throws(
() => ai.buildMessages('ask', '', 'q', [{ kind: 'page', image: { url: 'https://example.com/a.png' } }]),
/JPEG 或 PNG/
);
assert.throws(
() => ai.buildMessages('ask', '', 'q', [visualContext(), visualContext()]),
/最多发送 1 张/
);
});
test('OCR 预留契约仅在识别完成且勾选后附加文字', () => {
const ai = setup({ vision: true });
const context = visualContext({
ocr: { status: 'ready', text: '校对后的 OCR 内容', include: true }
});
const messages = ai.buildMessages('ask', '', '这是什么?', [context]);
const textPart = messages[1].content.find((part) => part.type === 'text');
assert.match(textPart.text, /OCR 识别文字/);
assert.match(textPart.text, /校对后的 OCR 内容/);
});
test('OCR-only 契约不要求视觉模型且不会发送图像', async () => {
const ai = setup({ vision: false });
let body = null;
h.setHandler((_url, options) => {
body = JSON.parse(options.body);
return streamResponse(sseBody(['文字回答']));
});
const context = visualContext({
includeImage: false,
ocr: { status: 'ready', text: '仅发送 OCR', include: true }
});
assert.strictEqual(await ai.stream({
task: 'ask',
text: '',
question: '内容是什么?',
visualContexts: [context]
}), '文字回答');
assert.strictEqual(typeof body.messages[1].content, 'string');
assert.match(body.messages[1].content, /仅发送 OCR/);
assert.doesNotMatch(body.messages[1].content, /data:image/);
});
test('超长上下文被截断且保留首尾', () => {
const ai = setup();
const long = 'A'.repeat(5000) + 'MIDDLE' + 'B'.repeat(5000) + 'TAIL_MARK';
const clipped = ai.clipContext(long, 2000);
assert.ok(clipped.length < long.length);
assert.ok(clipped.startsWith('A'), '开头丢失');
assert.ok(clipped.includes('TAIL_MARK'), '结尾丢失了,结论性内容会被切掉');
assert.ok(clipped.includes('省略'), '未标注截断');
});
test('不支持的任务类型被拒绝', () => {
const ai = setup();
assert.throws(() => ai.buildMessages('hack', 'x'), /不支持的任务/);
});
test('ask 任务把问题与片段一起送出', () => {
const ai = setup();
const msgs = ai.buildMessages('ask', '文档内容', '这讲了什么');
assert.strictEqual(msgs.length, 2);
assert.ok(msgs[1].content.includes('文档内容'));
assert.ok(msgs[1].content.includes('这讲了什么'));
assert.ok(/编造|没有提到/.test(msgs[0].content), '缺少防幻觉约束');
});
test('取消请求时抛出 AbortError 而不是静默返回', async () => {
const ai = setup();
const ctl = new AbortController();
h.setHandler(() => { ctl.abort(); return streamResponse(sseBody(['x'])); });
await assert.rejects(
() => ai.stream({ task: 'translate', text: 'x', signal: ctl.signal }),
(e) => e.name === 'AbortError'
);
});