feat: add AI-generated alt text and captions for image placeholders
This commit is contained in:
parent
068bf9be8a
commit
d6b4109b22
@ -1,8 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
import { Box, Button, Alert, Stack, Dialog, DialogTitle, DialogContent, DialogActions, List, ListItem, ListItemButton, ListItemText } from '@mui/material';
|
||||
import { Box, Button, Alert, Stack, Dialog, DialogTitle, DialogContent, DialogActions, List, ListItem, ListItemButton, ListItemText, CircularProgress, Typography, FormControlLabel, Checkbox } from '@mui/material';
|
||||
import RichEditor, { type RichEditorHandle } from '../RichEditor';
|
||||
import StepHeader from './StepHeader';
|
||||
import type { ForwardedRef } from 'react';
|
||||
import { generateAltText } from '../../services/ai';
|
||||
|
||||
export default function StepEdit({
|
||||
editorRef,
|
||||
@ -23,6 +24,9 @@ export default function StepEdit({
|
||||
}) {
|
||||
const [showImagePicker, setShowImagePicker] = useState(false);
|
||||
const [currentPlaceholder, setCurrentPlaceholder] = useState<string>('');
|
||||
const [generatingAltText, setGeneratingAltText] = useState(false);
|
||||
const [altTextCache, setAltTextCache] = useState<Record<string, { altText: string; caption: string }>>({});
|
||||
const [includeCaptions, setIncludeCaptions] = useState(true);
|
||||
|
||||
const loadGeneratedDraft = () => {
|
||||
if (!generatedDraft) return;
|
||||
@ -32,16 +36,61 @@ export default function StepEdit({
|
||||
onChangeDraft(generatedDraft);
|
||||
};
|
||||
|
||||
const replacePlaceholder = (placeholder: string, imageKey: string) => {
|
||||
const imageUrl = `/api/media/obj?key=${encodeURIComponent(imageKey)}`;
|
||||
const placeholderPattern = `{{IMAGE:${placeholder}}}`;
|
||||
const replacement = `<img src="${imageUrl}" alt="${placeholder.replace(/_/g, ' ')}" />`;
|
||||
const newHtml = draftHtml.replace(placeholderPattern, replacement);
|
||||
onChangeDraft(newHtml);
|
||||
setShowImagePicker(false);
|
||||
// Trigger auto-save after replacing placeholder
|
||||
if (onAutoSave) {
|
||||
setTimeout(() => onAutoSave(), 0);
|
||||
const replacePlaceholder = async (placeholder: string, imageKey: string) => {
|
||||
setGeneratingAltText(true);
|
||||
|
||||
try {
|
||||
// Check cache first
|
||||
let cached = altTextCache[placeholder];
|
||||
|
||||
// Generate AI alt text and caption if not cached
|
||||
if (!cached) {
|
||||
const result = await generateAltText({
|
||||
placeholderDescription: placeholder,
|
||||
contentHtml: draftHtml,
|
||||
includeCaption: includeCaptions,
|
||||
});
|
||||
cached = { altText: result.altText, caption: result.caption };
|
||||
|
||||
// Cache the result
|
||||
setAltTextCache(prev => ({ ...prev, [placeholder]: cached }));
|
||||
}
|
||||
|
||||
const imageUrl = `/api/media/obj?key=${encodeURIComponent(imageKey)}`;
|
||||
const placeholderPattern = `{{IMAGE:${placeholder}}}`;
|
||||
|
||||
// Use figure/figcaption if caption exists, otherwise plain img
|
||||
let replacement: string;
|
||||
if (includeCaptions && cached.caption) {
|
||||
replacement = `<figure><img src="${imageUrl}" alt="${cached.altText}" /><figcaption>${cached.caption}</figcaption></figure>`;
|
||||
} else {
|
||||
replacement = `<img src="${imageUrl}" alt="${cached.altText}" />`;
|
||||
}
|
||||
|
||||
const newHtml = draftHtml.replace(placeholderPattern, replacement);
|
||||
onChangeDraft(newHtml);
|
||||
setShowImagePicker(false);
|
||||
|
||||
// Trigger auto-save after replacing placeholder
|
||||
if (onAutoSave) {
|
||||
setTimeout(() => onAutoSave(), 0);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to generate image text:', err);
|
||||
// Fallback to simple alt text
|
||||
const imageUrl = `/api/media/obj?key=${encodeURIComponent(imageKey)}`;
|
||||
const placeholderPattern = `{{IMAGE:${placeholder}}}`;
|
||||
const fallbackAlt = placeholder.replace(/_/g, ' ');
|
||||
const replacement = `<img src="${imageUrl}" alt="${fallbackAlt}" />`;
|
||||
const newHtml = draftHtml.replace(placeholderPattern, replacement);
|
||||
onChangeDraft(newHtml);
|
||||
setShowImagePicker(false);
|
||||
|
||||
if (onAutoSave) {
|
||||
setTimeout(() => onAutoSave(), 0);
|
||||
}
|
||||
} finally {
|
||||
setGeneratingAltText(false);
|
||||
}
|
||||
};
|
||||
|
||||
@ -99,15 +148,43 @@ export default function StepEdit({
|
||||
<Box sx={{
|
||||
overflowX: 'auto',
|
||||
'& img': { maxWidth: '100%', height: 'auto' },
|
||||
'& figure img': { display: 'block', margin: '0 auto' },
|
||||
'& figure': { margin: '2rem 0', textAlign: 'center' },
|
||||
'& figure img': { display: 'block', margin: '0 auto', maxWidth: '100%' },
|
||||
'& figcaption': {
|
||||
marginTop: '0.5rem',
|
||||
fontSize: '0.9rem',
|
||||
color: 'text.secondary',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
'& video, & iframe': { maxWidth: '100%' },
|
||||
}}>
|
||||
<RichEditor ref={editorRef} value={draftHtml} onChange={onChangeDraft} placeholder="Write your post..." />
|
||||
</Box>
|
||||
|
||||
{/* Image picker dialog */}
|
||||
<Dialog open={showImagePicker} onClose={() => setShowImagePicker(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Replace: {`{{IMAGE:${currentPlaceholder}}}`}</DialogTitle>
|
||||
<Dialog open={showImagePicker} onClose={() => !generatingAltText && setShowImagePicker(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>
|
||||
<Box>
|
||||
Replace: {`{{IMAGE:${currentPlaceholder}}}`}
|
||||
{generatingAltText && (
|
||||
<Typography variant="caption" display="block" sx={{ mt: 1, color: 'text.secondary' }}>
|
||||
<CircularProgress size={12} sx={{ mr: 1 }} />
|
||||
Generating AI {includeCaptions ? 'alt text & caption' : 'alt text'}...
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={includeCaptions}
|
||||
onChange={(e) => setIncludeCaptions(e.target.checked)}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
label="Generate captions (SEO boost)"
|
||||
sx={{ mt: 1 }}
|
||||
/>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
{selectedImageKeys && selectedImageKeys.length > 0 ? (
|
||||
<List>
|
||||
|
||||
@ -30,3 +30,18 @@ export async function generateMetadata(contentHtml: string) {
|
||||
canonicalUrl: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function generateAltText(payload: {
|
||||
placeholderDescription: string;
|
||||
contentHtml?: string;
|
||||
surroundingText?: string;
|
||||
includeCaption?: boolean;
|
||||
}) {
|
||||
const res = await fetch('/api/ai/generate-alt-text', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json() as Promise<{ altText: string; caption: string }>;
|
||||
}
|
||||
|
||||
@ -222,4 +222,142 @@ Return ONLY valid JSON, no markdown, no explanation.`,
|
||||
}
|
||||
});
|
||||
|
||||
// Generate alt text and caption for image placeholder based on context
|
||||
router.post('/generate-alt-text', async (req, res) => {
|
||||
try {
|
||||
const { placeholderDescription, contentHtml, surroundingText, includeCaption = true } = req.body as {
|
||||
placeholderDescription: string;
|
||||
contentHtml?: string;
|
||||
surroundingText?: string;
|
||||
includeCaption?: boolean;
|
||||
};
|
||||
|
||||
if (!placeholderDescription) {
|
||||
return res.status(400).json({ error: 'placeholderDescription is required' });
|
||||
}
|
||||
|
||||
const apiKey = process.env.OPENAI_API_KEY;
|
||||
if (!apiKey) {
|
||||
return res.status(500).json({ error: 'OpenAI API key not configured' });
|
||||
}
|
||||
|
||||
const openai = new OpenAI({ apiKey });
|
||||
|
||||
// Build context
|
||||
let context = `Placeholder description: ${placeholderDescription}`;
|
||||
|
||||
if (surroundingText) {
|
||||
context += `\n\nSurrounding text:\n${surroundingText}`;
|
||||
} else if (contentHtml) {
|
||||
// Extract text from HTML for context
|
||||
const textContent = contentHtml.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
const preview = textContent.slice(0, 1000);
|
||||
context += `\n\nArticle context:\n${preview}`;
|
||||
}
|
||||
|
||||
console.log('[AI Image Text] Generating for:', placeholderDescription);
|
||||
|
||||
const systemPrompt = includeCaption
|
||||
? `You are an accessibility and SEO expert. Generate alt text AND caption for images.
|
||||
|
||||
REQUIREMENTS:
|
||||
Alt Text:
|
||||
- Descriptive and specific (50-125 characters)
|
||||
- Include relevant keywords naturally
|
||||
- Describe what's IN the image, not around it
|
||||
- Don't start with "Image of" or "Picture of"
|
||||
- Concise but informative
|
||||
|
||||
Caption:
|
||||
- Engaging and contextual (1-2 sentences)
|
||||
- Add value beyond the alt text
|
||||
- Can include context, explanation, or insight
|
||||
- SEO-friendly with natural keywords
|
||||
- Reader-friendly and informative
|
||||
|
||||
OUTPUT FORMAT (JSON):
|
||||
{
|
||||
"altText": "Your alt text here",
|
||||
"caption": "Your engaging caption here"
|
||||
}
|
||||
|
||||
EXAMPLES:
|
||||
Input: "dashboard_screenshot"
|
||||
Output: {
|
||||
"altText": "Analytics dashboard showing user engagement metrics and conversion rates",
|
||||
"caption": "Our analytics platform provides real-time insights into user behavior and conversion patterns."
|
||||
}
|
||||
|
||||
Input: "team_photo"
|
||||
Output: {
|
||||
"altText": "Development team collaborating in modern office space",
|
||||
"caption": "The engineering team during our quarterly planning session, where we align on product roadmap priorities."
|
||||
}
|
||||
|
||||
Return ONLY valid JSON, no markdown, no explanation.`
|
||||
: `You are an accessibility and SEO expert. Generate descriptive alt text for images.
|
||||
|
||||
REQUIREMENTS:
|
||||
1. Be descriptive and specific (50-125 characters ideal)
|
||||
2. Include relevant keywords naturally
|
||||
3. Describe what's IN the image, not around it
|
||||
4. Don't start with "Image of" or "Picture of"
|
||||
5. Be concise but informative
|
||||
6. Consider the article context
|
||||
|
||||
Return ONLY the alt text, no quotes, no explanation.`;
|
||||
|
||||
const completion = await openai.chat.completions.create({
|
||||
model: 'gpt-4o',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: systemPrompt,
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: context,
|
||||
},
|
||||
],
|
||||
temperature: 0.7,
|
||||
max_tokens: includeCaption ? 200 : 100,
|
||||
});
|
||||
|
||||
const response = completion.choices[0]?.message?.content?.trim() || '';
|
||||
|
||||
if (!response) {
|
||||
return res.status(500).json({ error: 'No content generated' });
|
||||
}
|
||||
|
||||
if (includeCaption) {
|
||||
// Parse JSON response
|
||||
try {
|
||||
const cleaned = response.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
|
||||
const parsed = JSON.parse(cleaned);
|
||||
|
||||
console.log('[AI Image Text] Generated:', parsed);
|
||||
|
||||
return res.json({
|
||||
altText: parsed.altText || '',
|
||||
caption: parsed.caption || '',
|
||||
});
|
||||
} catch (parseErr) {
|
||||
console.error('[AI Image Text] JSON parse error:', parseErr);
|
||||
// Fallback: treat as alt text only
|
||||
return res.json({ altText: response, caption: '' });
|
||||
}
|
||||
} else {
|
||||
// Alt text only
|
||||
console.log('[AI Image Text] Generated alt text:', response);
|
||||
return res.json({ altText: response, caption: '' });
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('[AI Image Text] Error:', err);
|
||||
return res.status(500).json({
|
||||
error: 'Image text generation failed',
|
||||
details: err?.message || 'Unknown error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user