feat: add AI-generated alt text and captions for image placeholders

This commit is contained in:
Ender 2025-10-25 12:56:50 +02:00
parent 068bf9be8a
commit d6b4109b22
3 changed files with 244 additions and 14 deletions

View File

@ -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>

View File

@ -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 }>;
}

View File

@ -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;