Imágenes: - analyze_image y upload resuelven los bytes por el endpoint Python /api/image-bytes (pythonGetBinary). analyze_image enruta los dominios forge (env ACAI_FORGE_DOMAIN) al endpoint en vez de fetch directo (que daba ECONNREFUSED 127.0.0.1 dentro del container). Aislamiento de entorno (vscode = solo test): - resolveCurrentModeOverride(): sesión MCP HTTP (mcpSessionId presente) → "local"; stdio (chat/cron) → ACAI_MODE_OVERRIDE de entorno. Lo usan los builders de headers (pythonServerClient, files/helpers) → toda tool del MCP HTTP manda X-Acai-Mode: local. - httpServer.resolveProjectCredentials fuerza forceMode:"local" al resolver project-info → la sesión obtiene web_url/api_web_url forge-local y opera siempre contra test, nunca producción. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
198 lines
8.7 KiB
JavaScript
198 lines
8.7 KiB
JavaScript
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 });
|
|
}
|
|
})
|
|
);
|
|
}
|