imagees del agente generadas y subidas con proxy server
This commit is contained in:
21
mcp-server/tools/helpers/pythonServerClient.js
Normal file
21
mcp-server/tools/helpers/pythonServerClient.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
const PYTHON_BASE = `http://app:${process.env.ACAI_PORT || 9091}`;
|
||||||
|
|
||||||
|
export async function pythonPost(path, data, timeout = 120000) {
|
||||||
|
const authHeader = process.env.ACAI_AUTH_HEADER || "";
|
||||||
|
const mode = process.env.ACAI_MODE_OVERRIDE || process.env.ACAI_MODE || "";
|
||||||
|
const role = process.env.ACAI_ROLE_OVERRIDE || "";
|
||||||
|
const response = await axios.post(`${PYTHON_BASE}${path}`, data, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(authHeader ? { "Authorization": authHeader } : {}),
|
||||||
|
...(mode ? { "X-Acai-Mode": mode } : {}),
|
||||||
|
...(role ? { "X-Acai-Role": role } : {}),
|
||||||
|
},
|
||||||
|
timeout,
|
||||||
|
maxBodyLength: Infinity,
|
||||||
|
maxContentLength: Infinity,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
@@ -2,19 +2,13 @@ import { z } from "zod";
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import sharp from "sharp";
|
import { withAuth } from "../../auth/index.js";
|
||||||
import { withAuth, getSessionCredentials } from "../../auth/index.js";
|
|
||||||
import { handleToolError } from "../helpers/errorHandler.js";
|
import { handleToolError } from "../helpers/errorHandler.js";
|
||||||
import { withAuthParams } from "../helpers/authSchema.js";
|
import { withAuthParams } from "../helpers/authSchema.js";
|
||||||
|
import { pythonPost } from "../helpers/pythonServerClient.js";
|
||||||
|
|
||||||
// --- Verificación de créditos y reporte de uso ---
|
// --- Verificacion de creditos ---
|
||||||
const WS_BASE = "https://ws.cocosolution.com/api/handler_acaicode.php";
|
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() {
|
function getAcaiToken() {
|
||||||
const projectDir = process.env.ACAI_PROJECT_DIR || "";
|
const projectDir = process.env.ACAI_PROJECT_DIR || "";
|
||||||
@@ -39,31 +33,6 @@ async function checkCredits() {
|
|||||||
} catch { return false; }
|
} 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) {
|
export function registerGenerateImageTool(server) {
|
||||||
server.tool(
|
server.tool(
|
||||||
"generate_image",
|
"generate_image",
|
||||||
@@ -78,140 +47,46 @@ export function registerGenerateImageTool(server) {
|
|||||||
{ readOnlyHint: false, destructiveHint: false },
|
{ readOnlyHint: false, destructiveHint: false },
|
||||||
withAuth(async ({ prompt, width = 1024, height = 1024, style, fileName }, extra) => {
|
withAuth(async ({ prompt, width = 1024, height = 1024, style, fileName }, extra) => {
|
||||||
try {
|
try {
|
||||||
const nanoBananaApiKey = process.env.NANO_BANANA_API_KEY;
|
// Verificar creditos antes de generar
|
||||||
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();
|
const exceeded = await checkCredits();
|
||||||
if (exceeded) {
|
if (exceeded) {
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: "Error: No te quedan créditos. Mejora tu plan para seguir usando el asistente." }],
|
content: [{ type: "text", text: "Error: No te quedan creditos. Mejora tu plan para seguir usando el asistente." }],
|
||||||
isError: true,
|
isError: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build prompt with style hint
|
const projectSlug = path.basename(process.env.ACAI_PROJECT_DIR || "");
|
||||||
const fullPrompt = style ? `${prompt}. Style: ${style}` : prompt;
|
const safeFileName = fileName || `generated-${Date.now()}`;
|
||||||
|
const destRelativePath = `cms/uploads/generated/${safeFileName}.jpg`;
|
||||||
|
const fullPrompt = style ? `${style} style: ${prompt}` : prompt;
|
||||||
|
|
||||||
// Generate image via Google Gemini
|
let result;
|
||||||
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 {
|
try {
|
||||||
imageBuffer = await sharp(imageBuffer)
|
result = await pythonPost("/api/generate-image", {
|
||||||
.jpeg({ quality: 85 })
|
project: projectSlug,
|
||||||
.toBuffer();
|
prompt: fullPrompt,
|
||||||
console.error(`[generate_image] Compressed: ${Math.round(originalSize / 1024)}KB → ${Math.round(imageBuffer.length / 1024)}KB`);
|
destRelativePath,
|
||||||
} catch (e) {
|
}, 180000); // 3min timeout para generacion IA
|
||||||
console.error(`[generate_image] Compression failed, using original:`, e.message);
|
} catch (pyErr) {
|
||||||
|
return handleToolError(new Error(`Python generate-image failed: ${pyErr.response?.data?.error || pyErr.message}`), 'generate_image', { prompt });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save to cms/uploads/generated/
|
if (!result?.success) {
|
||||||
const uploadsDir = path.join(projectDir, "cms", "uploads", "generated");
|
return { content: [{ type: "text", text: JSON.stringify({ error: result?.error || "Generation failed" }, null, 2) }], isError: true };
|
||||||
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;
|
|
||||||
|
|
||||||
// En modo produccion el archivo solo existe en el filesystem local del
|
|
||||||
// container agentic, no en el servidor real. Devolvemos el path absoluto
|
|
||||||
// como uploadUrl para que upload_record_image lo lea del disco (via base64).
|
|
||||||
// En local/forge el container web puede servirlo desde web:80, asi que
|
|
||||||
// seguimos devolviendo la URL HTTP.
|
|
||||||
const uploadUrl = credentials.mode === "production"
|
|
||||||
? filePath // absolute filesystem path
|
|
||||||
: (credentials.web_url ? fullUrl : dockerUrl);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [{
|
content: [{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: JSON.stringify({
|
text: JSON.stringify({
|
||||||
success: true,
|
success: true,
|
||||||
prompt: fullPrompt,
|
message: `Image generated and saved to ${result.relativePath}`,
|
||||||
fileName: safeName,
|
uploadUrl: result.fullUrl || result.dockerUrl,
|
||||||
filePath,
|
fullUrl: result.fullUrl || result.dockerUrl,
|
||||||
relativePath,
|
relativePath: result.relativePath,
|
||||||
dockerUrl,
|
fileName: result.fileName,
|
||||||
fullUrl,
|
size: result.size,
|
||||||
uploadUrl,
|
|
||||||
size: `${Math.round(imageBuffer.length / 1024)}KB`,
|
|
||||||
note: credentials.mode === "production"
|
|
||||||
? `Image saved locally. Use imageUrl="${uploadUrl}" with upload_record_image — it is a filesystem path that will be read and uploaded as base64 to production.`
|
|
||||||
: `Image saved. To assign it with upload_record_image, use imageUrl="${uploadUrl}". dockerUrl is mainly for local preview/debugging.`,
|
|
||||||
}, null, 2),
|
}, null, 2),
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { withAuth, getSessionCredentials } from "../../auth/index.js";
|
|||||||
import { handleToolError, validateRequired, handleApiResponse } from "../helpers/errorHandler.js";
|
import { handleToolError, validateRequired, handleApiResponse } from "../helpers/errorHandler.js";
|
||||||
import { withAuthParams } from "../helpers/authSchema.js";
|
import { withAuthParams } from "../helpers/authSchema.js";
|
||||||
import { AcaiHttpClient } from "../helpers/acaiHttpClient.js";
|
import { AcaiHttpClient } from "../helpers/acaiHttpClient.js";
|
||||||
|
import { pythonPost } from "../helpers/pythonServerClient.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper: POST to mcp_respond.php via viewer_functions.php
|
* Helper: POST to mcp_respond.php via viewer_functions.php
|
||||||
@@ -117,36 +118,44 @@ export function registerUploadRecordImageTool(server) {
|
|||||||
);
|
);
|
||||||
if (validationError) return validationError;
|
if (validationError) return validationError;
|
||||||
|
|
||||||
const credentials = await getSessionCredentials(extra.sessionId);
|
const projectSlug = path.basename(process.env.ACAI_PROJECT_DIR || "");
|
||||||
|
|
||||||
// Si la URL es local, descargar y enviar base64 (el servidor remoto no
|
// Intentar via Python server (tiene sync + optimizacion)
|
||||||
// puede descargarla en modo produccion).
|
let result;
|
||||||
|
try {
|
||||||
|
result = await pythonPost("/api/cms/upload-to-field", {
|
||||||
|
project: projectSlug,
|
||||||
|
table: tableName,
|
||||||
|
num: recordId,
|
||||||
|
field: fieldName,
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
});
|
||||||
|
} catch (pyErr) {
|
||||||
|
// Fallback: si Python no es accesible, usar el flujo directo al PHP
|
||||||
|
console.error(`[upload_record_image] Python server failed (${pyErr.message}), falling back to PHP direct`);
|
||||||
|
const credentials = await getSessionCredentials(extra.sessionId);
|
||||||
const localFile = await resolveLocalImageAsBase64(imageUrl);
|
const localFile = await resolveLocalImageAsBase64(imageUrl);
|
||||||
const uploadPayload = localFile
|
const uploadPayload = localFile
|
||||||
? { tableName, recordId, fieldName, alt, fileBase64: localFile.fileBase64, fileName: localFile.fileName }
|
? { tableName, recordId, fieldName, alt, fileBase64: localFile.fileBase64, fileName: localFile.fileName }
|
||||||
: { tableName, recordId, fieldName, imageUrl, alt };
|
: { tableName, recordId, fieldName, imageUrl, alt };
|
||||||
|
const response = await mcpPost(credentials, "uploadRecordImage", uploadPayload, credentials.token, credentials.tokenHash);
|
||||||
const response = await mcpPost(
|
|
||||||
credentials,
|
|
||||||
"uploadRecordImage",
|
|
||||||
uploadPayload,
|
|
||||||
credentials.token,
|
|
||||||
credentials.tokenHash
|
|
||||||
);
|
|
||||||
|
|
||||||
const apiError = handleApiResponse(response.data, 'upload_record_image');
|
const apiError = handleApiResponse(response.data, 'upload_record_image');
|
||||||
if (apiError) return apiError;
|
if (apiError) return apiError;
|
||||||
|
result = { success: true, data: response.data };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result?.success && !result?.data?.success) {
|
||||||
|
const errMsg = result?.error || result?.data?.error || "Unknown error";
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify({ error: errMsg }, null, 2) }], isError: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadData = result.data || result;
|
||||||
return {
|
return {
|
||||||
content: [{
|
content: [{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: JSON.stringify({
|
text: JSON.stringify({
|
||||||
success: true,
|
success: true,
|
||||||
message: "Image uploaded successfully",
|
...uploadData,
|
||||||
tableName,
|
|
||||||
recordId,
|
|
||||||
fieldName,
|
|
||||||
...response.data
|
|
||||||
}, null, 2)
|
}, null, 2)
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user