Compare commits

..
Author SHA1 Message Date
Claude 2188fa458d fix: recover gracefully from stale sessions causing repeated 401s
The SPA's session check (/api/auth/session) is cached indefinitely by
react-query (staleTime: Infinity, no refetch on focus/interval), so once a
protected page mounts successfully, the app never re-verifies auth. If the
server-side session later becomes invalid (server restart with in-memory
sessions, cookie/session expiry, etc.), every subsequent API call
(pages, scheduled-posts, audio-tracks, media, media/upload, ...) starts
failing with 401 in a loop with no way for the user to recover short of a
manual page reload.

Add a shared handleUnauthorized() in queryClient.ts that redirects to
/login on any non-auth API 401, wired into both the shared fetch helpers
(apiRequest/getQueryFn) and the raw fetch() calls used for file uploads
and Remotion rendering, which also lacked this recovery path. Also add the
missing credentials: "include" to those raw fetch() calls for consistency
with the rest of the app's API requests.
2026-07-27 11:56:56 +00:00
LogiFlow 75e1d88c26 Merge pull request #109 from R0m1k3/claude/openrouter-api-404-h97d7e
fix(openrouter): resolve AI generation 401/404 errors
2026-07-14 08:33:35 +02:00
Claude 8155b30c63 fix(openrouter): stop logging API key material, use a hash fingerprint
Address Codex review: the debug log previously included the key's
first 10 characters. Replace with a non-reversible SHA-256 fingerprint
so logs remain useful for correlating support reports without ever
exposing key material.
2026-07-14 06:31:18 +00:00
Claude de7fc7d03c fix(openrouter): trim API key and guard against empty key
An OpenRouter key with trailing whitespace/newline (common from
copy-paste) produces "Missing Authentication header" from their API,
which looked identical to a missing key. Trim apiKey on save (schema
level) and on use (defense in depth), fail fast with a clear message
if the stored key is empty, and log a masked key preview + model on
each generation call to make future auth failures diagnosable from
container logs.
2026-07-14 06:25:47 +00:00
LogiFlow 517c786be6 Merge pull request #108 from R0m1k3/claude/openrouter-api-404-h97d7e
fix(openrouter): respect the requesting user's saved model config
2026-07-14 08:12:07 +02:00
Claude 09b4e597ae fix(openrouter): respect the requesting user's saved model config
generatePostText ignored the userId it received and always read an
arbitrary row via getAnyOpenrouterConfig() (LIMIT 1, no ORDER BY),
so a model chosen and saved in Settings could be shadowed by another
stale config row. Now it prefers the requesting user's own config,
falling back to the most recently updated shared one. Also swap the
retired anthropic/claude-3.5-sonnet default for a live OpenRouter slug.
2026-07-14 05:49:31 +00:00
Michael b00d88f6b0 docs: add comprehensive Socialflow API documentation 2026-05-28 19:25:34 +02:00
MichaelandClaude Opus 4.7 c008b41eb7 feat(ffmpeg): Gemini TTS as primary engine, Edge TTS as fallback
Default tts_engine changed from "edge" to "gemini".
Both /process-reel and /preview-tts now try Gemini first,
fall back to Edge TTS on failure.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 16:42:45 +02:00
18 changed files with 1323 additions and 39 deletions

No files matched your search

