u
This commit is contained in:
@@ -4,6 +4,7 @@ import sharp from 'sharp';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { defineTool, ToolError } from 'libllmorbiter';
|
||||
import env from '../config/env.js';
|
||||
import { getImageByHash } from '../db/pic.js';
|
||||
import { prepareVfs, syncVfsToDb, extractFullHash, isReadOnlyDatabasePath } from './vfs.js';
|
||||
import { resolveOpenRouterKey, resolveImageModel } from './llm-key.js';
|
||||
|
||||
@@ -20,11 +21,12 @@ import { resolveOpenRouterKey, resolveImageModel } from './llm-key.js';
|
||||
* - attachments: array of { vfsfilename, high: bool } reference images.
|
||||
* - size: resolution tier — 3 choices ('1K', '2K', '4K').
|
||||
* - ratio: aspect ratio — 3 choices ('1:1', '4:3', '3:4').
|
||||
* - vfsoutputfilename: VFS path where the resulting image is saved.
|
||||
* - vfsoutputfilename: VFS path where the resulting image is saved (always saved as AVIF).
|
||||
*/
|
||||
|
||||
const DEFAULT_SIZE = '1K';
|
||||
const DEFAULT_RATIO = '1:1';
|
||||
const AVIF_QUALITY = 80;
|
||||
const IMAGE_FETCH_TIMEOUT_MS = 10_000;
|
||||
const GENERATE_TIMEOUT_MS = 120_000;
|
||||
|
||||
@@ -132,20 +134,34 @@ function resolveImagesApiUrl() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a database image by its 64-char content hash using authenticated local endpoint.
|
||||
* Fetches a database image by its 64-char content hash using store or authenticated local endpoint.
|
||||
* @param {string} hash
|
||||
* @param {number} googleAuthId
|
||||
* @returns {Promise<Buffer>}
|
||||
*/
|
||||
async function fetchDatabaseImage(hash, googleAuthId) {
|
||||
const url = `http://127.0.0.1:${env.port}/image/${hash}`;
|
||||
const token = jwt.sign({ id: googleAuthId }, env.jwtSecret, { expiresIn: '5m' });
|
||||
const res = await fetch(url, {
|
||||
headers: { Cookie: `token=${token}` },
|
||||
signal: AbortSignal.timeout(IMAGE_FETCH_TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
return Buffer.from(await res.arrayBuffer());
|
||||
try {
|
||||
const image = await getImageByHash(googleAuthId, hash);
|
||||
if (image?.buffer) {
|
||||
return image.buffer;
|
||||
}
|
||||
} catch (dbErr) {
|
||||
try {
|
||||
const url = `http://127.0.0.1:${env.port}/api/image/${hash}`;
|
||||
const token = jwt.sign({ id: googleAuthId }, env.jwtSecret, { expiresIn: '5m' });
|
||||
const res = await fetch(url, {
|
||||
headers: { Cookie: `token=${token}` },
|
||||
signal: AbortSignal.timeout(IMAGE_FETCH_TIMEOUT_MS),
|
||||
});
|
||||
if (res.ok) {
|
||||
return Buffer.from(await res.arrayBuffer());
|
||||
}
|
||||
} catch {
|
||||
// fallback error ignored
|
||||
}
|
||||
throw dbErr;
|
||||
}
|
||||
throw new Error('Image not found');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -168,7 +184,12 @@ async function loadAttachmentBytes(targetPath, vfsRoot, googleAuthId) {
|
||||
if (fs.existsSync(cached)) {
|
||||
return fs.readFileSync(cached);
|
||||
}
|
||||
return await fetchDatabaseImage(hash, googleAuthId);
|
||||
const buf = await fetchDatabaseImage(hash, googleAuthId);
|
||||
try {
|
||||
fs.mkdirSync(path.join(vfsRoot, 'images'), { recursive: true });
|
||||
fs.writeFileSync(cached, buf);
|
||||
} catch { /* ignore cache write failure */ }
|
||||
return buf;
|
||||
}
|
||||
|
||||
const clean = targetPath.replace(/^([a-zA-Z]:|\/)+/, '').replace(/^\.\/+/, '');
|
||||
@@ -233,20 +254,24 @@ export function createEditImageTool({ googleAuthId, model: fallbackModel }) {
|
||||
description:
|
||||
'Generate or edit images using the configured company image model (modelslugimage). ' +
|
||||
'Supports reference image attachments fed in low or high quality, output size tier, ' +
|
||||
'aspect ratio, and saving directly to a specified VFS output path.',
|
||||
'aspect ratio, and saving directly to a specified VFS output path (always saved as AVIF).',
|
||||
strict: false,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['prompt'],
|
||||
properties: {
|
||||
note: {
|
||||
type: ['string', 'null'],
|
||||
description: 'A note explaining what this image generation or edit is doing (or null). This note will be displayed to the user while the image is being generated or edited.',
|
||||
},
|
||||
prompt: {
|
||||
type: 'string',
|
||||
description: 'Description of the image to generate, or edit/transformation instructions when attachments are provided.',
|
||||
},
|
||||
attachments: {
|
||||
type: 'array',
|
||||
description: 'Optional reference image(s) for editing/image-to-image. Omit for text-to-image generation.',
|
||||
type: ['array', 'null'],
|
||||
description: 'Optional reference image(s) for editing/image-to-image or null. Omit for text-to-image generation.',
|
||||
items: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -257,29 +282,29 @@ export function createEditImageTool({ googleAuthId, model: fallbackModel }) {
|
||||
description: 'VFS path (e.g. "images/sample.png") or database path ("image/<hash>").',
|
||||
},
|
||||
high: {
|
||||
type: 'boolean',
|
||||
description: 'Feed image in high quality/resolution if true; low quality/resolution if false (default).',
|
||||
type: ['boolean', 'null'],
|
||||
description: 'Feed image in high quality/resolution if true; low quality/resolution if false or null (default).',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
size: {
|
||||
type: 'string',
|
||||
enum: VALID_SIZES,
|
||||
description: 'Resolution tier (3 choices): "1K" (standard, default), "2K" (high quality), "4K" (ultra high).',
|
||||
type: ['string', 'null'],
|
||||
enum: [...VALID_SIZES, null],
|
||||
description: 'Resolution tier (3 choices): "1K" (standard, default), "2K" (high quality), "4K" (ultra high), or null.',
|
||||
},
|
||||
ratio: {
|
||||
type: 'string',
|
||||
enum: VALID_RATIOS,
|
||||
description: 'Aspect ratio (3 choices): "1:1" (square, default), "4:3" (landscape), "3:4" (portrait).',
|
||||
type: ['string', 'null'],
|
||||
enum: [...VALID_RATIOS, null],
|
||||
description: 'Aspect ratio (3 choices): "1:1" (square, default), "4:3" (landscape), "3:4" (portrait), or null.',
|
||||
},
|
||||
vfsoutputfilename: {
|
||||
type: 'string',
|
||||
description: 'VFS path where the output image should be saved (e.g. "images/edited.png"). Defaults to "images/generated-<timestamp>.png".',
|
||||
type: ['string', 'null'],
|
||||
description: 'VFS path where the output image should be saved (e.g. "images/edited.avif") or null. Defaults to "images/generated-<timestamp>.avif". Output is always converted to AVIF.',
|
||||
},
|
||||
},
|
||||
},
|
||||
invoke: async ({ prompt, attachments, size, ratio, vfsoutputfilename } = {}) => {
|
||||
invoke: async ({ prompt, attachments, size, ratio, vfsoutputfilename, note } = {}) => {
|
||||
const cleanPrompt = typeof prompt === 'string' ? prompt.trim() : '';
|
||||
if (!cleanPrompt) {
|
||||
throw new ToolError('edit_image requires a non-empty "prompt" describing the image or edit instructions.');
|
||||
@@ -402,21 +427,34 @@ export function createEditImageTool({ googleAuthId, model: fallbackModel }) {
|
||||
throw new ToolError('edit_image: received empty image buffer from model.');
|
||||
}
|
||||
|
||||
// ── Determine target output path ──────────────────────────────
|
||||
// ── Determine target output path (always .avif) ───────────────
|
||||
let targetRel = typeof vfsoutputfilename === 'string' && vfsoutputfilename.trim()
|
||||
? vfsoutputfilename.trim()
|
||||
: '';
|
||||
|
||||
if (!targetRel) {
|
||||
const prefix = inputReferences.length > 0 ? 'edited' : 'generated';
|
||||
targetRel = `images/${prefix}-${Date.now()}.png`;
|
||||
targetRel = `images/${prefix}-${Date.now()}.avif`;
|
||||
}
|
||||
|
||||
const cleanRel = targetRel.replace(/^([a-zA-Z]:|\/)+/, '').replace(/^\.\/+/, '');
|
||||
let cleanRel = targetRel.replace(/^([a-zA-Z]:|\/)+/, '').replace(/^\.\/+/, '');
|
||||
if (isReadOnlyDatabasePath(cleanRel)) {
|
||||
throw new ToolError(
|
||||
`Access denied: "${targetRel}" matches read-only database image storage ("image/<hash>"). ` +
|
||||
'Please specify a normal VFS path such as "images/output.png".'
|
||||
'Please specify a normal VFS path such as "images/output.avif".'
|
||||
);
|
||||
}
|
||||
|
||||
// Always enforce .avif extension
|
||||
const ext = path.extname(cleanRel);
|
||||
if (ext.toLowerCase() !== '.avif') {
|
||||
cleanRel = ext ? `${cleanRel.slice(0, -ext.length)}.avif` : `${cleanRel}.avif`;
|
||||
}
|
||||
|
||||
if (isReadOnlyDatabasePath(cleanRel)) {
|
||||
throw new ToolError(
|
||||
`Access denied: "${cleanRel}" matches read-only database image storage ("image/<hash>"). ` +
|
||||
'Please specify a normal VFS path such as "images/output.avif".'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -426,38 +464,17 @@ export function createEditImageTool({ googleAuthId, model: fallbackModel }) {
|
||||
throw new ToolError(`Access denied: path "${targetRel}" escapes virtual filesystem boundary.`);
|
||||
}
|
||||
|
||||
// ── Format conversion if requested by output extension ────────
|
||||
let ext = path.extname(cleanRel).toLowerCase();
|
||||
let finalBuffer = resultBuffer;
|
||||
let mimeType = item.media_type || 'image/png';
|
||||
|
||||
if (!ext) {
|
||||
// No extension supplied; infer from media_type or Sharp format
|
||||
const meta = await sharp(resultBuffer).metadata().catch(() => ({}));
|
||||
const format = meta.format || 'png';
|
||||
ext = `.${format}`;
|
||||
targetRel = `${cleanRel}${ext}`;
|
||||
mimeType = `image/${format}`;
|
||||
}
|
||||
|
||||
// ── Convert output to AVIF format ─────────────────────────────
|
||||
let finalBuffer;
|
||||
try {
|
||||
if (ext === '.avif') {
|
||||
finalBuffer = await sharp(resultBuffer).avif({ quality: 80 }).toBuffer();
|
||||
mimeType = 'image/avif';
|
||||
} else if (ext === '.webp') {
|
||||
finalBuffer = await sharp(resultBuffer).webp({ quality: 80 }).toBuffer();
|
||||
mimeType = 'image/webp';
|
||||
} else if (ext === '.jpg' || ext === '.jpeg') {
|
||||
finalBuffer = await sharp(resultBuffer).jpeg({ quality: 85 }).toBuffer();
|
||||
mimeType = 'image/jpeg';
|
||||
} else if (ext === '.png') {
|
||||
finalBuffer = await sharp(resultBuffer).png().toBuffer();
|
||||
mimeType = 'image/png';
|
||||
}
|
||||
finalBuffer = await sharp(resultBuffer)
|
||||
.rotate()
|
||||
.avif({ quality: AVIF_QUALITY })
|
||||
.toBuffer();
|
||||
} catch (convErr) {
|
||||
console.warn('[edit_image] sharp format conversion warning:', convErr.message);
|
||||
finalBuffer = resultBuffer;
|
||||
throw new ToolError(`edit_image: failed to encode output image to AVIF: ${convErr.message}`);
|
||||
}
|
||||
const mimeType = 'image/avif';
|
||||
|
||||
// ── Write to VFS disk and sync to database ────────────────────
|
||||
fs.mkdirSync(path.dirname(absTarget), { recursive: true });
|
||||
|
||||
Reference in New Issue
Block a user