mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-11 17:26:45 +02:00
La route /api/ai/generate connaissait la cause exacte de l'échec (configuration absente, clé invalide, crédits épuisés, modèle inconnu) mais la remplaçait par un « Failed to generate text » en 500, et l'interface affichait de son côté un message figé. Sans accès aux logs du serveur, l'utilisateur n'avait donc aucun moyen de savoir quoi corriger. Le motif renvoyé par OpenRouter est désormais extrait, tronqué et propagé jusqu'au bandeau d'erreur, avec un code HTTP qui distingue un problème de configuration (400) d'un refus du service tiers (502). La clé API n'apparaît à aucun moment dans ces messages. Même traitement pour /api/reels/generate-text, qui souffrait du même masquage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018uUPZ1GNpEkYB1dpXHYPek
290 lines
11 KiB
TypeScript
290 lines
11 KiB
TypeScript
interface ProductInfo {
|
|
name: string;
|
|
description?: string;
|
|
price?: string;
|
|
features?: string[];
|
|
[key: string]: any;
|
|
}
|
|
|
|
interface GeneratedText {
|
|
variant: string;
|
|
text: string;
|
|
characterCount: number;
|
|
}
|
|
|
|
import crypto from 'crypto';
|
|
import { storage } from '../storage';
|
|
|
|
/**
|
|
* Extrait le message d'erreur d'une réponse OpenRouter, qu'elle soit au format
|
|
* JSON ({ error: { message } }) ou en texte brut. Tronqué pour éviter de
|
|
* déverser une page HTML entière dans l'interface.
|
|
*/
|
|
function extractUpstreamMessage(body: string): string {
|
|
const trimmed = (body || '').trim();
|
|
if (!trimmed) return 'aucun détail fourni';
|
|
|
|
try {
|
|
const parsed = JSON.parse(trimmed);
|
|
const message = parsed?.error?.message || parsed?.message || parsed?.error;
|
|
if (typeof message === 'string' && message.trim()) {
|
|
return message.trim().slice(0, 300);
|
|
}
|
|
} catch {
|
|
// Réponse non-JSON (page d'erreur HTML d'un proxy, par exemple)
|
|
}
|
|
|
|
return trimmed.replace(/\s+/g, ' ').slice(0, 300);
|
|
}
|
|
|
|
/**
|
|
* Traduit une erreur de génération en réponse HTTP exploitable côté interface.
|
|
* Sans cela, l'utilisateur ne voit qu'un 500 générique alors que la cause
|
|
* (configuration absente, clé invalide, crédits épuisés) est connue du serveur.
|
|
*/
|
|
export function describeGenerationError(error: unknown): { status: number; message: string } {
|
|
const message = error instanceof Error ? error.message : 'Erreur inconnue lors de la génération';
|
|
|
|
// Problème de configuration : l'administrateur peut le corriger lui-même
|
|
if (/Configuration OpenRouter non trouvée|Clé API OpenRouter manquante/.test(message)) {
|
|
return { status: 400, message };
|
|
}
|
|
|
|
// Refus du service tiers
|
|
if (/OpenRouter a refusé la requête|Réponse invalide de l'API OpenRouter/.test(message)) {
|
|
return { status: 502, message };
|
|
}
|
|
|
|
return { status: 500, message };
|
|
}
|
|
|
|
export class OpenRouterService {
|
|
private baseUrl = "https://openrouter.ai/api/v1/chat/completions";
|
|
|
|
async generatePostText(productInfo: ProductInfo, userId: string, modelOverride?: string): Promise<GeneratedText[]> {
|
|
// Prefer the requesting user's own configuration; fall back to the most
|
|
// recently saved shared configuration if this user hasn't set one up.
|
|
const config = (await storage.getOpenrouterConfig(userId)) || (await storage.getAnyOpenrouterConfig());
|
|
|
|
if (!config) {
|
|
throw new Error('Configuration OpenRouter non trouvée. Veuillez demander à un administrateur de configurer OpenRouter dans les Paramètres.');
|
|
}
|
|
|
|
if (!config.apiKey || !config.apiKey.trim()) {
|
|
throw new Error('Clé API OpenRouter manquante ou vide en base de données. Veuillez la ressaisir dans les Paramètres.');
|
|
}
|
|
|
|
const prompt = this.buildPrompt(productInfo, config.systemPrompt);
|
|
|
|
// Use provided model or fall back to config model
|
|
const modelToUse = modelOverride || config.model;
|
|
|
|
const keyFingerprint = crypto.createHash('sha256').update(config.apiKey).digest('hex').slice(0, 8);
|
|
console.log(`[OpenRouter] Generating with model="${modelToUse}" keyFingerprint=${keyFingerprint} keyLength=${config.apiKey.length} configUserId=${config.userId}`);
|
|
|
|
try {
|
|
const response = await fetch(this.baseUrl, {
|
|
method: "POST",
|
|
headers: {
|
|
"Authorization": `Bearer ${config.apiKey.trim()}`,
|
|
"Content-Type": "application/json",
|
|
"HTTP-Referer": process.env.APP_URL || "http://localhost:5555",
|
|
"X-Title": "Social Flow"
|
|
},
|
|
body: JSON.stringify({
|
|
model: modelToUse,
|
|
messages: [
|
|
{
|
|
role: "user",
|
|
content: prompt
|
|
}
|
|
],
|
|
temperature: 0.8,
|
|
max_tokens: 1000
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
console.error('OpenRouter API Error Response:', errorText);
|
|
// Le motif renvoyé par OpenRouter (clé invalide, crédits épuisés, modèle
|
|
// inconnu…) est la seule information exploitable : on le fait remonter
|
|
// jusqu'à l'utilisateur, qui n'a pas accès aux logs du serveur.
|
|
throw new Error(
|
|
`OpenRouter a refusé la requête (${response.status} ${response.statusText}) ` +
|
|
`avec le modèle "${modelToUse}" : ${extractUpstreamMessage(errorText)}`
|
|
);
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
if (!data.choices || !data.choices[0]) {
|
|
console.error('Invalid OpenRouter response - no choices:', { hasChoices: !!data.choices, model: modelToUse });
|
|
throw new Error('Réponse invalide de l\'API OpenRouter. Vérifiez votre clé API et votre modèle.');
|
|
}
|
|
|
|
const content = data.choices[0]?.message?.content || "";
|
|
|
|
return this.parseGeneratedText(content);
|
|
} catch (error) {
|
|
console.error("Error generating text with OpenRouter:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private buildPrompt(productInfo: ProductInfo, systemPrompt: string): string {
|
|
const features = productInfo.features?.join(", ") || "";
|
|
|
|
const productDetails = `Nom: ${productInfo.name}
|
|
${productInfo.description ? `Description: ${productInfo.description}` : ''}
|
|
${productInfo.price ? `Prix: ${productInfo.price}` : ''}
|
|
${features ? `Caractéristiques: ${features}` : ''}`;
|
|
|
|
return `${systemPrompt}
|
|
|
|
Informations produit:
|
|
${productDetails}
|
|
|
|
Format de réponse:
|
|
VERSION 1 - DYNAMIQUE:
|
|
[texte]
|
|
|
|
VERSION 2 - INFORMATIVE:
|
|
[texte]
|
|
|
|
VERSION 3 - ÉMOTIONNELLE:
|
|
[texte]`;
|
|
}
|
|
|
|
private parseGeneratedText(content: string): GeneratedText[] {
|
|
// Normaliser les retours à la ligne
|
|
const normalizedContent = content.replace(/\r\n/g, '\n').trim();
|
|
|
|
console.log('🔍 Parsing AI response (length:', normalizedContent.length, ')');
|
|
|
|
// Stratégie 1: Détection des headers VERSION avec parsing intelligent
|
|
const variants = this.tryMultipleParsingStrategies(normalizedContent);
|
|
|
|
if (variants.length > 0) {
|
|
console.log('✅ Parsed successfully:', variants.length, 'variants');
|
|
return variants;
|
|
}
|
|
|
|
// Fallback: retourner le texte complet
|
|
console.log('⚠️ Parsing failed, using fallback');
|
|
return [{
|
|
variant: "Généré par IA",
|
|
text: normalizedContent,
|
|
characterCount: normalizedContent.length
|
|
}];
|
|
}
|
|
|
|
private tryMultipleParsingStrategies(content: string): GeneratedText[] {
|
|
// Stratégie 1: Split par headers de VERSION (le plus flexible)
|
|
const strategy1 = this.parseByVersionHeaders(content);
|
|
if (strategy1.length >= 3) return strategy1;
|
|
|
|
// Stratégie 2: Regex avec markdown **VERSION N:**
|
|
const strategy2 = this.parseWithMarkdownHeaders(content);
|
|
if (strategy2.length >= 3) return strategy2;
|
|
|
|
// Stratégie 3: Regex simple sans markdown
|
|
const strategy3 = this.parseWithSimpleHeaders(content);
|
|
if (strategy3.length >= 3) return strategy3;
|
|
|
|
// Retourner le meilleur résultat obtenu
|
|
return strategy1.length > 0 ? strategy1 : (strategy2.length > 0 ? strategy2 : strategy3);
|
|
}
|
|
|
|
private parseByVersionHeaders(content: string): GeneratedText[] {
|
|
const variants: GeneratedText[] = [];
|
|
|
|
// Chercher tous les patterns VERSION N (très flexible)
|
|
const versionPattern = /(?:\*{2})?VERSION\s+(\d+)\s*[-–—]\s*([A-ZÀÂÄÉÈÊËÏÎÔÙÛÜ]+)(?:\*{2})?:?/gi;
|
|
const matches = Array.from(content.matchAll(versionPattern));
|
|
|
|
if (matches.length < 3) return variants;
|
|
|
|
// Extraire le texte entre chaque header
|
|
for (let i = 0; i < matches.length; i++) {
|
|
const match = matches[i];
|
|
const versionNum = match[1];
|
|
const style = match[2];
|
|
const startPos = match.index! + match[0].length;
|
|
const endPos = i < matches.length - 1 ? matches[i + 1].index! : content.length;
|
|
|
|
let text = content.substring(startPos, endPos).trim();
|
|
|
|
// Nettoyer le texte (enlever les séparateurs, notes entre parenthèses au début, etc.)
|
|
text = text.replace(/^[-*]{3,}\s*/gm, ''); // Enlever les séparateurs
|
|
text = text.replace(/^\([^)]+\)\s*/gm, ''); // Enlever les notes entre parenthèses
|
|
text = text.trim();
|
|
|
|
if (text.length > 0) {
|
|
variants.push({
|
|
variant: `Version ${versionNum} - ${this.capitalizeFirst(style.toLowerCase())}`,
|
|
text,
|
|
characterCount: text.length
|
|
});
|
|
}
|
|
}
|
|
|
|
return variants;
|
|
}
|
|
|
|
private parseWithMarkdownHeaders(content: string): GeneratedText[] {
|
|
const variants: GeneratedText[] = [];
|
|
|
|
const v1 = content.match(/\*{2}VERSION\s+1\s*[-–—]\s*DYNAMIQUE:?\*{2}\s*\n([\s\S]*?)(?=\*{2}VERSION\s+2|$)/i);
|
|
const v2 = content.match(/\*{2}VERSION\s+2\s*[-–—]\s*INFORMATIVE?:?\*{2}\s*\n([\s\S]*?)(?=\*{2}VERSION\s+3|$)/i);
|
|
const v3 = content.match(/\*{2}VERSION\s+3\s*[-–—]\s*[ÉE]MOTIONNELLE?:?\*{2}\s*\n([\s\S]*?)$/i);
|
|
|
|
if (v1) variants.push({ variant: "Version 1 - Dynamique", text: v1[1].trim(), characterCount: v1[1].trim().length });
|
|
if (v2) variants.push({ variant: "Version 2 - Informative", text: v2[1].trim(), characterCount: v2[1].trim().length });
|
|
if (v3) variants.push({ variant: "Version 3 - Émotionnelle", text: v3[1].trim(), characterCount: v3[1].trim().length });
|
|
|
|
return variants;
|
|
}
|
|
|
|
private parseWithSimpleHeaders(content: string): GeneratedText[] {
|
|
const variants: GeneratedText[] = [];
|
|
|
|
const v1 = content.match(/VERSION\s+1\s*[-–—]\s*DYNAMIQUE:?\s*\n([\s\S]*?)(?=VERSION\s+2|$)/i);
|
|
const v2 = content.match(/VERSION\s+2\s*[-–—]\s*INFORMATIVE?:?\s*\n([\s\S]*?)(?=VERSION\s+3|$)/i);
|
|
const v3 = content.match(/VERSION\s+3\s*[-–—]\s*[ÉE]MOTIONNELLE?:?\s*\n([\s\S]*?)$/i);
|
|
|
|
if (v1) variants.push({ variant: "Version 1 - Dynamique", text: v1[1].trim(), characterCount: v1[1].trim().length });
|
|
if (v2) variants.push({ variant: "Version 2 - Informative", text: v2[1].trim(), characterCount: v2[1].trim().length });
|
|
if (v3) variants.push({ variant: "Version 3 - Émotionnelle", text: v3[1].trim(), characterCount: v3[1].trim().length });
|
|
|
|
return variants;
|
|
}
|
|
|
|
private capitalizeFirst(text: string): string {
|
|
return text.charAt(0).toUpperCase() + text.slice(1).toLowerCase();
|
|
}
|
|
|
|
async getAvailableModels(): Promise<any[]> {
|
|
try {
|
|
const response = await fetch("https://openrouter.ai/api/v1/models", {
|
|
method: "GET",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to fetch models: ${response.status} ${response.statusText}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
return data.data || [];
|
|
} catch (error) {
|
|
console.error("Error fetching OpenRouter models:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
export const openRouterService = new OpenRouterService();
|