feat: 支持对话与设置页单图编辑
This commit is contained in:
1 parent
7e2ec28b80
commit
99e087112b
8 files changed
+361
-30
No files matched your search
+60
-9
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user