From a098ea3bcacc7bec206a66d7fa9cdd23a815dd70 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 1 Nov 2026 01:22:40 -0400 Subject: [PATCH] Use local heuristic engine first for extraction and offer user-initiated AI Polish action --- public/app.js | 90 +++++++++++++++++++++++++++++++++ public/index.html | 14 ++++++ public/style.css | 73 +++++++++++++++++++++++++++ server.js | 125 +++++++++++++++++++++++++--------------------- 4 files changed, 246 insertions(+), 56 deletions(-) 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.

+ + +
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 {