File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -32,7 +32,7 @@ export default function AiChat() {
}, },
]); ]);
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [selectedModel, setSelectedModel] = useState("anthropic/claude-3.5-sonnet"); const [selectedModel, setSelectedModel] = useState("anthropic/claude-sonnet-4.5");
const { toast } = useToast(); const { toast } = useToast();
// Fetch available models from OpenRouter // Fetch available models from OpenRouter
+11 -3
View File
@@ -3,7 +3,7 @@ import { useDropzone } from "react-dropzone";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dialog, DialogContent } from "@/components/ui/dialog"; import { Dialog, DialogContent } from "@/components/ui/dialog";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { apiRequest, queryClient } from "@/lib/queryClient"; import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient";
import { MediaThumbnail } from "@/components/media-thumbnail"; import { MediaThumbnail } from "@/components/media-thumbnail";
import { CloudUpload, Image as ImageIcon, Video, X, Upload, Loader2, ZoomIn, Camera } from "lucide-react"; import { CloudUpload, Image as ImageIcon, Video, X, Upload, Loader2, ZoomIn, Camera } from "lucide-react";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
@@ -30,8 +30,12 @@ export default function MediaUpload() {
const response = await fetch("/api/media/upload", { const response = await fetch("/api/media/upload", {
method: "POST", method: "POST",
body: formData, body: formData,
credentials: "include",
}); });
if (!response.ok) throw new Error("Upload failed"); if (!response.ok) {
if (response.status === 401) handleUnauthorized("/api/media/upload");
throw new Error("Upload failed");
}
return response.json(); return response.json();
}, },
onSuccess: (data) => { onSuccess: (data) => {
@@ -55,8 +59,12 @@ export default function MediaUpload() {
const response = await fetch("/api/media/upload", { const response = await fetch("/api/media/upload", {
method: "POST", method: "POST",
body: formData, body: formData,
credentials: "include",
}); });
if (!response.ok) throw new Error("Upload failed"); if (!response.ok) {
if (response.status === 401) handleUnauthorized("/api/media/upload");
throw new Error("Upload failed");
}
successCount++; successCount++;
return response.json(); return response.json();
} catch (error) { } catch (error) {
+28 -4
View File
@@ -1,7 +1,30 @@
import { QueryClient, QueryFunction } from "@tanstack/react-query"; import { QueryClient, QueryFunction } from "@tanstack/react-query";
async function throwIfResNotOk(res: Response) { // Les routes d'authentification elles-mêmes ne doivent jamais déclencher
// de redirection automatique (ex: mauvais mot de passe sur /login).
function isAuthRoute(url: string): boolean {
return url.startsWith("/api/auth/");
}
// Si la session serveur a expiré (ex: redémarrage du serveur, expiration du
// cookie) alors que la SPA pense toujours l'utilisateur connecté (le check
// de session initial est mis en cache indéfiniment par react-query), toutes
// les requêtes suivantes échouent silencieusement en 401 sans jamais
// renvoyer l'utilisateur vers l'écran de connexion. On force donc un
// rechargement vers /login dès qu'une requête API (hors routes d'auth)
// renvoie 401, ce qui réinitialise aussi le cache react-query.
export function handleUnauthorized(url: string) {
if (isAuthRoute(url) || window.location.pathname === "/login") {
return;
}
window.location.href = "/login";
}
async function throwIfResNotOk(res: Response, url: string) {
if (!res.ok) { if (!res.ok) {
if (res.status === 401) {
handleUnauthorized(url);
}
const text = (await res.text()) || res.statusText; const text = (await res.text()) || res.statusText;
throw new Error(`${res.status}: ${text}`); throw new Error(`${res.status}: ${text}`);
} }
@@ -19,7 +42,7 @@ export async function apiRequest(
credentials: "include", credentials: "include",
}); });
await throwIfResNotOk(res); await throwIfResNotOk(res, url);
return res; return res;
} }
@@ -29,7 +52,8 @@ export const getQueryFn: <T>(options: {
}) => QueryFunction<T> = }) => QueryFunction<T> =
({ on401: unauthorizedBehavior }) => ({ on401: unauthorizedBehavior }) =>
async ({ queryKey }) => { async ({ queryKey }) => {
const res = await fetch(queryKey.join("/") as string, { const url = queryKey.join("/") as string;
const res = await fetch(url, {
credentials: "include", credentials: "include",
}); });
@@ -37,7 +61,7 @@ export const getQueryFn: <T>(options: {
return null; return null;
} }
await throwIfResNotOk(res); await throwIfResNotOk(res, url);
return await res.json(); return await res.json();
}; };
+5
View File
@@ -8,6 +8,7 @@ import { useToast } from "@/hooks/use-toast";
import { Trash2, Music, Upload, Loader2, Play, Pause } from "lucide-react"; import { Trash2, Music, Upload, Loader2, Play, Pause } from "lucide-react";
import { format } from "date-fns"; import { format } from "date-fns";
import { fr } from "date-fns/locale"; import { fr } from "date-fns/locale";
import { handleUnauthorized } from "@/lib/queryClient";
interface AudioTrack { interface AudioTrack {
id: number; id: number;
@@ -41,9 +42,11 @@ export default function AudioAdmin() {
const res = await fetch("/api/audio-tracks", { const res = await fetch("/api/audio-tracks", {
method: "POST", method: "POST",
body: formData, body: formData,
credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
if (res.status === 401) handleUnauthorized("/api/audio-tracks");
const error = await res.json(); const error = await res.json();
throw new Error(error.error || "Erreur lors de l'upload"); throw new Error(error.error || "Erreur lors de l'upload");
} }
@@ -77,9 +80,11 @@ export default function AudioAdmin() {
mutationFn: async (id: number) => { mutationFn: async (id: number) => {
const res = await fetch(`/api/audio-tracks/${id}`, { const res = await fetch(`/api/audio-tracks/${id}`, {
method: "DELETE", method: "DELETE",
credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
if (res.status === 401) handleUnauthorized(`/api/audio-tracks/${id}`);
throw new Error("Erreur lors de la suppression"); throw new Error("Erreur lors de la suppression");
} }
}, },
+6 -2
View File
@@ -32,7 +32,7 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent } from "@/components/ui/dialog"; import { Dialog, DialogContent } from "@/components/ui/dialog";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient"; import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient";
import type { SocialPage, Media, ScheduledPost } from "@shared/schema"; import type { SocialPage, Media, ScheduledPost } from "@shared/schema";
import { PreviewModal } from "@/components/preview-modal"; import { PreviewModal } from "@/components/preview-modal";
import { DateTimePicker } from "@/components/datetime-picker"; import { DateTimePicker } from "@/components/datetime-picker";
@@ -152,8 +152,12 @@ export default function NewPostMobile() {
const response = await fetch("/api/media/upload", { const response = await fetch("/api/media/upload", {
method: "POST", method: "POST",
body: formData, body: formData,
credentials: "include",
}); });
if (!response.ok) throw new Error("Upload failed"); if (!response.ok) {
if (response.status === 401) handleUnauthorized("/api/media/upload");
throw new Error("Upload failed");
}
return response.json(); return response.json();
}, },
onSuccess: (data: Media) => { onSuccess: (data: Media) => {
+3 -1
View File
@@ -16,7 +16,7 @@ import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Slider } from "@/components/ui/slider"; import { Slider } from "@/components/ui/slider";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient"; import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient";
import type { SocialPage, Media } from "@shared/schema"; import type { SocialPage, Media } from "@shared/schema";
import { DateTimePicker } from "@/components/datetime-picker"; import { DateTimePicker } from "@/components/datetime-picker";
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"; import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
@@ -145,8 +145,10 @@ export default function MobileNewReel() {
const response = await fetch("/api/media/upload", { const response = await fetch("/api/media/upload", {
method: "POST", method: "POST",
body: formData, body: formData,
credentials: "include",
}); });
if (!response.ok) { if (!response.ok) {
if (response.status === 401) handleUnauthorized("/api/media/upload");
const errorText = await response.text(); const errorText = await response.text();
throw new Error(`Upload failed: ${response.status} ${response.statusText} - ${errorText.substring(0, 100)}`); throw new Error(`Upload failed: ${response.status} ${response.statusText} - ${errorText.substring(0, 100)}`);
} }
+8 -5
View File
@@ -13,7 +13,7 @@ import Sidebar from "@/components/sidebar";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { DateTimePicker } from "@/components/datetime-picker"; import { DateTimePicker } from "@/components/datetime-picker";
import { useQuery, useMutation } from "@tanstack/react-query"; import { useQuery, useMutation } from "@tanstack/react-query";
import { apiRequest, queryClient } from "@/lib/queryClient"; import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient";
import type { Media, SocialPage } from "@shared/schema"; import type { Media, SocialPage } from "@shared/schema";
interface AudioTrack { id: string; title: string; fileName: string; url: string; duration: number; } interface AudioTrack { id: string; title: string; fileName: string; url: string; duration: number; }
@@ -152,8 +152,8 @@ export default function MobileRemotionVideoPage() {
if (selectedPageIds[0]) formData.append("selectedPageId", selectedPageIds[0]); if (selectedPageIds[0]) formData.append("selectedPageId", selectedPageIds[0]);
if (musicFile) { formData.append("music", musicFile); formData.append("musicVolume", String(musicVolume)); } if (musicFile) { formData.append("music", musicFile); formData.append("musicVolume", String(musicVolume)); }
else if (selectedTrack) { formData.append("musicTrackUrl", selectedTrack.url); formData.append("musicVolume", String(musicVolume)); } else if (selectedTrack) { formData.append("musicTrackUrl", selectedTrack.url); formData.append("musicVolume", String(musicVolume)); }
const response = await fetch("/api/remotion/render", { method: "POST", body: formData }); const response = await fetch("/api/remotion/render", { method: "POST", body: formData, credentials: "include" });
if (!response.ok) { const e = await response.json().catch(() => ({})); throw new Error(e.error || "Erreur"); } if (!response.ok) { if (response.status === 401) handleUnauthorized("/api/remotion/render"); const e = await response.json().catch(() => ({})); throw new Error(e.error || "Erreur"); }
const data = await response.json(); const data = await response.json();
if (data.jobId) { if (data.jobId) {
@@ -161,8 +161,11 @@ export default function MobileRemotionVideoPage() {
const checkStatus = async () => { const checkStatus = async () => {
try { try {
const res = await fetch(`/api/remotion/render/status/${data.jobId}`); const res = await fetch(`/api/remotion/render/status/${data.jobId}`, { credentials: "include" });
if (!res.ok) throw new Error("Erreur de suivi du rendu"); if (!res.ok) {
if (res.status === 401) handleUnauthorized(`/api/remotion/render/status/${data.jobId}`);
throw new Error("Erreur de suivi du rendu");
}
const job = await res.json(); const job = await res.json();
if (job.status === 'done') { if (job.status === 'done') {
+2 -2
View File
@@ -23,7 +23,7 @@ export default function SettingsMobile() {
const [endpointUrl, setEndpointUrl] = useState(""); const [endpointUrl, setEndpointUrl] = useState("");
const [publicUrl, setPublicUrl] = useState(""); const [publicUrl, setPublicUrl] = useState("");
const [openrouterApiKey, setOpenrouterApiKey] = useState(""); const [openrouterApiKey, setOpenrouterApiKey] = useState("");
const [openrouterModel, setOpenrouterModel] = useState("anthropic/claude-3.5-sonnet"); const [openrouterModel, setOpenrouterModel] = useState("anthropic/claude-sonnet-4.5");
const [openrouterSystemPrompt, setOpenrouterSystemPrompt] = useState("Tu es un expert en marketing des réseaux sociaux. Génère 3 variations de textes engageants pour des publications Facebook et Instagram à partir des informations produit fournies. Chaque variation doit être unique, captivante et optimisée pour l'engagement."); const [openrouterSystemPrompt, setOpenrouterSystemPrompt] = useState("Tu es un expert en marketing des réseaux sociaux. Génère 3 variations de textes engageants pour des publications Facebook et Instagram à partir des informations produit fournies. Chaque variation doit être unique, captivante et optimisée pour l'engagement.");
const [logoFile, setLogoFile] = useState<File | null>(null); const [logoFile, setLogoFile] = useState<File | null>(null);
const [logoPreview, setLogoPreview] = useState<string | null>(null); const [logoPreview, setLogoPreview] = useState<string | null>(null);
@@ -64,7 +64,7 @@ export default function SettingsMobile() {
useEffect(() => { useEffect(() => {
if (openrouterConfig) { if (openrouterConfig) {
setOpenrouterModel((openrouterConfig as any).model || "anthropic/claude-3.5-sonnet"); setOpenrouterModel((openrouterConfig as any).model || "anthropic/claude-sonnet-4.5");
setOpenrouterSystemPrompt((openrouterConfig as any).systemPrompt || "Tu es un expert en marketing des réseaux sociaux. Génère 3 variations de textes engageants pour des publications Facebook et Instagram à partir des informations produit fournies. Chaque variation doit être unique, captivante et optimisée pour l'engagement."); setOpenrouterSystemPrompt((openrouterConfig as any).systemPrompt || "Tu es un expert en marketing des réseaux sociaux. Génère 3 variations de textes engageants pour des publications Facebook et Instagram à partir des informations produit fournies. Chaque variation doit être unique, captivante et optimisée pour l'engagement.");
} }
}, [openrouterConfig]); }, [openrouterConfig]);
+6 -2
View File
@@ -31,7 +31,7 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent } from "@/components/ui/dialog"; import { Dialog, DialogContent } from "@/components/ui/dialog";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient"; import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient";
import { MediaThumbnail } from "@/components/media-thumbnail"; import { MediaThumbnail } from "@/components/media-thumbnail";
import type { SocialPage, Media, ScheduledPost } from "@shared/schema"; import type { SocialPage, Media, ScheduledPost } from "@shared/schema";
import { PreviewModal } from "@/components/preview-modal"; import { PreviewModal } from "@/components/preview-modal";
@@ -148,8 +148,12 @@ export default function NewPost() {
const response = await fetch("/api/media/upload", { const response = await fetch("/api/media/upload", {
method: "POST", method: "POST",
body: formData, body: formData,
credentials: "include",
}); });
if (!response.ok) throw new Error("Upload failed"); if (!response.ok) {
if (response.status === 401) handleUnauthorized("/api/media/upload");
throw new Error("Upload failed");
}
return response.json(); return response.json();
}, },
onSuccess: (data) => { onSuccess: (data) => {
+6 -2
View File
@@ -25,7 +25,7 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient"; import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient";
import type { SocialPage, Media } from "@shared/schema"; import type { SocialPage, Media } from "@shared/schema";
import { MediaThumbnail } from "@/components/media-thumbnail"; import { MediaThumbnail } from "@/components/media-thumbnail";
import { DateTimePicker } from "@/components/datetime-picker"; import { DateTimePicker } from "@/components/datetime-picker";
@@ -166,8 +166,12 @@ export default function NewReel() {
const response = await fetch("/api/media/upload", { const response = await fetch("/api/media/upload", {
method: "POST", method: "POST",
body: formData, body: formData,
credentials: "include",
}); });
if (!response.ok) throw new Error("Upload failed"); if (!response.ok) {
if (response.status === 401) handleUnauthorized("/api/media/upload");
throw new Error("Upload failed");
}
return response.json(); return response.json();
}, },
onSuccess: (data) => { onSuccess: (data) => {
+8 -5
View File
@@ -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 { UploadCloud, Video, Loader2, Check, Sparkles, Mic, Volume2, Music, Play, Pause, Send } from "lucide-react";
import { Slider } from "@/components/ui/slider"; import { Slider } from "@/components/ui/slider";
import { useQuery, useMutation } from "@tanstack/react-query"; import { useQuery, useMutation } from "@tanstack/react-query";
import { apiRequest, queryClient } from "@/lib/queryClient"; import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
@@ -119,8 +119,8 @@ export default function RemotionVideoPage() {
if (selectedPageIds[0]) formData.append("selectedPageId", selectedPageIds[0]); if (selectedPageIds[0]) formData.append("selectedPageId", selectedPageIds[0]);
if (musicFile) { formData.append("music", musicFile); formData.append("musicVolume", String(musicVolume)); } if (musicFile) { formData.append("music", musicFile); formData.append("musicVolume", String(musicVolume)); }
else if (selectedTrack) { formData.append("musicTrackUrl", selectedTrack.url); formData.append("musicVolume", String(musicVolume)); } else if (selectedTrack) { formData.append("musicTrackUrl", selectedTrack.url); formData.append("musicVolume", String(musicVolume)); }
const response = await fetch("/api/remotion/render", { method: "POST", body: formData }); const response = await fetch("/api/remotion/render", { method: "POST", body: formData, credentials: "include" });
if (!response.ok) { const e = await response.json().catch(() => ({})); throw new Error(e.error || "Erreur"); } if (!response.ok) { if (response.status === 401) handleUnauthorized("/api/remotion/render"); const e = await response.json().catch(() => ({})); throw new Error(e.error || "Erreur"); }
const data = await response.json(); const data = await response.json();
if (data.jobId) { if (data.jobId) {
@@ -128,8 +128,11 @@ export default function RemotionVideoPage() {
const checkStatus = async () => { const checkStatus = async () => {
try { try {
const res = await fetch(`/api/remotion/render/status/${data.jobId}`); const res = await fetch(`/api/remotion/render/status/${data.jobId}`, { credentials: "include" });
if (!res.ok) throw new Error("Erreur de suivi du rendu"); if (!res.ok) {
if (res.status === 401) handleUnauthorized(`/api/remotion/render/status/${data.jobId}`);
throw new Error("Erreur de suivi du rendu");
}
const job = await res.json(); const job = await res.json();
if (job.status === 'done') { if (job.status === 'done') {
+2 -2
View File
@@ -23,7 +23,7 @@ export default function Settings() {
const [endpointUrl, setEndpointUrl] = useState(""); const [endpointUrl, setEndpointUrl] = useState("");
const [publicUrl, setPublicUrl] = useState(""); const [publicUrl, setPublicUrl] = useState("");
const [openrouterApiKey, setOpenrouterApiKey] = useState(""); const [openrouterApiKey, setOpenrouterApiKey] = useState("");
const [openrouterModel, setOpenrouterModel] = useState("anthropic/claude-3.5-sonnet"); const [openrouterModel, setOpenrouterModel] = useState("anthropic/claude-sonnet-4.5");
const [openrouterSystemPrompt, setOpenrouterSystemPrompt] = useState("Tu es un expert en marketing des réseaux sociaux. Génère 3 variations de textes engageants pour des publications Facebook et Instagram à partir des informations produit fournies. Chaque variation doit être unique, captivante et optimisée pour l'engagement."); const [openrouterSystemPrompt, setOpenrouterSystemPrompt] = useState("Tu es un expert en marketing des réseaux sociaux. Génère 3 variations de textes engageants pour des publications Facebook et Instagram à partir des informations produit fournies. Chaque variation doit être unique, captivante et optimisée pour l'engagement.");
const [logoFile, setLogoFile] = useState<File | null>(null); const [logoFile, setLogoFile] = useState<File | null>(null);
const [logoPreview, setLogoPreview] = useState<string | null>(null); const [logoPreview, setLogoPreview] = useState<string | null>(null);
@@ -81,7 +81,7 @@ export default function Settings() {
useEffect(() => { useEffect(() => {
if (openrouterConfig) { if (openrouterConfig) {
setOpenrouterModel((openrouterConfig as any).model || "anthropic/claude-3.5-sonnet"); setOpenrouterModel((openrouterConfig as any).model || "anthropic/claude-sonnet-4.5");
setOpenrouterSystemPrompt((openrouterConfig as any).systemPrompt || "Tu es un expert en marketing des réseaux sociaux. Génère 3 variations de textes engageants pour des publications Facebook et Instagram à partir des informations produit fournies. Chaque variation doit être unique, captivante et optimisée pour l'engagement."); setOpenrouterSystemPrompt((openrouterConfig as any).systemPrompt || "Tu es un expert en marketing des réseaux sociaux. Génère 3 variations de textes engageants pour des publications Facebook et Instagram à partir des informations produit fournies. Chaque variation doit être unique, captivante et optimisée pour l'engagement.");
} }
}, [openrouterConfig]); }, [openrouterConfig]);
+26 -3
View File
@@ -159,7 +159,7 @@ class ReelRequest(BaseModel):
music_volume: float = 0.25 music_volume: float = 0.25
tts_enabled: bool = False tts_enabled: bool = False
tts_voice: str = "fr-FR-VivienneMultilingualNeural" tts_voice: str = "fr-FR-VivienneMultilingualNeural"
tts_engine: str = "edge" # "edge" or "gemini" tts_engine: str = "gemini" # "gemini" or "edge"
gemini_api_key: Optional[str] = None # Google Cloud API key for Gemini TTS gemini_api_key: Optional[str] = None # Google Cloud API key for Gemini TTS
draw_text: bool = True draw_text: bool = True
stabilize: bool = False # Stabilisation vidéo via vidstab stabilize: bool = False # Stabilisation vidéo via vidstab
@@ -924,9 +924,11 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)):
if tts_clean_text: if tts_clean_text:
print(f"🔊 Generating TTS audio to: {tts_audio_path}") print(f"🔊 Generating TTS audio to: {tts_audio_path}")
engine = request.tts_engine or "edge" engine = request.tts_engine or "gemini"
# Primary: Gemini TTS. Fallback: Edge TTS
if engine == "gemini" and request.gemini_api_key: if engine == "gemini" and request.gemini_api_key:
try:
await generate_tts_gemini( await generate_tts_gemini(
tts_clean_text, tts_clean_text,
voice, voice,
@@ -936,6 +938,16 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)):
display_text=clean_text_for_display(request.text), display_text=clean_text_for_display(request.text),
delay=2.0, delay=2.0,
) )
except Exception as gemini_err:
print(f"⚠️ Gemini TTS failed ({gemini_err}), falling back to Edge TTS")
await generate_tts_with_subs(
tts_clean_text,
voice,
tts_audio_path,
tts_ass_path,
display_text=clean_text_for_display(request.text),
delay=2.0,
)
else: else:
await generate_tts_with_subs( await generate_tts_with_subs(
tts_clean_text, tts_clean_text,
@@ -1323,9 +1335,11 @@ async def preview_tts(request: ReelRequest, x_api_key: str = Header(None)):
# Note: Gemini voices (fr-FR-Standard-A etc.) are valid Edge voices too, # Note: Gemini voices (fr-FR-Standard-A etc.) are valid Edge voices too,
# so we don't check for "Neural" - let edge_tts handle voice resolution # so we don't check for "Neural" - let edge_tts handle voice resolution
engine = request.tts_engine or "edge" engine = request.tts_engine or "gemini"
# Primary: Gemini TTS. Fallback: Edge TTS
if engine == "gemini" and request.gemini_api_key: if engine == "gemini" and request.gemini_api_key:
try:
await generate_tts_gemini( await generate_tts_gemini(
clean_text, clean_text,
voice, voice,
@@ -1334,6 +1348,15 @@ async def preview_tts(request: ReelRequest, x_api_key: str = Header(None)):
tts_srt_path, tts_srt_path,
display_text=clean_text_for_display(request.text), display_text=clean_text_for_display(request.text),
) )
except Exception as gemini_err:
print(f"⚠️ Gemini TTS failed ({gemini_err}), falling back to Edge TTS")
await generate_tts_with_subs(
clean_text,
voice,
tts_audio_path,
tts_srt_path,
display_text=clean_text_for_display(request.text),
)
else: else:
await generate_tts_with_subs( await generate_tts_with_subs(
clean_text, clean_text,
+1 -1
View File
@@ -88,7 +88,7 @@ CREATE TABLE IF NOT EXISTS openrouter_config (
id VARCHAR PRIMARY KEY DEFAULT gen_random_uuid(), id VARCHAR PRIMARY KEY DEFAULT gen_random_uuid(),
user_id VARCHAR NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, user_id VARCHAR NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
api_key TEXT NOT NULL, api_key TEXT NOT NULL,
model TEXT NOT NULL DEFAULT 'anthropic/claude-3.5-sonnet', model TEXT NOT NULL DEFAULT 'anthropic/claude-sonnet-4.5',
system_prompt TEXT NOT NULL DEFAULT 'Tu es un expert en marketing des réseaux sociaux. Génère 3 variations de textes engageants pour des publications Facebook et Instagram à partir des informations produit fournies. Chaque variation doit être unique, captivante et optimisée pour l''engagement.', system_prompt TEXT NOT NULL DEFAULT 'Tu es un expert en marketing des réseaux sociaux. Génère 3 variations de textes engageants pour des publications Facebook et Instagram à partir des informations produit fournies. Chaque variation doit être unique, captivante et optimisée pour l''engagement.',
created_at TIMESTAMP DEFAULT NOW(), created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW() updated_at TIMESTAMP DEFAULT NOW()
+12 -3
View File
@@ -12,29 +12,38 @@ interface GeneratedText {
characterCount: number; characterCount: number;
} }
import crypto from 'crypto';
import { storage } from '../storage'; import { storage } from '../storage';
export class OpenRouterService { export class OpenRouterService {
private baseUrl = "https://openrouter.ai/api/v1/chat/completions"; private baseUrl = "https://openrouter.ai/api/v1/chat/completions";
async generatePostText(productInfo: ProductInfo, userId: string, modelOverride?: string): Promise<GeneratedText[]> { async generatePostText(productInfo: ProductInfo, userId: string, modelOverride?: string): Promise<GeneratedText[]> {
// Get any available OpenRouter configuration (shared across all users) // Prefer the requesting user's own configuration; fall back to the most
const config = await storage.getAnyOpenrouterConfig(); // recently saved shared configuration if this user hasn't set one up.
const config = (await storage.getOpenrouterConfig(userId)) || (await storage.getAnyOpenrouterConfig());
if (!config) { if (!config) {
throw new Error('Configuration OpenRouter non trouvée. Veuillez demander à un administrateur de configurer OpenRouter dans les Paramètres.'); 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); const prompt = this.buildPrompt(productInfo, config.systemPrompt);
// Use provided model or fall back to config model // Use provided model or fall back to config model
const modelToUse = modelOverride || 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 { try {
const response = await fetch(this.baseUrl, { const response = await fetch(this.baseUrl, {
method: "POST", method: "POST",
headers: { headers: {
"Authorization": `Bearer ${config.apiKey}`, "Authorization": `Bearer ${config.apiKey.trim()}`,
"Content-Type": "application/json", "Content-Type": "application/json",
"HTTP-Referer": process.env.APP_URL || "http://localhost:5555", "HTTP-Referer": process.env.APP_URL || "http://localhost:5555",
"X-Title": "Social Flow" "X-Title": "Social Flow"
+1 -1
View File
@@ -461,7 +461,7 @@ export class DatabaseStorage implements IStorage {
} }
async getAnyOpenrouterConfig(): Promise<OpenrouterConfig | undefined> { async getAnyOpenrouterConfig(): Promise<OpenrouterConfig | undefined> {
const [config] = await db.select().from(openrouterConfig).limit(1); const [config] = await db.select().from(openrouterConfig).orderBy(desc(openrouterConfig.updatedAt)).limit(1);
return config || undefined; return config || undefined;
} }
+2 -2
View File
@@ -127,7 +127,7 @@ export const openrouterConfig = pgTable("openrouter_config", {
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`), id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
userId: varchar("user_id").notNull().unique().references(() => users.id, { onDelete: "cascade" }), userId: varchar("user_id").notNull().unique().references(() => users.id, { onDelete: "cascade" }),
apiKey: text("api_key").notNull(), apiKey: text("api_key").notNull(),
model: text("model").notNull().default("anthropic/claude-3.5-sonnet"), model: text("model").notNull().default("anthropic/claude-sonnet-4.5"),
systemPrompt: text("system_prompt").notNull().default("Tu es un expert en marketing des réseaux sociaux. Génère 3 variations de textes engageants pour des publications Facebook et Instagram à partir des informations produit fournies. Chaque variation doit être unique, captivante et optimisée pour l'engagement."), systemPrompt: text("system_prompt").notNull().default("Tu es un expert en marketing des réseaux sociaux. Génère 3 variations de textes engageants pour des publications Facebook et Instagram à partir des informations produit fournies. Chaque variation doit être unique, captivante et optimisée pour l'engagement."),
createdAt: timestamp("created_at").defaultNow(), createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(),
@@ -394,7 +394,7 @@ export const insertOpenrouterConfigSchema = createInsertSchema(openrouterConfig)
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
}).extend({ }).extend({
apiKey: z.string().min(1, "La clé API ne peut pas être vide"), apiKey: z.string().trim().min(1, "La clé API ne peut pas être vide"),
}); });
export const updateOpenrouterConfigSchema = insertOpenrouterConfigSchema.partial({ export const updateOpenrouterConfigSchema = insertOpenrouterConfigSchema.partial({