feat: 支持对话与设置页单图编辑

This commit is contained in:
cneicy committed 2026-10-05 04:21:45 +08:00
1 parent 7e2ec28b80
commit 99e087112b
8 files changed
+361 -30

No files matched your search

+128 -1
View File
@@ -2,7 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import { once } from 'node:events';
import { validateBaseUrl, validateGeneration, listModels, generateImages } from '../lib/engine.mjs';
import { validateBaseUrl, validateGeneration, listModels, generateImages, validateEditImage, editImages } from '../lib/engine.mjs';
const apiKey = 'secret-do-not-echo';
const input = { model: 'explicit-third-party-model', prompt: 'a blue bird' };
@@ -31,6 +31,70 @@ async function requestJson(req) {
return JSON.parse(Buffer.concat(chunks).toString());
}
test('edit image validation enforces bytes, signatures, upload limit and owned copies', () => {
const padded = Buffer.concat([Buffer.from('prefix'), png, Buffer.from('suffix')]);
const view = new Uint8Array(padded.buffer, padded.byteOffset + 6, png.length);
const validated = validateEditImage(view);
assert.equal(validated.mediaType, 'image/png');
assert.ok(Buffer.isBuffer(validated.data));
assert.deepEqual(validated.data, png);
view.fill(0);
assert.deepEqual(validated.data, png);
for (const data of [undefined, null, 'image.png', [], new ArrayBuffer(8), new Uint16Array(8), Buffer.alloc(0), Buffer.from('GIF89a'), Buffer.from('<svg/>')]) {
assert.throws(() => validateEditImage(data), /PNG, JPEG, or WebP/);
}
const boundary = Buffer.alloc(20 * 1024 * 1024 + 1);
png.copy(boundary);
assert.throws(() => validateEditImage(boundary), /20 MiB/);
assert.equal(validateEditImage(boundary.subarray(0, -1)).data.length, 20 * 1024 * 1024);
});
test('edits upload real multipart with singular image, correct filename/type and validated fields', async t => {
const fixtures = [
[png, 'image/png', 'png'],
[Buffer.from([255, 216, 255, 224, 0, 0]), 'image/jpeg', 'jpg'],
[Buffer.from('RIFF\x04\0\0\0WEBP', 'latin1'), 'image/webp', 'webp'],
];
let index = 0;
const specified = { ...input, prompt: '把背景换成海边', n: 2, size: 'auto', quality: 'high', background: 'opaque', output_format: 'webp' };
const base = await server(t, async (req, res) => {
const [bytes, type, extension] = fixtures[index++];
assert.equal(req.method, 'POST');
assert.equal(req.url, '/custom/v1/images/edits');
assert.equal(req.headers.authorization, `Bearer ${apiKey}`);
assert.match(req.headers['content-type'], /^multipart\/form-data; boundary=/);
const request = new Request('http://localhost', { method: 'POST', headers: req.headers, body: req, duplex: 'half' });
const form = await request.formData();
const expected = index === 1 ? specified : { ...input, n: 1 };
assert.deepEqual([...form.keys()].sort(), [...Object.keys(expected), 'image'].sort());
for (const [key, value] of Object.entries(expected)) assert.equal(form.get(key), String(value));
assert.equal(form.getAll('image').length, 1);
const file = form.get('image');
assert.equal(file.name, `image.${extension}`);
assert.equal(file.type, type);
assert.deepEqual(Buffer.from(await file.arrayBuffer()), bytes);
json(res, { data: [{ b64_json: png.toString('base64'), revised_prompt: 'edited bird' }] });
});
for (const [i, [image]] of fixtures.entries()) {
const options = i === 0 ? specified : input;
const result = await editImages({ baseUrl: `${base}/custom/v1/`, apiKey, input: { ...options, ignored: true }, image });
assert.deepEqual(result, { model: options.model, prompt: options.prompt, images: [{ data: png, mediaType: 'image/png', revisedPrompt: 'edited bird' }] });
}
assert.equal(index, 3);
});
test('edits reject invalid images and options before any API request', async t => {
let count = 0;
const baseUrl = await server(t, (req, res) => { count++; json(res, { data: [] }); });
for (const image of [Buffer.alloc(0), Buffer.from('GIF89a'), Buffer.alloc(20 * 1024 * 1024 + 1)]) {
await assert.rejects(editImages({ baseUrl, apiKey, input, image }));
}
for (const change of [{ model: '' }, { prompt: '' }, { n: 5 }, { quality: 'invalid' }]) {
await assert.rejects(editImages({ baseUrl, apiKey, input: { ...input, ...change }, image: png }));
}
assert.equal(count, 0);
});
test('base URL normalization preserves API prefix and rejects unsafe parts', () => {
assert.equal(validateBaseUrl(' HTTPS://EXAMPLE.COM/v1/// '), 'https://example.com/v1');
for (const value of ['', null, '/v1', 'ftp://example.com', 'https://a:b@example.com', 'https://example.com?x=1', 'https://example.com#x', 'https://example.com?', 'https://example.com#']) {
@@ -207,6 +271,69 @@ test('180 second timeout aborts a stalled request', async t => {
assert.equal(configuredTimeout, 180_000);
});
test('edit response downloads omit auth and retain output validation and bounds', async t => {
const imageBase = await server(t, (req, res) => {
assert.equal(req.headers.authorization, undefined);
res.end(png);
});
let payload = { data: [{ url: `${imageBase}/image` }] };
let oversized = false;
const baseUrl = await server(t, (req, res) => {
if (oversized) { res.writeHead(200, { 'Content-Length': 64 * 1024 * 1024 + 1 }); res.flushHeaders(); }
else json(res, payload);
});
const edit = () => editImages({ baseUrl, apiKey, input, image: png });
assert.deepEqual((await edit()).images, [{ data: png, mediaType: 'image/png' }]);
payload = { data: [{ b64_json: '!!!!' }] };
await assert.rejects(edit(), /invalid base64/);
payload = { data: [] };
await assert.rejects(edit(), /malformed image response/);
oversized = true;
await assert.rejects(edit(), /64 MB response limit/);
});
test('edit API failure is sanitized and never retried', async t => {
let count = 0;
const baseUrl = await server(t, (req, res) => { count++; res.writeHead(503); res.end(`provider-private ${apiKey}`); });
await assert.rejects(editImages({ baseUrl, apiKey, input, image: png }), error => {
assert.equal(error.message, 'API request failed with HTTP 503.');
assert.equal(error.cause, undefined);
return true;
});
assert.equal(count, 1);
});
test('edits cancel pending API requests, downloads and pre-aborted requests', async t => {
let count = 0;
let controller = new AbortController();
let download = false;
const imageBase = await server(t, (req, res) => {
assert.equal(req.headers.authorization, undefined);
res.write(png.subarray(0, 4));
controller.abort(new Error(apiKey));
});
const baseUrl = await server(t, (req, res) => {
count++;
if (download) json(res, { data: [{ url: `${imageBase}/slow` }] });
else controller.abort(new Error(apiKey));
});
const edit = signal => editImages({ baseUrl, apiKey, input, image: png, signal });
await assert.rejects(edit(AbortSignal.abort(apiKey)), /^Error: Request aborted\.$/);
assert.equal(count, 0);
await assert.rejects(edit(controller.signal), /^Error: Request aborted\.$/);
controller = new AbortController();
download = true;
await assert.rejects(edit(controller.signal), /^Error: Request aborted\.$/);
assert.equal(count, 2);
});
test('edits share the 180 second operation timeout', async t => {
const baseUrl = await server(t, () => {});
const original = globalThis.setTimeout;
t.mock.method(globalThis, 'setTimeout', (callback, milliseconds, ...args) => original(callback, milliseconds === 180_000 ? 20 : milliseconds, ...args));
await assert.rejects(editImages({ baseUrl, apiKey, input, image: png }), /timed out after 180 seconds/);
});
test('pre-aborted signal does not send an API request', async t => {
let count = 0;
const baseUrl = await server(t, (req, res) => { count++; json(res, { data: [] }); });
+60 -9
View File
@@ -1,7 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, dirname, basename } from 'node:path';
@@ -27,12 +27,16 @@ test('native host integration using only a local image API', async t => {
const requests = [];
const server = createServer(async (request, response) => {
try {
let raw = '';
for await (const chunk of request) raw += chunk;
requests.push({ path: request.url, authorization: request.headers.authorization, body: raw ? JSON.parse(raw) : undefined });
const chunks = [];
for await (const chunk of request) chunks.push(chunk);
const raw = Buffer.concat(chunks);
const body = request.url === '/v1/images/edits'
? await new Response(raw, { headers: { 'Content-Type': request.headers['content-type'] } }).formData()
: raw.length ? JSON.parse(raw.toString()) : undefined;
requests.push({ path: request.url, authorization: request.headers.authorization, body });
response.setHeader('Content-Type', 'application/json');
if (request.url === '/v1/models') response.end(JSON.stringify({ data: [{ id: 'test-image-model' }] }));
else if (request.url === '/v1/images/generations') response.end(JSON.stringify({ data: [{ b64_json: PNG.toString('base64'), revised_prompt: 'Local test image' }] }));
else if (['/v1/images/generations', '/v1/images/edits'].includes(request.url)) response.end(JSON.stringify({ data: [{ b64_json: PNG.toString('base64'), revised_prompt: 'Local test image' }] }));
else { response.statusCode = 404; response.end('{}'); }
} catch { response.statusCode = 500; response.end('{}'); }
});
@@ -46,7 +50,7 @@ test('native host integration using only a local image API', async t => {
const ctx = {
on(event, listener) { listeners.set(event, listener); },
connection: { fetch: { register(route) {
assert.equal(route.requestBody, 'buffered');
assert.equal(route.requestBody, route.path === '/api/imagegen/edit' ? 'streaming' : 'buffered');
assert.ok(!routes.has(route.path));
routes.set(route.path, route);
return async () => routes.delete(route.path);
@@ -57,7 +61,7 @@ test('native host integration using only a local image API', async t => {
async modifyRecord(key, change) { validateCredentialName(key); const value = await change(records.get(key)); records.set(key, value); return value; },
async deleteRecord(key) { validateCredentialName(key); records.delete(key); },
},
attachments: { async saveImage(image) {
attachments: { async validateImage(image) { assert.deepEqual(Buffer.from(image.data), PNG); }, async saveImage(image) {
assert.deepEqual(Buffer.from(image.data), PNG);
assert.equal(image.mediaType, 'image/png');
const ref = { attachmentId: `test-attachment-${attachments.length}`, mediaType: image.mediaType, bytes: image.data.length, width: 1, height: 1, name: image.name };
@@ -80,8 +84,8 @@ test('native host integration using only a local image API', async t => {
await t.test('registers dependencies, routes, tools and skill', () => {
assert.deepEqual(host.inject, ['connection', 'tools', 'credentials', 'attachments', 'skills']);
assert.deepEqual([...routes.keys()].sort(), ['/api/imagegen/file', '/api/imagegen/generate', '/api/imagegen/models', '/api/imagegen/settings']);
assert.deepEqual([...tools.keys()].sort(), ['imagegen_generate', 'imagegen_status']);
assert.deepEqual([...routes.keys()].sort(), ['/api/imagegen/edit', '/api/imagegen/file', '/api/imagegen/generate', '/api/imagegen/models', '/api/imagegen/settings']);
assert.deepEqual([...tools.keys()].sort(), ['imagegen_edit', 'imagegen_generate', 'imagegen_status']);
assert.equal(skills.length, 1);
assert.equal(skills[0].name, 'dsh-imagegen');
assert.ok(skills[0].content.length > 0);
@@ -175,6 +179,53 @@ test('native host integration using only a local image API', async t => {
assert.deepEqual(await readFile(value.images[0].path), PNG);
assert.ok(!JSON.stringify(value).includes(key));
});
await t.test('edit tool uploads original bytes and saves separate output', async () => {
const inputPath = join(home, 'input.png');
await writeFile(inputPath, PNG);
const value = await tools.get('imagegen_edit').execute({ image_path: inputPath, model: 'test-image-model', prompt: 'Change background only' }, { signal: new AbortController().signal });
const request = requests.at(-1);
assert.equal(request.path, '/v1/images/edits');
assert.equal(request.authorization, `Bearer ${key}`);
assert.equal(request.body.get('model'), 'test-image-model');
assert.deepEqual(Buffer.from(await request.body.get('image').arrayBuffer()), PNG);
assert.deepEqual(await readFile(inputPath), PNG);
assert.notEqual(value.images[0].path, inputPath);
assert.deepEqual(await readFile(value.images[0].path), PNG);
assert.equal(tools.get('imagegen_edit').output.render({}, value)[1].type, 'image');
assert.ok(!JSON.stringify(value).includes(key));
const before = requests.length;
for (const image_path of ['relative.png', join(home, 'missing.png')]) {
await assert.rejects(tools.get('imagegen_edit').execute({ image_path, model: 'test-image-model', prompt: 'edit' }, { signal: new AbortController().signal }));
}
await assert.rejects(tools.get('imagegen_edit').execute({ image_path: inputPath, prompt: 'edit' }, { signal: new AbortController().signal }), /model/);
assert.equal(requests.length, before);
});
await t.test('multipart edit route accepts one file and rejects invalid uploads before API calls', async () => {
const send = form => routes.get('/api/imagegen/edit').fetch(new Request('http://localhost/api/imagegen/edit', { method: 'POST', body: form }));
const make = () => { const form = new FormData(); form.set('input', JSON.stringify({ model: 'test-image-model', prompt: 'Edit via browser' })); form.set('image', new Blob([PNG], { type: 'image/png' }), 'input.png'); return form; };
const result = await send(make());
assert.equal(result.status, 200);
assert.deepEqual(await readFile((await result.json()).images[0].path), PNG);
const before = requests.length;
const missing = make(); missing.delete('image');
const duplicate = make(); duplicate.append('image', new Blob([PNG]), 'second.png');
const invalid = make(); invalid.set('image', new Blob(['not an image']), 'fake.png');
const unsupported = make(); unsupported.set('mask', new Blob([PNG]), 'mask.png');
const invalidJson = make(); invalidJson.set('input', '{');
const noModel = make(); noModel.set('input', JSON.stringify({ prompt: 'edit' }));
for (const form of [missing, duplicate, invalid, unsupported, invalidJson, noModel]) assert.equal((await send(form)).status, 400);
assert.equal((await call('/api/imagegen/edit', 'POST', { image_path: join(home, 'input.png') })).status, 400);
const tooLarge = new Request('http://localhost/api/imagegen/edit', { method: 'POST', headers: { 'Content-Length': String(22 * 1024 * 1024) }, body: 'x' });
assert.equal((await routes.get('/api/imagegen/edit').fetch(tooLarge)).status, 400);
// Model bytes arriving from the browser transport; cancelling Node's own
// FormData encoder mid-Blob triggers an unrelated undici closed-stream bug.
const oversized = new Request('http://localhost/api/imagegen/edit', { method: 'POST', headers: { 'Content-Type': 'multipart/form-data; boundary=test' }, body: Buffer.alloc(22 * 1024 * 1024) });
assert.equal((await routes.get('/api/imagegen/edit').fetch(oversized)).status, 400, 'streamed body limit applies even without Content-Length');
const decode = ctx.attachments.validateImage;
ctx.attachments.validateImage = async () => { throw new Error('Image decode refused'); };
try { assert.equal((await send(make())).status, 400); } finally { ctx.attachments.validateImage = decode; }
assert.equal(requests.length, before);
});
await t.test('all digest prefixes are valid and clearing only removes the selected endpoint', async () => {
const originalRecords = new Map(records);
const prefixes = new Set();