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 { 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 RichEditor, { type RichEditorHandle } from '../RichEditor';
|
||||||
import StepHeader from './StepHeader';
|
import StepHeader from './StepHeader';
|
||||||
import type { ForwardedRef } from 'react';
|
import type { ForwardedRef } from 'react';
|
||||||
|
import { generateAltText } from '../../services/ai';
|
||||||
|
|
||||||
export default function StepEdit({
|
export default function StepEdit({
|
||||||
editorRef,
|
editorRef,
|
||||||
@ -23,6 +24,9 @@ export default function StepEdit({
|
|||||||
}) {
|
}) {
|
||||||
const [showImagePicker, setShowImagePicker] = useState(false);
|
const [showImagePicker, setShowImagePicker] = useState(false);
|
||||||
const [currentPlaceholder, setCurrentPlaceholder] = useState<string>('');
|
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 = () => {
|
const loadGeneratedDraft = () => {
|
||||||
if (!generatedDraft) return;
|
if (!generatedDraft) return;
|
||||||
@ -32,16 +36,61 @@ export default function StepEdit({
|
|||||||
onChangeDraft(generatedDraft);
|
onChangeDraft(generatedDraft);
|
||||||
};
|
};
|
||||||
|
|
||||||
const replacePlaceholder = (placeholder: string, imageKey: string) => {
|
const replacePlaceholder = async (placeholder: string, imageKey: string) => {
|
||||||
const imageUrl = `/api/media/obj?key=${encodeURIComponent(imageKey)}`;
|
setGeneratingAltText(true);
|
||||||
const placeholderPattern = `{{IMAGE:${placeholder}}}`;
|
|
||||||
const replacement = `<img src="${imageUrl}" alt="${placeholder.replace(/_/g, ' ')}" />`;
|
try {
|
||||||
const newHtml = draftHtml.replace(placeholderPattern, replacement);
|
// Check cache first
|
||||||
onChangeDraft(newHtml);
|
let cached = altTextCache[placeholder];
|
||||||
setShowImagePicker(false);
|
|
||||||
// Trigger auto-save after replacing placeholder
|
// Generate AI alt text and caption if not cached
|
||||||
if (onAutoSave) {
|
if (!cached) {
|
||||||
setTimeout(() => onAutoSave(), 0);
|
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={{
|
<Box sx={{
|
||||||
overflowX: 'auto',
|
overflowX: 'auto',
|
||||||
'& img': { maxWidth: '100%', height: '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%' },
|
'& video, & iframe': { maxWidth: '100%' },
|
||||||
}}>
|
}}>
|
||||||
<RichEditor ref={editorRef} value={draftHtml} onChange={onChangeDraft} placeholder="Write your post..." />
|
<RichEditor ref={editorRef} value={draftHtml} onChange={onChangeDraft} placeholder="Write your post..." />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Image picker dialog */}
|
{/* Image picker dialog */}
|
||||||
<Dialog open={showImagePicker} onClose={() => setShowImagePicker(false)} maxWidth="sm" fullWidth>
|
<Dialog open={showImagePicker} onClose={() => !generatingAltText && setShowImagePicker(false)} maxWidth="sm" fullWidth>
|
||||||
<DialogTitle>Replace: {`{{IMAGE:${currentPlaceholder}}}`}</DialogTitle>
|
<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>
|
<DialogContent>
|
||||||
{selectedImageKeys && selectedImageKeys.length > 0 ? (
|
{selectedImageKeys && selectedImageKeys.length > 0 ? (
|
||||||
<List>
|
<List>
|
||||||
|
|||||||
@ -30,3 +30,18 @@ export async function generateMetadata(contentHtml: string) {
|
|||||||
canonicalUrl: 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;
|
export default router;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user