Files
website-ai-generator/web/generator.php

391 lines
13 KiB
PHP

<?php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
function readLocalEnvValue(string $envFilePath, string $key): string
{
if (! is_file($envFilePath) || ! is_readable($envFilePath)) {
return '';
}
$lines = file($envFilePath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if ($lines === false) {
return '';
}
foreach ($lines as $line) {
$trimmed = trim($line);
if ($trimmed === '' || str_starts_with($trimmed, '#')) {
continue;
}
$parts = explode('=', $trimmed, 2);
if (count($parts) !== 2) {
continue;
}
if (trim($parts[0]) !== $key) {
continue;
}
return trim(trim($parts[1]), "\"'");
}
return '';
}
function extractHtmlTitle(string $html): string
{
if (preg_match('/<title>(.*?)<\/title>/is', $html, $matches) === 1) {
return trim(strip_tags($matches[1]));
}
return '';
}
/**
* Fetch one landscape-ish photo from Pexels for the given query.
*
* @return array{url:string,alt:string,photographer:string,photoUrl:string}|null
*/
function fetchPexelsPhoto(string $pexelsKey, string $query): ?array
{
if ($pexelsKey === '' || $query === '') {
return null;
}
$endpoint = 'https://api.pexels.com/v1/search?query=' . rawurlencode($query) . '&per_page=1&orientation=landscape&size=large';
$raw = false;
if (function_exists('curl_init')) {
$ch = curl_init($endpoint);
if ($ch !== false) {
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: ' . $pexelsKey,
'Accept: application/json',
],
CURLOPT_TIMEOUT => 12,
]);
$raw = curl_exec($ch);
curl_close($ch);
}
}
if ($raw === false) {
$context = stream_context_create([
'http' => [
'method' => 'GET',
'header' => "Authorization: {$pexelsKey}\r\nAccept: application/json\r\n",
'timeout' => 12,
],
]);
$raw = @file_get_contents($endpoint, false, $context);
}
if ($raw === false) {
return null;
}
$json = json_decode($raw, true);
if (! is_array($json) || ! isset($json['photos'][0]) || ! is_array($json['photos'][0])) {
return null;
}
$photo = $json['photos'][0];
$src = $photo['src'] ?? [];
$url = (string) ($src['landscape'] ?? $src['large'] ?? $src['original'] ?? '');
if ($url === '') {
return null;
}
return [
'url' => $url,
'alt' => trim((string) ($photo['alt'] ?? 'Hero image')),
'photographer' => trim((string) ($photo['photographer'] ?? 'Pexels photographer')),
'photoUrl' => trim((string) ($photo['url'] ?? 'https://www.pexels.com/')),
];
}
/**
* Replace hero placeholders with Pexels image metadata.
*/
function injectHeroImagePlaceholders(string $html, ?array $photo): string
{
$fallbackSvg = rawurlencode('<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="627"><rect width="100%" height="100%" fill="#111827"/><text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" fill="#9CA3AF" font-family="Arial, sans-serif" font-size="36">Hero image unavailable</text></svg>');
$fallbackUrl = 'data:image/svg+xml;utf8,' . $fallbackSvg;
$imageUrl = $photo['url'] ?? $fallbackUrl;
$imageAlt = $photo['alt'] ?? 'Hero image';
$photographer = $photo['photographer'] ?? 'Pexels';
$photoUrl = $photo['photoUrl'] ?? 'https://www.pexels.com/';
$replacements = [
'__PEXELS_HERO_IMAGE__' => htmlspecialchars((string) $imageUrl, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'),
'__PEXELS_HERO_ALT__' => htmlspecialchars((string) $imageAlt, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'),
'__PEXELS_PHOTOGRAPHER__' => htmlspecialchars((string) $photographer, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'),
'__PEXELS_PHOTO_URL__' => htmlspecialchars((string) $photoUrl, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'),
];
return strtr($html, $replacements);
}
/**
* Render a JPG preview for a generated page using a local headless browser.
*/
function renderPagePreview(string $seed, string $htmlFilePath): void
{
$previewDir = __DIR__ . '/previews';
if (! is_dir($previewDir)) {
mkdir($previewDir, 0775, true);
}
$realHtmlPath = realpath($htmlFilePath);
if ($realHtmlPath === false) {
return;
}
$pageUrl = 'file://' . $realHtmlPath;
$previewPngPath = $previewDir . '/' . $seed . '.png';
$previewJpgPath = $previewDir . '/' . $seed . '.jpg';
$browserCandidates = [
'/usr/bin/google-chrome',
'/usr/bin/google-chrome-stable',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
'google-chrome',
'google-chrome-stable',
'chromium',
'chromium-browser',
];
$browserBinary = null;
foreach ($browserCandidates as $candidate) {
$candidatePath = null;
if (str_starts_with($candidate, '/')) {
if (! is_executable($candidate)) {
continue;
}
$candidatePath = $candidate;
} else {
$pathOut = [];
$pathExitCode = 1;
exec('command -v ' . escapeshellarg($candidate) . ' 2>/dev/null', $pathOut, $pathExitCode);
if ($pathExitCode !== 0 || ! isset($pathOut[0]) || $pathOut[0] === '') {
continue;
}
$candidatePath = $pathOut[0];
}
$versionOut = [];
$versionExitCode = 1;
exec(escapeshellarg($candidatePath) . ' --version 2>/dev/null', $versionOut, $versionExitCode);
if ($versionExitCode === 0) {
$browserBinary = $candidatePath;
break;
}
}
if ($browserBinary === null) {
return;
}
$command = sprintf(
'%s --headless --disable-gpu --no-sandbox --disable-dev-shm-usage --hide-scrollbars --window-size=1440,900 --screenshot=%s --virtual-time-budget=8000 --run-all-compositor-stages-before-draw %s 2>/dev/null',
escapeshellarg($browserBinary),
escapeshellarg($previewPngPath),
escapeshellarg($pageUrl)
);
$output = [];
$exitCode = 1;
exec($command, $output, $exitCode);
if ($exitCode !== 0 || ! file_exists($previewPngPath)) {
@unlink($previewPngPath);
return;
}
if (function_exists('imagecreatefrompng') && function_exists('imagejpeg')) {
$image = @imagecreatefrompng($previewPngPath);
if ($image !== false) {
imagejpeg($image, $previewJpgPath, 85);
imagedestroy($image);
}
}
if (file_exists($previewJpgPath)) {
@unlink($previewPngPath);
} else {
// Keep PNG as fallback if JPG conversion is unavailable.
@rename($previewPngPath, $previewJpgPath);
}
}
header('Content-Type: text/html; charset=UTF-8');
$startedAt = microtime(true);
$pexelsKey = 'Clyqp9i9pewaxiiAdqVya0yZuuedjHtUtJdjRipNltp7wf0dY8K10h4h';
$apiKey = getenv('LLAMA_API_KEY') ?: '';
$model = getenv('LLAMA_MODEL') ?: 'local-model';
$maxTokens = (int) (getenv('LLAMA_MAX_TOKENS') ?: 4096);
$reasoningEffort = getenv('LLAMA_REASONING_EFFORT') ?: 'none';
$topicHint = isset($_GET['topic']) ? trim((string) $_GET['topic']) : '';
$topicHint = mb_substr($topicHint, 0, 120);
$seed = sprintf('seed-%08x%08x', random_int(0, 0xffffffff), random_int(0, 0xffffffff));
header('HX-Redirect: /' . $seed);
$topicCategories = [
'local restaurant landing page',
'fitness coaching brand site',
'travel destination mini-guide',
'event festival one-page promo',
'indie game launch page',
'architect portfolio page',
'music artist release page',
'bookstore seasonal campaign',
'pet adoption center homepage',
'artisan coffee roaster website',
'online course product page',
'nonprofit donation campaign page',
];
$selectedCategory = $topicCategories[array_rand($topicCategories)];
$bannedTerms = [
'chrono',
'temporal',
'chrono-',
'timewarp',
'timescape',
'timeshift',
'quantum',
'aether',
'epoch',
'singularity',
];
$bannedTermsList = implode(', ', $bannedTerms);
$factory = OpenAI::factory()->withBaseUri('http://localhost:8080/v1');
if ($apiKey !== '') {
$factory = $factory->withApiKey($apiKey);
}
$client = $factory->make();
$systemPrompt = <<<PROMPT
You are a senior creative web designer and front-end engineer.
Task:
- On every run, first choose a unique page concept with a specific topic, visual theme, and style direction.
- Then generate one complete, production-ready HTML document from scratch.
- The page should be fully in English, including all text content and any text in images.
Hard output rules:
- Return only raw HTML. No markdown. No code fences. No explanations.
- Output must begin with <!doctype html> and include full <html>, <head>, and <body>.
- Use Tailwind CSS via CDN in the same HTML document.
- Do not rely on local/external project files. Everything required must be inside this single HTML file.
- Inline any custom CSS and JavaScript in <style> and <script> tags.
- Make the page responsive and fully usable on mobile and desktop.
- Include accessible semantic structure and visible focus states.
- In the hero section, include one main image with: src="__PEXELS_HERO_IMAGE__" and alt="__PEXELS_HERO_ALT__".
- Add a small attribution near that image: "Photo by __PEXELS_PHOTOGRAPHER__ on Pexels" and link to __PEXELS_PHOTO_URL__.
- Do not use other stock-image providers or random external image URLs for the hero image.
Creative direction:
- Avoid generic templates.
- Use bold, intentional typography and layout.
- Include subtle but meaningful animation/interaction.
- Provide complete content, not placeholder lorem ipsum.
Diversity constraints:
- Do not make a sci-fi, futuristic, or time-themed website unless the user explicitly asks for it.
- Avoid these words in brand names, title, headings, and body copy: {$bannedTermsList}
- If no topic is provided by user, choose a grounded real-world business/topic from the requested category.
PROMPT;
$userPrompt = 'Create a complete one-page website now. Use random token: ' . $seed . '.';
if ($topicHint === '') {
$userPrompt .= ' Required category for this run: ' . $selectedCategory . '.';
}
if ($topicHint !== '') {
$userPrompt .= ' Topic request: ' . $topicHint . '.';
}
$request = [
'model' => $model,
'temperature' => 0.95,
'top_p' => 0.95,
'max_tokens' => $maxTokens,
'messages' => [
['role' => 'system', 'content' => $systemPrompt],
['role' => 'user', 'content' => $userPrompt],
],
'reasoning_effort' => $reasoningEffort,
];
try {
$response = $client->chat()->create($request);
$html = trim((string) ($response->choices[0]->message->content ?? ''));
if ($topicHint === '' && preg_match('/\b(?:chrono\w*|temporal\w*|quantum\w*|aether\w*|epoch\w*|singularity\w*|timeshift\w*|timescape\w*|timewarp\w*)\b/i', $html) === 1) {
$request['messages'][] = [
'role' => 'system',
'content' => 'Retry with a completely different, non-sci-fi, non-time-themed concept. Do not use banned words.',
];
$response = $client->chat()->create($request);
$html = trim((string) ($response->choices[0]->message->content ?? ''));
}
$elapsedSeconds = number_format(microtime(true) - $startedAt, 2);
// If the model wraps output in a markdown fence, unwrap it safely.
if (preg_match('/^```(?:html)?\s*(.*?)\s*```$/is', $html, $matches) === 1) {
$html = $matches[1];
}
if ($html === '' || stripos($html, '<html') === false) {
throw new RuntimeException('Model did not return a full HTML document.');
}
$imageQuery = $topicHint !== '' ? $topicHint : extractHtmlTitle($html);
if ($imageQuery === '') {
$imageQuery = $selectedCategory;
}
$pexelsPhoto = fetchPexelsPhoto($pexelsKey, $imageQuery);
$html = injectHeroImagePlaceholders($html, $pexelsPhoto);
$timingBlock = '<div style="position:fixed;right:12px;bottom:12px;z-index:2147483647;background:rgba(17,24,39,.92);color:#fff;padding:8px 10px;border-radius:10px;font:12px/1.3 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;box-shadow:0 8px 20px rgba(0,0,0,.28)">Generated in ' . $elapsedSeconds . 's</div>';
if (stripos($html, '</body>') !== false) {
$html = preg_replace('/<\/body>/i', $timingBlock . '</body>', $html, 1) ?? ($html . $timingBlock);
} else {
$html .= $timingBlock;
}
//echo $html;
$htmlPath = __DIR__ . '/../pages/' . $seed . '.html';
file_put_contents($htmlPath, $html);
renderPagePreview($seed, $htmlPath);
} catch (Throwable $e) {
http_response_code(500);
$message = htmlspecialchars($e->getMessage(), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
echo '<!doctype html><html><head><meta charset="UTF-8"><title>Generation Error</title>';
echo '<script src="https://cdn.tailwindcss.com"></script></head><body class="min-h-screen bg-zinc-950 text-zinc-100 p-8">';
echo '<h1 class="text-2xl font-bold mb-4">Page generation failed</h1>';
echo '<p class="text-zinc-300 mb-3">' . $message . '</p>';
echo '<p class="text-zinc-400 text-sm">Tip: set LLAMA_MODEL to a loaded model and optionally pass ?topic=your-idea in the URL.</p>';
echo '</body></html>';
}