Sandbox de renders en el image-worker + fixes del pipeline
Sandbox web (/sandbox) servido por el worker, reusando los mismos servicios del pipeline, para iterar prompts/modelos con una imagen y ver variaciones+score, y guardar la config ganadora (aplica al worker al instante, persistida en volumen): - SettingsService: store de config (system prompts + modelos) con defaults de prompts/*.txt + env y override persistido en /app/data (sobrevive a redeploys). - /sandbox (HTML), GET /sandbox/config, POST /sandbox/render, POST /sandbox/save (auth Bearer FUNNEL_API_KEY). DOM seguro, sin innerHTML de contenido externo. - prompt-builder/supervisor/image-generator aceptan overrides y leen de Settings. Fixes del pipeline de generación: - image-generator: pide modalities ['image','text'] y extrae la imagen de message.images[] (forma real de OpenRouter), no solo de content. - main.ts: sube el límite de body a 30mb (las fotos en data URI rompían el 100kb). Deja de versionar artefactos de build (dist/ + *.tsbuildinfo); .gitignore en image-worker y Whatsapp-bot. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,84 +1,130 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios from 'axios';
|
||||
import { SettingsService } from '../settings/settings.service';
|
||||
|
||||
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
||||
|
||||
export interface GenerarRenderResultado {
|
||||
imagen: string | null;
|
||||
error?: string;
|
||||
debug: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ImageGeneratorService {
|
||||
private readonly logger = new Logger(ImageGeneratorService.name);
|
||||
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly settings: SettingsService,
|
||||
) {}
|
||||
|
||||
async generarRender(prompt: string, fotoAntesDataUri: string): Promise<string> {
|
||||
// Usado por el pipeline real: devuelve la imagen o lanza.
|
||||
async generarRender(prompt: string, fotoAntesDataUri: string, opts?: { model?: string }): Promise<string> {
|
||||
const r = await this.generarConDebug(prompt, fotoAntesDataUri, opts);
|
||||
if (!r.imagen) throw new Error(r.error || 'No se pudo extraer imagen de la respuesta');
|
||||
return r.imagen;
|
||||
}
|
||||
|
||||
// Usado por el sandbox: nunca lanza, devuelve imagen|null + info de depuración (sin volcar base64).
|
||||
async generarConDebug(
|
||||
prompt: string,
|
||||
fotoAntesDataUri: string,
|
||||
opts?: { model?: string },
|
||||
): Promise<GenerarRenderResultado> {
|
||||
const apiKey = this.config.get<string>('OPENROUTER_API_KEY');
|
||||
const model = this.config.get<string>('OPENROUTER_MODEL_IMAGEN', 'google/gemini-2.0-flash-exp-image-generation');
|
||||
const model = opts?.model?.trim() || this.settings.getModeloImagen();
|
||||
|
||||
const intentosRateLimit = 1;
|
||||
for (let attempt = 0; attempt <= intentosRateLimit; attempt++) {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
OPENROUTER_URL,
|
||||
{
|
||||
model,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: prompt },
|
||||
{ type: 'image_url', image_url: { url: fotoAntesDataUri } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'HTTP-Referer': 'https://reformix.es',
|
||||
'X-Title': 'Reformix Image Worker',
|
||||
try {
|
||||
const response = await axios.post(
|
||||
OPENROUTER_URL,
|
||||
{
|
||||
model,
|
||||
// Necesario para que OpenRouter devuelva imagen además de texto.
|
||||
modalities: ['image', 'text'],
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: prompt },
|
||||
{ type: 'image_url', image_url: { url: fotoAntesDataUri } },
|
||||
],
|
||||
},
|
||||
timeout: 60000,
|
||||
],
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'HTTP-Referer': 'https://reformix.es',
|
||||
'X-Title': 'Reformix Image Worker',
|
||||
},
|
||||
);
|
||||
timeout: 90000,
|
||||
},
|
||||
);
|
||||
|
||||
const content = response.data.choices?.[0]?.message?.content;
|
||||
if (!content) throw new Error('OpenRouter no devolvio contenido');
|
||||
const message = response.data?.choices?.[0]?.message;
|
||||
const imagen = this.extraerImagen(message);
|
||||
return {
|
||||
imagen,
|
||||
error: imagen ? undefined : 'No se encontró imagen en la respuesta de OpenRouter',
|
||||
debug: this.resumenDebug(response.data, model, imagen),
|
||||
};
|
||||
} catch (err: any) {
|
||||
const status = err.response?.status;
|
||||
const msg = err.response?.data?.error?.message || err.message;
|
||||
this.logger.error(`Error generando imagen (${status ?? 'sin status'}): ${msg}`);
|
||||
return {
|
||||
imagen: null,
|
||||
error: msg,
|
||||
debug: { model, status, error: msg },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const imagen = this.extraerImagenDeRespuesta(content, response.data);
|
||||
if (!imagen) throw new Error('No se pudo extraer imagen de la respuesta');
|
||||
private extraerImagen(message: any): string | null {
|
||||
if (!message) return null;
|
||||
|
||||
return imagen;
|
||||
} catch (err: any) {
|
||||
if (err.response?.status === 429 && attempt < intentosRateLimit) {
|
||||
this.logger.warn('Rate limit (429), esperando 5s y reintentando...');
|
||||
await new Promise((r) => setTimeout(r, 5000));
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
// 1) Forma de OpenRouter para image-gen: message.images[].image_url.url
|
||||
if (Array.isArray(message.images)) {
|
||||
for (const img of message.images) {
|
||||
const url = img?.image_url?.url ?? img?.url ?? (typeof img === 'string' ? img : null);
|
||||
if (typeof url === 'string' && url) return url;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Fallaron todos los intentos de generacion de imagen');
|
||||
}
|
||||
|
||||
private extraerImagenDeRespuesta(content: string, rawResponse?: any): string | null {
|
||||
if (content.startsWith('data:image')) return content;
|
||||
|
||||
const dataUriMatch = content.match(/data:image\/[a-zA-Z]+;base64,[^\s"']+/);
|
||||
if (dataUriMatch) return dataUriMatch[0];
|
||||
|
||||
const urlMatch = content.match(/https?:\/\/[^\s"'()]+\.(png|jpg|jpeg|webp)/i);
|
||||
if (urlMatch) return urlMatch[0];
|
||||
|
||||
const parts = rawResponse?.choices?.[0]?.message?.content;
|
||||
if (Array.isArray(parts)) {
|
||||
for (const part of parts) {
|
||||
if (part.type === 'image_url' && part.image_url?.url) return part.image_url.url;
|
||||
if (part.image_url?.url?.startsWith('data:image')) return part.image_url.url;
|
||||
const content = message.content;
|
||||
if (typeof content === 'string') {
|
||||
if (content.startsWith('data:image')) return content;
|
||||
const dataUri = content.match(/data:image\/[a-zA-Z+]+;base64,[A-Za-z0-9+/=]+/);
|
||||
if (dataUri) return dataUri[0];
|
||||
const url = content.match(/https?:\/\/[^\s"'()]+\.(?:png|jpg|jpeg|webp)/i);
|
||||
if (url) return url[0];
|
||||
} else if (Array.isArray(content)) {
|
||||
for (const part of content) {
|
||||
const url = part?.image_url?.url;
|
||||
if (typeof url === 'string' && url) return url;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Resumen compacto para depurar el formato real sin meter el base64 (enorme) en la respuesta.
|
||||
private resumenDebug(data: any, model: string, imagen: string | null): Record<string, unknown> {
|
||||
const msg = data?.choices?.[0]?.message;
|
||||
return {
|
||||
model,
|
||||
modelDevuelto: data?.model,
|
||||
finishReason: data?.choices?.[0]?.finish_reason,
|
||||
messageKeys: msg ? Object.keys(msg) : [],
|
||||
imagesCount: Array.isArray(msg?.images) ? msg.images.length : 0,
|
||||
contentType: Array.isArray(msg?.content) ? 'array' : typeof msg?.content,
|
||||
contentPreview: typeof msg?.content === 'string' ? msg.content.slice(0, 200) : undefined,
|
||||
imagenEncontrada: !!imagen,
|
||||
imagenTipo: imagen ? (imagen.startsWith('data:') ? 'data-uri' : 'url') : null,
|
||||
usage: data?.usage,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user