diff --git a/src/api/routes/vfs.js b/src/api/routes/vfs.js index b9d474d..881fc40 100644 --- a/src/api/routes/vfs.js +++ b/src/api/routes/vfs.js @@ -1,3 +1,8 @@ +import fs from 'fs'; +import path from 'path'; +import multer from 'multer'; +import { emit } from '../sse.js'; +import { prepareVfs, resolveVfsRoot, isReadOnlyDatabasePath } from '../../tools/vfs.js'; import * as db from '../../db/modelvfs.js'; import sharp from 'sharp'; import { getMimeType } from '../../tools/vfs.js'; @@ -355,7 +360,23 @@ async function renderThumbnail(buffer, { mime, filename }) { * @param {import('express').Application} app * @param {string} [basePath=''] */ + +export function isTextMime(mime, filePath = '') { + if (!mime) return false; + const lower = mime.toLowerCase(); + if (lower.startsWith('text/')) return true; + if (lower === 'application/json' || lower === 'application/javascript' || lower === 'application/xml') return true; + const ext = path.extname(filePath).toLowerCase(); + return ['.txt', '.text', '.md', '.csv', '.tsv', '.json', '.js', '.mjs', '.css', '.html', '.htm', '.xml', '.skill', '.sql', '.env', '.log', '.yaml', '.yml', '.ini'].includes(ext); +} + +const vfsUpload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 100 * 1024 * 1024 }, +}); + export function register(app, basePath = '') { + /** * @openapi * /vfs/{*path}: @@ -436,6 +457,199 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + + app.get(`${basePath}/vfs`, async (req, res) => { + const selected = requireSelectedCompany(req); + if (selected.error) { + return res.status(400).json({ error: selected.error }); + } + try { + const vfsRoot = await prepareVfs({ googleAuthId: req.user.id }); + function walk(dir, rel = '') { + if (!fs.existsSync(dir)) return []; + const entries = fs.readdirSync(dir, { withFileTypes: true }); + const list = []; + for (const e of entries) { + if (e.name.startsWith('.')) continue; + const relPath = rel ? `${rel}/${e.name}` : e.name; + const fullPath = path.join(dir, e.name); + if (e.isDirectory()) { + list.push(...walk(fullPath, relPath)); + } else if (e.isFile()) { + try { + const stat = fs.statSync(fullPath); + const mime = getMimeType(relPath); + const isText = isTextMime(mime, relPath); + const isImage = IMAGE_THUMBNAIL_MIME.has(mime.split(';')[0].trim().toLowerCase()); + const isPdf = mime === 'application/pdf' || relPath.toLowerCase().endsWith('.pdf'); + list.push({ + name: relPath, + size: stat.size, + mime, + updated_at: stat.mtime.toISOString(), + created_at: stat.birthtime ? stat.birthtime.toISOString() : stat.mtime.toISOString(), + isText, + isImage, + isPdf, + }); + } catch {} + } + } + return list; + } + const files = walk(vfsRoot).sort((a, b) => a.name.localeCompare(b.name)); + return res.json({ files }); + } catch (error) { + const status = vfsFileErrorStatus(error); + if (status === 500) console.error('List vfs files failed:', error); + return res.status(status).json({ error: error.message }); + } + }); + + app.post(`${basePath}/vfs/upload`, vfsUpload.array('files'), async (req, res) => { + const selected = requireSelectedCompany(req); + if (selected.error) { + return res.status(400).json({ error: selected.error }); + } + const files = req.files || (req.file ? [req.file] : []); + if (!files.length) { + return res.status(400).json({ error: 'No files uploaded' }); + } + + try { + const vfsRoot = await resolveVfsRoot(req.user.id); + const targetDir = typeof req.body?.targetDir === 'string' + ? req.body.targetDir.trim().replace(/^\/+|\/+$/g, '') + : ''; + const uploaded = []; + + for (const f of files) { + const origName = (f.originalname || 'upload').replace(/[\0\\]/g, '/'); + const relPath = targetDir ? `${targetDir}/${origName}` : origName; + const parsed = parseVfsRef(relPath); + if (parsed.error) continue; + if (isReadOnlyDatabasePath(parsed.name)) continue; + + const absPath = path.resolve(vfsRoot, parsed.name); + fs.mkdirSync(path.dirname(absPath), { recursive: true }); + fs.writeFileSync(absPath, f.buffer); + + const effectiveMime = f.mimetype && f.mimetype !== 'application/octet-stream' + ? f.mimetype + : getMimeType(parsed.name); + + await db.write(req.user.id, { + name: parsed.name, + mime: effectiveMime, + content: f.buffer, + }); + + uploaded.push({ + name: parsed.name, + size: f.buffer.length, + mime: effectiveMime, + }); + } + + emit('vfs', { companyId: selected.companyId }); + return res.json({ success: true, uploaded }); + } catch (error) { + const status = vfsFileErrorStatus(error); + if (status === 500) console.error('Upload vfs files failed:', error); + return res.status(status).json({ error: error.message }); + } + }); + + app.put(`${basePath}/vfs/*path`, async (req, res) => { + const selected = requireSelectedCompany(req); + if (selected.error) { + return res.status(400).json({ error: selected.error }); + } + const raw = Array.isArray(req.params.path) ? req.params.path.join('/') : req.params.path; + const parsed = parseVfsRef(raw); + if (parsed.error) { + return res.status(400).json({ error: parsed.error }); + } + if (isReadOnlyDatabasePath(parsed.name)) { + return res.status(403).json({ error: 'Cannot modify read-only database files' }); + } + + let buffer; + let mime = parsed.mime; + if (typeof req.body?.content === 'string') { + buffer = Buffer.from(req.body.content, 'utf8'); + if (req.body.mime) mime = req.body.mime; + } else if (Buffer.isBuffer(req.body)) { + buffer = req.body; + if (req.headers['content-type'] && req.headers['content-type'] !== 'application/octet-stream') { + mime = req.headers['content-type']; + } + } else if (typeof req.body === 'string') { + buffer = Buffer.from(req.body, 'utf8'); + } else { + return res.status(400).json({ error: 'No content provided' }); + } + + try { + const vfsRoot = await resolveVfsRoot(req.user.id); + const absPath = path.resolve(vfsRoot, parsed.name); + fs.mkdirSync(path.dirname(absPath), { recursive: true }); + fs.writeFileSync(absPath, buffer); + + await db.write(req.user.id, { + name: parsed.name, + mime, + content: buffer, + }); + + emit('vfs', { companyId: selected.companyId }); + return res.json({ + success: true, + file: { + name: parsed.name, + size: buffer.length, + mime, + updated_at: new Date().toISOString(), + }, + }); + } catch (error) { + const status = vfsFileErrorStatus(error); + if (status === 500) console.error('Write vfs file failed:', error); + return res.status(status).json({ error: error.message }); + } + }); + + app.delete(`${basePath}/vfs/*path`, async (req, res) => { + const selected = requireSelectedCompany(req); + if (selected.error) { + return res.status(400).json({ error: selected.error }); + } + const raw = Array.isArray(req.params.path) ? req.params.path.join('/') : req.params.path; + const parsed = parseVfsRef(raw); + if (parsed.error) { + return res.status(400).json({ error: parsed.error }); + } + if (isReadOnlyDatabasePath(parsed.name)) { + return res.status(403).json({ error: 'Cannot delete read-only database files' }); + } + + try { + const vfsRoot = await resolveVfsRoot(req.user.id); + const absPath = path.resolve(vfsRoot, parsed.name); + if (fs.existsSync(absPath)) { + try { fs.unlinkSync(absPath); } catch {} + } + await db.remove(req.user.id, parsed.name); + + emit('vfs', { companyId: selected.companyId }); + return res.json({ success: true, deleted: parsed.name }); + } catch (error) { + const status = vfsFileErrorStatus(error); + if (status === 500) console.error('Delete vfs file failed:', error); + return res.status(status).json({ error: error.message }); + } + }); + app.get(`${basePath}/vfs/*path`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { diff --git a/src/services/conversationManager.js b/src/services/conversationManager.js index 652b713..bf308e9 100644 --- a/src/services/conversationManager.js +++ b/src/services/conversationManager.js @@ -21,6 +21,7 @@ import { createPatchTool } from '../tools/patch.js'; import { createRmTool } from '../tools/rm.js'; import { createSearchImagesTool } from '../tools/search-images.js'; import { createAnalyzeImageTool } from '../tools/analyze-image.js'; +import { createEditImageTool } from '../tools/edit-image.js'; import { createWebSearchTool } from '../tools/web-search.js'; import { createUrlGetTool } from '../tools/url-get.js'; import { createPdfReadTool } from '../tools/pdf-read.js'; @@ -126,6 +127,7 @@ async function buildSystemPrompt(vfsRoot, googleAuthId) { '13. rm: Delete single VFS file (used to delete SKILL files or specific artifacts).', '14. apisuggestionsorissues: Report missing API endpoints, bugs, or issues for triage.', '15. search_articles: Search articles across name, brand, EAN, SKU, and supplier SKU. Returns window of full articles (default 3, max 20), offset, and total count.', + '16. edit_image: Generate or edit images using the configured company image model (modelslugimage). Supports attachments (low/high quality), size tiers (1K/2K/4K), and aspect ratios (1:1/4:3/3:4). Writes output to VFS.', '', 'VIRTUAL FILESYSTEM:', '- Persistent per-company VFS. Paths are root-relative forward-slash (e.g. "labels/sheet1.png").', @@ -869,6 +871,7 @@ export class ConversationManager { createRmTool({ googleAuthId }), createSearchImagesTool({ googleAuthId }), createAnalyzeImageTool({ googleAuthId, model }), + createEditImageTool({ googleAuthId, model }), createWebSearchTool({ googleAuthId }), createUrlGetTool({ googleAuthId }), createPdfReadTool({ googleAuthId }), diff --git a/src/tools/analyze-image.js b/src/tools/analyze-image.js index d206289..8a027f2 100644 --- a/src/tools/analyze-image.js +++ b/src/tools/analyze-image.js @@ -5,7 +5,7 @@ import jwt from 'jsonwebtoken'; import { defineTool, ToolError, OpenRouterProvider } from 'libllmorbiter'; import env from '../config/env.js'; import { prepareVfs } from './vfs.js'; -import { resolveOpenRouterKey, resolveImageModel } from './llm-key.js'; +import { resolveOpenRouterKey, resolveDefaultModel } from './llm-key.js'; /** * analyze_image — lets a vision model actually LOOK at images held in the VFS. @@ -365,10 +365,10 @@ export function createAnalyzeImageTool({ googleAuthId, model }) { : question || 'Describe this image, judge its visual quality, flag any watermark, text overlay or defect, and say whether it is usable as a product or category image.'; const apiKey = await resolveOpenRouterKey(googleAuthId); - const imageModel = (await resolveImageModel(googleAuthId)) || model; + const visionModel = (await resolveDefaultModel(googleAuthId)) || model; const provider = new OpenRouterProvider({ apiKey, apiUrl: env.openrouterApiUrl }); const response = await provider.chatCompletions({ - model: imageModel, + model: visionModel, messages: [ { role: 'user', diff --git a/src/tools/edit-image.js b/src/tools/edit-image.js new file mode 100644 index 0000000..67f6ea9 --- /dev/null +++ b/src/tools/edit-image.js @@ -0,0 +1,489 @@ +import fs from 'fs'; +import path from 'path'; +import sharp from 'sharp'; +import jwt from 'jsonwebtoken'; +import { defineTool, ToolError } from 'libllmorbiter'; +import env from '../config/env.js'; +import { prepareVfs, syncVfsToDb, extractFullHash, isReadOnlyDatabasePath } from './vfs.js'; +import { resolveOpenRouterKey, resolveImageModel } from './llm-key.js'; + +/** + * edit-image — generate or edit images using the modelslugimage company setting. + * + * Capabilities: + * - Text-to-image: generates an image from scratch when no attachments are passed. + * - Image-to-image / editing: transforms, restyles, or composes reference images + * supplied via `attachments`, fed in either low or high quality. + * + * Parameters: + * - prompt: text instructions for generation or editing. + * - 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. + */ + +const DEFAULT_SIZE = '1K'; +const DEFAULT_RATIO = '1:1'; +const IMAGE_FETCH_TIMEOUT_MS = 10_000; +const GENERATE_TIMEOUT_MS = 120_000; + +const VALID_SIZES = ['1K', '2K', '4K']; +const VALID_RATIOS = ['1:1', '4:3', '3:4']; + +/** + * Normalises size parameter into one of the 3 supported tiers ('1K', '2K', '4K'). + * @param {string} [val] + * @returns {'1K'|'2K'|'4K'} + */ +function normalizeSize(val) { + if (!val) return DEFAULT_SIZE; + const clean = String(val).trim().toUpperCase(); + if (clean === '1K' || clean === 'SMALL' || clean === '512') return '1K'; + if (clean === '2K' || clean === 'MEDIUM' || clean === '1.5K') return '2K'; + if (clean === '4K' || clean === 'LARGE') return '4K'; + return DEFAULT_SIZE; +} + +/** + * Normalises ratio parameter into one of the 3 supported aspect ratios ('1:1', '4:3', '3:4'). + * @param {string} [val] + * @returns {'1:1'|'4:3'|'3:4'} + */ +function normalizeRatio(val) { + if (!val) return DEFAULT_RATIO; + const clean = String(val).trim().toLowerCase(); + if (clean === '1:1' || clean === 'square') return '1:1'; + if (clean === '4:3' || clean === 'landscape' || clean === 'wide') return '4:3'; + if (clean === '3:4' || clean === 'portrait' || clean === 'vertical') return '3:4'; + return DEFAULT_RATIO; +} + +/** + * Normalises the attachments argument into a clean array of { vfsfilename: string, high: boolean }. + * Permissive on argument shapes (array of objects, strings, comma-separated string, JSON array). + * + * @param {any} raw + * @returns {Array<{ vfsfilename: string, high: boolean }>} + */ +function extractAttachments(raw) { + if (!raw) return []; + const items = []; + + const add = (entry) => { + if (!entry) return; + if (typeof entry === 'string') { + const trimmed = entry.trim(); + if (!trimmed) return; + + if (trimmed.startsWith('[') && trimmed.endsWith(']')) { + try { + const parsed = JSON.parse(trimmed); + if (Array.isArray(parsed)) { + parsed.forEach(add); + return; + } + } catch { /* treat as plain string */ } + } + + if (trimmed.includes(',')) { + trimmed.split(',').map((s) => s.trim()).filter(Boolean).forEach((p) => { + items.push({ vfsfilename: p, high: false }); + }); + return; + } + + items.push({ vfsfilename: trimmed, high: false }); + return; + } + + if (typeof entry === 'object') { + const file = String(entry.vfsfilename || entry.filename || entry.path || entry.file || '').trim(); + if (!file) return; + const high = Boolean(entry.high); + items.push({ vfsfilename: file, high }); + } + }; + + if (Array.isArray(raw)) { + raw.forEach(add); + } else { + add(raw); + } + + return items; +} + +/** + * Resolves the OpenRouter Images API URL from configured env.openrouterApiUrl or default. + * @returns {string} + */ +function resolveImagesApiUrl() { + if (env.openrouterApiUrl) { + try { + const u = new URL(env.openrouterApiUrl); + u.pathname = u.pathname.replace(/\/chat\/completions\/?$/, '/images'); + return u.toString(); + } catch { + return env.openrouterApiUrl.replace(/\/chat\/completions\/?$/, '/images'); + } + } + return 'https://openrouter.ai/api/v1/images'; +} + +/** + * Fetches a database image by its 64-char content hash using authenticated local endpoint. + * @param {string} hash + * @param {number} googleAuthId + * @returns {Promise} + */ +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()); +} + +/** + * Loads image bytes for an attachment path (VFS relative path, DB hash, or HTTP URL). + * @param {string} targetPath + * @param {string} vfsRoot + * @param {number} googleAuthId + * @returns {Promise} + */ +async function loadAttachmentBytes(targetPath, vfsRoot, googleAuthId) { + if (/^https?:\/\//i.test(targetPath)) { + const res = await fetch(targetPath, { signal: AbortSignal.timeout(IMAGE_FETCH_TIMEOUT_MS) }); + if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`); + return Buffer.from(await res.arrayBuffer()); + } + + const hash = extractFullHash(targetPath); + if (hash) { + const cached = path.join(vfsRoot, 'images', `${hash}.avif`); + if (fs.existsSync(cached)) { + return fs.readFileSync(cached); + } + return await fetchDatabaseImage(hash, googleAuthId); + } + + const clean = targetPath.replace(/^([a-zA-Z]:|\/)+/, '').replace(/^\.\/+/, ''); + const abs = path.resolve(vfsRoot, clean); + const rel = path.relative(vfsRoot, abs); + if (rel.startsWith('..') || path.isAbsolute(rel)) { + throw new ToolError(`Access denied: path "${targetPath}" escapes virtual filesystem boundary.`); + } + + if (fs.existsSync(abs) && fs.statSync(abs).isFile()) { + return fs.readFileSync(abs); + } + + throw new Error(`File not found at VFS path "${targetPath}".`); +} + +/** + * Prepares an attachment buffer for feeding into the model as low or high quality data URL. + * + * - High quality: up to 1536×1536 max edge, JPEG quality 90 (or PNG with low compression if alpha). + * - Low quality: up to 512×512 max edge, JPEG quality 70 (or PNG with high compression if alpha). + * + * @param {Buffer} rawBuffer + * @param {boolean} high + * @returns {Promise} data URL + */ +async function processAttachmentToDataUrl(rawBuffer, high) { + const maxEdge = high ? 1536 : 512; + const metadata = await sharp(rawBuffer).metadata(); + const hasAlpha = Boolean(metadata.hasAlpha); + + let mime = 'image/jpeg'; + let outputBuffer; + + if (hasAlpha) { + mime = 'image/png'; + outputBuffer = await sharp(rawBuffer) + .rotate() + .resize(maxEdge, maxEdge, { fit: 'inside', withoutEnlargement: true }) + .png({ compressionLevel: high ? 6 : 9 }) + .toBuffer(); + } else { + const quality = high ? 90 : 70; + outputBuffer = await sharp(rawBuffer) + .rotate() + .resize(maxEdge, maxEdge, { fit: 'inside', withoutEnlargement: true }) + .jpeg({ quality }) + .toBuffer(); + } + + return `data:${mime};base64,${outputBuffer.toString('base64')}`; +} + +/** + * Creates the edit_image tool. + * + * @param {{ googleAuthId: number, model?: string }} ctx + */ +export function createEditImageTool({ googleAuthId, model: fallbackModel }) { + return defineTool({ + name: 'edit_image', + 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.', + strict: false, + parameters: { + type: 'object', + additionalProperties: false, + required: ['prompt'], + properties: { + 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.', + items: { + type: 'object', + additionalProperties: false, + required: ['vfsfilename'], + properties: { + vfsfilename: { + type: 'string', + description: 'VFS path (e.g. "images/sample.png") or database path ("image/").', + }, + high: { + type: 'boolean', + description: 'Feed image in high quality/resolution if true; low quality/resolution if false (default).', + }, + }, + }, + }, + size: { + type: 'string', + enum: VALID_SIZES, + description: 'Resolution tier (3 choices): "1K" (standard, default), "2K" (high quality), "4K" (ultra high).', + }, + ratio: { + type: 'string', + enum: VALID_RATIOS, + description: 'Aspect ratio (3 choices): "1:1" (square, default), "4:3" (landscape), "3:4" (portrait).', + }, + vfsoutputfilename: { + type: 'string', + description: 'VFS path where the output image should be saved (e.g. "images/edited.png"). Defaults to "images/generated-.png".', + }, + }, + }, + invoke: async ({ prompt, attachments, size, ratio, vfsoutputfilename } = {}) => { + 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.'); + } + + const chosenSize = normalizeSize(size); + const chosenRatio = normalizeRatio(ratio); + const attachmentItems = extractAttachments(attachments); + + const vfsRoot = await prepareVfs({ googleAuthId }); + + // ── Prepare attachments (if any) ────────────────────────────── + const inputReferences = []; + const loadedAttachments = []; + + for (const att of attachmentItems) { + let buf; + try { + buf = await loadAttachmentBytes(att.vfsfilename, vfsRoot, googleAuthId); + } catch (err) { + throw new ToolError( + `edit_image: failed to load attachment "${att.vfsfilename}": ${err.message}. ` + + 'Check available files with find_files or search_images.' + ); + } + + if (!buf || buf.length === 0) { + throw new ToolError(`edit_image: attachment "${att.vfsfilename}" is empty.`); + } + + const dataUrl = await processAttachmentToDataUrl(buf, att.high); + inputReferences.push({ + type: 'image_url', + image_url: { url: dataUrl }, + }); + loadedAttachments.push({ + vfsfilename: att.vfsfilename, + high: att.high, + bytes: buf.length, + }); + } + + // ── Resolve image model & API key ───────────────────────────── + const apiKey = await resolveOpenRouterKey(googleAuthId); + if (!apiKey) { + throw new ToolError( + 'edit_image is unavailable: OpenRouter API key not configured. ' + + 'Please configure openrouterApiKey in companysettings or set OPENROUTER_FREE_KEY.' + ); + } + + const imageModel = (await resolveImageModel(googleAuthId)) || fallbackModel || 'meta/muse-image'; + const apiUrl = resolveImagesApiUrl(); + + const payload = { + model: imageModel, + prompt: cleanPrompt, + resolution: chosenSize, + aspect_ratio: chosenRatio, + }; + + if (inputReferences.length > 0) { + payload.input_references = inputReferences; + } + + // ── Call OpenRouter Images API ──────────────────────────────── + let response; + try { + response = await fetch(apiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + 'HTTP-Referer': 'https://quixpos.com', + 'X-Title': 'QuixPOS', + }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(GENERATE_TIMEOUT_MS), + }); + } catch (err) { + throw new ToolError(`edit_image: network request to OpenRouter failed: ${err.message}`); + } + + if (!response.ok) { + let detail = `${response.status} ${response.statusText}`; + try { + const errBody = await response.json(); + if (errBody?.error?.message) { + detail = errBody.error.message; + } else if (typeof errBody === 'object') { + detail = JSON.stringify(errBody); + } + } catch { + const txt = await response.text().catch(() => ''); + if (txt) detail += ` - ${txt.slice(0, 300)}`; + } + throw new ToolError(`edit_image: OpenRouter Image API error (${detail})`); + } + + const responseData = await response.json(); + const item = responseData?.data?.[0]; + if (!item) { + throw new ToolError('edit_image: OpenRouter returned no image data.'); + } + + let resultBuffer = null; + if (item.b64_json) { + resultBuffer = Buffer.from(item.b64_json, 'base64'); + } else if (item.url) { + try { + const imgRes = await fetch(item.url, { signal: AbortSignal.timeout(IMAGE_FETCH_TIMEOUT_MS) }); + if (!imgRes.ok) throw new Error(`HTTP ${imgRes.status}`); + resultBuffer = Buffer.from(await imgRes.arrayBuffer()); + } catch (err) { + throw new ToolError(`edit_image: failed to download generated image from URL: ${err.message}`); + } + } + + if (!resultBuffer || resultBuffer.length === 0) { + throw new ToolError('edit_image: received empty image buffer from model.'); + } + + // ── Determine target output path ────────────────────────────── + let targetRel = typeof vfsoutputfilename === 'string' && vfsoutputfilename.trim() + ? vfsoutputfilename.trim() + : ''; + + if (!targetRel) { + const prefix = inputReferences.length > 0 ? 'edited' : 'generated'; + targetRel = `images/${prefix}-${Date.now()}.png`; + } + + const cleanRel = targetRel.replace(/^([a-zA-Z]:|\/)+/, '').replace(/^\.\/+/, ''); + if (isReadOnlyDatabasePath(cleanRel)) { + throw new ToolError( + `Access denied: "${targetRel}" matches read-only database image storage ("image/"). ` + + 'Please specify a normal VFS path such as "images/output.png".' + ); + } + + const absTarget = path.resolve(vfsRoot, cleanRel); + const checkRel = path.relative(vfsRoot, absTarget); + if (checkRel.startsWith('..') || path.isAbsolute(checkRel)) { + 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}`; + } + + 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'; + } + } catch (convErr) { + console.warn('[edit_image] sharp format conversion warning:', convErr.message); + finalBuffer = resultBuffer; + } + + // ── Write to VFS disk and sync to database ──────────────────── + fs.mkdirSync(path.dirname(absTarget), { recursive: true }); + fs.writeFileSync(absTarget, finalBuffer); + + try { + await syncVfsToDb({ googleAuthId, vfsRoot }); + } catch (syncErr) { + console.warn('[edit_image] warning: failed to sync output image to modelvfs db:', syncErr.message); + } + + const outFinalRel = path.relative(vfsRoot, absTarget).replace(/\\/g, '/'); + + return { + outputFile: outFinalRel, + prompt: cleanPrompt, + model: imageModel, + size: chosenSize, + ratio: chosenRatio, + attachments: loadedAttachments.map((a) => a.vfsfilename), + bytes: finalBuffer.length, + mimeType, + cost: responseData?.usage?.cost, + usage: responseData?.usage, + markdown: `![${path.posix.basename(outFinalRel)}](${outFinalRel})`, + }; + }, + }); +}