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}
|
value={meta}
|
||||||
onChange={setMeta}
|
onChange={setMeta}
|
||||||
onAutoSave={triggerImmediateAutoSave}
|
onAutoSave={triggerImmediateAutoSave}
|
||||||
|
draftHtml={draft}
|
||||||
/>
|
/>
|
||||||
</StepContainer>
|
</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 MetadataPanel, { type Metadata } from '../MetadataPanel';
|
||||||
import StepHeader from './StepHeader';
|
import StepHeader from './StepHeader';
|
||||||
|
import { generateMetadata } from '../../services/ai';
|
||||||
|
|
||||||
export default function StepMetadata({
|
export default function StepMetadata({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
onAutoSave
|
onAutoSave,
|
||||||
|
draftHtml,
|
||||||
}: {
|
}: {
|
||||||
value: Metadata;
|
value: Metadata;
|
||||||
onChange: (v: Metadata) => void;
|
onChange: (v: Metadata) => void;
|
||||||
onAutoSave?: () => void;
|
onAutoSave?: () => void;
|
||||||
|
draftHtml?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const [generating, setGenerating] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
const handleChange = (v: Metadata) => {
|
const handleChange = (v: Metadata) => {
|
||||||
onChange(v);
|
onChange(v);
|
||||||
// Trigger auto-save after metadata change
|
// 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 (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<StepHeader
|
<StepHeader
|
||||||
title="Metadata"
|
title="Metadata"
|
||||||
description="Configure post metadata including tags, canonical URL, and feature image."
|
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} />
|
<MetadataPanel value={value} onChange={handleChange} />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -16,3 +16,17 @@ export async function generateDraft(payload: {
|
|||||||
model: string;
|
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;
|
export default router;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user