feat: add AI-powered alt text generation for recipe images
- Implement local Ollama integration for bilingual (DE/EN) alt text generation - Add image management UI to German edit page and English translation section - Update Card and recipe detail pages to display alt text from images array - Include GenerateAltTextButton component for manual alt text generation - Add bulk processing admin page for batch alt text generation - Optimize images to 1024x1024 before AI processing for 75% faster generation - Store alt text in recipe.images[].alt and translations.en.images[].alt
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { generateAltText, type RecipeContext } from '$lib/server/ai/alttext.js';
|
||||
import { checkOllamaHealth } from '$lib/server/ai/ollama.js';
|
||||
import { Recipe } from '$models/Recipe.js';
|
||||
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
// Check authentication
|
||||
const session = await locals.auth();
|
||||
if (!session?.user) {
|
||||
throw error(401, 'Unauthorized');
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { filter = 'missing', limit = 10, modelName } = body;
|
||||
|
||||
// Check if Ollama is available
|
||||
const isOllamaAvailable = await checkOllamaHealth();
|
||||
if (!isOllamaAvailable) {
|
||||
throw error(503, 'Ollama service is not available. Make sure Ollama is running.');
|
||||
}
|
||||
|
||||
// Build query based on filter
|
||||
let query: any = { images: { $exists: true, $ne: [] } };
|
||||
|
||||
if (filter === 'missing') {
|
||||
// Find recipes with images but missing alt text
|
||||
query = {
|
||||
images: {
|
||||
$elemMatch: {
|
||||
mediapath: { $exists: true },
|
||||
$or: [{ alt: { $exists: false } }, { alt: '' }],
|
||||
},
|
||||
},
|
||||
};
|
||||
} else if (filter === 'all') {
|
||||
// Process all recipes with images
|
||||
query = { images: { $exists: true, $ne: [] } };
|
||||
}
|
||||
|
||||
// Fetch recipes
|
||||
const recipes = await Recipe.find(query).limit(limit);
|
||||
|
||||
if (recipes.length === 0) {
|
||||
return json({
|
||||
success: true,
|
||||
processed: 0,
|
||||
message: 'No recipes found matching criteria',
|
||||
});
|
||||
}
|
||||
|
||||
const results: Array<{
|
||||
shortName: string;
|
||||
name: string;
|
||||
processed: number;
|
||||
failed: number;
|
||||
}> = [];
|
||||
|
||||
// Process each recipe
|
||||
for (const recipe of recipes) {
|
||||
let processed = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (let i = 0; i < recipe.images.length; i++) {
|
||||
const image = recipe.images[i];
|
||||
|
||||
// Skip if alt text exists and we're only processing missing ones
|
||||
if (filter === 'missing' && image.alt) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Prepare context
|
||||
const context: RecipeContext = {
|
||||
name: recipe.name,
|
||||
category: recipe.category,
|
||||
tags: recipe.tags,
|
||||
};
|
||||
|
||||
// Generate alt text
|
||||
const altTextResult = await generateAltText(
|
||||
image.mediapath,
|
||||
context,
|
||||
modelName || 'gemma3:latest'
|
||||
);
|
||||
|
||||
// Update German alt text
|
||||
recipe.images[i].alt = altTextResult.de;
|
||||
|
||||
// Ensure translations.en.images array exists
|
||||
if (!recipe.translations) {
|
||||
recipe.translations = { en: { images: [] } };
|
||||
}
|
||||
if (!recipe.translations.en) {
|
||||
recipe.translations.en = { images: [] };
|
||||
}
|
||||
if (!recipe.translations.en.images) {
|
||||
recipe.translations.en.images = [];
|
||||
}
|
||||
|
||||
// Ensure array has enough entries
|
||||
while (recipe.translations.en.images.length <= i) {
|
||||
recipe.translations.en.images.push({ alt: '', caption: '' });
|
||||
}
|
||||
|
||||
// Update English alt text
|
||||
recipe.translations.en.images[i].alt = altTextResult.en;
|
||||
|
||||
processed++;
|
||||
} catch (err) {
|
||||
console.error(`Failed to process image ${i} for recipe ${recipe.short_name}:`, err);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
// Save recipe if any images were processed
|
||||
if (processed > 0) {
|
||||
await recipe.save();
|
||||
}
|
||||
|
||||
results.push({
|
||||
shortName: recipe.short_name,
|
||||
name: recipe.name,
|
||||
processed,
|
||||
failed,
|
||||
});
|
||||
}
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
processed: results.reduce((sum, r) => sum + r.processed, 0),
|
||||
failed: results.reduce((sum, r) => sum + r.failed, 0),
|
||||
results,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error in bulk alt text generation:', err);
|
||||
|
||||
if (err instanceof Error && 'status' in err) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
throw error(500, err instanceof Error ? err.message : 'Failed to generate alt text');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET endpoint to check status and get stats
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
// Check authentication
|
||||
const session = await locals.auth();
|
||||
if (!session?.user) {
|
||||
throw error(401, 'Unauthorized');
|
||||
}
|
||||
|
||||
try {
|
||||
// Count recipes with missing alt text
|
||||
const totalWithImages = await Recipe.countDocuments({
|
||||
images: { $exists: true, $ne: [] },
|
||||
});
|
||||
|
||||
const missingAltText = await Recipe.countDocuments({
|
||||
images: {
|
||||
$elemMatch: {
|
||||
mediapath: { $exists: true },
|
||||
$or: [{ alt: { $exists: false } }, { alt: '' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Check Ollama health
|
||||
const ollamaAvailable = await checkOllamaHealth();
|
||||
|
||||
return json({
|
||||
totalWithImages,
|
||||
missingAltText,
|
||||
ollamaAvailable,
|
||||
});
|
||||
} catch (err) {
|
||||
throw error(500, 'Failed to fetch statistics');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { generateAltText, type RecipeContext } from '$lib/server/ai/alttext.js';
|
||||
import { checkOllamaHealth } from '$lib/server/ai/ollama.js';
|
||||
import { Recipe } from '$models/Recipe.js';
|
||||
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
// Check authentication
|
||||
const session = await locals.auth();
|
||||
if (!session?.user) {
|
||||
throw error(401, 'Unauthorized');
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { shortName, imageIndex, modelName } = body;
|
||||
|
||||
if (!shortName || imageIndex === undefined) {
|
||||
throw error(400, 'Missing required fields: shortName and imageIndex');
|
||||
}
|
||||
|
||||
// Check if Ollama is available
|
||||
const isOllamaAvailable = await checkOllamaHealth();
|
||||
if (!isOllamaAvailable) {
|
||||
throw error(503, 'Ollama service is not available. Make sure Ollama is running.');
|
||||
}
|
||||
|
||||
// Fetch recipe from database
|
||||
const recipe = await Recipe.findOne({ short_name: shortName });
|
||||
if (!recipe) {
|
||||
throw error(404, 'Recipe not found');
|
||||
}
|
||||
|
||||
// Validate image index
|
||||
if (!recipe.images || !recipe.images[imageIndex]) {
|
||||
throw error(404, 'Image not found at specified index');
|
||||
}
|
||||
|
||||
const image = recipe.images[imageIndex];
|
||||
|
||||
// Prepare context for alt text generation
|
||||
const context: RecipeContext = {
|
||||
name: recipe.name,
|
||||
category: recipe.category,
|
||||
tags: recipe.tags,
|
||||
};
|
||||
|
||||
// Generate alt text in both languages
|
||||
const altTextResult = await generateAltText(
|
||||
image.mediapath,
|
||||
context,
|
||||
modelName || 'gemma3:latest'
|
||||
);
|
||||
|
||||
// Update recipe in database
|
||||
recipe.images[imageIndex].alt = altTextResult.de;
|
||||
|
||||
// Ensure translations.en.images array exists and has the right length
|
||||
if (!recipe.translations) {
|
||||
recipe.translations = { en: { images: [] } };
|
||||
}
|
||||
if (!recipe.translations.en) {
|
||||
recipe.translations.en = { images: [] };
|
||||
}
|
||||
if (!recipe.translations.en.images) {
|
||||
recipe.translations.en.images = [];
|
||||
}
|
||||
|
||||
// Ensure the en.images array has entries for all images
|
||||
while (recipe.translations.en.images.length <= imageIndex) {
|
||||
recipe.translations.en.images.push({ alt: '', caption: '' });
|
||||
}
|
||||
|
||||
// Update English alt text
|
||||
recipe.translations.en.images[imageIndex].alt = altTextResult.en;
|
||||
|
||||
// Save to database
|
||||
await recipe.save();
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
altText: altTextResult,
|
||||
message: 'Alt text generated and saved successfully',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error generating alt text:', err);
|
||||
|
||||
if (err instanceof Error && 'status' in err) {
|
||||
throw err; // Re-throw SvelteKit errors
|
||||
}
|
||||
|
||||
throw error(500, err instanceof Error ? err.message : 'Failed to generate alt text');
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user