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

+57 -7
View File
@@ -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);