diff --git a/mcp-server/tools/helpers/pythonServerClient.js b/mcp-server/tools/helpers/pythonServerClient.js new file mode 100644 index 0000000..afc18c4 --- /dev/null +++ b/mcp-server/tools/helpers/pythonServerClient.js @@ -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; +} diff --git a/mcp-server/tools/media/generateImage.js b/mcp-server/tools/media/generateImage.js index ee384c1..c7893e0 100644 --- a/mcp-server/tools/media/generateImage.js +++ b/mcp-server/tools/media/generateImage.js @@ -2,19 +2,13 @@ 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 { withAuth } from "../../auth/index.js"; import { handleToolError } from "../helpers/errorHandler.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"; -// 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 || ""; @@ -39,31 +33,6 @@ async function checkCredits() { } 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", @@ -78,140 +47,46 @@ export function registerGenerateImageTool(server) { { 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 + // Verificar creditos 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." }], + content: [{ type: "text", text: "Error: No te quedan creditos. Mejora tu plan para seguir usando el asistente." }], isError: true, }; } - // Build prompt with style hint - const fullPrompt = style ? `${prompt}. Style: ${style}` : prompt; + const projectSlug = path.basename(process.env.ACAI_PROJECT_DIR || ""); + 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 - 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; + let result; 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); + result = await pythonPost("/api/generate-image", { + project: projectSlug, + prompt: fullPrompt, + destRelativePath, + }, 180000); // 3min timeout para generacion IA + } catch (pyErr) { + return handleToolError(new Error(`Python generate-image failed: ${pyErr.response?.data?.error || pyErr.message}`), 'generate_image', { prompt }); } - // 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; - - // 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); + if (!result?.success) { + return { content: [{ type: "text", text: JSON.stringify({ error: result?.error || "Generation failed" }, null, 2) }], isError: true }; + } 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: 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.`, + message: `Image generated and saved to ${result.relativePath}`, + uploadUrl: result.fullUrl || result.dockerUrl, + fullUrl: result.fullUrl || result.dockerUrl, + relativePath: result.relativePath, + fileName: result.fileName, + size: result.size, }, null, 2), }], }; diff --git a/mcp-server/tools/media/upload.js b/mcp-server/tools/media/upload.js index 6d98be9..1720338 100644 --- a/mcp-server/tools/media/upload.js +++ b/mcp-server/tools/media/upload.js @@ -5,6 +5,7 @@ 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"; +import { pythonPost } from "../helpers/pythonServerClient.js"; /** * Helper: POST to mcp_respond.php via viewer_functions.php @@ -117,36 +118,44 @@ export function registerUploadRecordImageTool(server) { ); 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 - // puede descargarla en modo produccion). - const localFile = await resolveLocalImageAsBase64(imageUrl); - const uploadPayload = localFile - ? { tableName, recordId, fieldName, alt, fileBase64: localFile.fileBase64, fileName: localFile.fileName } - : { tableName, recordId, fieldName, imageUrl, alt }; + // Intentar via Python server (tiene sync + optimizacion) + 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 uploadPayload = localFile + ? { tableName, recordId, fieldName, alt, fileBase64: localFile.fileBase64, fileName: localFile.fileName } + : { tableName, recordId, fieldName, imageUrl, alt }; + const response = await mcpPost(credentials, "uploadRecordImage", uploadPayload, credentials.token, credentials.tokenHash); + const apiError = handleApiResponse(response.data, 'upload_record_image'); + if (apiError) return apiError; + result = { success: true, data: response.data }; + } - const response = await mcpPost( - credentials, - "uploadRecordImage", - uploadPayload, - credentials.token, - credentials.tokenHash - ); - - const apiError = handleApiResponse(response.data, 'upload_record_image'); - if (apiError) return apiError; + 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 { content: [{ type: "text", text: JSON.stringify({ success: true, - message: "Image uploaded successfully", - tableName, - recordId, - fieldName, - ...response.data + ...uploadData, }, null, 2) }], };