feat: add AI-powered metadata generation for blog posts
This commit is contained in:
parent
5685f03b7e
commit
068bf9be8a
@ -183,6 +183,7 @@ export default function EditorShell({ onLogout: _onLogout, initialPostId, onBack
|
||||
value={meta}
|
||||
onChange={setMeta}
|
||||
onAutoSave={triggerImmediateAutoSave}
|
||||
draftHtml={draft}
|
||||
/>
|
||||
</StepContainer>
|
||||
)}
|
||||
|
||||
@ -1,16 +1,22 @@
|
||||
import { Box } from '@mui/material';
|
||||
import { useState } from 'react';
|
||||
import { Box, Button, Alert, CircularProgress } from '@mui/material';
|
||||
import MetadataPanel, { type Metadata } from '../MetadataPanel';
|
||||
import StepHeader from './StepHeader';
|
||||
import { generateMetadata } from '../../services/ai';
|
||||
|
||||
export default function StepMetadata({
|
||||
value,
|
||||
onChange,
|
||||
onAutoSave
|
||||
onAutoSave,
|
||||
draftHtml,
|
||||
}: {
|
||||
value: Metadata;
|
||||
onChange: (v: Metadata) => void;
|
||||
onAutoSave?: () => void;
|
||||
draftHtml?: string;
|
||||
}) {
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const handleChange = (v: Metadata) => {
|
||||
onChange(v);
|
||||
// Trigger auto-save after metadata change
|
||||
@ -19,12 +25,57 @@ export default function StepMetadata({
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateMetadata = async () => {
|
||||
if (!draftHtml || draftHtml === '<p></p>') {
|
||||
setError('Please write some content first before generating metadata.');
|
||||
return;
|
||||
}
|
||||
|
||||
setGenerating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const metadata = await generateMetadata(draftHtml);
|
||||
|
||||
// Update metadata, preserving feature image
|
||||
const newMetadata: Metadata = {
|
||||
title: metadata.title,
|
||||
tagsText: metadata.tags,
|
||||
canonicalUrl: metadata.canonicalUrl,
|
||||
featureImage: value.featureImage, // Keep existing feature image
|
||||
};
|
||||
|
||||
handleChange(newMetadata);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || 'Failed to generate metadata');
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<StepHeader
|
||||
title="Metadata"
|
||||
description="Configure post metadata including tags, canonical URL, and feature image."
|
||||
/>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleGenerateMetadata}
|
||||
disabled={generating || !draftHtml || draftHtml === '<p></p>'}
|
||||
startIcon={generating ? <CircularProgress size={16} /> : null}
|
||||
>
|
||||
{generating ? 'Generating...' : 'Generate Metadata with AI'}
|
||||
</Button>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mt: 1 }} onClose={() => setError(null)}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<MetadataPanel value={value} onChange={handleChange} />
|
||||
</Box>
|
||||
);
|
||||
|
||||
@ -16,3 +16,17 @@ export async function generateDraft(payload: {
|
||||
model: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function generateMetadata(contentHtml: string) {
|
||||
const res = await fetch('/api/ai/generate-metadata', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ contentHtml }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json() as Promise<{
|
||||
title: string;
|
||||
tags: string;
|
||||
canonicalUrl: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
@ -135,4 +135,91 @@ router.post('/generate', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Generate metadata (title, tags, canonical URL) from content
|
||||
router.post('/generate-metadata', async (req, res) => {
|
||||
try {
|
||||
const { contentHtml } = req.body as { contentHtml: string };
|
||||
|
||||
if (!contentHtml) {
|
||||
return res.status(400).json({ error: 'contentHtml 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 });
|
||||
|
||||
// Strip HTML tags for better analysis
|
||||
const textContent = contentHtml.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
const preview = textContent.slice(0, 2000); // First 2000 chars
|
||||
|
||||
console.log('[AI Metadata] Generating metadata...');
|
||||
|
||||
const completion = await openai.chat.completions.create({
|
||||
model: 'gpt-4o',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: `You are an SEO expert. Generate metadata for blog posts.
|
||||
|
||||
REQUIREMENTS:
|
||||
1. Title: Compelling, SEO-friendly, 50-60 characters
|
||||
2. Tags: 3-5 relevant tags, comma-separated
|
||||
3. Canonical URL: SEO-friendly slug based on title (lowercase, hyphens, no special chars)
|
||||
|
||||
OUTPUT FORMAT (JSON):
|
||||
{
|
||||
"title": "Your Compelling Title Here",
|
||||
"tags": "tag1, tag2, tag3",
|
||||
"canonicalUrl": "your-seo-friendly-slug"
|
||||
}
|
||||
|
||||
Return ONLY valid JSON, no markdown, no explanation.`,
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `Generate metadata for this article:\n\n${preview}`,
|
||||
},
|
||||
],
|
||||
temperature: 0.7,
|
||||
max_tokens: 300,
|
||||
});
|
||||
|
||||
const response = completion.choices[0]?.message?.content || '';
|
||||
|
||||
if (!response) {
|
||||
return res.status(500).json({ error: 'No metadata generated' });
|
||||
}
|
||||
|
||||
console.log('[AI Metadata] Raw response:', response);
|
||||
|
||||
// Parse JSON response
|
||||
let metadata;
|
||||
try {
|
||||
// Remove markdown code blocks if present
|
||||
const cleaned = response.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
|
||||
metadata = JSON.parse(cleaned);
|
||||
} catch (parseErr) {
|
||||
console.error('[AI Metadata] JSON parse error:', parseErr);
|
||||
return res.status(500).json({ error: 'Failed to parse metadata response' });
|
||||
}
|
||||
|
||||
console.log('[AI Metadata] Generated:', metadata);
|
||||
|
||||
return res.json({
|
||||
title: metadata.title || '',
|
||||
tags: metadata.tags || '',
|
||||
canonicalUrl: metadata.canonicalUrl || '',
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error('[AI Metadata] Error:', err);
|
||||
return res.status(500).json({
|
||||
error: 'Metadata generation failed',
|
||||
details: err?.message || 'Unknown error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user