feat: add AI pet name generator page and API endpoint

Client-side generator page with species (dog/cat) and theme selectors
submits a job to /api/pet-names POST, then polls GET until completed.
API uses nameGeneratorLimiter for rate limiting and delegates to
queueNameGenerationJob/getJobStatus from lib/server/ai.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-26 12:14:52 -05:00
co-authored by Claude Sonnet 4.6
parent 464f300775
commit fccc89630e
2 changed files with 287 additions and 0 deletions
@@ -0,0 +1,233 @@
<script lang="ts">
let species = $state('dog');
let theme = $state('classic');
let names = $state<string[]>([]);
let loading = $state(false);
let error = $state('');
const themes = [
{ value: 'classic', label: 'Classic' },
{ value: 'food', label: 'Foodie Names' },
{ value: 'disney', label: 'Disney' },
{ value: 'nature', label: 'Nature' },
{ value: 'funny', label: 'Funny & Punny' },
{ value: 'celebrity', label: 'Celebrity' },
{ value: 'random', label: 'Surprise Me!' }
];
const thinkingMessages = [
'Our AI is thinking...',
'Brainstorming names...',
'Consulting the name archives...',
'Sniffing out good names...',
'Almost there...'
];
let thinkingIndex = $state(0);
let thinkingInterval: ReturnType<typeof setInterval> | null = null;
function startThinking() {
thinkingIndex = 0;
thinkingInterval = setInterval(() => {
thinkingIndex = (thinkingIndex + 1) % thinkingMessages.length;
}, 3000);
}
function stopThinking() {
if (thinkingInterval) {
clearInterval(thinkingInterval);
thinkingInterval = null;
}
}
async function generate() {
loading = true;
error = '';
names = [];
startThinking();
try {
// Step 1: Submit job
const submitRes = await fetch('/api/pet-names', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ species, theme, count: 10 })
});
const submitData = await submitRes.json();
if (submitData.error) {
error = submitData.error;
loading = false;
stopThinking();
return;
}
const jobId = submitData.jobId;
// Step 2: Poll for results
const maxAttempts = 60;
let attempts = 0;
while (attempts < maxAttempts) {
await new Promise(r => setTimeout(r, 1500));
attempts++;
const pollRes = await fetch(`/api/pet-names?jobId=${jobId}`);
const pollData = await pollRes.json();
if (pollData.status === 'completed') {
names = pollData.names ?? [];
break;
}
if (pollData.status === 'failed') {
error = pollData.error || 'Name generation failed. Please try again.';
break;
}
}
if (attempts >= maxAttempts && names.length === 0 && !error) {
error = 'Request timed out. The AI might be busy — please try again in a moment.';
}
} catch {
error = 'Something went wrong. Please try again.';
}
loading = false;
stopThinking();
}
function copyName(name: string) {
navigator.clipboard.writeText(name);
}
</script>
<svelte:head>
<title>Pet Name Generator</title>
<meta name="description" content="Need a name for your new rescue pet? Our AI-powered name generator creates creative, themed pet names. Try it!" />
</svelte:head>
<section class="bg-gradient-to-br from-teal-700 via-teal-600 to-teal-700 py-16 text-center text-white">
<div class="mx-auto max-w-4xl px-4">
<h1 class="text-4xl font-bold sm:text-5xl">Pet Name Generator</h1>
<p class="mt-4 text-lg text-teal-100">
Can't decide on a name? Let our AI help! Powered by local AI — no data leaves our servers.
</p>
</div>
</section>
<section class="bg-gray-50 py-16">
<div class="mx-auto max-w-2xl px-4">
<!-- Controls -->
<div class="bg-white rounded-2xl shadow-sm border border-gray-100 p-6 sm:p-8">
<div class="grid gap-6">
<!-- Species -->
<div>
<label class="block text-sm font-semibold text-gray-800 mb-3">I'm naming a...</label>
<div class="flex gap-3">
<button
onclick={() => species = 'dog'}
class="flex-1 py-3 px-4 rounded-xl text-center font-medium transition cursor-pointer {species === 'dog'
? 'bg-teal-600 text-white shadow-md'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'}"
>
Dog
</button>
<button
onclick={() => species = 'cat'}
class="flex-1 py-3 px-4 rounded-xl text-center font-medium transition cursor-pointer {species === 'cat'
? 'bg-teal-600 text-white shadow-md'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'}"
>
Cat
</button>
</div>
</div>
<!-- Theme -->
<div>
<label class="block text-sm font-semibold text-gray-800 mb-3">Name theme</label>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2">
{#each themes as t}
<button
onclick={() => theme = t.value}
class="py-2.5 px-3 rounded-lg text-sm font-medium transition cursor-pointer {theme === t.value
? 'bg-teal-600 text-white shadow-md'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'}"
>
{t.label}
</button>
{/each}
</div>
</div>
<!-- Generate Button -->
<button
onclick={generate}
disabled={loading}
class="w-full py-4 rounded-xl bg-teal-600 text-white text-lg font-bold hover:bg-teal-700 disabled:opacity-50 transition shadow-lg hover:shadow-xl cursor-pointer"
>
{#if loading}
<span class="inline-flex items-center gap-2">
<svg class="animate-spin h-5 w-5" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{thinkingMessages[thinkingIndex]}
</span>
{:else}
Generate Names!
{/if}
</button>
</div>
</div>
<!-- Error -->
{#if error}
<div class="mt-6 p-4 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700">
{error}
</div>
{/if}
<!-- Results -->
{#if names.length > 0}
<div class="mt-8">
<h2 class="text-xl font-bold text-gray-800 mb-4 text-center">
Here are your {species} names!
</h2>
<div class="grid grid-cols-2 gap-3">
{#each names as name}
<button
onclick={() => copyName(name)}
class="group bg-white rounded-xl border border-gray-200 p-4 text-left hover:border-teal-500 hover:shadow-md transition cursor-pointer"
title="Click to copy"
>
<span class="text-lg font-semibold text-gray-800 group-hover:text-teal-600">{name}</span>
<span class="block text-xs text-gray-400 mt-1 group-hover:text-teal-500/60">Click to copy</span>
</button>
{/each}
</div>
<p class="text-center text-xs text-gray-400 mt-4">
Generated by AI running locally
</p>
<div class="text-center mt-4">
<button
onclick={generate}
class="text-teal-600 font-semibold hover:text-teal-700 transition cursor-pointer"
>
Generate more &rarr;
</button>
</div>
</div>
{/if}
<!-- Fun note -->
<div class="mt-12 text-center">
<p class="text-gray-500 text-sm">
Looking to adopt? Check out our
<a href="/pets" class="text-teal-600 font-medium hover:text-teal-700">available pets</a>
&mdash; they already have great names!
</p>
</div>
</div>
</section>
+54
View File
@@ -0,0 +1,54 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { queueNameGenerationJob, getJobStatus } from '$lib/server/ai';
import { nameGeneratorLimiter } from '$lib/server/rate-limit';
export const POST: RequestHandler = async ({ request, getClientAddress }) => {
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? getClientAddress();
// Rate limit: 10 requests per 5 minutes per IP
if (!nameGeneratorLimiter.check(ip)) {
return json({ error: 'Too many requests. Please slow down.' }, { status: 429 });
}
const { species, theme, count } = await request.json();
const nameCount = Math.min(Math.max(parseInt(count) || 10, 1), 20);
const animalType = species === 'cat' ? 'cat' : 'dog';
const jobId = await queueNameGenerationJob({
species: animalType,
theme: theme || 'classic',
count: nameCount
});
return json({ jobId });
};
export const GET: RequestHandler = async ({ url }) => {
const jobId = parseInt(url.searchParams.get('jobId') || '0');
if (!jobId || isNaN(jobId)) {
return json({ error: 'Missing or invalid jobId' }, { status: 400 });
}
const job = await getJobStatus(jobId);
if (!job) {
return json({ error: 'Job not found' }, { status: 404 });
}
if (job.status === 'completed') {
let output = job.output as { names: string[] } | string | null;
if (typeof output === 'string') {
try { output = JSON.parse(output); } catch { output = null; }
}
const names = (output as { names: string[] } | null)?.names || [];
return json({ status: 'completed', names });
}
if (job.status === 'failed') {
return json({ status: 'failed', error: job.error || 'Unknown error' });
}
// pending or processing
return json({ status: job.status });
};