214 lines
9.6 KiB
JavaScript
214 lines
9.6 KiB
JavaScript
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 });
|
|
}
|
|
})
|
|
);
|
|
}
|