diff --git a/client/src/lib/queryClient.ts b/client/src/lib/queryClient.ts index 56f2fae..aac81aa 100644 --- a/client/src/lib/queryClient.ts +++ b/client/src/lib/queryClient.ts @@ -20,6 +20,29 @@ export function handleUnauthorized(url: string) { window.location.href = "/login"; } +/** + * Extrait un message lisible d'une erreur d'API. + * apiRequest lève des erreurs de la forme `500: {"error":"…"}` : sans ce + * décodage, l'interface ne peut afficher qu'un message générique alors que le + * serveur a renvoyé la cause exacte. + */ +export function getErrorMessage(error: unknown, fallback: string): string { + if (!(error instanceof Error) || !error.message) return fallback; + + const withoutStatus = error.message.replace(/^\d{3}:\s*/, '').trim(); + if (!withoutStatus) return fallback; + + try { + const parsed = JSON.parse(withoutStatus); + const message = parsed?.error || parsed?.message; + if (typeof message === 'string' && message.trim()) return message.trim(); + } catch { + // Corps non-JSON : on affiche le texte tel quel + } + + return withoutStatus.startsWith('<') ? fallback : withoutStatus; +} + async function throwIfResNotOk(res: Response, url: string) { if (!res.ok) { if (res.status === 401) { diff --git a/client/src/pages/mobile/new-post.tsx b/client/src/pages/mobile/new-post.tsx index 985d2a2..9cbf6a1 100644 --- a/client/src/pages/mobile/new-post.tsx +++ b/client/src/pages/mobile/new-post.tsx @@ -32,7 +32,7 @@ import { Checkbox } from "@/components/ui/checkbox"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Dialog, DialogContent } from "@/components/ui/dialog"; import { useToast } from "@/hooks/use-toast"; -import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient"; +import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient"; import type { SocialPage, Media, ScheduledPost } from "@shared/schema"; import { PreviewModal } from "@/components/preview-modal"; import { DateTimePicker } from "@/components/datetime-picker"; @@ -230,10 +230,10 @@ export default function NewPostMobile() { }); queryClient.invalidateQueries({ queryKey: ['/api/ai/generations'] }); }, - onError: () => { + onError: (error: unknown) => { toast({ title: "Erreur", - description: "Impossible de générer le texte", + description: getErrorMessage(error, "Impossible de générer le texte"), variant: "destructive", }); }, diff --git a/client/src/pages/mobile/remotion-video.tsx b/client/src/pages/mobile/remotion-video.tsx index 90a7043..c3d09ed 100644 --- a/client/src/pages/mobile/remotion-video.tsx +++ b/client/src/pages/mobile/remotion-video.tsx @@ -13,7 +13,7 @@ import Sidebar from "@/components/sidebar"; import { Checkbox } from "@/components/ui/checkbox"; import { DateTimePicker } from "@/components/datetime-picker"; import { useQuery, useMutation } from "@tanstack/react-query"; -import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient"; +import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient"; import type { Media, SocialPage } from "@shared/schema"; import { SiFacebook, SiTiktok } from "react-icons/si"; @@ -91,7 +91,7 @@ export default function MobileRemotionVideoPage() { const generateTextMutation = useMutation({ mutationFn: async (text: string) => (await apiRequest('POST', '/api/reels/generate-text', { productInfo: text })).json(), onSuccess: (data: any) => { setGeneratedVariants(data.variants || []); toast({ title: "Textes générés" }); }, - onError: () => toast({ title: "Erreur IA", variant: "destructive" }), + onError: (error: unknown) => toast({ title: "Erreur IA", description: getErrorMessage(error, "Impossible de générer le texte"), variant: "destructive" }), }); const handleFileChange = (e: React.ChangeEvent) => { diff --git a/client/src/pages/new-post.tsx b/client/src/pages/new-post.tsx index 80af7be..3cec2e6 100644 --- a/client/src/pages/new-post.tsx +++ b/client/src/pages/new-post.tsx @@ -31,7 +31,7 @@ import { Checkbox } from "@/components/ui/checkbox"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Dialog, DialogContent } from "@/components/ui/dialog"; import { useToast } from "@/hooks/use-toast"; -import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient"; +import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient"; import { MediaThumbnail } from "@/components/media-thumbnail"; import type { SocialPage, Media, ScheduledPost } from "@shared/schema"; import { PreviewModal } from "@/components/preview-modal"; @@ -226,10 +226,10 @@ export default function NewPost() { }); queryClient.invalidateQueries({ queryKey: ['/api/ai/generations'] }); }, - onError: () => { + onError: (error: unknown) => { toast({ title: "Erreur", - description: "Impossible de générer le texte", + description: getErrorMessage(error, "Impossible de générer le texte"), variant: "destructive", }); }, diff --git a/client/src/pages/new-reel.tsx b/client/src/pages/new-reel.tsx index adb0753..e72e897 100644 --- a/client/src/pages/new-reel.tsx +++ b/client/src/pages/new-reel.tsx @@ -25,7 +25,7 @@ import { SelectValue, } from "@/components/ui/select"; import { useToast } from "@/hooks/use-toast"; -import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient"; +import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient"; import type { SocialPage, Media } from "@shared/schema"; import { SiFacebook, SiTiktok } from "react-icons/si"; import { MediaThumbnail } from "@/components/media-thumbnail"; @@ -235,10 +235,10 @@ export default function NewReel() { description: `${variants.length} variations créées`, }); }, - onError: () => { + onError: (error: unknown) => { toast({ title: "Erreur", - description: "Impossible de générer le texte", + description: getErrorMessage(error, "Impossible de générer le texte"), variant: "destructive", }); }, diff --git a/client/src/pages/remotion-video.tsx b/client/src/pages/remotion-video.tsx index 3df0661..6371bec 100644 --- a/client/src/pages/remotion-video.tsx +++ b/client/src/pages/remotion-video.tsx @@ -8,7 +8,7 @@ import { Textarea } from "@/components/ui/textarea"; import { UploadCloud, Video, Loader2, Check, Sparkles, Mic, Volume2, Music, Play, Pause, Send } from "lucide-react"; import { Slider } from "@/components/ui/slider"; import { useQuery, useMutation } from "@tanstack/react-query"; -import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient"; +import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { Checkbox } from "@/components/ui/checkbox"; @@ -93,7 +93,7 @@ export default function RemotionVideoPage() { setGeneratedVariants(data.variants || []); toast({ title: "Textes générés", description: "Cliquez pour appliquer." }); }, - onError: () => toast({ title: "Erreur IA", variant: "destructive" }), + onError: (error: unknown) => toast({ title: "Erreur IA", description: getErrorMessage(error, "Impossible de générer le texte"), variant: "destructive" }), }); const handleFileChange = (e: React.ChangeEvent) => { diff --git a/server/routes.ts b/server/routes.ts index dabb47e..40780a0 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -9,7 +9,7 @@ import multer from "multer"; import bcrypt from "bcrypt"; import passport from "./auth"; import { z } from "zod"; -import { openRouterService } from "./services/openrouter"; +import { openRouterService, describeGenerationError } from "./services/openrouter"; import { minioService as cloudinaryService, buildMinioUrl } from "./services/minio"; import { insertPostSchema, insertScheduledPostSchema, insertSocialPageSchema, insertAiGenerationSchema, insertCloudinaryConfigSchema, updateCloudinaryConfigSchema, insertOpenrouterConfigSchema, updateOpenrouterConfigSchema, insertUserSchema, postMedia, type SocialPage } from "@shared/schema"; import type { User, InsertUser, ScheduledPost } from "@shared/schema"; @@ -631,7 +631,8 @@ export async function registerRoutes(app: Express): Promise { res.json({ variants: generatedTexts }); } catch (error) { console.error("Error generating text:", error); - res.status(500).json({ error: "Failed to generate text" }); + const { status, message } = describeGenerationError(error); + res.status(status).json({ error: message }); } }); diff --git a/server/routes/reels.ts b/server/routes/reels.ts index eeca4fa..5522aa0 100644 --- a/server/routes/reels.ts +++ b/server/routes/reels.ts @@ -9,7 +9,7 @@ import { ffmpegService } from '../services/ffmpeg'; import { facebookService } from '../services/facebook'; import { tiktokService } from '../services/tiktok'; import { minioService as cloudinaryService, buildMinioUrl, resolveInternalUrl } from '../services/minio'; -import { openRouterService } from '../services/openrouter'; +import { openRouterService, describeGenerationError } from '../services/openrouter'; import { db } from '../db'; import { cloudinaryConfig } from '@shared/schema'; import { eq } from 'drizzle-orm'; @@ -254,7 +254,8 @@ reelsRouter.post('/reels/generate-text', async (req: Request, res: Response) => res.json({ variants: generatedTexts }); } catch (error) { console.error('❌ Error generating Reel text:', error); - res.status(500).json({ error: 'Erreur lors de la génération du texte' }); + const { status, message } = describeGenerationError(error); + res.status(status).json({ error: message }); } }); diff --git a/server/services/openrouter.ts b/server/services/openrouter.ts index 2137458..98d2fc5 100644 --- a/server/services/openrouter.ts +++ b/server/services/openrouter.ts @@ -15,6 +15,49 @@ interface GeneratedText { 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"; @@ -64,7 +107,13 @@ export class OpenRouterService { if (!response.ok) { const errorText = await response.text(); console.error('OpenRouter API Error Response:', errorText); - throw new Error(`OpenRouter API error: ${response.status} ${response.statusText}`); + // 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();