diff --git a/public/app.js b/public/app.js
index 494c08b..f8b3c24 100644
--- a/public/app.js
+++ b/public/app.js
@@ -1,6 +1,7 @@
// ForkFolio client application state
let allRecipes = [];
let activeRecipe = null;
+let currentDraftRawCaption = '';
let currentMultiplier = 1;
let selectedCoverImage = '';
let stopwatchInterval = null;
@@ -144,6 +145,10 @@ const elements = {
modalActionTitle: document.getElementById('modal-action-title'),
btnSaveConfirm: document.getElementById('btn-save-confirm'),
editIsPublic: document.getElementById('edit-is-public'),
+ aiPolishBanner: document.getElementById('ai-polish-banner'),
+ btnAiPolish: document.getElementById('btn-ai-polish'),
+ aiPolishTitle: document.getElementById('ai-polish-title'),
+ aiPolishSubtitle: document.getElementById('ai-polish-subtitle'),
// Detail Modal
detailModal: document.getElementById('detail-modal'),
@@ -265,6 +270,7 @@ function registerEvents() {
// Recipe Form Save
elements.recipeEditForm.addEventListener('submit', handleRecipeSave);
+ elements.btnAiPolish?.addEventListener('click', handleAiPolish);
// Action Buttons Detail Modal
elements.btnDetailEdit.addEventListener('click', editActiveRecipe);
@@ -606,6 +612,66 @@ async function handleScrapeSubmit(e) {
}
}
+// User-initiated AI Polish action for draft recipe
+async function handleAiPolish() {
+ if (!elements.btnAiPolish) return;
+ const originalBtnHtml = elements.btnAiPolish.innerHTML;
+ elements.btnAiPolish.disabled = true;
+ elements.btnAiPolish.innerHTML = ' Polishing...';
+ lucide.createIcons();
+
+ try {
+ const response = await fetch('/api/recipes/ai-polish', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...getAuthHeaders()
+ },
+ body: JSON.stringify({
+ rawCaption: currentDraftRawCaption,
+ title: elements.editTitle.value.trim(),
+ ingredients: elements.editIngredients.value.trim().split(/\r?\n/).filter(Boolean),
+ instructions: elements.editInstructions.value.trim().split(/\r?\n/).filter(Boolean)
+ })
+ });
+
+ if (response.status === 401) {
+ logout();
+ return;
+ }
+
+ const result = await response.json();
+ if (!response.ok) {
+ throw new Error(result.error || 'Failed to polish recipe with AI.');
+ }
+
+ // Populate polished fields
+ if (result.title) elements.editTitle.value = result.title;
+ if (result.description) elements.editDescription.value = result.description;
+ if (result.servings) elements.editServings.value = result.servings;
+ if (result.prepTime) elements.editPrepTime.value = result.prepTime;
+ if (result.cookTime) elements.editCookTime.value = result.cookTime;
+ if (Array.isArray(result.ingredients) && result.ingredients.length > 0) {
+ elements.editIngredients.value = result.ingredients.join('\n');
+ }
+ if (Array.isArray(result.instructions) && result.instructions.length > 0) {
+ elements.editInstructions.value = result.instructions.join('\n');
+ }
+
+ // Update banner to success state
+ elements.aiPolishBanner.className = 'ai-polish-banner success';
+ elements.aiPolishTitle.textContent = 'Polished with Gemini AI';
+ elements.aiPolishSubtitle.textContent = 'Measurements and instruction steps have been organized.';
+ elements.btnAiPolish.style.display = 'none';
+ lucide.createIcons();
+ } catch (err) {
+ elements.btnAiPolish.disabled = false;
+ elements.btnAiPolish.innerHTML = originalBtnHtml;
+ lucide.createIcons();
+ alert(err.message);
+ }
+}
+
// Save or Edit submit
async function handleRecipeSave(e) {
e.preventDefault();
@@ -856,6 +922,9 @@ async function revokeActiveRecipeShare() {
// -------------------------------------------------------------
function openManualAdd() {
+ currentDraftRawCaption = '';
+ if (elements.aiPolishBanner) elements.aiPolishBanner.style.display = 'none';
+
elements.modalActionTitle.textContent = 'Create New Recipe';
elements.editRecipeId.value = '';
elements.recipeEditForm.reset();
@@ -886,12 +955,33 @@ function openDraftPreview(data) {
// Render Image Selector
renderImageSelector(data.images || []);
+
+ // Handle AI Polish Suggestion Banner
+ if (data.suggestAiPolish && data.rawCaption) {
+ currentDraftRawCaption = data.rawCaption;
+ if (elements.aiPolishBanner) {
+ elements.aiPolishBanner.style.display = 'flex';
+ elements.aiPolishBanner.className = 'ai-polish-banner';
+ elements.aiPolishTitle.textContent = 'Extracted with local engine';
+ elements.aiPolishSubtitle.textContent = 'Want AI to polish measurements, separate run-on steps, and format times?';
+ elements.btnAiPolish.style.display = 'inline-flex';
+ elements.btnAiPolish.disabled = false;
+ elements.btnAiPolish.innerHTML = ' Polish with AI';
+ lucide.createIcons();
+ }
+ } else {
+ currentDraftRawCaption = '';
+ if (elements.aiPolishBanner) elements.aiPolishBanner.style.display = 'none';
+ }
openModal('save-modal');
}
function editActiveRecipe() {
if (!activeRecipe) return;
+
+ currentDraftRawCaption = '';
+ if (elements.aiPolishBanner) elements.aiPolishBanner.style.display = 'none';
elements.modalActionTitle.textContent = 'Edit Recipe Details';
elements.editRecipeId.value = activeRecipe.id;
diff --git a/public/index.html b/public/index.html
index 382fd50..12da8e5 100644
--- a/public/index.html
+++ b/public/index.html
@@ -691,6 +691,20 @@
If enabled, this recipe will be shared to the global Shared Cookbook feed for everyone to view and clone.
+
+
+
+
+
+ Extracted with local engine
+ Want AI to polish measurements, separate run-on steps, and format times?
+
+
+
+
+
diff --git a/public/style.css b/public/style.css
index ce633f3..d1b32b7 100644
--- a/public/style.css
+++ b/public/style.css
@@ -719,6 +719,79 @@ h1, h2, h3, h4, h5, h6 {
}
}
+/* AI Polish Suggestion Banner */
+.ai-polish-banner {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 14px 18px;
+ margin-bottom: 20px;
+ background-color: hsla(var(--hue-secondary), 100%, 51%, 0.08);
+ border: 1px solid hsla(var(--hue-secondary), 100%, 51%, 0.25);
+ border-radius: var(--border-radius-md);
+ animation: fadeIn 0.3s ease-out;
+}
+
+.ai-polish-info {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.ai-polish-icon {
+ width: 36px;
+ height: 36px;
+ border-radius: var(--border-radius-full);
+ background-color: hsla(var(--hue-secondary), 100%, 51%, 0.18);
+ color: var(--secondary);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+.ai-polish-icon i,
+.ai-polish-icon svg {
+ width: 18px;
+ height: 18px;
+}
+
+.ai-polish-text {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.ai-polish-title {
+ font-weight: 600;
+ font-size: 0.92rem;
+ color: var(--text-main);
+}
+
+.ai-polish-subtitle {
+ font-size: 0.82rem;
+ color: var(--text-muted);
+}
+
+.ai-polish-banner.success {
+ background-color: hsla(118, 19%, 43%, 0.1);
+ border-color: hsla(118, 19%, 43%, 0.3);
+}
+
+.ai-polish-banner.success .ai-polish-icon {
+ background-color: hsla(118, 19%, 43%, 0.2);
+ color: var(--accent);
+}
+
+@media (max-width: 640px) {
+ .ai-polish-banner {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 12px;
+ }
+}
+
.form-group {
margin-bottom: 20px;
}
diff --git a/server.js b/server.js
index 17b2540..e4bbf5e 100644
--- a/server.js
+++ b/server.js
@@ -1557,71 +1557,84 @@ async function scrapeTikTokRecipe(inputUrl) {
throw new Error('No caption or description found on this TikTok video. The creator might not have written recipe details.');
}
- // 3. Try Gemini AI structuring if API key is configured
- const settingsRow = db.prepare('SELECT geminiApiKey FROM settings LIMIT 1').get();
- const geminiKey = settingsRow?.geminiApiKey?.trim();
- const isPlaceholder = !geminiKey || geminiKey === 'PASTE_YOUR_GEMINI_API_KEY_HERE';
-
- if (!isPlaceholder) {
- try {
- console.log('[TikTok] Attempting Gemini AI parsing...');
- const promptText = `You are a culinary expert assistant for a recipe app. Extract and structure the recipe from this TikTok video caption. If prep time, cook time, or servings are not explicitly stated, estimate reasonably or leave as empty string. Clean up any hashtag clutter, emojis, or sponsorship notes from the title and instructions.
-
-Creator: ${author || 'TikTok Chef'}
-Caption:
-"""
-${caption}
-"""
-
-The output MUST be a valid JSON object matching this schema:
-{
- "title": "Recipe Name",
- "description": "Short description of the recipe",
- "servings": "e.g., 4",
- "prepTime": "e.g., 10 mins",
- "cookTime": "e.g., 20 mins",
- "totalTime": "e.g., 30 mins",
- "ingredients": ["ingredient line 1", "ingredient line 2"],
- "instructions": ["Step 1", "Step 2"]
-}
-Return ONLY raw JSON without markdown code fences.`;
-
- const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${geminiKey}`;
- const aiRes = await axios.post(geminiUrl, {
- contents: [{ parts: [{ text: promptText }] }],
- generationConfig: { responseMimeType: "application/json" }
- }, {
- headers: { 'Content-Type': 'application/json' },
- timeout: 15000
- });
-
- if (aiRes.data && aiRes.data.candidates?.[0]?.content?.parts?.[0]?.text) {
- const rawText = aiRes.data.candidates[0].content.parts[0].text;
- const parsed = JSON.parse(rawText);
- if (Array.isArray(parsed.ingredients) && parsed.ingredients.length > 0) {
- parsed.image = cover || '';
- parsed.images = cover ? [cover] : [];
- parsed.sourceUrl = canonicalUrl;
- parsed._scrapingMethod = 'tiktok-gemini-ai';
- return parsed;
- }
- }
- } catch (aiErr) {
- console.warn('[TikTok] Gemini parsing note (falling back to heuristic):', aiErr.response?.data?.error?.message || aiErr.message);
- }
- }
-
- // 4. Heuristic Fallback Parser
- console.log('[TikTok] Using heuristic caption parser...');
+ // Fast & Local Heuristic Engine First (Zero API cost, instant execution)
+ console.log('[TikTok] Running local heuristic parser on video caption...');
const heuristicResult = parseRecipeCaptionHeuristic(caption, author, cover, canonicalUrl);
if (!heuristicResult.ingredients || heuristicResult.ingredients.length === 0) {
throw new Error('This TikTok caption does not appear to contain an ingredients list. The recipe might be in the audio, video comments, or on-screen text.');
}
heuristicResult._scrapingMethod = 'tiktok-caption-heuristic';
+ heuristicResult.rawCaption = caption;
+ heuristicResult.author = author;
+ heuristicResult.suggestAiPolish = true;
return heuristicResult;
}
+// API: Polish and format recipe using Gemini AI (User-initiated)
+app.post('/api/recipes/ai-polish', requireAuth, async (req, res) => {
+ const { rawCaption, title, ingredients, instructions } = req.body;
+
+ const settingsRow = db.prepare('SELECT geminiApiKey FROM settings LIMIT 1').get();
+ const geminiKey = settingsRow?.geminiApiKey?.trim();
+ const isPlaceholder = !geminiKey || geminiKey === 'PASTE_YOUR_GEMINI_API_KEY_HERE';
+
+ if (isPlaceholder) {
+ return res.status(400).json({
+ error: 'Google Gemini API Key is not configured. Please open Settings (top right gear icon) and add your Gemini API key to enable AI text polishing.'
+ });
+ }
+
+ try {
+ const promptText = `You are a culinary expert assistant for a recipe app. Clean up, format, and organize the following recipe.
+- Standardize ingredient measurements (amounts, units, descriptions) onto separate lines.
+- Turn run-on or conversational directions into clean, ordered, numbered steps.
+- Infer or estimate prep time, cook time, and servings if mentioned or implied.
+- Clean up any hashtags, social media slang, or promotional sponsor notes.
+
+Source Text / Current Draft:
+Title: ${title || ''}
+${rawCaption ? `Original Caption:\n"""\n${rawCaption}\n"""` : ''}
+Ingredients so far:
+${Array.isArray(ingredients) ? ingredients.join('\n') : (ingredients || '')}
+Instructions so far:
+${Array.isArray(instructions) ? instructions.join('\n') : (instructions || '')}
+
+Return ONLY a valid JSON object matching this schema without markdown code blocks:
+{
+ "title": "Cleaned Recipe Title",
+ "description": "Short description of the recipe",
+ "servings": "e.g., 4",
+ "prepTime": "e.g., 15 mins",
+ "cookTime": "e.g., 45 mins",
+ "totalTime": "e.g., 1 hour",
+ "ingredients": ["ingredient line 1", "ingredient line 2"],
+ "instructions": ["Step 1", "Step 2"]
+}`;
+
+ const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${geminiKey}`;
+ const aiRes = await axios.post(geminiUrl, {
+ contents: [{ parts: [{ text: promptText }] }],
+ generationConfig: { responseMimeType: "application/json" }
+ }, {
+ headers: { 'Content-Type': 'application/json' },
+ timeout: 15000
+ });
+
+ if (aiRes.data && aiRes.data.candidates?.[0]?.content?.parts?.[0]?.text) {
+ const rawText = aiRes.data.candidates[0].content.parts[0].text;
+ const parsed = JSON.parse(rawText);
+ return res.json(parsed);
+ } else {
+ throw new Error('Gemini returned an empty response.');
+ }
+ } catch (err) {
+ console.error('[AI Polish] Error:', err.response?.data?.error?.message || err.message);
+ const msg = err.response?.data?.error?.message || err.message;
+ return res.status(500).json({ error: `AI Polish failed: ${msg}` });
+ }
+});
+
// API: Load System Settings & User Preferences
app.get('/api/settings', requireAuth, (req, res) => {
try {