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>
131 lines
4.5 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|