Major changes

This commit is contained in:
owenqwenstarsky
2026-01-24 16:52:19 -06:00
parent 011e40119d
commit 7181791734
46 changed files with 8371 additions and 127 deletions
+252
View File
@@ -0,0 +1,252 @@
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(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/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: ![Description](url)
- 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',
},
});
}
+34
View File
@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
const { slug } = await params;
// Fetch article to get its ID
const article = await prisma.article.findUnique({
where: { slug },
select: { id: true },
});
if (!article) {
return NextResponse.json({ error: 'Article not found' }, { status: 404 });
}
// Fetch history entries
const history = await prisma.articleHistory.findMany({
where: { articleId: article.id },
orderBy: { createdAt: 'desc' },
select: {
id: true,
oldContent: true,
newContent: true,
reason: true,
createdAt: true,
},
});
return NextResponse.json(history);
}
+147
View File
@@ -0,0 +1,147 @@
import { NextRequest } from 'next/server';
import { prisma } from '@/lib/db';
import * as Diff from 'diff';
function generateDiffText(oldContent: string, newContent: string): string {
const changes = Diff.diffLines(oldContent, newContent);
const diffLines: string[] = [];
for (const change of changes) {
const lines = change.value.split('\n').filter(line => line !== '');
for (const line of lines) {
if (change.added) {
diffLines.push(`+ ${line}`);
} else if (change.removed) {
diffLines.push(`- ${line}`);
}
}
}
return diffLines.join('\n');
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
const { slug } = await params;
const body = await request.json();
const { newContent } = body;
if (!newContent || typeof newContent !== 'string') {
return new Response('Missing newContent', { status: 400 });
}
// Fetch current article
const article = await prisma.article.findUnique({
where: { slug },
});
if (!article) {
return new Response('Article not found', { status: 404 });
}
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) {
return new Response('OpenRouter API key not configured', { status: 500 });
}
// Generate diff between old and new content
const diffText = generateDiffText(article.content, newContent);
const systemPrompt = `You are an article edit reviewer. Your job is to evaluate proposed changes (diffs) to articles.
You will be given:
1. The current article content (assume this is factually correct and the source of truth)
2. A diff showing the proposed changes (lines starting with "-" are removed, lines starting with "+" are added)
Evaluate ONLY the changes based on:
1. **Consistency** - Do the edits contradict or conflict with the existing article content?
2. **Quality** - Is the new/modified text well-written and grammatically correct?
3. **Relevance** - Are the changes relevant to the article topic?
4. **Appropriateness** - Is the content appropriate (no spam, vandalism, offensive content)?
Do NOT fact-check against external knowledge. Assume the original article is correct and evaluate whether the edits are consistent with it.
After your evaluation, you MUST output your decision in this exact format:
<action approve="true" reason="Your brief reason for approval" />
OR
<action approve="false" reason="Your brief reason for rejection" />
Be constructive in your feedback. If rejecting, explain what could be improved.`;
const userPrompt = `Please review the following article edit:
**Current Article:**
${article.content.slice(0, 6000)}
**Proposed Changes (diff):**
\`\`\`diff
${diffText}
\`\`\`
Evaluate only the changes shown in the diff and provide your decision.`;
// 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: 'openai/gpt-oss-120b',
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 review article', { status: 500 });
}
// Transform the OpenRouter SSE stream
const encoder = new TextEncoder();
const decoder = new TextDecoder();
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) {
controller.enqueue(encoder.encode(contentChunk));
}
} catch {
// Skip invalid JSON
}
}
}
},
});
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',
},
});
}
+58
View File
@@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
const { slug } = await params;
const body = await request.json();
const { newContent, reason } = body;
if (!newContent || typeof newContent !== 'string') {
return NextResponse.json({ error: 'Missing newContent' }, { status: 400 });
}
if (!reason || typeof reason !== 'string') {
return NextResponse.json({ error: 'Missing reason' }, { status: 400 });
}
// Fetch current article
const article = await prisma.article.findUnique({
where: { slug },
});
if (!article) {
return NextResponse.json({ error: 'Article not found' }, { status: 404 });
}
// Transaction: create history entry and update article
const result = await prisma.$transaction(async (tx) => {
// Create history entry
const historyEntry = await tx.articleHistory.create({
data: {
articleId: article.id,
oldContent: article.content,
newContent: newContent,
reason: reason,
},
});
// Update article content
await tx.article.update({
where: { id: article.id },
data: {
content: newContent,
updatedAt: new Date(),
},
});
return historyEntry;
});
return NextResponse.json({
success: true,
historyId: result.id,
});
}
+36
View File
@@ -0,0 +1,36 @@
import { NextResponse } from 'next/server';
import type { WikipediaTopic } from '@/lib/types';
export async function GET() {
try {
const response = await fetch(
'https://en.wikipedia.org/api/rest_v1/page/random/summary',
{
headers: {
'User-Agent': 'TeachMeSomethingNew/1.0',
},
}
);
if (!response.ok) {
throw new Error('Failed to fetch from Wikipedia API');
}
const data = await response.json();
const topic: WikipediaTopic = {
title: data.title,
extract: data.extract,
imageUrl: data.thumbnail?.source || data.originalimage?.source,
pageUrl: data.content_urls?.desktop?.page,
};
return NextResponse.json(topic);
} catch (error) {
console.error('Error fetching random topic:', error);
return NextResponse.json(
{ error: 'Failed to fetch random topic' },
{ status: 500 }
);
}
}
+94
View File
@@ -0,0 +1,94 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
export interface SearchResult {
title: string;
description: string;
thumbnail?: string;
}
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const query = searchParams.get('query');
if (!query) {
return NextResponse.json(
{ error: 'Query parameter is required' },
{ status: 400 }
);
}
try {
// Use the Wikipedia Action API for search
const response = await fetch(
`https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch=${encodeURIComponent(query)}&srlimit=20&format=json&origin=*`,
{
headers: {
'User-Agent': 'WikiPlus/1.0',
},
}
);
if (!response.ok) {
throw new Error('Failed to fetch from Wikipedia API');
}
const data = await response.json();
// Get page IDs for thumbnail fetching
const searchResults = data.query?.search || [];
const pageIds = searchResults.map((r: { pageid: number }) => r.pageid).join('|');
// Fetch thumbnails for results
let thumbnails: Record<string, string> = {};
if (pageIds) {
const thumbResponse = await fetch(
`https://en.wikipedia.org/w/api.php?action=query&pageids=${pageIds}&prop=pageimages&pithumbsize=200&format=json&origin=*`,
{
headers: {
'User-Agent': 'WikiPlus/1.0',
},
}
);
if (thumbResponse.ok) {
const thumbData = await thumbResponse.json();
const pages = thumbData.query?.pages || {};
for (const pageId in pages) {
if (pages[pageId].thumbnail) {
thumbnails[pageId] = pages[pageId].thumbnail.source;
}
}
}
}
const results: SearchResult[] = searchResults.map((item: {
pageid: number;
title: string;
snippet: string;
}) => ({
title: item.title,
description: item.snippet.replace(/<[^>]*>/g, ''), // Strip HTML tags from snippet
thumbnail: thumbnails[item.pageid.toString()],
}));
// Log the search
try {
await prisma.search.create({
data: {
query,
},
});
} catch (error) {
console.error('Error logging search:', error);
// Don't fail the search if logging fails
}
return NextResponse.json({ results });
} catch (error) {
console.error('Error searching Wikipedia:', error);
return NextResponse.json(
{ error: 'Failed to search Wikipedia' },
{ status: 500 }
);
}
}
+42
View File
@@ -0,0 +1,42 @@
import { prisma } from '@/lib/db';
import { NextResponse } from 'next/server';
export async function GET() {
try {
const today = new Date();
today.setHours(0, 0, 0, 0);
const [totalArticles, searchesToday, allArticles] = await Promise.all([
prisma.article.count(),
prisma.search.count({
where: {
createdAt: {
gte: today,
},
},
}),
prisma.article.findMany({
select: {
content: true,
},
}),
]);
const totalWords = allArticles.reduce((sum, article) => {
const words = article.content.split(/\s+/).filter(word => word.length > 0).length;
return sum + words;
}, 0);
return NextResponse.json({
totalArticles,
searchesToday,
totalWords,
});
} catch (error) {
console.error('Error fetching stats:', error);
return NextResponse.json(
{ error: 'Failed to fetch statistics' },
{ status: 500 }
);
}
}