Added image generation to blog writer

This commit is contained in:
ajaysi
2025-10-31 15:59:16 +05:30
parent 3219e6bbe4
commit cdb41aec1b
80 changed files with 7662 additions and 3951 deletions

View File

@@ -0,0 +1,297 @@
import React, { useEffect, useMemo, useState, forwardRef, useImperativeHandle } from 'react';
import { Box, Button, MenuItem, Select, TextField, Typography, FormControl, InputLabel, Grid, Card, CardMedia, CircularProgress, LinearProgress, Collapse, IconButton, Tabs, Tab, Tooltip } from '@mui/material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import { useImageGeneration, ImageGenerationRequest, fetchPromptSuggestions } from './useImageGeneration';
type Provider = 'gemini' | 'huggingface' | 'stability';
interface ImageGeneratorProps {
defaultProvider?: Provider;
defaultModel?: string;
defaultPrompt?: string;
onImageReady?: (base64: string) => void;
// Optional context to build SME, provider-tailored prompts
context?: {
title?: string | null;
outline?: any[];
research?: any;
persona?: { audience?: string; tone?: string; industry?: string } | any;
section?: {
heading?: string;
subheadings?: string[];
key_points?: string[];
keywords?: string[];
[key: string]: any;
};
};
}
export interface ImageGeneratorHandle {
suggest: () => Promise<void> | void;
generate: () => Promise<void> | void;
openAdvanced: () => void;
closeAdvanced: () => void;
}
export const ImageGenerator = React.forwardRef<ImageGeneratorHandle, ImageGeneratorProps>((
{ defaultProvider, defaultModel, defaultPrompt, onImageReady, context },
ref
) => {
const [provider, setProvider] = useState<Provider>(defaultProvider || (process.env.NEXT_PUBLIC_GPT_PROVIDER as Provider) || 'huggingface');
const [model, setModel] = useState<string>(defaultModel || 'black-forest-labs/FLUX.1-Krea-dev');
const [prompt, setPrompt] = useState<string>(defaultPrompt || '');
const [negative, setNegative] = useState<string>('');
const [width, setWidth] = useState<number>(1024);
const [height, setHeight] = useState<number>(1024);
const [showAdvanced, setShowAdvanced] = useState(false);
const { isGenerating, error, result, generate } = useImageGeneration();
const [loadingSuggestions, setLoadingSuggestions] = useState(false);
const [suggestions, setSuggestions] = useState<Array<{ prompt: string; negative_prompt?: string; width?: number; height?: number; overlay_text?: string }>>([]);
const [suggestionIndex, setSuggestionIndex] = useState<number>(0);
const canGenerate = useMemo(() => prompt.trim().length > 0 && !isGenerating, [prompt, isGenerating]);
// High-contrast input styling for readability on light backgrounds
const textInputSx = {
'& .MuiInputBase-input': { color: '#202124' },
'& .MuiInputLabel-root': { color: '#5f6368' },
'& .MuiOutlinedInput-notchedOutline': { borderColor: '#cbd5e1' },
'&:hover .MuiOutlinedInput-notchedOutline': { borderColor: '#94a3b8' },
'&.Mui-focused .MuiOutlinedInput-notchedOutline': { borderColor: '#1976d2' },
backgroundColor: '#ffffff'
} as const;
// Default negative prompts by provider for blog writer use-case
useEffect(() => {
if (negative.trim().length > 0) return;
if (provider === 'huggingface') {
setNegative('blurry, distorted, cartoon, low quality, bad anatomy, extra limbs, watermark, brand logos, text artifacts, oversaturated, noisy, jpeg artifacts');
} else if (provider === 'gemini') {
setNegative('cartoon, clip-art, abstract, noisy, low resolution, artifacts, watermark, brand logos, text artifacts');
} else {
setNegative('blurry, distorted, low quality, bad anatomy, extra limbs, watermark, brand logos, jpeg artifacts, oversharpened, text artifacts');
}
// run once on mount (and when provider changes if negative is empty)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [provider]);
// Auto-suggest on open for better defaults (only if no initial prompt)
useEffect(() => {
if (!prompt || prompt.trim().length === 0) {
// fire and forget; UI shows spinner on the button if user clicks again
suggestPrompt().catch(() => {});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Provider-specialized prompt suggestions using backend structured response; fallback locally
const suggestPrompt = async () => {
setLoadingSuggestions(true);
try {
const payload = {
provider,
title: context?.title || context?.section?.heading || defaultPrompt || '',
section: context?.section || undefined,
research: context?.research || undefined,
persona: context?.persona || undefined,
};
const suggs = await fetchPromptSuggestions(payload);
setSuggestions(suggs);
if (suggs.length > 0) {
setPrompt(suggs[0].prompt || '');
if (suggs[0].negative_prompt) setNegative(suggs[0].negative_prompt);
if (suggs[0].width) setWidth(suggs[0].width);
if (suggs[0].height) setHeight(suggs[0].height);
setSuggestionIndex(0);
}
} catch (e) {
// fallback to local heuristic
const title = (context?.section?.heading || context?.title || '').trim();
const subheads: string[] = context?.section?.subheadings || [];
const keyPoints: string[] = context?.section?.key_points || [];
const keywords: string[] = Array.isArray(context?.section?.keywords)
? context?.section?.keywords
: (Array.isArray(context?.research?.keywords?.primary_keywords)
? context?.research?.keywords?.primary_keywords
: (context?.research?.keywords?.primary || []));
const primary = keywords?.slice(0, 5).filter(Boolean).join(', ');
const audience = context?.persona?.audience || 'content creators and digital marketers';
const industry = context?.persona?.industry || context?.research?.domain || 'your industry';
const tone = context?.persona?.tone || 'professional, trustworthy';
const narrativeHints = [
subheads?.length ? `Subheadings: ${subheads.slice(0,3).join(' | ')}` : null,
keyPoints?.length ? `Key points: ${keyPoints.slice(0,3).join(' | ')}` : null,
].filter(Boolean).join('. ');
setPrompt(`${title}${narrativeHints}. Emphasis: ${primary}. Audience: ${audience}. Industry: ${industry}. Tone: ${tone}.`);
} finally {
setLoadingSuggestions(false);
}
};
const onGenerate = async () => {
const req: ImageGenerationRequest = { prompt, negative_prompt: negative, provider, model, width, height };
const res = await generate(req);
if (res && onImageReady) onImageReady(res.image_base64);
// publish to image bus for downstream consumers (e.g., SEO metadata modal)
try {
const { publishImage } = await import('../../utils/imageBus');
publishImage({ base64: res.image_base64, provider: res.provider, model: res.model });
} catch {}
};
useImperativeHandle(ref, () => ({
suggest: () => suggestPrompt(),
generate: () => onGenerate(),
openAdvanced: () => setShowAdvanced(v => !v),
closeAdvanced: () => setShowAdvanced(false)
}));
return (
<Box>
<Typography variant="h6" sx={{ mb: 2, fontWeight: 600, color: '#202124' }}>Generate Blog Section Image</Typography>
{/* Advanced Options in Header Area */}
<Collapse in={showAdvanced}>
<Box sx={{ mb: 2, border: '1px solid #e0e0e0', borderRadius: 1, p: 1.5, backgroundColor: '#fafafa', color: '#202124' }}>
<Grid container spacing={2}>
<Grid item xs={12} md={3}>
<Tooltip title="Select the AI image generation provider. Hugging Face offers photorealistic Flux models, Gemini provides brand-safe editorial images, and Stability AI delivers SDXL-quality professional outputs." placement="top" arrow>
<FormControl fullWidth>
<InputLabel>Provider</InputLabel>
<Select value={provider} label="Provider" onChange={(e) => setProvider(e.target.value as Provider)} sx={textInputSx} MenuProps={{ PaperProps: { sx: { color: '#202124' } } }}>
<MenuItem value="huggingface">Hugging Face</MenuItem>
<MenuItem value="gemini">Gemini</MenuItem>
<MenuItem value="stability">Stability</MenuItem>
</Select>
</FormControl>
</Tooltip>
</Grid>
<Grid item xs={12} md={5}>
<Tooltip title="Specify the exact model to use. Leave empty to use the provider's default. For Hugging Face, the default is FLUX.1-Krea-dev, optimized for photorealistic blog images." placement="top" arrow>
<TextField fullWidth label="Model" value={model} onChange={(e) => setModel(e.target.value)} helperText={provider === 'huggingface' ? 'Default: black-forest-labs/FLUX.1-Krea-dev' : 'Leave empty to use provider default'} sx={textInputSx} />
</Tooltip>
</Grid>
<Grid item xs={6} md={2}>
<Tooltip title="Image width in pixels. Recommended: 1024 for square images, 1920 for landscape covers. Higher values increase quality but take longer to generate." placement="top" arrow>
<TextField fullWidth type="number" label="Width" value={width} onChange={(e) => setWidth(parseInt(e.target.value || '0', 10))} sx={textInputSx} />
</Tooltip>
</Grid>
<Grid item xs={6} md={2}>
<Tooltip title="Image height in pixels. Recommended: 1024 for square images, 1080 for portrait covers. Aspect ratio affects composition and visual appeal." placement="top" arrow>
<TextField fullWidth type="number" label="Height" value={height} onChange={(e) => setHeight(parseInt(e.target.value || '0', 10))} sx={textInputSx} />
</Tooltip>
</Grid>
</Grid>
</Box>
</Collapse>
{/* Loading indicators */}
{loadingSuggestions && (
<Box sx={{ mb: 2 }}>
<Typography variant="caption" sx={{ mb: 0.5, display: 'block' }}>Loading suggestions...</Typography>
<LinearProgress />
</Box>
)}
{isGenerating && (
<Box sx={{ mb: 2 }}>
<Typography variant="caption" sx={{ mb: 0.5, display: 'block' }}>Generating image...</Typography>
<LinearProgress />
</Box>
)}
{/* Prompt and Negative Prompt Side by Side - 80/20 split, stack on mobile */}
<Box sx={{ mb: 2, display: { xs: 'block', md: 'flex' }, gap: 2 }}>
<Tooltip title="Describe what you want in the image. Be specific: mention style (photorealistic, editorial, cinematic), subjects, composition, lighting, and mood. The AI uses this to generate your image. Tips: Include camera settings (e.g., '50mm lens, f/2.8'), lighting direction, and visual emphasis." placement="top" arrow>
<TextField
sx={{ flex: { md: '0 0 80%' }, width: { xs: '100%' }, mb: { xs: 2, md: 0 } }}
InputProps={{ sx: { color: '#202124' } }}
InputLabelProps={{ sx: { color: '#5f6368' } }}
FormHelperTextProps={{ sx: { color: '#5f6368' } }}
multiline
minRows={3}
label="Prompt"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="Describe the image..."
/>
</Tooltip>
<Tooltip title="List elements you want to avoid in the image (e.g., blurry, cartoon, watermark, low quality). This helps the AI exclude unwanted features. Common items: text artifacts, brand logos, distorted anatomy, oversaturation, noise." placement="top" arrow>
<TextField
sx={{ flex: { md: '0 0 20%' }, width: { xs: '100%' } }}
InputProps={{ sx: { color: '#202124' } }}
InputLabelProps={{ sx: { color: '#5f6368' } }}
multiline
minRows={3}
label="Negative Prompt (optional)"
value={negative}
onChange={(e) => setNegative(e.target.value)}
/>
</Tooltip>
</Box>
{/* Action Buttons */}
<Grid container spacing={2}>
<Grid item xs={12}>
<Tooltip title="Get AI-generated prompt suggestions tailored to your blog section. Uses your section title, subheadings, key points, keywords, and research data to create hyper-personalized prompts optimized for your chosen provider. Click to see multiple suggestions in tabs." placement="top" arrow>
<span>
<Button sx={{ mr: 1 }} variant="outlined" onClick={suggestPrompt} disabled={loadingSuggestions}>{loadingSuggestions ? 'Suggesting…' : 'Suggest prompt'}</Button>
</span>
</Tooltip>
<Tooltip title="Generate the image using your current prompt and settings. The process may take 10-30 seconds depending on provider and image size. Once generated, the image will appear below and can be used for your blog section." placement="top" arrow>
<span>
<Button variant="contained" disabled={!canGenerate} onClick={onGenerate} startIcon={isGenerating ? <CircularProgress size={18} /> : undefined}>
{isGenerating ? 'Generating…' : 'Generate Image'}
</Button>
</span>
</Tooltip>
</Grid>
{error && (
<Grid item xs={12}>
<Typography color="error" variant="body2">{error}</Typography>
</Grid>
)}
{result && (
<Grid item xs={12}>
<Card sx={{ maxWidth: 512 }}>
<CardMedia component="img" image={`data:image/png;base64,${result.image_base64}`} alt="generated" />
</Card>
</Grid>
)}
{suggestions.length > 0 && (
<Grid item xs={12}>
<Tooltip title="Browse through AI-generated prompt suggestions. Each tab shows a different prompt optimized for your section and provider. Click a tab to preview and auto-fill the prompt fields. You can then modify or use it directly." placement="top" arrow>
<div>
<Tabs value={suggestionIndex} onChange={(e, v) => {
setSuggestionIndex(v);
const s = suggestions[v];
if (s) {
setPrompt(s.prompt || '');
setNegative(s.negative_prompt || '');
if (s.width) setWidth(s.width);
if (s.height) setHeight(s.height);
}
}} variant="scrollable" scrollButtons allowScrollButtonsMobile>
{suggestions.map((_, i) => (
<Tab key={i} label={`Prompt ${i + 1}`} />
))}
</Tabs>
</div>
</Tooltip>
<Tooltip title="Preview of the currently selected prompt suggestion. Shows the main prompt and negative prompt (if any). This preview updates when you click different tabs above." placement="top" arrow>
<Box sx={{ p: 2, border: '1px solid #e0e0e0', borderTop: 'none', borderRadius: '0 0 8px 8px', background: '#fff' }}>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Preview</Typography>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', color: '#202124' }}>{suggestions[suggestionIndex]?.prompt}</Typography>
{suggestions[suggestionIndex]?.negative_prompt && (
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block', mt: 1 }}>Negative: {suggestions[suggestionIndex]?.negative_prompt}</Typography>
)}
</Box>
</Tooltip>
</Grid>
)}
</Grid>
</Box>
);
});
export default ImageGenerator;

View File

@@ -0,0 +1,109 @@
import React, { useMemo, useRef } from 'react';
import { Tooltip } from '@mui/material';
import ImageGenerator, { ImageGeneratorHandle } from './ImageGenerator';
interface ImageGeneratorModalProps {
isOpen: boolean;
onClose: () => void;
defaultPrompt?: string;
context?: any;
onImageGenerated?: (imageBase64: string, sectionId?: string) => void;
}
const overlayStyle: React.CSSProperties = {
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.5)',
zIndex: 2000,
display: 'flex',
justifyContent: 'center',
alignItems: 'stretch'
};
const modalStyle: React.CSSProperties = {
background: '#fff',
width: '100%',
maxWidth: '1200px',
margin: '24px',
borderRadius: 12,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column'
};
const headerStyle: React.CSSProperties = {
padding: '16px 20px',
borderBottom: '1px solid #e0e0e0',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
color: '#202124'
};
const bodyStyle: React.CSSProperties = {
padding: 20,
overflow: 'auto',
flex: 1
};
const ImageGeneratorModal: React.FC<ImageGeneratorModalProps> = ({ isOpen, onClose, defaultPrompt, context, onImageGenerated }) => {
const handleImageReady = (base64: string) => {
if (onImageGenerated) {
onImageGenerated(base64, context?.section?.id || context?.sectionId);
}
};
const imageRef = useRef<ImageGeneratorHandle>(null);
const sectionTitle = useMemo(() => context?.section?.heading || context?.title || 'Generate Blog Section Image', [context]);
if (!isOpen) return null;
return (
<div style={overlayStyle} onClick={onClose}>
<div style={modalStyle} onClick={(e) => e.stopPropagation()}>
<div style={headerStyle}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<h3 style={{ margin: 0 }}>{sectionTitle}</h3>
<span style={{ fontSize: 12, color: '#5f6368' }}>Generate Blog Section Image</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Tooltip title="Toggle advanced image generation settings. Opens provider selection (Hugging Face, Gemini, Stability AI), model specification, and image dimensions (width/height). Hover or click to show/hide these options." placement="bottom" arrow>
<button
onMouseEnter={() => imageRef.current?.openAdvanced()}
onClick={() => {
// toggle
if (imageRef.current) {
imageRef.current.openAdvanced();
}
}}
style={{ border: '1px solid #cbd5e1', background: '#ffffff', color: '#334155', borderRadius: 20, padding: '6px 12px', cursor: 'pointer', boxShadow: '0 1px 2px rgba(0,0,0,0.04)' }}
>
Advanced Image Options
</button>
</Tooltip>
<Tooltip title="Get AI-powered prompt suggestions tailored to your blog section. Uses section title, subheadings, key points, keywords, and research data to generate multiple hyper-personalized prompts. Suggestions appear as tabs below." placement="bottom" arrow>
<button
onClick={() => imageRef.current?.suggest()}
style={{ border: '1px solid #1976d2', background: '#fff', color: '#1976d2', borderRadius: 20, padding: '6px 12px', cursor: 'pointer' }}
>
Suggest Prompt
</button>
</Tooltip>
<Tooltip title="Close the image generator modal. Any generated images are saved and will appear in your blog section." placement="bottom" arrow>
<button onClick={onClose} style={{ border: '1px solid #ddd', background: '#f5f5f5', borderRadius: 6, padding: '6px 10px', cursor: 'pointer' }}>Close</button>
</Tooltip>
</div>
</div>
<div style={bodyStyle}>
<ImageGenerator ref={imageRef} defaultPrompt={defaultPrompt || ''} context={context} onImageReady={handleImageReady} />
</div>
</div>
</div>
);
};
export default ImageGeneratorModal;

View File

@@ -0,0 +1,73 @@
import { useCallback, useState } from 'react';
import { apiClient } from '../../api/client';
export interface ImageGenerationRequest {
prompt: string;
negative_prompt?: string;
provider?: 'gemini' | 'huggingface' | 'stability';
model?: string;
width?: number;
height?: number;
guidance_scale?: number;
steps?: number;
seed?: number;
}
export interface ImageGenerationResponse {
success: boolean;
image_base64: string;
width: number;
height: number;
provider: string;
model?: string;
seed?: number;
}
export function useImageGeneration() {
const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<ImageGenerationResponse | null>(null);
const generate = useCallback(async (req: ImageGenerationRequest) => {
setIsGenerating(true);
setError(null);
try {
const { data } = await apiClient.post<ImageGenerationResponse>('/api/images/generate', req);
setResult(data);
return data;
} catch (e: any) {
const message = e?.response?.data?.detail || e?.message || 'Image generation failed';
setError(message);
throw new Error(message);
} finally {
setIsGenerating(false);
}
}, []);
return { isGenerating, error, result, generate };
}
export interface PromptSuggestion {
prompt: string;
negative_prompt?: string;
width?: number;
height?: number;
overlay_text?: string;
}
export async function fetchPromptSuggestions(payload: any): Promise<PromptSuggestion[]> {
const res = await fetch('/api/images/suggest-prompts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(payload)
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || 'Failed to fetch prompt suggestions');
}
const data = await res.json();
return data.suggestions || [];
}