mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-11 17:26:45 +02:00
8 files changed
+139
-30
No files matched your search
@@ -34,6 +34,10 @@ externalPort = 5000
|
|||||||
localPort = 37659
|
localPort = 37659
|
||||||
externalPort = 4200
|
externalPort = 4200
|
||||||
|
|
||||||
|
[[ports]]
|
||||||
|
localPort = 38631
|
||||||
|
externalPort = 8080
|
||||||
|
|
||||||
[[ports]]
|
[[ports]]
|
||||||
localPort = 40537
|
localPort = 40537
|
||||||
externalPort = 3000
|
externalPort = 3000
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||||
import { Bot, User, Lightbulb, History, Zap, Sparkles } from "lucide-react";
|
import { Bot, User, Lightbulb, History, Zap, Sparkles, Loader2 } from "lucide-react";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
|
||||||
interface Message {
|
interface Message {
|
||||||
role: "user" | "assistant";
|
role: "user" | "assistant";
|
||||||
@@ -13,6 +14,16 @@ interface Message {
|
|||||||
variants?: Array<{ variant: string; text: string; characterCount: number }>;
|
variants?: Array<{ variant: string; text: string; characterCount: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface OpenRouterModel {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
context_length?: number;
|
||||||
|
pricing?: {
|
||||||
|
prompt: string;
|
||||||
|
completion: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default function AiChat() {
|
export default function AiChat() {
|
||||||
const [messages, setMessages] = useState<Message[]>([
|
const [messages, setMessages] = useState<Message[]>([
|
||||||
{
|
{
|
||||||
@@ -21,11 +32,22 @@ export default function AiChat() {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
|
const [selectedModel, setSelectedModel] = useState("anthropic/claude-3.5-sonnet");
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
// Fetch available models from OpenRouter
|
||||||
|
const { data: modelsData, isLoading: modelsLoading } = useQuery<{ models: OpenRouterModel[] }>({
|
||||||
|
queryKey: ['/api/ai/models'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const availableModels = modelsData?.models || [];
|
||||||
|
|
||||||
const generateMutation = useMutation({
|
const generateMutation = useMutation({
|
||||||
mutationFn: async (productInfo: any) => {
|
mutationFn: async (productInfo: any) => {
|
||||||
const response = await apiRequest("POST", "/api/ai/generate", productInfo);
|
const response = await apiRequest("POST", "/api/ai/generate", {
|
||||||
|
...productInfo,
|
||||||
|
model: selectedModel,
|
||||||
|
});
|
||||||
return response.json();
|
return response.json();
|
||||||
},
|
},
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
@@ -68,7 +90,7 @@ export default function AiChat() {
|
|||||||
return (
|
return (
|
||||||
<div className="bg-card rounded-2xl border border-border/50 overflow-hidden shadow-lg h-full flex flex-col">
|
<div className="bg-card rounded-2xl border border-border/50 overflow-hidden shadow-lg h-full flex flex-col">
|
||||||
<div className="border-b border-border/50 p-6 bg-gradient-to-r from-primary/5 to-secondary/5">
|
<div className="border-b border-border/50 p-6 bg-gradient-to-r from-primary/5 to-secondary/5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-primary to-secondary flex items-center justify-center shadow-lg">
|
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-primary to-secondary flex items-center justify-center shadow-lg">
|
||||||
<Sparkles className="text-white w-6 h-6" />
|
<Sparkles className="text-white w-6 h-6" />
|
||||||
@@ -89,6 +111,36 @@ export default function AiChat() {
|
|||||||
Nouveau chat
|
Nouveau chat
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="text-sm font-medium text-foreground">Modèle IA:</label>
|
||||||
|
<Select value={selectedModel} onValueChange={setSelectedModel} disabled={modelsLoading}>
|
||||||
|
<SelectTrigger className="w-[300px] rounded-xl" data-testid="select-ai-model">
|
||||||
|
{modelsLoading ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
<span>Chargement des modèles...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<SelectValue placeholder="Sélectionner un modèle" />
|
||||||
|
)}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-[300px] overflow-y-auto">
|
||||||
|
{availableModels.map((model: OpenRouterModel) => (
|
||||||
|
<SelectItem key={model.id} value={model.id} data-testid={`model-option-${model.id}`}>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{model.name}</span>
|
||||||
|
{model.pricing && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
${model.pricing.prompt} / ${model.pricing.completion}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6 space-y-6 bg-muted/20">
|
<div className="flex-1 overflow-y-auto p-6 space-y-6 bg-muted/20">
|
||||||
|
|||||||
@@ -417,6 +417,7 @@ export default function CalendarView() {
|
|||||||
mediaList={previewData.allMedia}
|
mediaList={previewData.allMedia}
|
||||||
onPublish={() => {}}
|
onPublish={() => {}}
|
||||||
isPublishing={false}
|
isPublishing={false}
|
||||||
|
readOnly={true}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ interface PreviewModalProps {
|
|||||||
mediaList: Media[];
|
mediaList: Media[];
|
||||||
onPublish: () => void;
|
onPublish: () => void;
|
||||||
isPublishing: boolean;
|
isPublishing: boolean;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type PreviewFormat = 'facebook-feed' | 'instagram-feed' | 'instagram-story';
|
type PreviewFormat = 'facebook-feed' | 'instagram-feed' | 'instagram-story';
|
||||||
@@ -24,7 +25,8 @@ export function PreviewModal({
|
|||||||
selectedMedia,
|
selectedMedia,
|
||||||
mediaList,
|
mediaList,
|
||||||
onPublish,
|
onPublish,
|
||||||
isPublishing
|
isPublishing,
|
||||||
|
readOnly = false
|
||||||
}: PreviewModalProps) {
|
}: PreviewModalProps) {
|
||||||
const [previewFormat, setPreviewFormat] = useState<PreviewFormat>('facebook-feed');
|
const [previewFormat, setPreviewFormat] = useState<PreviewFormat>('facebook-feed');
|
||||||
const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
|
const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
|
||||||
@@ -311,22 +313,33 @@ export function PreviewModal({
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex justify-end gap-3">
|
<div className="flex justify-end gap-3">
|
||||||
<Button
|
{readOnly ? (
|
||||||
variant="outline"
|
<Button
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
data-testid="button-cancel-preview"
|
data-testid="button-close-preview"
|
||||||
>
|
>
|
||||||
Annuler
|
Fermer
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
) : (
|
||||||
onClick={onPublish}
|
<>
|
||||||
disabled={isPublishing}
|
<Button
|
||||||
className="bg-gradient-to-r from-primary to-secondary hover:opacity-90"
|
variant="outline"
|
||||||
data-testid="button-publish-from-preview"
|
onClick={() => onOpenChange(false)}
|
||||||
>
|
data-testid="button-cancel-preview"
|
||||||
<Send className="w-4 h-4 mr-2" />
|
>
|
||||||
{isPublishing ? 'Publication...' : 'Publier'}
|
Annuler
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={onPublish}
|
||||||
|
disabled={isPublishing}
|
||||||
|
className="bg-gradient-to-r from-primary to-secondary hover:opacity-90"
|
||||||
|
data-testid="button-publish-from-preview"
|
||||||
|
>
|
||||||
|
<Send className="w-4 h-4 mr-2" />
|
||||||
|
{isPublishing ? 'Publication...' : 'Publier'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -23,10 +23,11 @@ export default function RecentPublications() {
|
|||||||
queryKey: ['/api/scheduled-posts'],
|
queryKey: ['/api/scheduled-posts'],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filter and sort published posts
|
// Filter and sort attempted posts (scheduled in the past)
|
||||||
|
const now = new Date();
|
||||||
const recentPublished = scheduledPosts
|
const recentPublished = scheduledPosts
|
||||||
.filter(sp => sp.publishedAt)
|
.filter(sp => sp.scheduledAt && new Date(sp.scheduledAt) <= now)
|
||||||
.sort((a, b) => new Date(b.publishedAt!).getTime() - new Date(a.publishedAt!).getTime())
|
.sort((a, b) => new Date(b.scheduledAt!).getTime() - new Date(a.scheduledAt!).getTime())
|
||||||
.slice(0, 10);
|
.slice(0, 10);
|
||||||
|
|
||||||
const handlePreviewPost = async (scheduledPost: ScheduledPostWithRelations) => {
|
const handlePreviewPost = async (scheduledPost: ScheduledPostWithRelations) => {
|
||||||
@@ -141,7 +142,7 @@ export default function RecentPublications() {
|
|||||||
</span>
|
</span>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>
|
<span>
|
||||||
{format(new Date(scheduledPost.publishedAt!), "d MMM yyyy 'à' HH:mm", { locale: fr })}
|
{format(new Date(scheduledPost.scheduledAt!), "d MMM yyyy 'à' HH:mm", { locale: fr })}
|
||||||
</span>
|
</span>
|
||||||
{scheduledPost.error && (
|
{scheduledPost.error && (
|
||||||
<>
|
<>
|
||||||
@@ -165,6 +166,7 @@ export default function RecentPublications() {
|
|||||||
mediaList={previewData.allMedia}
|
mediaList={previewData.allMedia}
|
||||||
onPublish={() => {}}
|
onPublish={() => {}}
|
||||||
isPublishing={false}
|
isPublishing={false}
|
||||||
|
readOnly={true}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ Preferred communication style: Simple, everyday language.
|
|||||||
- **Calendar Auto-Refresh**: Fixed calendar not updating after creating a new scheduled post. Added `queryClient.invalidateQueries` with `refetchType: 'all'` in `createPostMutation.onSuccess` to force cache invalidation and immediate refetch
|
- **Calendar Auto-Refresh**: Fixed calendar not updating after creating a new scheduled post. Added `queryClient.invalidateQueries` with `refetchType: 'all'` in `createPostMutation.onSuccess` to force cache invalidation and immediate refetch
|
||||||
- **Dashboard Scheduled Posts Count**: Fixed scheduled posts count showing 0 in dashboard. Problem was that posts with `scheduledFor` were created with status "draft" instead of "scheduled". Solution: POST /api/posts now sets `status = "scheduled"` when `scheduledFor` is provided. Legacy posts updated with SQL: `UPDATE posts SET status = 'scheduled' WHERE scheduled_for IS NOT NULL AND status = 'draft'`
|
- **Dashboard Scheduled Posts Count**: Fixed scheduled posts count showing 0 in dashboard. Problem was that posts with `scheduledFor` were created with status "draft" instead of "scheduled". Solution: POST /api/posts now sets `status = "scheduled"` when `scheduledFor` is provided. Legacy posts updated with SQL: `UPDATE posts SET status = 'scheduled' WHERE scheduled_for IS NOT NULL AND status = 'draft'`
|
||||||
- **Preview in Calendar & History**: Added eye icon (👁️) buttons to preview posts across calendar (desktop/mobile) and history sections. Clicking the eye icon fetches full post data via GET /api/posts/:id and opens PreviewModal with post text and all associated media. Desktop calendar shows icon on hover; mobile and history show icon always. Preview available in calendar grid, popover ("voir plus"), list view, and recent publications history.
|
- **Preview in Calendar & History**: Added eye icon (👁️) buttons to preview posts across calendar (desktop/mobile) and history sections. Clicking the eye icon fetches full post data via GET /api/posts/:id and opens PreviewModal with post text and all associated media. Desktop calendar shows icon on hover; mobile and history show icon always. Preview available in calendar grid, popover ("voir plus"), list view, and recent publications history.
|
||||||
|
- **Dynamic AI Model Selector**: Replaced hardcoded list of 13 models with dynamic loading from OpenRouter API. Added GET /api/ai/models endpoint that fetches complete catalog (326+ models) from https://openrouter.ai/api/v1/models. AI Assistant page now displays all available models with pricing info in scrollable dropdown. Backend accepts optional model parameter to override config model. Loading state shows spinner while fetching models.
|
||||||
|
- **Read-Only Preview Mode**: Enhanced PreviewModal with optional `readOnly` prop that hides the "Publier" button when viewing posts from calendar or history. Calendar and history now pass `readOnly={true}` to PreviewModal, showing only "Fermer" button. History section updated to display all attempted posts (where `scheduledAt` is in the past) instead of only successfully published posts, and displays `scheduledAt` date instead of `publishedAt`.
|
||||||
|
|
||||||
## System Architecture
|
## System Architecture
|
||||||
|
|
||||||
|
|||||||
+13
-2
@@ -423,14 +423,25 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Get available AI models from OpenRouter
|
||||||
|
app.get("/api/ai/models", requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const models = await openRouterService.getAvailableModels();
|
||||||
|
res.json({ models });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching models:", error);
|
||||||
|
res.status(500).json({ error: "Failed to fetch models" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// AI text generation
|
// AI text generation
|
||||||
app.post("/api/ai/generate", requireAuth, async (req, res) => {
|
app.post("/api/ai/generate", requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const user = req.user as User;
|
const user = req.user as User;
|
||||||
const userId = user.id;
|
const userId = user.id;
|
||||||
const productInfo = req.body;
|
const { model, ...productInfo } = req.body;
|
||||||
|
|
||||||
const generatedTexts = await openRouterService.generatePostText(productInfo, userId);
|
const generatedTexts = await openRouterService.generatePostText(productInfo, userId, model);
|
||||||
|
|
||||||
// Save to database
|
// Save to database
|
||||||
await storage.createAiGeneration({
|
await storage.createAiGeneration({
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ 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): Promise<GeneratedText[]> {
|
async generatePostText(productInfo: ProductInfo, userId: string, modelOverride?: string): Promise<GeneratedText[]> {
|
||||||
// Get user's OpenRouter configuration
|
// Get user's OpenRouter configuration
|
||||||
const config = await storage.getOpenrouterConfig(userId);
|
const config = await storage.getOpenrouterConfig(userId);
|
||||||
|
|
||||||
@@ -26,6 +26,9 @@ export class OpenRouterService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const prompt = this.buildPrompt(productInfo, config.systemPrompt);
|
const prompt = this.buildPrompt(productInfo, config.systemPrompt);
|
||||||
|
|
||||||
|
// Use provided model or fall back to config model
|
||||||
|
const modelToUse = modelOverride || config.model;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(this.baseUrl, {
|
const response = await fetch(this.baseUrl, {
|
||||||
@@ -37,7 +40,7 @@ export class OpenRouterService {
|
|||||||
"X-Title": "Social Flow"
|
"X-Title": "Social Flow"
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: config.model,
|
model: modelToUse,
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
role: "user",
|
role: "user",
|
||||||
@@ -58,7 +61,7 @@ export class OpenRouterService {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (!data.choices || !data.choices[0]) {
|
if (!data.choices || !data.choices[0]) {
|
||||||
console.error('Invalid OpenRouter response - no choices:', { hasChoices: !!data.choices, model: config.model });
|
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.');
|
throw new Error('Réponse invalide de l\'API OpenRouter. Vérifiez votre clé API et votre modèle.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,6 +205,27 @@ VERSION 3 - ÉMOTIONNELLE:
|
|||||||
private capitalizeFirst(text: string): string {
|
private capitalizeFirst(text: string): string {
|
||||||
return text.charAt(0).toUpperCase() + text.slice(1).toLowerCase();
|
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();
|
export const openRouterService = new OpenRouterService();
|
||||||
Reference in new issue
Block a user