feat: add AI-powered metadata generation for blog posts

This commit is contained in:
Ender 2025-10-25 00:27:26 +02:00
parent 5685f03b7e
commit 068bf9be8a
4 changed files with 155 additions and 2 deletions

View File

@ -183,6 +183,7 @@ export default function EditorShell({ onLogout: _onLogout, initialPostId, onBack
value={meta}
onChange={setMeta}
onAutoSave={triggerImmediateAutoSave}
draftHtml={draft}
/>
</StepContainer>
)}

View File

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

View File

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

View File

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