Initial commit
This commit is contained in:
213
mcp-server/tools/media/generateImage.js
Normal file
213
mcp-server/tools/media/generateImage.js
Normal file
@@ -0,0 +1,213 @@
|
||||
import { z } from "zod";
|
||||
import axios from "axios";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import sharp from "sharp";
|
||||
import { withAuth, getSessionCredentials } from "../../auth/index.js";
|
||||
import { handleToolError } from "../helpers/errorHandler.js";
|
||||
import { withAuthParams } from "../helpers/authSchema.js";
|
||||
|
||||
// --- Verificación de créditos y reporte de uso ---
|
||||
const WS_BASE = "https://ws.cocosolution.com/api/handler_acaicode.php";
|
||||
// Precios Gemini 2.5 Flash: input $0.15/1M tokens, output $0.60/1M tokens
|
||||
function calcCost(usageMetadata) {
|
||||
const input = usageMetadata?.promptTokenCount || 0;
|
||||
const output = usageMetadata?.candidatesTokenCount || 0;
|
||||
return Math.round(((input * 0.15 + output * 0.60) / 1_000_000) * 1e6) / 1e6;
|
||||
}
|
||||
|
||||
function getAcaiToken() {
|
||||
const projectDir = process.env.ACAI_PROJECT_DIR || "";
|
||||
if (!projectDir) return null;
|
||||
try {
|
||||
const acaiFile = path.join(projectDir, ".acai");
|
||||
const data = JSON.parse(fs.readFileSync(acaiFile, "utf-8"));
|
||||
return data.token || null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
async function checkCredits() {
|
||||
const token = getAcaiToken();
|
||||
if (!token) return false; // Si no hay token, no bloquear
|
||||
const testParam = process.env.STRIPE_MODE === "test" ? "&test" : "";
|
||||
try {
|
||||
const resp = await axios.put(`${WS_BASE}?action=getUsageLimits${testParam}`, {}, {
|
||||
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
||||
timeout: 10000,
|
||||
});
|
||||
return resp.data?.data?.exceeded === true;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
function reportImageUsage(usageMetadata, model) {
|
||||
const token = getAcaiToken();
|
||||
if (!token) return;
|
||||
const testParam = process.env.STRIPE_MODE === "test" ? "&test" : "";
|
||||
const cost = calcCost(usageMetadata);
|
||||
const payload = {
|
||||
action: "reportUsage",
|
||||
model: model || "gemini-2.5-flash-image",
|
||||
input_tokens: usageMetadata?.promptTokenCount || 0,
|
||||
output_tokens: usageMetadata?.candidatesTokenCount || 0,
|
||||
cache_read_tokens: 0,
|
||||
cache_creation_tokens: 0,
|
||||
cost_usd: cost,
|
||||
session_id: "",
|
||||
};
|
||||
// Fire and forget
|
||||
axios.put(`${WS_BASE}?action=reportUsage${testParam}`, payload, {
|
||||
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
||||
timeout: 10000,
|
||||
}).then(resp => {
|
||||
if (resp.data?.success) console.error(`[generate_image] Usage reported: ${model} cost=$${cost}`);
|
||||
else console.error(`[generate_image] Usage report failed:`, resp.data);
|
||||
}).catch(err => console.error(`[generate_image] Usage report error:`, err.message));
|
||||
}
|
||||
|
||||
export function registerGenerateImageTool(server) {
|
||||
server.tool(
|
||||
"generate_image",
|
||||
`Generate an AI image and save it to the project's uploads folder. Returns preview URLs plus the recommended upload URL for upload_record_image. In Forge environments, prefer uploadUrl (or fullUrl if uploadUrl is absent) over dockerUrl when assigning the image to a record field.`,
|
||||
withAuthParams({
|
||||
prompt: z.string().describe("Description of the image to generate"),
|
||||
width: z.number().optional().describe("Image width in pixels (default: 1024)"),
|
||||
height: z.number().optional().describe("Image height in pixels (default: 1024)"),
|
||||
style: z.string().optional().describe("Image style hint to add to prompt (e.g., 'photographic', 'digital-art', 'minimalist')"),
|
||||
fileName: z.string().optional().describe("Custom filename (without extension). If not provided, auto-generated."),
|
||||
}),
|
||||
{ readOnlyHint: false, destructiveHint: false },
|
||||
withAuth(async ({ prompt, width = 1024, height = 1024, style, fileName }, extra) => {
|
||||
try {
|
||||
const nanoBananaApiKey = process.env.NANO_BANANA_API_KEY;
|
||||
if (!nanoBananaApiKey) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: NANO_BANANA_API_KEY not set." }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const projectDir = process.env.ACAI_PROJECT_DIR || "";
|
||||
if (!projectDir) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: ACAI_PROJECT_DIR not set." }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Verificar créditos antes de generar
|
||||
const exceeded = await checkCredits();
|
||||
if (exceeded) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: No te quedan créditos. Mejora tu plan para seguir usando el asistente." }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Build prompt with style hint
|
||||
const fullPrompt = style ? `${prompt}. Style: ${style}` : prompt;
|
||||
|
||||
// Generate image via Google Gemini
|
||||
const geminiModel = process.env.NANO_BANANA_MODEL || "gemini-2.5-flash-image";
|
||||
const apiUrl = process.env.NANO_BANANA_URL ||
|
||||
`https://generativelanguage.googleapis.com/v1beta/models/${geminiModel}:generateContent`;
|
||||
|
||||
const generateResponse = await axios.post(
|
||||
apiUrl,
|
||||
{
|
||||
contents: [{ parts: [{ text: fullPrompt }] }],
|
||||
generationConfig: {
|
||||
responseModalities: ["TEXT", "IMAGE"],
|
||||
},
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"x-goog-api-key": nanoBananaApiKey,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout: 120000,
|
||||
validateStatus: (status) => status < 500,
|
||||
}
|
||||
);
|
||||
|
||||
// Extract image from response
|
||||
let imageBuffer = null;
|
||||
if (generateResponse.data.candidates?.[0]?.content?.parts) {
|
||||
for (const part of generateResponse.data.candidates[0].content.parts) {
|
||||
if (part.inlineData?.data) {
|
||||
imageBuffer = Buffer.from(part.inlineData.data, "base64");
|
||||
break;
|
||||
}
|
||||
if (part.text?.startsWith("data:image")) {
|
||||
const match = part.text.match(/data:image\/[^;]+;base64,(.+)/);
|
||||
if (match) {
|
||||
imageBuffer = Buffer.from(match[1], "base64");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!imageBuffer) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: `Error: Could not extract image from API response. Status: ${generateResponse.status}. Response: ${JSON.stringify(generateResponse.data).substring(0, 1000)}`
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Compress to JPEG
|
||||
const originalSize = imageBuffer.length;
|
||||
try {
|
||||
imageBuffer = await sharp(imageBuffer)
|
||||
.jpeg({ quality: 85 })
|
||||
.toBuffer();
|
||||
console.error(`[generate_image] Compressed: ${Math.round(originalSize / 1024)}KB → ${Math.round(imageBuffer.length / 1024)}KB`);
|
||||
} catch (e) {
|
||||
console.error(`[generate_image] Compression failed, using original:`, e.message);
|
||||
}
|
||||
|
||||
// Save to cms/uploads/generated/
|
||||
const uploadsDir = path.join(projectDir, "cms", "uploads", "generated");
|
||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
|
||||
const safeName = fileName
|
||||
? fileName.replace(/[^\w\-]/g, "_") + ".jpg"
|
||||
: `generated-${Date.now()}.jpg`;
|
||||
const filePath = path.join(uploadsDir, safeName);
|
||||
fs.writeFileSync(filePath, imageBuffer);
|
||||
|
||||
const relativePath = `cms/uploads/generated/${safeName}`;
|
||||
const dockerUrl = `http://localhost/${relativePath}`;
|
||||
|
||||
// Reportar uso (fire and forget)
|
||||
reportImageUsage(generateResponse.data.usageMetadata, geminiModel);
|
||||
|
||||
const credentials = await getSessionCredentials(extra.sessionId);
|
||||
const fullUrl = credentials.web_url ? `${credentials.web_url}/${relativePath}` : dockerUrl;
|
||||
const uploadUrl = credentials.web_url ? fullUrl : dockerUrl;
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
prompt: fullPrompt,
|
||||
fileName: safeName,
|
||||
filePath,
|
||||
relativePath,
|
||||
dockerUrl,
|
||||
fullUrl,
|
||||
uploadUrl,
|
||||
size: `${Math.round(imageBuffer.length / 1024)}KB`,
|
||||
note: `Image saved. To assign it with upload_record_image, use imageUrl="${uploadUrl}". dockerUrl is mainly for local preview/debugging.`,
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
} catch (error) {
|
||||
return handleToolError(error, "generate_image", { prompt });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
9
mcp-server/tools/media/index.js
Normal file
9
mcp-server/tools/media/index.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { registerUploadRecordImageTool } from './upload.js';
|
||||
import { registerUploadImageToAssetsTool } from './uploadImageToAssets.js';
|
||||
import { registerGenerateImageTool } from './generateImage.js';
|
||||
|
||||
export function registerMediaTools(server) {
|
||||
registerUploadRecordImageTool(server);
|
||||
registerUploadImageToAssetsTool(server);
|
||||
registerGenerateImageTool(server);
|
||||
}
|
||||
294
mcp-server/tools/media/upload.js
Normal file
294
mcp-server/tools/media/upload.js
Normal file
@@ -0,0 +1,294 @@
|
||||
import { z } from "zod";
|
||||
import { withAuth, getSessionCredentials } from "../../auth/index.js";
|
||||
import { handleToolError, validateRequired, handleApiResponse } from "../helpers/errorHandler.js";
|
||||
import { withAuthParams } from "../helpers/authSchema.js";
|
||||
import { AcaiHttpClient } from "../helpers/acaiHttpClient.js";
|
||||
|
||||
/**
|
||||
* Helper: POST to mcp_respond.php via viewer_functions.php
|
||||
*/
|
||||
async function mcpPost(target, actionWs, payload, token, tokenHash) {
|
||||
return AcaiHttpClient.postViewerAction(
|
||||
target,
|
||||
actionWs,
|
||||
payload,
|
||||
token,
|
||||
tokenHash,
|
||||
{},
|
||||
60000
|
||||
);
|
||||
}
|
||||
|
||||
export function registerUploadRecordImageTool(server) {
|
||||
server.tool(
|
||||
"upload_record_image",
|
||||
"Upload an image to a specific record field in Acai CMS. Downloads the image from a URL and uploads it. Table names are WITHOUT the 'cms_' prefix. The recordId is the 'num' primary key, never 'id'. If the URL came from generate_image, prefer uploadUrl (or fullUrl) over dockerUrl in Forge environments.",
|
||||
withAuthParams({
|
||||
tableName: z.string().describe("Table name without 'cms_' prefix (e.g., 'productos')"),
|
||||
recordId: z.string().describe("Record 'num' (primary key)"),
|
||||
fieldName: z.string().describe("Field name (e.g., 'galeria_imagenes')"),
|
||||
imageUrl: z.string().describe("URL of the image to upload"),
|
||||
alt: z.string().optional().describe("Alt text for the image (optional)"),
|
||||
}),
|
||||
{ readOnlyHint: false, destructiveHint: false },
|
||||
withAuth(async ({ tableName, recordId, fieldName, imageUrl, alt = "" }, extra) => {
|
||||
try {
|
||||
const validationError = validateRequired(
|
||||
{ tableName, recordId, fieldName, imageUrl },
|
||||
['tableName', 'recordId', 'fieldName', 'imageUrl'],
|
||||
'upload_record_image'
|
||||
);
|
||||
if (validationError) return validationError;
|
||||
|
||||
const credentials = await getSessionCredentials(extra.sessionId);
|
||||
|
||||
// Upload via mcp_respond.php uploadRecordImage (sends imageUrl, PHP downloads it)
|
||||
const response = await mcpPost(
|
||||
credentials,
|
||||
"uploadRecordImage",
|
||||
{ tableName, recordId, fieldName, imageUrl, alt },
|
||||
credentials.token,
|
||||
credentials.tokenHash
|
||||
);
|
||||
|
||||
const apiError = handleApiResponse(response.data, 'upload_record_image');
|
||||
if (apiError) return apiError;
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
message: "Image uploaded successfully",
|
||||
tableName,
|
||||
recordId,
|
||||
fieldName,
|
||||
...response.data
|
||||
}, null, 2)
|
||||
}],
|
||||
};
|
||||
} catch (error) {
|
||||
return handleToolError(error, 'upload_record_image', { tableName, recordId, fieldName });
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"list_record_uploads",
|
||||
"List all uploaded files in a specific upload field of a record. Table names are WITHOUT the 'cms_' prefix. The recordId is the 'num' primary key, never 'id'.",
|
||||
withAuthParams({
|
||||
tableName: z.string().describe("Table name without 'cms_' prefix (e.g., 'noticias')"),
|
||||
recordId: z.string().describe("Record 'num' (primary key)"),
|
||||
fieldName: z.string().describe("Upload field name (e.g., 'imagen_destacada')"),
|
||||
}),
|
||||
{ readOnlyHint: true, destructiveHint: false },
|
||||
withAuth(async ({ tableName, recordId, fieldName }, extra) => {
|
||||
try {
|
||||
const validationError = validateRequired(
|
||||
{ tableName, recordId, fieldName },
|
||||
['tableName', 'recordId', 'fieldName'],
|
||||
'list_record_uploads'
|
||||
);
|
||||
if (validationError) return validationError;
|
||||
|
||||
const credentials = await getSessionCredentials(extra.sessionId);
|
||||
|
||||
const response = await mcpPost(
|
||||
credentials,
|
||||
"listRecordUploads",
|
||||
{ tableName, recordId, fieldName },
|
||||
credentials.token,
|
||||
credentials.tokenHash
|
||||
);
|
||||
|
||||
const apiError = handleApiResponse(response.data, 'list_record_uploads');
|
||||
if (apiError) return apiError;
|
||||
|
||||
const uploads = (response.data.data || []).map(upload => ({
|
||||
uploadId: upload.num,
|
||||
filePath: upload.filePath,
|
||||
urlPath: upload.urlPath,
|
||||
fileName: (upload.filePath || "").split('/').pop(),
|
||||
altText: upload.info1 || upload.alt || "",
|
||||
width: upload.width,
|
||||
height: upload.height,
|
||||
filesize: upload.filesize,
|
||||
createdTime: upload.createdTime,
|
||||
order: upload.order
|
||||
}));
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
tableName,
|
||||
recordId,
|
||||
fieldName,
|
||||
uploadsCount: uploads.length,
|
||||
uploads,
|
||||
note: "Use uploadId (num field) to replace or delete a specific file"
|
||||
}, null, 2)
|
||||
}],
|
||||
};
|
||||
} catch (error) {
|
||||
return handleToolError(error, 'list_record_uploads', { tableName, recordId, fieldName });
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"replace_record_image",
|
||||
"Replace an existing image in an upload field. Downloads a new image from URL and replaces the specified upload. Use list_record_uploads to get the uploadId first. Table names are WITHOUT the 'cms_' prefix.",
|
||||
withAuthParams({
|
||||
tableName: z.string().describe("Table name without 'cms_' prefix"),
|
||||
recordId: z.string().describe("Record 'num' (primary key)"),
|
||||
fieldName: z.string().describe("Upload field name"),
|
||||
uploadId: z.string().describe("Upload ID to replace (get from list_record_uploads)"),
|
||||
imageUrl: z.string().describe("URL of the new image to upload"),
|
||||
alt: z.string().optional().describe("Alt text for the image (optional)"),
|
||||
}),
|
||||
{ readOnlyHint: false, destructiveHint: false },
|
||||
withAuth(async ({ tableName, recordId, fieldName, uploadId, imageUrl, alt = "" }, extra) => {
|
||||
try {
|
||||
const validationError = validateRequired(
|
||||
{ tableName, recordId, fieldName, uploadId, imageUrl },
|
||||
['tableName', 'recordId', 'fieldName', 'uploadId', 'imageUrl'],
|
||||
'replace_record_image'
|
||||
);
|
||||
if (validationError) return validationError;
|
||||
|
||||
const credentials = await getSessionCredentials(extra.sessionId);
|
||||
|
||||
// Step 1: Delete old upload
|
||||
await mcpPost(
|
||||
credentials,
|
||||
"deleteRecordUpload",
|
||||
{ uploadId },
|
||||
credentials.token,
|
||||
credentials.tokenHash
|
||||
);
|
||||
|
||||
// Step 2: Upload new image
|
||||
const response = await mcpPost(
|
||||
credentials,
|
||||
"uploadRecordImage",
|
||||
{ tableName, recordId, fieldName, imageUrl, alt },
|
||||
credentials.token,
|
||||
credentials.tokenHash
|
||||
);
|
||||
|
||||
const apiError = handleApiResponse(response.data, 'replace_record_image');
|
||||
if (apiError) return apiError;
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
message: "Image replaced successfully",
|
||||
tableName,
|
||||
recordId,
|
||||
fieldName,
|
||||
replacedUploadId: uploadId,
|
||||
...response.data
|
||||
}, null, 2)
|
||||
}],
|
||||
};
|
||||
} catch (error) {
|
||||
return handleToolError(error, 'replace_record_image', { tableName, recordId, fieldName, uploadId });
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"delete_record_upload",
|
||||
"Delete an uploaded file from a record's upload field. Use list_record_uploads to get the uploadId first. Table names are WITHOUT the 'cms_' prefix.",
|
||||
withAuthParams({
|
||||
uploadId: z.string().describe("Upload ID to delete (get from list_record_uploads)"),
|
||||
}),
|
||||
{ readOnlyHint: false, destructiveHint: true },
|
||||
withAuth(async ({ uploadId }, extra) => {
|
||||
try {
|
||||
const validationError = validateRequired(
|
||||
{ uploadId },
|
||||
['uploadId'],
|
||||
'delete_record_upload'
|
||||
);
|
||||
if (validationError) return validationError;
|
||||
|
||||
const credentials = await getSessionCredentials(extra.sessionId);
|
||||
|
||||
const response = await mcpPost(
|
||||
credentials,
|
||||
"deleteRecordUpload",
|
||||
{ uploadId },
|
||||
credentials.token,
|
||||
credentials.tokenHash
|
||||
);
|
||||
|
||||
const apiError = handleApiResponse(response.data, 'delete_record_upload');
|
||||
if (apiError) return apiError;
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
message: "Upload deleted successfully",
|
||||
uploadId,
|
||||
...response.data
|
||||
}, null, 2)
|
||||
}],
|
||||
};
|
||||
} catch (error) {
|
||||
return handleToolError(error, 'delete_record_upload', { uploadId });
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"reorder_record_uploads",
|
||||
"Reorder uploaded files in a record's upload field. Pass an array of upload IDs (num) in the desired order. Use list_record_uploads to get the current upload IDs first.",
|
||||
withAuthParams({
|
||||
uploadIds: z.array(z.union([z.string(), z.number()])).describe("Array of upload IDs (num field) in the desired display order"),
|
||||
}),
|
||||
{ readOnlyHint: false, destructiveHint: false },
|
||||
withAuth(async ({ uploadIds }, extra) => {
|
||||
try {
|
||||
const validationError = validateRequired(
|
||||
{ uploadIds },
|
||||
['uploadIds'],
|
||||
'reorder_record_uploads'
|
||||
);
|
||||
if (validationError) return validationError;
|
||||
|
||||
const credentials = await getSessionCredentials(extra.sessionId);
|
||||
|
||||
const response = await mcpPost(
|
||||
credentials,
|
||||
"reorderRecordUploads",
|
||||
{ uploadIds },
|
||||
credentials.token,
|
||||
credentials.tokenHash
|
||||
);
|
||||
|
||||
const apiError = handleApiResponse(response.data, 'reorder_record_uploads');
|
||||
if (apiError) return apiError;
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
message: "Uploads reordered successfully",
|
||||
...response.data
|
||||
}, null, 2)
|
||||
}],
|
||||
};
|
||||
} catch (error) {
|
||||
return handleToolError(error, 'reorder_record_uploads', { uploadIds });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
211
mcp-server/tools/media/uploadImageToAssets.js
Normal file
211
mcp-server/tools/media/uploadImageToAssets.js
Normal file
@@ -0,0 +1,211 @@
|
||||
import { z } from "zod";
|
||||
import axios from "axios";
|
||||
import sharp from "sharp";
|
||||
import { withAuth, getSessionCredentials } from "../../auth/index.js";
|
||||
import { handleToolError } from "../helpers/errorHandler.js";
|
||||
import { saveFileBuilder } from "../helpers/fileBuilder.js";
|
||||
import { withAuthParams } from "../helpers/authSchema.js";
|
||||
|
||||
/**
|
||||
* Upload an image to the website assets folder
|
||||
* Accepts base64, data URI, or URL
|
||||
* Optionally resizes/compresses the image
|
||||
*/
|
||||
export function registerUploadImageToAssetsTool(server) {
|
||||
server.tool(
|
||||
"upload_image_to_assets",
|
||||
"Upload an image to website assets (/images/). Accepts: base64, data URI, or URL. Optional resize (maxWidth/maxHeight) and compression (quality). Returns public URL.",
|
||||
withAuthParams({
|
||||
image: z.string().describe("Image data: base64 string, data URI, or URL to download from"),
|
||||
fileName: z.string().optional().describe("Custom filename (without extension). If not provided, auto-generated name will be used"),
|
||||
path: z.string().optional().default("/images/").describe("Path within assets folder (default: '/images/')"),
|
||||
|
||||
// Resize options
|
||||
maxWidth: z.number().optional().describe("Maximum width in pixels. Image will be resized proportionally if larger"),
|
||||
maxHeight: z.number().optional().describe("Maximum height in pixels. Image will be resized proportionally if larger"),
|
||||
quality: z.number().min(1).max(100).optional().default(85).describe("JPEG/WebP quality (1-100, default: 85)"),
|
||||
format: z.enum(["png", "jpg", "webp"]).optional().default("png").describe("Output format (default: png)"),
|
||||
}),
|
||||
{ readOnlyHint: false, destructiveHint: false },
|
||||
withAuth(async ({
|
||||
image,
|
||||
fileName,
|
||||
path: assetsPath = "/images/",
|
||||
maxWidth,
|
||||
maxHeight,
|
||||
quality = 85,
|
||||
format = "png"
|
||||
}, extra) => {
|
||||
try {
|
||||
let imageBuffer;
|
||||
|
||||
// Step 1: Get image buffer from various sources
|
||||
if (image.startsWith('data:')) {
|
||||
// Data URI format: data:image/png;base64,xxxxx
|
||||
const match = image.match(/data:image\/[^;]+;base64,(.+)/);
|
||||
if (match) {
|
||||
imageBuffer = Buffer.from(match[1], 'base64');
|
||||
} else {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: "Error: Invalid data URI format. Expected: data:image/xxx;base64,..."
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
} else if (image.startsWith('http://') || image.startsWith('https://')) {
|
||||
// URL - download the image
|
||||
try {
|
||||
const response = await axios.get(image, {
|
||||
responseType: 'arraybuffer',
|
||||
timeout: 30000,
|
||||
maxContentLength: 50 * 1024 * 1024 // 50MB max
|
||||
});
|
||||
imageBuffer = Buffer.from(response.data, 'binary');
|
||||
} catch (downloadError) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: `Error downloading image from URL: ${downloadError.message}`
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// Assume it's raw base64
|
||||
try {
|
||||
imageBuffer = Buffer.from(image, 'base64');
|
||||
} catch (base64Error) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: "Error: Could not parse image data. Provide base64, data URI, or URL"
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Validate we have a buffer
|
||||
if (!imageBuffer || imageBuffer.length === 0) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: "Error: No valid image data received"
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Step 2: Process image with sharp (resize/compress)
|
||||
let sharpInstance = sharp(imageBuffer);
|
||||
|
||||
// Get original metadata
|
||||
const metadata = await sharpInstance.metadata();
|
||||
const originalSize = imageBuffer.length;
|
||||
|
||||
// Resize if dimensions specified
|
||||
if (maxWidth || maxHeight) {
|
||||
sharpInstance = sharpInstance.resize({
|
||||
width: maxWidth,
|
||||
height: maxHeight,
|
||||
fit: 'inside', // Maintain aspect ratio
|
||||
withoutEnlargement: true // Don't upscale
|
||||
});
|
||||
}
|
||||
|
||||
// Convert to target format with quality
|
||||
let outputBuffer;
|
||||
let mimeType;
|
||||
let extension;
|
||||
|
||||
switch (format) {
|
||||
case 'jpg':
|
||||
outputBuffer = await sharpInstance.jpeg({ quality }).toBuffer();
|
||||
mimeType = 'image/jpeg';
|
||||
extension = 'jpg';
|
||||
break;
|
||||
case 'webp':
|
||||
outputBuffer = await sharpInstance.webp({ quality }).toBuffer();
|
||||
mimeType = 'image/webp';
|
||||
extension = 'webp';
|
||||
break;
|
||||
case 'png':
|
||||
default:
|
||||
outputBuffer = await sharpInstance.png({
|
||||
compressionLevel: Math.floor((100 - quality) / 11) // 0-9 compression
|
||||
}).toBuffer();
|
||||
mimeType = 'image/png';
|
||||
extension = 'png';
|
||||
break;
|
||||
}
|
||||
|
||||
// Step 3: Upload to assets
|
||||
const base64Image = outputBuffer.toString('base64');
|
||||
|
||||
// Generate filename if not provided
|
||||
const finalFileName = fileName
|
||||
? fileName.replace(/\.(jpg|jpeg|png|webp|gif)$/i, '') + '.' + extension
|
||||
: `uploaded-${Date.now()}.${extension}`;
|
||||
|
||||
// Get credentials
|
||||
const credentials = await getSessionCredentials(extra.sessionId);
|
||||
|
||||
// Upload using saveFileBuilder
|
||||
const uploadResult = await saveFileBuilder({
|
||||
web_url: credentials.web_url,
|
||||
token: credentials.token,
|
||||
tokenHash: credentials.tokenHash,
|
||||
path: assetsPath,
|
||||
fileName: finalFileName,
|
||||
content: base64Image,
|
||||
rawDataSended: false
|
||||
});
|
||||
|
||||
if (uploadResult && uploadResult.success) {
|
||||
// Build the public URL for the uploaded image
|
||||
const imageUrl = `${credentials.web_url}/template/estandar/images/${finalFileName}`;
|
||||
|
||||
// Get final metadata
|
||||
const finalMetadata = await sharp(outputBuffer).metadata();
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
imageUrl: imageUrl,
|
||||
fileName: finalFileName,
|
||||
path: assetsPath,
|
||||
format: format,
|
||||
originalSize: originalSize,
|
||||
finalSize: outputBuffer.length,
|
||||
compressionRatio: ((1 - outputBuffer.length / originalSize) * 100).toFixed(1) + '%',
|
||||
dimensions: {
|
||||
original: { width: metadata.width, height: metadata.height },
|
||||
final: { width: finalMetadata.width, height: finalMetadata.height }
|
||||
},
|
||||
message: `Image uploaded successfully. Use this URL: ${imageUrl}`
|
||||
}, null, 2)
|
||||
}],
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: uploadResult?.message || "Unknown error uploading to assets"
|
||||
}, null, 2)
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
return handleToolError(error, 'upload_image_to_assets', { fileName, path: assetsPath });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user