Files
reformix-hackaton/mvp/image-worker/src/pipeline/image-generator.service.ts
Carlos Narro 062a34c144 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>
2026-06-09 16:32:11 +02:00

131 lines
4.5 KiB
TypeScript

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,
private readonly settings: SettingsService,
) {}
// 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 = opts?.model?.trim() || this.settings.getModeloImagen();
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 } },
],
},
],
},
{
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://reformix.es',
'X-Title': 'Reformix Image Worker',
},
timeout: 90000,
},
);
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 },
};
}
}
private extraerImagen(message: any): string | null {
if (!message) return null;
// 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;
}
}
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,
};
}
}