import { z } from "zod"; import axios from "axios"; import path from "path"; import { withAuth } from "../../auth/index.js"; import { handleToolError } from "../helpers/errorHandler.js"; import { withAuthParams } from "../helpers/authSchema.js"; import { resolveCurrentProjectDir } from "../files/helpers.js"; import { pythonGetBinary } from "../helpers/pythonServerClient.js"; const GEMINI_ENDPOINT = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent"; // Hosts locales NO alcanzables por HTTP desde este container (localhost = el // propio agentic). Para esas refs y rutas del proyecto, los bytes los resuelve // el server Python (/api/image-bytes), que decide disco (standalone) vs fetch de // producción (Acai / stub local). const LOCAL_HOSTS = ["localhost", "127.0.0.1", "acai-app", "acai-web", "web", "host.docker.internal"]; // Dominio forge del entorno (forge.acai.test en local, forge.acaisuite.com en // prod). Los hosts forge resuelven (dnsmasq) a 127.0.0.1, que dentro de este // container es él mismo → un fetch directo da ECONNREFUSED, sea http o https. // Por eso NO son "remotos": sus bytes los resuelve el server Python // (/api/image-bytes), que para un proyecto Acai trae el stub desde producción. // Env-driven, igual que is_local_project_host en Python. const FORGE_DOMAIN = (process.env.ACAI_FORGE_DOMAIN || "forge.acaisuite.com").toLowerCase(); /** * ¿Es un host que debe resolverse vía el server Python (no alcanzable / no * conviene un fetch directo desde este container)? Cubre loopback/hosts Docker * internos y el dominio forge del entorno. */ function isLocalResolvableHost(hostname) { if (!hostname) return false; const h = hostname.toLowerCase(); if (LOCAL_HOSTS.includes(h)) return true; if (FORGE_DOMAIN && (h === FORGE_DOMAIN || h.endsWith("." + FORGE_DOMAIN))) return true; return false; } const DEFAULT_PROMPT = "Describe esta imagen detalladamente, mencionando elementos visuales, texto, layout y proposito aparente."; /** * Detecta el mime type a partir de la extension del fichero o del primer byte (magic number). */ function detectMimeType(filename, buffer) { const ext = (filename || "").toLowerCase().split('.').pop(); const byExt = { jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", webp: "image/webp", gif: "image/gif", bmp: "image/bmp", heic: "image/heic", heif: "image/heif", }; if (byExt[ext]) return byExt[ext]; // Magic numbers fallback if (buffer && buffer.length >= 4) { if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return "image/jpeg"; if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) return "image/png"; if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46) return "image/gif"; if (buffer.length >= 12 && buffer.slice(8, 12).toString() === "WEBP") return "image/webp"; } return "image/jpeg"; } /** * Carga la imagen como { mimeType, base64 }. * - URL remota real (host público) → fetch directo por HTTP. * - Adjunto de chat, ruta del proyecto, o URL con host local → los bytes los * resuelve el server Python (/api/image-bytes): disco para standalone, fetch * de producción para imágenes Acai cuyo fichero local es un stub. */ async function loadImage(imageUrl) { let parsed = null; try { parsed = new URL(imageUrl); } catch { parsed = null; } const isRemote = parsed && (parsed.protocol === "http:" || parsed.protocol === "https:") && parsed.hostname && !isLocalResolvableHost(parsed.hostname); if (isRemote) { const response = await axios.get(imageUrl, { responseType: "arraybuffer", timeout: 30000, maxContentLength: 20 * 1024 * 1024, // 20MB max }); const buffer = Buffer.from(response.data, "binary"); const headerMime = response.headers?.["content-type"]?.split(";")[0]?.trim(); const mimeType = headerMime && headerMime.startsWith("image/") ? headerMime : detectMimeType(imageUrl.split("?")[0], buffer); return { mimeType, base64: buffer.toString("base64") }; } const project = path.basename(resolveCurrentProjectDir() || ""); if (!project) { throw new Error("No hay proyecto activo para resolver la imagen."); } const { buffer, mimeType } = await pythonGetBinary("/api/image-bytes", { project, ref: imageUrl }); return { mimeType, base64: buffer.toString("base64") }; } export function registerAnalyzeImageTool(server) { server.tool( "analyze_image", "Analiza una imagen usando Gemini Vision. Usala SOLO para imagenes que NO puedes ver directamente (p.ej. una URL/imagen del CMS que no esta adjunta a la conversacion, o un screenshot de Playwright). Si la imagen ya esta adjunta y visible en el mensaje del usuario, descríbela tú mismo SIN llamar a esta tool. Devuelve descripcion text del contenido visual.", withAuthParams({ image_url: z.string().describe("URL de la imagen. Acepta URL publica http(s):// o ruta relativa /api/chat-preview?file=..."), prompt: z.string().optional().describe("Que quieres saber de la imagen. Default: descripcion detallada."), }), { readOnlyHint: true, destructiveHint: false }, withAuth(async ({ image_url, prompt }) => { try { const apiKey = process.env.NANO_BANANA_API_KEY; if (!apiKey) { return { content: [{ type: "text", text: JSON.stringify({ success: false, error: "NANO_BANANA_API_KEY no esta configurada en el entorno del MCP server.", }, null, 2), }], isError: true, }; } // 1) Cargar imagen (local o remota) -> base64 + mime let image; try { image = await loadImage(image_url); } catch (loadErr) { return { content: [{ type: "text", text: JSON.stringify({ success: false, error: `No se pudo cargar la imagen: ${loadErr.message}`, }, null, 2), }], isError: true, }; } // 2) Llamar a Gemini Vision const finalPrompt = (prompt && prompt.trim()) || DEFAULT_PROMPT; const payload = { contents: [{ parts: [ { inline_data: { mime_type: image.mimeType, data: image.base64 } }, { text: finalPrompt }, ], }], }; const geminiResp = await axios.post(GEMINI_ENDPOINT, payload, { headers: { "x-goog-api-key": apiKey, "Content-Type": "application/json", }, timeout: 60000, maxBodyLength: 30 * 1024 * 1024, }); const description = geminiResp.data?.candidates?.[0]?.content?.parts?.[0]?.text; if (!description) { return { content: [{ type: "text", text: JSON.stringify({ success: false, error: "Gemini no devolvio descripcion.", raw: geminiResp.data, }, null, 2), }], isError: true, }; } return { content: [{ type: "text", text: description, }], }; } catch (error) { // Mejorar error si es respuesta de Gemini if (error.response?.data) { return handleToolError( new Error(`Gemini API error: ${JSON.stringify(error.response.data).slice(0, 500)}`), "analyze_image", { image_url, status: error.response.status } ); } return handleToolError(error, "analyze_image", { image_url }); } }) ); }