364 lines
11 KiB
Python
364 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
DreamWiki - A Single-File, AI-Generated Wikipedia Clone
|
|
A web application that generates Wikipedia-style articles on-demand using LLMs via OpenRouter API.
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
import uvicorn
|
|
from fastapi import FastAPI, HTTPException
|
|
from fastapi.responses import HTMLResponse
|
|
from dotenv import load_dotenv
|
|
import requests
|
|
import markdown2
|
|
from jinja2 import Template
|
|
|
|
# ============================================================================
|
|
# Configuration
|
|
# ============================================================================
|
|
|
|
# Load environment variables
|
|
load_dotenv()
|
|
|
|
# Constants
|
|
PAGES_DIR = Path("pages")
|
|
OPENROUTER_API_URL = "https://openrouter.ai/api/v1/chat/completions"
|
|
|
|
# System prompt for article generation
|
|
SYSTEM_PROMPT = """You are an encyclopedia author for a fictional, dream-like universe called DreamWiki. Your sole purpose is to generate complete, imaginative, and engaging encyclopedia articles.
|
|
|
|
You must follow these rules strictly:
|
|
1. Your entire response must be in Markdown format.
|
|
2. The article should be comprehensive, with a short introductory paragraph followed by several sections (e.g., ## History, ## Characteristics, ## In Popular Culture).
|
|
3. You MUST invent and include 3-5 links to other DreamWiki articles. The links must be relevant and use the exact format: `[Link Text](/wiki/Article_Title)`. Do not use full URLs.
|
|
4. You MUST include at least one Markdown table.
|
|
5. You MUST include at least one placeholder image using the format ``.
|
|
6. Do not add any commentary, preamble, or sign-off. Respond only with the Markdown content of the article."""
|
|
|
|
# ============================================================================
|
|
# Templates and Styling
|
|
# ============================================================================
|
|
|
|
CSS_STYLE = """
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Lato, Helvetica, Arial, sans-serif;
|
|
line-height: 1.6;
|
|
color: #222;
|
|
max-width: 960px;
|
|
margin: 0 auto;
|
|
padding: 20px;
|
|
background-color: #f8f9fa;
|
|
}
|
|
|
|
.header {
|
|
border-bottom: 3px solid #a2a9b1;
|
|
padding-bottom: 10px;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.header h1 {
|
|
margin: 0;
|
|
color: #000;
|
|
font-size: 2.2em;
|
|
font-weight: normal;
|
|
}
|
|
|
|
.content {
|
|
background: white;
|
|
padding: 30px;
|
|
border: 1px solid #a2a9b1;
|
|
border-radius: 2px;
|
|
}
|
|
|
|
h1, h2, h3, h4, h5, h6 {
|
|
color: #000;
|
|
border-bottom: 1px solid #eaecf0;
|
|
padding-bottom: 2px;
|
|
margin-top: 30px;
|
|
margin-bottom: 15px;
|
|
}
|
|
|
|
h1 { font-size: 2.2em; }
|
|
h2 { font-size: 1.8em; }
|
|
h3 { font-size: 1.4em; }
|
|
|
|
a {
|
|
color: #0645ad;
|
|
text-decoration: none;
|
|
}
|
|
|
|
a:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
a:visited {
|
|
color: #0b0080;
|
|
}
|
|
|
|
table {
|
|
border-collapse: collapse;
|
|
margin: 20px 0;
|
|
width: 100%;
|
|
}
|
|
|
|
table, th, td {
|
|
border: 1px solid #a2a9b1;
|
|
}
|
|
|
|
th, td {
|
|
padding: 8px 12px;
|
|
text-align: left;
|
|
}
|
|
|
|
th {
|
|
background-color: #eaecf0;
|
|
font-weight: bold;
|
|
}
|
|
|
|
img {
|
|
max-width: 100%;
|
|
height: auto;
|
|
margin: 15px 0;
|
|
}
|
|
|
|
.search-form {
|
|
margin: 20px 0;
|
|
}
|
|
|
|
.search-input {
|
|
padding: 8px 12px;
|
|
font-size: 16px;
|
|
border: 1px solid #a2a9b1;
|
|
border-radius: 2px;
|
|
width: 300px;
|
|
}
|
|
|
|
.search-button {
|
|
padding: 8px 16px;
|
|
font-size: 16px;
|
|
background-color: #0645ad;
|
|
color: white;
|
|
border: none;
|
|
border-radius: 2px;
|
|
cursor: pointer;
|
|
margin-left: 5px;
|
|
}
|
|
|
|
.search-button:hover {
|
|
background-color: #0b0080;
|
|
}
|
|
|
|
.intro {
|
|
background-color: #f8f9fa;
|
|
padding: 20px;
|
|
border-left: 5px solid #36c;
|
|
margin: 20px 0;
|
|
}
|
|
"""
|
|
|
|
HTML_TEMPLATE = """<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>{{ title }} - DreamWiki</title>
|
|
<style>{{ style }}</style>
|
|
</head>
|
|
<body>
|
|
<div class="header">
|
|
<h1><a href="/" style="text-decoration: none; color: inherit;">DreamWiki</a></h1>
|
|
</div>
|
|
<div class="content">
|
|
{{ content }}
|
|
</div>
|
|
</body>
|
|
</html>"""
|
|
|
|
# ============================================================================
|
|
# Helper Functions
|
|
# ============================================================================
|
|
|
|
def slugify(title: str) -> str:
|
|
"""Convert a title into a safe filename slug."""
|
|
# Convert to lowercase and replace spaces with underscores
|
|
slug = title.lower().replace(" ", "_")
|
|
# Remove any characters that aren't alphanumeric, underscore, or hyphen
|
|
slug = re.sub(r'[^a-z0-9_-]', '', slug)
|
|
# Remove multiple consecutive underscores
|
|
slug = re.sub(r'_+', '_', slug)
|
|
# Remove leading/trailing underscores
|
|
slug = slug.strip('_')
|
|
return slug
|
|
|
|
def page_exists(title_slug: str) -> bool:
|
|
"""Check if a page file exists for the given slug."""
|
|
file_path = PAGES_DIR / f"{title_slug}.md"
|
|
return file_path.exists()
|
|
|
|
def load_page(title_slug: str) -> str:
|
|
"""Load and return the content of a page file."""
|
|
file_path = PAGES_DIR / f"{title_slug}.md"
|
|
try:
|
|
with open(file_path, 'r', encoding='utf-8') as f:
|
|
return f.read()
|
|
except FileNotFoundError:
|
|
raise HTTPException(status_code=404, detail="Page not found")
|
|
|
|
def save_page(title_slug: str, content: str) -> None:
|
|
"""Save generated content to a page file."""
|
|
# Ensure pages directory exists
|
|
PAGES_DIR.mkdir(exist_ok=True)
|
|
|
|
file_path = PAGES_DIR / f"{title_slug}.md"
|
|
with open(file_path, 'w', encoding='utf-8') as f:
|
|
f.write(content)
|
|
|
|
def generate_article(title: str) -> str:
|
|
"""Generate an article using the OpenRouter API."""
|
|
api_key = os.getenv('OPENROUTER_API_KEY')
|
|
model = os.getenv('OPENROUTER_MODEL')
|
|
|
|
if not api_key:
|
|
raise HTTPException(status_code=500, detail="OPENROUTER_API_KEY not configured")
|
|
if not model:
|
|
raise HTTPException(status_code=500, detail="OPENROUTER_MODEL not configured")
|
|
|
|
# Prepare the prompt
|
|
title_slug = slugify(title)
|
|
prompt = SYSTEM_PROMPT.replace("REPLACE_WITH_SLUG", title_slug)
|
|
|
|
headers = {
|
|
"Authorization": f"Bearer {api_key}",
|
|
"Content-Type": "application/json",
|
|
"HTTP-Referer": "https://dreamwiki.dws.rip"
|
|
}
|
|
|
|
payload = {
|
|
"model": model,
|
|
"messages": [
|
|
{"role": "system", "content": prompt},
|
|
{"role": "user", "content": f"Generate a comprehensive DreamWiki article about: {title}"}
|
|
]
|
|
}
|
|
|
|
try:
|
|
response = requests.post(OPENROUTER_API_URL, headers=headers, json=payload, timeout=30)
|
|
response.raise_for_status()
|
|
|
|
data = response.json()
|
|
content = data['choices'][0]['message']['content']
|
|
return content
|
|
|
|
except requests.exceptions.RequestException as e:
|
|
raise HTTPException(status_code=500, detail=f"Failed to generate article: {str(e)}")
|
|
except (KeyError, IndexError) as e:
|
|
raise HTTPException(status_code=500, detail="Invalid response from OpenRouter API")
|
|
|
|
# ============================================================================
|
|
# FastAPI Application
|
|
# ============================================================================
|
|
|
|
app = FastAPI(title="DreamWiki", description="AI-Generated Wikipedia Clone")
|
|
|
|
@app.on_event("startup")
|
|
async def startup_event():
|
|
"""Initialize the application on startup."""
|
|
# Create pages directory
|
|
PAGES_DIR.mkdir(exist_ok=True)
|
|
|
|
# Log configuration status
|
|
api_key = os.getenv('OPENROUTER_API_KEY')
|
|
model = os.getenv('OPENROUTER_MODEL')
|
|
|
|
print("🌟 DreamWiki Starting Up...")
|
|
print(f"📁 Pages directory: {PAGES_DIR.absolute()}")
|
|
print(f"🔑 API Key configured: {'✓' if api_key else '✗'}")
|
|
print(f"🤖 Model configured: {model if model else '✗'}")
|
|
print("🚀 Ready to generate dream articles!")
|
|
|
|
@app.get("/", response_class=HTMLResponse)
|
|
async def home():
|
|
"""Render the home page."""
|
|
content = """
|
|
<h1>Welcome to DreamWiki</h1>
|
|
|
|
<div class="intro">
|
|
<p><strong>DreamWiki</strong> is a fictional encyclopedia where every article is generated on-demand by artificial intelligence.
|
|
Enter any topic below and watch as an entire article materializes from the digital ether, complete with interconnected
|
|
lore and references to other dream-like entities.</p>
|
|
|
|
<p><em>Note: This is a creative experiment. All content is fictional and generated by AI.</em></p>
|
|
</div>
|
|
|
|
<form class="search-form" action="/wiki/" method="get" onsubmit="return searchRedirect(event)">
|
|
<input type="text" name="q" placeholder="Enter any topic..." class="search-input" required>
|
|
<button type="submit" class="search-button">Explore</button>
|
|
</form>
|
|
|
|
<h2>Recent Explorations</h2>
|
|
<p>Try searching for anything that comes to mind: fictional creatures, imaginary places, made-up technologies,
|
|
or abstract concepts. Each search creates a new page in our ever-expanding dream universe.</p>
|
|
|
|
<script>
|
|
function searchRedirect(event) {
|
|
event.preventDefault();
|
|
const query = event.target.q.value.trim();
|
|
if (query) {
|
|
window.location.href = '/wiki/' + encodeURIComponent(query);
|
|
}
|
|
return false;
|
|
}
|
|
</script>
|
|
"""
|
|
|
|
template = Template(HTML_TEMPLATE)
|
|
return template.render(
|
|
title="Home",
|
|
style=CSS_STYLE,
|
|
content=content
|
|
)
|
|
|
|
@app.get("/wiki/{page_title:path}", response_class=HTMLResponse)
|
|
async def wiki_page(page_title: str):
|
|
"""Render a wiki page, generating it if it doesn't exist."""
|
|
title_slug = slugify(page_title)
|
|
|
|
if not title_slug:
|
|
raise HTTPException(status_code=400, detail="Invalid page title")
|
|
|
|
# Check if page exists
|
|
if page_exists(title_slug):
|
|
# Load existing page
|
|
markdown_content = load_page(title_slug)
|
|
else:
|
|
# Generate new page
|
|
try:
|
|
markdown_content = generate_article(page_title)
|
|
save_page(title_slug, markdown_content)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Failed to generate article: {str(e)}")
|
|
|
|
# Convert Markdown to HTML
|
|
html_content = markdown2.markdown(markdown_content, extras=['tables', 'fenced-code-blocks'])
|
|
|
|
# Render the final page
|
|
template = Template(HTML_TEMPLATE)
|
|
return template.render(
|
|
title=page_title,
|
|
style=CSS_STYLE,
|
|
content=html_content
|
|
)
|
|
|
|
# ============================================================================
|
|
# Main Execution
|
|
# ============================================================================
|
|
|
|
if __name__ == "__main__":
|
|
uvicorn.run("app:app", host="0.0.0.0", port=8000, reload=True) |