u
This commit is contained in:
@@ -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 * as db from '../../db/modelvfs.js';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
import { getMimeType } from '../../tools/vfs.js';
|
import { getMimeType } from '../../tools/vfs.js';
|
||||||
@@ -355,7 +360,23 @@ async function renderThumbnail(buffer, { mime, filename }) {
|
|||||||
* @param {import('express').Application} app
|
* @param {import('express').Application} app
|
||||||
* @param {string} [basePath='']
|
* @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 = '') {
|
export function register(app, basePath = '') {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
* /vfs/{*path}:
|
* /vfs/{*path}:
|
||||||
@@ -436,6 +457,199 @@ export function register(app, basePath = '') {
|
|||||||
* schema:
|
* schema:
|
||||||
* $ref: '#/components/schemas/Error'
|
* $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) => {
|
app.get(`${basePath}/vfs/*path`, async (req, res) => {
|
||||||
const selected = requireSelectedCompany(req);
|
const selected = requireSelectedCompany(req);
|
||||||
if (selected.error) {
|
if (selected.error) {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { createPatchTool } from '../tools/patch.js';
|
|||||||
import { createRmTool } from '../tools/rm.js';
|
import { createRmTool } from '../tools/rm.js';
|
||||||
import { createSearchImagesTool } from '../tools/search-images.js';
|
import { createSearchImagesTool } from '../tools/search-images.js';
|
||||||
import { createAnalyzeImageTool } from '../tools/analyze-image.js';
|
import { createAnalyzeImageTool } from '../tools/analyze-image.js';
|
||||||
|
import { createEditImageTool } from '../tools/edit-image.js';
|
||||||
import { createWebSearchTool } from '../tools/web-search.js';
|
import { createWebSearchTool } from '../tools/web-search.js';
|
||||||
import { createUrlGetTool } from '../tools/url-get.js';
|
import { createUrlGetTool } from '../tools/url-get.js';
|
||||||
import { createPdfReadTool } from '../tools/pdf-read.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).',
|
'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.',
|
'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.',
|
'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:',
|
'VIRTUAL FILESYSTEM:',
|
||||||
'- Persistent per-company VFS. Paths are root-relative forward-slash (e.g. "labels/sheet1.png").',
|
'- Persistent per-company VFS. Paths are root-relative forward-slash (e.g. "labels/sheet1.png").',
|
||||||
@@ -869,6 +871,7 @@ export class ConversationManager {
|
|||||||
createRmTool({ googleAuthId }),
|
createRmTool({ googleAuthId }),
|
||||||
createSearchImagesTool({ googleAuthId }),
|
createSearchImagesTool({ googleAuthId }),
|
||||||
createAnalyzeImageTool({ googleAuthId, model }),
|
createAnalyzeImageTool({ googleAuthId, model }),
|
||||||
|
createEditImageTool({ googleAuthId, model }),
|
||||||
createWebSearchTool({ googleAuthId }),
|
createWebSearchTool({ googleAuthId }),
|
||||||
createUrlGetTool({ googleAuthId }),
|
createUrlGetTool({ googleAuthId }),
|
||||||
createPdfReadTool({ googleAuthId }),
|
createPdfReadTool({ googleAuthId }),
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import jwt from 'jsonwebtoken';
|
|||||||
import { defineTool, ToolError, OpenRouterProvider } from 'libllmorbiter';
|
import { defineTool, ToolError, OpenRouterProvider } from 'libllmorbiter';
|
||||||
import env from '../config/env.js';
|
import env from '../config/env.js';
|
||||||
import { prepareVfs } from './vfs.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.
|
* 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.';
|
: 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 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 provider = new OpenRouterProvider({ apiKey, apiUrl: env.openrouterApiUrl });
|
||||||
const response = await provider.chatCompletions({
|
const response = await provider.chatCompletions({
|
||||||
model: imageModel,
|
model: visionModel,
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
role: 'user',
|
role: 'user',
|
||||||
|
|||||||
489
src/tools/edit-image.js
Normal file
489
src/tools/edit-image.js
Normal file
@@ -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<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());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<Buffer>}
|
||||||
|
*/
|
||||||
|
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<string>} 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/<hash>").',
|
||||||
|
},
|
||||||
|
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-<timestamp>.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/<hash>"). ` +
|
||||||
|
'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: ``,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user