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:
co-authored by
factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent
b8c8d24107
commit
3ccd044527
@@ -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'
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user