253 lines
8.2 KiB
TypeScript
253 lines
8.2 KiB
TypeScript
import { NextRequest } from 'next/server';
|
|
import { prisma } from '@/lib/db';
|
|
|
|
interface WikipediaSummary {
|
|
title: string;
|
|
extract: string;
|
|
thumbnail?: { source: string };
|
|
originalimage?: { source: string };
|
|
}
|
|
|
|
interface WikiParseResult {
|
|
parse: {
|
|
title: string;
|
|
text: { '*': string };
|
|
images: string[];
|
|
};
|
|
}
|
|
|
|
async function getWikipediaSummary(title: string): Promise<WikipediaSummary | null> {
|
|
try {
|
|
const apiTitle = title.replace(/%20/g, '_');
|
|
const response = await fetch(
|
|
`https://en.wikipedia.org/api/rest_v1/page/summary/${apiTitle}`,
|
|
{
|
|
headers: { 'User-Agent': 'WikiPlus/1.0' },
|
|
}
|
|
);
|
|
if (!response.ok) return null;
|
|
return await response.json();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function getWikipediaContent(title: string): Promise<{ text: string; images: string[] } | null> {
|
|
try {
|
|
const apiTitle = title.replace(/%20/g, '_');
|
|
const response = await fetch(
|
|
`https://en.wikipedia.org/w/api.php?action=parse&page=${encodeURIComponent(apiTitle)}&format=json&prop=text|images&disableeditsection=true&redirects=true`,
|
|
{
|
|
headers: { 'User-Agent': 'WikiPlus/1.0' },
|
|
}
|
|
);
|
|
if (!response.ok) return null;
|
|
const data: WikiParseResult = await response.json();
|
|
if (!data.parse) return null;
|
|
|
|
// Extract plain text from HTML
|
|
const html = data.parse.text?.['*'] || '';
|
|
const plainText = html
|
|
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
|
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
|
.replace(/<[^>]+>/g, ' ')
|
|
.replace(/ /g, ' ')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, "'")
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
|
|
// Get image URLs
|
|
const images = (data.parse.images || [])
|
|
.filter((img: string) => !img.includes('icon') && !img.includes('logo') && !img.includes('Commons-logo'))
|
|
.slice(0, 5)
|
|
.map((img: string) => `https://en.wikipedia.org/wiki/Special:FilePath/${encodeURIComponent(img)}`);
|
|
|
|
return { text: plainText.slice(0, 15000), images };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function GET(request: NextRequest) {
|
|
const searchParams = request.nextUrl.searchParams;
|
|
const title = searchParams.get('title');
|
|
|
|
if (!title) {
|
|
return new Response('Missing title parameter', { status: 400 });
|
|
}
|
|
|
|
// Normalize the slug for database lookup
|
|
const slug = title.replace(/%20/g, '_').replace(/ /g, '_');
|
|
|
|
// Check if article exists in database
|
|
const existingArticle = await prisma.article.findUnique({
|
|
where: { slug },
|
|
});
|
|
|
|
if (existingArticle) {
|
|
// Return cached article wrapped in <article> tags
|
|
const cachedContent = `<article>${existingArticle.content}</article>`;
|
|
return new Response(cachedContent, {
|
|
headers: {
|
|
'Content-Type': 'text/plain; charset=utf-8',
|
|
'X-Cache': 'HIT',
|
|
},
|
|
});
|
|
}
|
|
|
|
const apiKey = process.env.OPENROUTER_API_KEY;
|
|
if (!apiKey) {
|
|
return new Response('OpenRouter API key not configured', { status: 500 });
|
|
}
|
|
|
|
// Fetch Wikipedia data
|
|
const [summary, content] = await Promise.all([
|
|
getWikipediaSummary(title),
|
|
getWikipediaContent(title),
|
|
]);
|
|
|
|
if (!summary || !content) {
|
|
return new Response('Article not found', { status: 404 });
|
|
}
|
|
|
|
const imageUrl = summary.thumbnail?.source || summary.originalimage?.source;
|
|
const imageContext = imageUrl
|
|
? `\n\nMain image available: ${imageUrl}\nAdditional images: ${content.images.join(', ')}`
|
|
: content.images.length > 0
|
|
? `\n\nImages available: ${content.images.join(', ')}`
|
|
: '';
|
|
|
|
const systemPrompt = `You are an expert writer creating comprehensive, in-depth articles for an AI-powered Wikipedia alternative. Your goal is to transform encyclopedia content into rich, detailed, and engaging prose that thoroughly covers the topic.
|
|
|
|
Guidelines:
|
|
- Write a COMPREHENSIVE and DETAILED article - aim for depth and thoroughness
|
|
- Cover ALL major aspects of the topic: history, significance, key details, related concepts, and impact
|
|
- Use markdown formatting extensively:
|
|
- Use ## for main sections and ### for subsections
|
|
- Use **bold** for key terms and *italic* for emphasis
|
|
- Use bullet lists and numbered lists where appropriate
|
|
- Use > blockquotes for notable quotes or key facts
|
|
- Include the main image at the top using markdown: 
|
|
- Structure the article with multiple well-developed sections (5-8 sections minimum)
|
|
- Each section should have multiple paragraphs with detailed explanations
|
|
- Add context that helps readers understand why this topic matters
|
|
- Include interesting facts, historical context, and connections to broader themes
|
|
- Maintain factual accuracy - expand on the source material but don't invent facts
|
|
- Write at least 1000-1500 words for a thorough treatment of the topic
|
|
- Output ONLY the article content wrapped in <article></article> tags
|
|
- Do not include any text outside the <article> tags`;
|
|
|
|
const userPrompt = `Write a comprehensive, detailed article about "${summary.title}" based on this Wikipedia content:
|
|
|
|
Summary: ${summary.extract}
|
|
|
|
Full content: ${content.text}${imageContext}
|
|
|
|
Requirements:
|
|
- Write a THOROUGH article with 5-8 well-developed sections minimum
|
|
- Each section should have multiple detailed paragraphs
|
|
- Cover history, significance, key facts, and broader context
|
|
- Use rich markdown formatting throughout
|
|
- Aim for 1000-1500+ words total
|
|
- Output in markdown wrapped in <article></article> tags`;
|
|
|
|
// Call OpenRouter API with streaming
|
|
const openRouterResponse = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${apiKey}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
model: 'minimax/minimax-m2.1',
|
|
messages: [
|
|
{ role: 'system', content: systemPrompt },
|
|
{ role: 'user', content: userPrompt },
|
|
],
|
|
stream: true,
|
|
}),
|
|
});
|
|
|
|
if (!openRouterResponse.ok) {
|
|
const error = await openRouterResponse.text();
|
|
console.error('OpenRouter error:', error);
|
|
return new Response('Failed to generate article', { status: 500 });
|
|
}
|
|
|
|
// Transform the OpenRouter SSE stream to extract content and save to DB
|
|
const encoder = new TextEncoder();
|
|
const decoder = new TextDecoder();
|
|
let fullContent = '';
|
|
|
|
const transformStream = new TransformStream({
|
|
async transform(chunk, controller) {
|
|
const text = decoder.decode(chunk);
|
|
const lines = text.split('\n');
|
|
|
|
for (const line of lines) {
|
|
if (line.startsWith('data: ')) {
|
|
const data = line.slice(6);
|
|
if (data === '[DONE]') {
|
|
continue;
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(data);
|
|
const contentChunk = parsed.choices?.[0]?.delta?.content;
|
|
if (contentChunk) {
|
|
fullContent += contentChunk;
|
|
controller.enqueue(encoder.encode(contentChunk));
|
|
}
|
|
} catch {
|
|
// Skip invalid JSON
|
|
}
|
|
}
|
|
}
|
|
},
|
|
async flush() {
|
|
// Extract content between <article> tags and save to database
|
|
const articleMatch = fullContent.match(/<article>([\s\S]*?)<\/article>/);
|
|
const articleContent = articleMatch ? articleMatch[1].trim() : fullContent;
|
|
|
|
if (articleContent) {
|
|
try {
|
|
const newArticle = await prisma.article.create({
|
|
data: {
|
|
slug,
|
|
title: summary.title,
|
|
content: articleContent,
|
|
imageUrl: imageUrl || null,
|
|
},
|
|
});
|
|
|
|
// Create initial history entry
|
|
await prisma.articleHistory.create({
|
|
data: {
|
|
articleId: newArticle.id,
|
|
oldContent: '',
|
|
newContent: articleContent,
|
|
reason: 'Initial article',
|
|
},
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to save article to database:', error);
|
|
}
|
|
}
|
|
},
|
|
});
|
|
|
|
const stream = openRouterResponse.body?.pipeThrough(transformStream);
|
|
|
|
return new Response(stream, {
|
|
headers: {
|
|
'Content-Type': 'text/plain; charset=utf-8',
|
|
'Transfer-Encoding': 'chunked',
|
|
'Cache-Control': 'no-cache',
|
|
'X-Cache': 'MISS',
|
|
},
|
|
});
|
|
}
|