mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-23 06:58:12 +02:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2188fa458d | ||
|
|
75e1d88c26 | ||
|
|
8155b30c63 | ||
|
|
de7fc7d03c | ||
|
|
517c786be6 | ||
|
|
09b4e597ae | ||
|
|
b00d88f6b0 | ||
|
|
c008b41eb7 |
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -32,7 +32,7 @@ export default function AiChat() {
|
||||
},
|
||||
]);
|
||||
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();
|
||||
|
||||
// Fetch available models from OpenRouter
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useDropzone } from "react-dropzone";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
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 { CloudUpload, Image as ImageIcon, Video, X, Upload, Loader2, ZoomIn, Camera } from "lucide-react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
@@ -30,8 +30,12 @@ export default function MediaUpload() {
|
||||
const response = await fetch("/api/media/upload", {
|
||||
method: "POST",
|
||||
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();
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
@@ -55,8 +59,12 @@ export default function MediaUpload() {
|
||||
const response = await fetch("/api/media/upload", {
|
||||
method: "POST",
|
||||
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++;
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,7 +1,30 @@
|
||||
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.status === 401) {
|
||||
handleUnauthorized(url);
|
||||
}
|
||||
const text = (await res.text()) || res.statusText;
|
||||
throw new Error(`${res.status}: ${text}`);
|
||||
}
|
||||
@@ -19,7 +42,7 @@ export async function apiRequest(
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
await throwIfResNotOk(res);
|
||||
await throwIfResNotOk(res, url);
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -29,7 +52,8 @@ export const getQueryFn: <T>(options: {
|
||||
}) => QueryFunction<T> =
|
||||
({ on401: unauthorizedBehavior }) =>
|
||||
async ({ queryKey }) => {
|
||||
const res = await fetch(queryKey.join("/") as string, {
|
||||
const url = queryKey.join("/") as string;
|
||||
const res = await fetch(url, {
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
@@ -37,7 +61,7 @@ export const getQueryFn: <T>(options: {
|
||||
return null;
|
||||
}
|
||||
|
||||
await throwIfResNotOk(res);
|
||||
await throwIfResNotOk(res, url);
|
||||
return await res.json();
|
||||
};
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useToast } from "@/hooks/use-toast";
|
||||
import { Trash2, Music, Upload, Loader2, Play, Pause } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
import { handleUnauthorized } from "@/lib/queryClient";
|
||||
|
||||
interface AudioTrack {
|
||||
id: number;
|
||||
@@ -41,9 +42,11 @@ export default function AudioAdmin() {
|
||||
const res = await fetch("/api/audio-tracks", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) handleUnauthorized("/api/audio-tracks");
|
||||
const error = await res.json();
|
||||
throw new Error(error.error || "Erreur lors de l'upload");
|
||||
}
|
||||
@@ -77,9 +80,11 @@ export default function AudioAdmin() {
|
||||
mutationFn: async (id: number) => {
|
||||
const res = await fetch(`/api/audio-tracks/${id}`, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) handleUnauthorized(`/api/audio-tracks/${id}`);
|
||||
throw new Error("Erreur lors de la suppression");
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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 } from "@/lib/queryClient";
|
||||
import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient";
|
||||
import type { SocialPage, Media, ScheduledPost } from "@shared/schema";
|
||||
import { PreviewModal } from "@/components/preview-modal";
|
||||
import { DateTimePicker } from "@/components/datetime-picker";
|
||||
@@ -152,8 +152,12 @@ export default function NewPostMobile() {
|
||||
const response = await fetch("/api/media/upload", {
|
||||
method: "POST",
|
||||
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();
|
||||
},
|
||||
onSuccess: (data: Media) => {
|
||||
|
||||
@@ -16,7 +16,7 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
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 { DateTimePicker } from "@/components/datetime-picker";
|
||||
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
||||
@@ -145,8 +145,10 @@ export default function MobileNewReel() {
|
||||
const response = await fetch("/api/media/upload", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
credentials: "include",
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) handleUnauthorized("/api/media/upload");
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Upload failed: ${response.status} ${response.statusText} - ${errorText.substring(0, 100)}`);
|
||||
}
|
||||
|
||||
@@ -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 } from "@/lib/queryClient";
|
||||
import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient";
|
||||
import type { Media, SocialPage } from "@shared/schema";
|
||||
|
||||
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 (musicFile) { formData.append("music", musicFile); 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 });
|
||||
if (!response.ok) { const e = await response.json().catch(() => ({})); throw new Error(e.error || "Erreur"); }
|
||||
const response = await fetch("/api/remotion/render", { method: "POST", body: formData, credentials: "include" });
|
||||
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();
|
||||
|
||||
if (data.jobId) {
|
||||
@@ -161,8 +161,11 @@ export default function MobileRemotionVideoPage() {
|
||||
|
||||
const checkStatus = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/remotion/render/status/${data.jobId}`);
|
||||
if (!res.ok) throw new Error("Erreur de suivi du rendu");
|
||||
const res = await fetch(`/api/remotion/render/status/${data.jobId}`, { credentials: "include" });
|
||||
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();
|
||||
|
||||
if (job.status === 'done') {
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function SettingsMobile() {
|
||||
const [endpointUrl, setEndpointUrl] = useState("");
|
||||
const [publicUrl, setPublicUrl] = 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 [logoFile, setLogoFile] = useState<File | null>(null);
|
||||
const [logoPreview, setLogoPreview] = useState<string | null>(null);
|
||||
@@ -64,7 +64,7 @@ export default function SettingsMobile() {
|
||||
|
||||
useEffect(() => {
|
||||
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.");
|
||||
}
|
||||
}, [openrouterConfig]);
|
||||
|
||||
@@ -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 } from "@/lib/queryClient";
|
||||
import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient";
|
||||
import { MediaThumbnail } from "@/components/media-thumbnail";
|
||||
import type { SocialPage, Media, ScheduledPost } from "@shared/schema";
|
||||
import { PreviewModal } from "@/components/preview-modal";
|
||||
@@ -148,8 +148,12 @@ export default function NewPost() {
|
||||
const response = await fetch("/api/media/upload", {
|
||||
method: "POST",
|
||||
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();
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
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 { MediaThumbnail } from "@/components/media-thumbnail";
|
||||
import { DateTimePicker } from "@/components/datetime-picker";
|
||||
@@ -166,8 +166,12 @@ export default function NewReel() {
|
||||
const response = await fetch("/api/media/upload", {
|
||||
method: "POST",
|
||||
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();
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
|
||||
@@ -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 } from "@/lib/queryClient";
|
||||
import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
@@ -119,8 +119,8 @@ export default function RemotionVideoPage() {
|
||||
if (selectedPageIds[0]) formData.append("selectedPageId", selectedPageIds[0]);
|
||||
if (musicFile) { formData.append("music", musicFile); 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 });
|
||||
if (!response.ok) { const e = await response.json().catch(() => ({})); throw new Error(e.error || "Erreur"); }
|
||||
const response = await fetch("/api/remotion/render", { method: "POST", body: formData, credentials: "include" });
|
||||
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();
|
||||
|
||||
if (data.jobId) {
|
||||
@@ -128,8 +128,11 @@ export default function RemotionVideoPage() {
|
||||
|
||||
const checkStatus = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/remotion/render/status/${data.jobId}`);
|
||||
if (!res.ok) throw new Error("Erreur de suivi du rendu");
|
||||
const res = await fetch(`/api/remotion/render/status/${data.jobId}`, { credentials: "include" });
|
||||
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();
|
||||
|
||||
if (job.status === 'done') {
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function Settings() {
|
||||
const [endpointUrl, setEndpointUrl] = useState("");
|
||||
const [publicUrl, setPublicUrl] = 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 [logoFile, setLogoFile] = useState<File | null>(null);
|
||||
const [logoPreview, setLogoPreview] = useState<string | null>(null);
|
||||
@@ -81,7 +81,7 @@ export default function Settings() {
|
||||
|
||||
useEffect(() => {
|
||||
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.");
|
||||
}
|
||||
}, [openrouterConfig]);
|
||||
|
||||
+43
-20
@@ -159,7 +159,7 @@ class ReelRequest(BaseModel):
|
||||
music_volume: float = 0.25
|
||||
tts_enabled: bool = False
|
||||
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
|
||||
draw_text: bool = True
|
||||
stabilize: bool = False # Stabilisation vidéo via vidstab
|
||||
@@ -924,18 +924,30 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)):
|
||||
|
||||
if tts_clean_text:
|
||||
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:
|
||||
await generate_tts_gemini(
|
||||
tts_clean_text,
|
||||
voice,
|
||||
request.gemini_api_key,
|
||||
tts_audio_path,
|
||||
tts_ass_path,
|
||||
display_text=clean_text_for_display(request.text),
|
||||
delay=2.0,
|
||||
)
|
||||
try:
|
||||
await generate_tts_gemini(
|
||||
tts_clean_text,
|
||||
voice,
|
||||
request.gemini_api_key,
|
||||
tts_audio_path,
|
||||
tts_ass_path,
|
||||
display_text=clean_text_for_display(request.text),
|
||||
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:
|
||||
await generate_tts_with_subs(
|
||||
tts_clean_text,
|
||||
@@ -1323,17 +1335,28 @@ 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,
|
||||
# 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:
|
||||
await generate_tts_gemini(
|
||||
clean_text,
|
||||
voice,
|
||||
request.gemini_api_key,
|
||||
tts_audio_path,
|
||||
tts_srt_path,
|
||||
display_text=clean_text_for_display(request.text),
|
||||
)
|
||||
try:
|
||||
await generate_tts_gemini(
|
||||
clean_text,
|
||||
voice,
|
||||
request.gemini_api_key,
|
||||
tts_audio_path,
|
||||
tts_srt_path,
|
||||
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:
|
||||
await generate_tts_with_subs(
|
||||
clean_text,
|
||||
|
||||
@@ -88,7 +88,7 @@ CREATE TABLE IF NOT EXISTS openrouter_config (
|
||||
id VARCHAR PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id VARCHAR NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
|
||||
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.',
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
|
||||
@@ -12,29 +12,38 @@ interface GeneratedText {
|
||||
characterCount: number;
|
||||
}
|
||||
|
||||
import crypto from 'crypto';
|
||||
import { storage } from '../storage';
|
||||
|
||||
export class OpenRouterService {
|
||||
private baseUrl = "https://openrouter.ai/api/v1/chat/completions";
|
||||
|
||||
async generatePostText(productInfo: ProductInfo, userId: string, modelOverride?: string): Promise<GeneratedText[]> {
|
||||
// Get any available OpenRouter configuration (shared across all users)
|
||||
const config = await storage.getAnyOpenrouterConfig();
|
||||
// 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}`,
|
||||
"Authorization": `Bearer ${config.apiKey.trim()}`,
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": process.env.APP_URL || "http://localhost:5555",
|
||||
"X-Title": "Social Flow"
|
||||
|
||||
+1
-1
@@ -461,7 +461,7 @@ export class DatabaseStorage implements IStorage {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -127,7 +127,7 @@ export const openrouterConfig = pgTable("openrouter_config", {
|
||||
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||
userId: varchar("user_id").notNull().unique().references(() => users.id, { onDelete: "cascade" }),
|
||||
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."),
|
||||
createdAt: timestamp("created_at").defaultNow(),
|
||||
updatedAt: timestamp("updated_at").defaultNow(),
|
||||
@@ -394,7 +394,7 @@ export const insertOpenrouterConfigSchema = createInsertSchema(openrouterConfig)
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
}).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({
|
||||
|
||||
Reference in new issue
Block a user