feat: add seed scripts and AI worker
Adds scripts/seed.ts for fresh self-hosted installs, scripts/seed-tenant.ts for cloud provisioner use, and scripts/ai-worker.ts adapted from AHCR with improved theme prompts, name filtering, and WORKER_API_KEY guard. Installs tsx as a dev dependency. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* FosterFlow AI Worker — runs on a machine with local GPU/Ollama access.
|
||||
* Polls the FosterFlow API for pending AI jobs, processes them through local Ollama,
|
||||
* and posts results back.
|
||||
*
|
||||
* Usage:
|
||||
* PUBLIC_SITE_URL=https://yourrescue.fosterflow.app WORKER_API_KEY=your-key npx tsx scripts/ai-worker.ts
|
||||
*
|
||||
* Environment variables:
|
||||
* PUBLIC_SITE_URL — FosterFlow base URL (default: http://localhost:5173)
|
||||
* WORKER_API_KEY — Shared secret for authenticating with the FosterFlow API
|
||||
* OLLAMA_URL — Ollama API URL (default: http://localhost:11434)
|
||||
* POLL_INTERVAL — Milliseconds between polls (default: 10000)
|
||||
*
|
||||
* NOTE: Old completed/failed jobs should be cleaned up periodically.
|
||||
* Consider a cron job: DELETE FROM ai_jobs WHERE status IN ('completed','failed') AND completed_at < NOW() - INTERVAL 7 DAY;
|
||||
*/
|
||||
|
||||
const API_URL = process.env.PUBLIC_SITE_URL || 'http://localhost:5173';
|
||||
const WORKER_KEY = process.env.WORKER_API_KEY || '';
|
||||
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://localhost:11434';
|
||||
const POLL_INTERVAL = parseInt(process.env.POLL_INTERVAL || '10000', 10);
|
||||
|
||||
if (!WORKER_KEY) {
|
||||
console.error('ERROR: WORKER_API_KEY environment variable is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function fetchNextJob() {
|
||||
const res = await fetch(`${API_URL}/api/worker/jobs`, {
|
||||
headers: { Authorization: `Bearer ${WORKER_KEY}` }
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function reportResult(jobId: number, output: unknown) {
|
||||
await fetch(`${API_URL}/api/worker/jobs/${jobId}`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${WORKER_KEY}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: 'completed', output })
|
||||
});
|
||||
}
|
||||
|
||||
async function reportError(jobId: number, error: string) {
|
||||
await fetch(`${API_URL}/api/worker/jobs/${jobId}`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${WORKER_KEY}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: 'failed', error })
|
||||
});
|
||||
}
|
||||
|
||||
async function generateNames(input: { species: string; theme: string; count: number; existingNames?: string }) {
|
||||
const themes: Record<string, string> = {
|
||||
food: 'food and cooking themed names (like Biscuit, Waffles, Chimichurri, Mochi, Pancake)',
|
||||
disney: 'Disney character themed names (like Simba, Nala, Elsa, Olaf, Stitch)',
|
||||
celebrity: 'celebrity or pop culture themed names (like Bowie, Dolly, Elvis, Beyonce)',
|
||||
nature: 'nature themed names (like Willow, Storm, River, Daisy, Bear)',
|
||||
classic: 'classic pet names (like Max, Bella, Charlie, Luna, Cooper)',
|
||||
funny: 'funny and punny names (like Sir Barks-a-Lot, Bark Twain, Chewbarka, Purrlock Holmes)',
|
||||
random: 'creative and unique names'
|
||||
};
|
||||
|
||||
const themePrompt = themes[input.theme] || themes.random;
|
||||
const count = input.count || 10;
|
||||
|
||||
const prompt = `Generate exactly ${count} creative ${input.species || 'dog'} names. Theme: ${themePrompt}.
|
||||
|
||||
IMPORTANT: These names are ALREADY TAKEN and must NOT be reused: ${input.existingNames || 'none'}
|
||||
|
||||
Rules:
|
||||
- Just the names, one per line
|
||||
- No numbers, no explanations, no extra text
|
||||
- Be creative and fun
|
||||
- Mix single names and two-word names
|
||||
- Do NOT repeat any name from the taken list above`;
|
||||
|
||||
const res = await fetch(`${OLLAMA_URL}/api/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'gemma2:9b',
|
||||
prompt,
|
||||
stream: false,
|
||||
options: {
|
||||
temperature: 1.2,
|
||||
top_p: 0.95,
|
||||
num_predict: 200
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`Ollama error: ${res.status}: ${await res.text()}`);
|
||||
|
||||
const data = await res.json();
|
||||
const names = (data.response || '')
|
||||
.split('\n')
|
||||
.map((n: string) => n.replace(/^\d+[\.\)]\s*/, '').replace(/^[-*•]\s*/, '').trim())
|
||||
.filter((name: string) => name && name.length > 1 && name.length < 40 && !name.includes(':'))
|
||||
.filter((name: string, index: number, self: string[]) => self.indexOf(name) === index)
|
||||
.slice(0, count);
|
||||
|
||||
return { names };
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
console.log('FosterFlow AI Worker started');
|
||||
console.log(` Polling: ${API_URL} every ${POLL_INTERVAL}ms`);
|
||||
console.log(` Ollama: ${OLLAMA_URL}`);
|
||||
|
||||
// Run immediately on start, then on interval
|
||||
const tick = async () => {
|
||||
try {
|
||||
const job = await fetchNextJob();
|
||||
if (job && job.id) {
|
||||
console.log(`Processing job ${job.id} (${job.type})`);
|
||||
try {
|
||||
if (job.type === 'pet-names') {
|
||||
const result = await generateNames(job.input);
|
||||
await reportResult(job.id, result);
|
||||
console.log(`Job ${job.id} completed`);
|
||||
} else {
|
||||
await reportError(job.id, `Unknown job type: ${job.type}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
await reportError(job.id, err.message);
|
||||
console.error(`Job ${job.id} failed:`, err.message);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Connection error — will retry on next interval
|
||||
}
|
||||
};
|
||||
|
||||
tick();
|
||||
setInterval(tick, POLL_INTERVAL);
|
||||
}
|
||||
|
||||
poll();
|
||||
Reference in New Issue
Block a user