Merge pull request #53 from R0m1k3/dev

Dev
This commit is contained in:
LogiFlow authored and GitHub committed 2025-10-10 06:53:46 +02:00
commit e5baacc9d4
8 files changed
+139 -30

No files matched your search

+4
View File
@@ -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
+56 -4
View File
@@ -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">
+1
View File
@@ -417,6 +417,7 @@ export default function CalendarView() {
mediaList={previewData.allMedia} mediaList={previewData.allMedia}
onPublish={() => {}} onPublish={() => {}}
isPublishing={false} isPublishing={false}
readOnly={true}
/> />
</div> </div>
); );
+30 -17
View File
@@ -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>
); );
+2
View File
@@ -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
View File
@@ -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({
+27 -3
View File
@@ -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();