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
|
||||
externalPort = 4200
|
||||
|
||||
[[ports]]
|
||||
localPort = 38631
|
||||
externalPort = 8080
|
||||
|
||||
[[ports]]
|
||||
localPort = 40537
|
||||
externalPort = 3000
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
interface Message {
|
||||
role: "user" | "assistant";
|
||||
@@ -13,6 +14,16 @@ interface Message {
|
||||
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() {
|
||||
const [messages, setMessages] = useState<Message[]>([
|
||||
{
|
||||
@@ -21,11 +32,22 @@ export default function AiChat() {
|
||||
},
|
||||
]);
|
||||
const [input, setInput] = useState("");
|
||||
const [selectedModel, setSelectedModel] = useState("anthropic/claude-3.5-sonnet");
|
||||
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({
|
||||
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();
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
@@ -68,7 +90,7 @@ export default function AiChat() {
|
||||
return (
|
||||
<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="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between mb-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">
|
||||
<Sparkles className="text-white w-6 h-6" />
|
||||
@@ -89,6 +111,36 @@ export default function AiChat() {
|
||||
Nouveau chat
|
||||
</Button>
|
||||
</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 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}
|
||||
onPublish={() => {}}
|
||||
isPublishing={false}
|
||||
readOnly={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ interface PreviewModalProps {
|
||||
mediaList: Media[];
|
||||
onPublish: () => void;
|
||||
isPublishing: boolean;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
type PreviewFormat = 'facebook-feed' | 'instagram-feed' | 'instagram-story';
|
||||
@@ -24,7 +25,8 @@ export function PreviewModal({
|
||||
selectedMedia,
|
||||
mediaList,
|
||||
onPublish,
|
||||
isPublishing
|
||||
isPublishing,
|
||||
readOnly = false
|
||||
}: PreviewModalProps) {
|
||||
const [previewFormat, setPreviewFormat] = useState<PreviewFormat>('facebook-feed');
|
||||
const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
|
||||
@@ -311,22 +313,33 @@ export function PreviewModal({
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
data-testid="button-cancel-preview"
|
||||
>
|
||||
Annuler
|
||||
</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>
|
||||
{readOnly ? (
|
||||
<Button
|
||||
onClick={() => onOpenChange(false)}
|
||||
data-testid="button-close-preview"
|
||||
>
|
||||
Fermer
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
data-testid="button-cancel-preview"
|
||||
>
|
||||
Annuler
|
||||
</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>
|
||||
</DialogContent>
|
||||
|
||||
@@ -23,10 +23,11 @@ export default function RecentPublications() {
|
||||
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
|
||||
.filter(sp => sp.publishedAt)
|
||||
.sort((a, b) => new Date(b.publishedAt!).getTime() - new Date(a.publishedAt!).getTime())
|
||||
.filter(sp => sp.scheduledAt && new Date(sp.scheduledAt) <= now)
|
||||
.sort((a, b) => new Date(b.scheduledAt!).getTime() - new Date(a.scheduledAt!).getTime())
|
||||
.slice(0, 10);
|
||||
|
||||
const handlePreviewPost = async (scheduledPost: ScheduledPostWithRelations) => {
|
||||
@@ -141,7 +142,7 @@ export default function RecentPublications() {
|
||||
</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>
|
||||
{scheduledPost.error && (
|
||||
<>
|
||||
@@ -165,6 +166,7 @@ export default function RecentPublications() {
|
||||
mediaList={previewData.allMedia}
|
||||
onPublish={() => {}}
|
||||
isPublishing={false}
|
||||
readOnly={true}
|
||||
/>
|
||||
</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
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
|
||||
+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
|
||||
app.post("/api/ai/generate", requireAuth, async (req, res) => {
|
||||
try {
|
||||
const user = req.user as User;
|
||||
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
|
||||
await storage.createAiGeneration({
|
||||
|
||||
@@ -17,7 +17,7 @@ import { storage } from '../storage';
|
||||
export class OpenRouterService {
|
||||
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
|
||||
const config = await storage.getOpenrouterConfig(userId);
|
||||
|
||||
@@ -26,6 +26,9 @@ export class OpenRouterService {
|
||||
}
|
||||
|
||||
const prompt = this.buildPrompt(productInfo, config.systemPrompt);
|
||||
|
||||
// Use provided model or fall back to config model
|
||||
const modelToUse = modelOverride || config.model;
|
||||
|
||||
try {
|
||||
const response = await fetch(this.baseUrl, {
|
||||
@@ -37,7 +40,7 @@ export class OpenRouterService {
|
||||
"X-Title": "Social Flow"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: config.model,
|
||||
model: modelToUse,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
@@ -58,7 +61,7 @@ export class OpenRouterService {
|
||||
const data = await response.json();
|
||||
|
||||
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.');
|
||||
}
|
||||
|
||||
@@ -202,6 +205,27 @@ VERSION 3 - ÉMOTIONNELLE:
|
||||
private capitalizeFirst(text: string): string {
|
||||
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();
|
||||
Reference in new issue
Block a user