feat: 支持对话与设置页单图编辑
This commit is contained in:
1 parent
7e2ec28b80
commit
99e087112b
8 files changed
+361
-30
No files matched your search
+57
-7
@@ -1,9 +1,11 @@
|
||||
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { Readable, addAbortSignal } from 'node:stream';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { join, dirname, isAbsolute } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { validateBaseUrl, validateGeneration, generateImages, listModels } from './engine.mjs';
|
||||
import { validateBaseUrl, validateGeneration, generateImages, editImages, validateEditImage, listModels } from './engine.mjs';
|
||||
|
||||
export const name = 'imagegen-local';
|
||||
export const inject = ['connection', 'tools', 'credentials', 'attachments', 'skills'];
|
||||
@@ -42,12 +44,14 @@ export async function apply(ctx) {
|
||||
config = next;
|
||||
return view();
|
||||
}
|
||||
async function generate(input, signal) {
|
||||
async function generate(input, signal, image) {
|
||||
const args = validateGeneration(input);
|
||||
const snapshot = { ...config };
|
||||
const apiKey = await readKey(snapshot.baseUrl);
|
||||
if (!apiKey) throw new Error('请先在 设置 → 生图 中为当前 Base URL 保存 API Key。');
|
||||
const result = await generateImages({ baseUrl: snapshot.baseUrl, apiKey, input: args, signal: signalFor(signal) });
|
||||
const options = { baseUrl: snapshot.baseUrl, apiKey, input: args, signal: signalFor(signal) };
|
||||
if (image !== undefined) await ctx.attachments.validateImage(validateEditImage(image));
|
||||
const result = image === undefined ? await generateImages(options) : await editImages({ ...options, image });
|
||||
await mkdir(outputs, { recursive: true });
|
||||
const images = [];
|
||||
for (const item of result.images) {
|
||||
@@ -61,8 +65,8 @@ export async function apply(ctx) {
|
||||
}
|
||||
return { model: args.model, prompt: args.prompt, images };
|
||||
}
|
||||
function route(path, methods, handler) {
|
||||
ctx.connection.fetch.register({ path, methods, requestBody: 'buffered', fetch: async request => {
|
||||
function route(path, methods, handler, requestBody = 'buffered') {
|
||||
ctx.connection.fetch.register({ path, methods, requestBody, fetch: async request => {
|
||||
try { return await handler(request); }
|
||||
catch (error) {
|
||||
// Never include arbitrary provider responses or request headers in a browser error.
|
||||
@@ -92,6 +96,22 @@ export async function apply(ctx) {
|
||||
return json({ models: await listModels({ baseUrl: snapshot.baseUrl, apiKey, signal: signalFor(request.signal) }) });
|
||||
});
|
||||
route('/api/imagegen/generate', ['POST'], async request => json(await generate(await body(request), request.signal)));
|
||||
route('/api/imagegen/edit', ['POST'], async request => {
|
||||
const limit = 21 * 1024 * 1024;
|
||||
if (Number(request.headers.get('content-length')) > limit) throw new Error('编辑上传内容过大(图片最大 20 MiB)。');
|
||||
if (!request.headers.get('content-type')?.startsWith('multipart/form-data;')) throw new Error('请使用图片上传表单。');
|
||||
const bytes = await boundedBytes(request.body, limit, signalFor(request.signal));
|
||||
let form;
|
||||
try { form = await new Response(bytes, { headers: { 'Content-Type': request.headers.get('content-type') } }).formData(); }
|
||||
catch { throw new Error('图片上传表单无效。'); }
|
||||
if ([...form.keys()].some(key => !['image', 'input'].includes(key)) || form.getAll('image').length !== 1 || form.getAll('input').length !== 1) throw new Error('请只上传一张图片和一组编辑参数。');
|
||||
const image = form.get('image'), raw = form.get('input');
|
||||
if (!image || typeof image.arrayBuffer !== 'function' || image.size > 20 * 1024 * 1024) throw new Error('请选择不超过 20 MiB 的图片。');
|
||||
if (typeof raw !== 'string' || raw.length > 100000) throw new Error('编辑参数无效。');
|
||||
let input;
|
||||
try { input = JSON.parse(raw); } catch { throw new Error('编辑参数必须为 JSON。'); }
|
||||
return json(await generate(input, request.signal, Buffer.from(await image.arrayBuffer())));
|
||||
}, 'streaming');
|
||||
route('/api/imagegen/file', ['GET'], async request => {
|
||||
const filename = new URL(request.url).searchParams.get('name') || '';
|
||||
if (!/^\d+-[0-9a-f-]{36}\.(png|jpg|webp|gif)$/.test(filename)) return json({ error: '图片不存在。' }, 404);
|
||||
@@ -108,7 +128,7 @@ export async function apply(ctx) {
|
||||
execute: () => view(),
|
||||
isConcurrencySafe: () => true,
|
||||
});
|
||||
ctx.tools.register({
|
||||
const generationTool = {
|
||||
name: 'imagegen_generate',
|
||||
description: 'Generate images using the configured Images API. Requires an exact model explicitly chosen by the user in chat or Settings → 生图 (query imagegen_status for their default). Never silently switch models. Returns saved original file paths and inline image attachments. API calls may incur charges. Text-to-image only; no reference-image editing. Reuse dsh-imagegen skill prompt guidance.',
|
||||
parameters: { type: 'object', properties: {
|
||||
@@ -124,10 +144,40 @@ export async function apply(ctx) {
|
||||
output: { schema, render: (_, value) => [text({ model: value.model, prompt: value.prompt, images: value.images.map(({ path, revisedPrompt }) => ({ path, revisedPrompt })) }), ...value.images.map(image => ({ type: 'image', attachment: image.attachment }))] },
|
||||
execute: (args, exec) => generate(args, exec.signal),
|
||||
presentCall: args => ({ card: 'generic', title: `生图 · ${args.model}`, kind: 'fetch', rawInput: args }),
|
||||
};
|
||||
ctx.tools.register(generationTool);
|
||||
ctx.tools.register({
|
||||
...generationTool,
|
||||
name: 'imagegen_edit',
|
||||
description: 'Edit one user-provided image using POST /images/edits. Requires an absolute local image_path (uploaded attachment host path or existing image), exact user-selected model and edit prompt. Query imagegen_status first. PNG/JPEG/WebP up to 20 MiB; no masks or multiple inputs. Sends image bytes to the configured provider and may incur charges. Preserve requested unchanged features; never substitute text-only generation or silently change models.',
|
||||
parameters: { ...generationTool.parameters, properties: { ...generationTool.parameters.properties, image_path: { type: 'string', description: 'Absolute host path of the user-authorized input image; use the attachment host path for a chat upload. Never guess a path.' } }, required: ['model', 'prompt', 'image_path'] },
|
||||
execute: async (args, exec) => {
|
||||
validateGeneration(args);
|
||||
if (typeof args.image_path !== 'string' || !isAbsolute(args.image_path)) throw new Error('image_path 必须是图片的本机绝对路径。');
|
||||
let image;
|
||||
try { image = await boundedBytes(createReadStream(args.image_path), 20 * 1024 * 1024, signalFor(exec.signal)); }
|
||||
catch { throw new Error('无法读取编辑图片,或图片超过 20 MiB,或读取已取消。'); }
|
||||
return generate(args, exec.signal, image);
|
||||
},
|
||||
presentCall: args => ({ card: 'generic', title: `编辑图片 · ${args.model}`, kind: 'fetch', rawInput: args }),
|
||||
});
|
||||
ctx.skills.register({ name: 'dsh-imagegen', description: '使用生图插件生成图片,复用 imagegen 提示词规范,支持自定义 API 与显式模型选择。', source: 'runtime', path: join(root, 'skills', 'SKILL.md'), resourceBase: { kind: 'directory', path: join(root, 'skills') }, content: await readFile(join(root, 'skills', 'SKILL.md'), 'utf8') });
|
||||
}
|
||||
|
||||
async function boundedBytes(source, limit, signal) {
|
||||
if (!source) throw new Error('图片内容为空。');
|
||||
const stream = typeof source.getReader === 'function' ? Readable.fromWeb(source) : source;
|
||||
if (signal) addAbortSignal(signal, stream);
|
||||
const chunks = [];
|
||||
let length = 0;
|
||||
for await (const chunk of stream) {
|
||||
length += chunk.length;
|
||||
if (length > limit) throw new Error('图片上传超过大小限制。');
|
||||
chunks.push(chunk);
|
||||
}
|
||||
return Buffer.concat(chunks, length);
|
||||
}
|
||||
|
||||
export function validateSettings(input) {
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('生图设置格式无效。');
|
||||
const baseUrl = validateBaseUrl(input.baseUrl);
|
||||
|
||||
Reference in new issue
Block a user