diff --git a/apps/admin/src/components/steps/StepEdit.tsx b/apps/admin/src/components/steps/StepEdit.tsx index a317d5e..cb389bc 100644 --- a/apps/admin/src/components/steps/StepEdit.tsx +++ b/apps/admin/src/components/steps/StepEdit.tsx @@ -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(''); + const [generatingAltText, setGeneratingAltText] = useState(false); + const [altTextCache, setAltTextCache] = useState>({}); + 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 = `${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 = `
${cached.altText}
${cached.caption}
`; + } else { + replacement = `${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 = `${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({ {/* Image picker dialog */} - setShowImagePicker(false)} maxWidth="sm" fullWidth> - Replace: {`{{IMAGE:${currentPlaceholder}}}`} + !generatingAltText && setShowImagePicker(false)} maxWidth="sm" fullWidth> + + + Replace: {`{{IMAGE:${currentPlaceholder}}}`} + {generatingAltText && ( + + + Generating AI {includeCaptions ? 'alt text & caption' : 'alt text'}... + + )} + + setIncludeCaptions(e.target.checked)} + size="small" + /> + } + label="Generate captions (SEO boost)" + sx={{ mt: 1 }} + /> + {selectedImageKeys && selectedImageKeys.length > 0 ? ( diff --git a/apps/admin/src/services/ai.ts b/apps/admin/src/services/ai.ts index 3a2e270..c7ce40a 100644 --- a/apps/admin/src/services/ai.ts +++ b/apps/admin/src/services/ai.ts @@ -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 }>; +} diff --git a/apps/api/src/ai-generate.ts b/apps/api/src/ai-generate.ts index 11e8591..6edbb2d 100644 --- a/apps/api/src/ai-generate.ts +++ b/apps/api/src/ai-generate.ts @@ -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;