From 311c03136cfe708de61d5222c341c2e2f813cfd5 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 1 Nov 2026 01:44:40 -0400 Subject: [PATCH] Support dynamic Gemini AI model selection with model discovery and default to gemini-2.0-flash --- public/app.js | 134 ++++++++++++++++++++++++++++++++-- public/index.html | 23 +++++- public/settings.json.template | 3 +- server.js | 76 +++++++++++++++++-- 4 files changed, 217 insertions(+), 19 deletions(-) diff --git a/public/app.js b/public/app.js index f8b3c24..8888d72 100644 --- a/public/app.js +++ b/public/app.js @@ -25,6 +25,9 @@ const elements = { settingsModal: document.getElementById('settings-modal'), settingsApiForm: document.getElementById('settings-api-form'), settingsGeminiKeyInput: document.getElementById('settings-gemini-key'), + settingsGeminiModelSelect: document.getElementById('settings-gemini-model'), + btnDiscoverModels: document.getElementById('btn-discover-models'), + modelsStatusHint: document.getElementById('models-status-hint'), btnToggleGeminiKey: document.getElementById('btn-toggle-gemini-key'), settingsStatusMsg: document.getElementById('settings-status-msg'), settingsStatusText: document.getElementById('settings-status-text'), @@ -259,6 +262,9 @@ function registerEvents() { elements.btnSettings.addEventListener('click', openSettingsModal); elements.settingsApiForm.addEventListener('submit', handleSaveSettings); elements.btnToggleGeminiKey.addEventListener('click', toggleSettingsKeyVisibility); + if (elements.btnDiscoverModels) { + elements.btnDiscoverModels.addEventListener('click', () => handleDiscoverModels(true)); + } // Image Scanning Actions elements.btnScanImage.addEventListener('click', () => elements.scanImageInput.click()); @@ -2024,6 +2030,91 @@ window.toggleRecipeManagerRole = async function(id, makeManager) { // SYSTEM SETTINGS & GEMINI AI PHOTO SCANNER // ------------------------------------------------------------- +function populateModelDropdown(models, selectedModel) { + if (!elements.settingsGeminiModelSelect) return; + const select = elements.settingsGeminiModelSelect; + select.innerHTML = ''; + + const targetModel = selectedModel || 'gemini-2.0-flash'; + let foundTarget = false; + + models.forEach(m => { + const opt = document.createElement('option'); + opt.value = m.id; + opt.textContent = m.displayName || m.id; + if (m.id === 'gemini-2.0-flash') { + opt.textContent = `${m.displayName || 'Gemini 2.0 Flash'} (Recommended)`; + } + if (m.id === targetModel) { + opt.selected = true; + foundTarget = true; + } + select.appendChild(opt); + }); + + if (!foundTarget && targetModel) { + const customOpt = document.createElement('option'); + customOpt.value = targetModel; + customOpt.textContent = targetModel; + customOpt.selected = true; + select.appendChild(customOpt); + } +} + +async function handleDiscoverModels(showNotice = true) { + const key = elements.settingsGeminiKeyInput ? elements.settingsGeminiKeyInput.value.trim() : ''; + if (elements.modelsStatusHint) { + elements.modelsStatusHint.textContent = 'Discovering available models from Google API...'; + elements.modelsStatusHint.style.color = 'var(--primary)'; + } + if (elements.btnDiscoverModels) { + elements.btnDiscoverModels.disabled = true; + } + + try { + const res = await fetch('/api/settings/discover-models', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...getAuthHeaders() + }, + body: JSON.stringify({ apiKey: key }) + }); + + const data = await res.json(); + if (!res.ok) { + throw new Error(data.error || 'Failed to discover models.'); + } + + if (data.models && data.models.length > 0) { + const currentSelected = elements.settingsGeminiModelSelect ? elements.settingsGeminiModelSelect.value : 'gemini-2.0-flash'; + populateModelDropdown(data.models, currentSelected); + if (elements.modelsStatusHint) { + elements.modelsStatusHint.textContent = `Discovered ${data.models.length} compatible models.`; + elements.modelsStatusHint.style.color = 'var(--text-muted)'; + } + } else { + if (elements.modelsStatusHint) { + elements.modelsStatusHint.textContent = 'No compatible models found for this key.'; + elements.modelsStatusHint.style.color = '#ef4444'; + } + } + } catch (err) { + console.error('Discover models error:', err); + if (elements.modelsStatusHint) { + elements.modelsStatusHint.textContent = `Discovery error: ${err.message}`; + elements.modelsStatusHint.style.color = '#ef4444'; + } + if (showNotice) { + alert(`Model Discovery Failed: ${err.message}\n\nPlease check that your Gemini API Key is valid.`); + } + } finally { + if (elements.btnDiscoverModels) { + elements.btnDiscoverModels.disabled = false; + } + } +} + async function openSettingsModal() { // Reset visibility states and statuses elements.settingsStatusMsg.style.display = 'none'; @@ -2034,6 +2125,10 @@ async function openSettingsModal() { elements.settingsGeminiKeyInput.value = ''; elements.settingsGeminiKeyInput.placeholder = 'Loading credentials...'; + if (elements.modelsStatusHint) { + elements.modelsStatusHint.textContent = 'Used for both recipe photo scanning and AI text polishing.'; + elements.modelsStatusHint.style.color = 'var(--text-muted)'; + } openModal('settings-modal'); @@ -2056,6 +2151,25 @@ async function openSettingsModal() { if (key && !isPlaceholder) { elements.settingsGeminiKeyInput.value = key; } + + if (elements.settingsGeminiModelSelect) { + const savedModel = settings.geminiModel || 'gemini-2.0-flash'; + let exists = false; + for (let i = 0; i < elements.settingsGeminiModelSelect.options.length; i++) { + if (elements.settingsGeminiModelSelect.options[i].value === savedModel) { + exists = true; + break; + } + } + if (!exists && savedModel) { + const opt = document.createElement('option'); + opt.value = savedModel; + opt.textContent = savedModel; + elements.settingsGeminiModelSelect.appendChild(opt); + } + elements.settingsGeminiModelSelect.value = savedModel; + } + if (elements.settingsDefaultConsolidate && typeof settings.defaultConsolidateLists === 'boolean') { elements.settingsDefaultConsolidate.checked = settings.defaultConsolidateLists; applyUserDefaultConsolidationPreference(settings.defaultConsolidateLists); @@ -2071,6 +2185,7 @@ async function handleSaveSettings(e) { elements.settingsStatusMsg.style.display = 'none'; const key = elements.settingsGeminiKeyInput.value.trim(); + const model = elements.settingsGeminiModelSelect ? elements.settingsGeminiModelSelect.value : 'gemini-2.0-flash'; const defaultConsolidate = elements.settingsDefaultConsolidate ? elements.settingsDefaultConsolidate.checked : true; try { @@ -2082,6 +2197,7 @@ async function handleSaveSettings(e) { }, body: JSON.stringify({ geminiApiKey: key, + geminiModel: model, defaultConsolidateLists: defaultConsolidate }) }); @@ -2243,6 +2359,7 @@ async function scanRecipeFile(file) { } const settings = await settingsResponse.json(); const apiKey = settings.geminiApiKey ? settings.geminiApiKey.trim() : ''; + const geminiModel = settings.geminiModel ? settings.geminiModel.trim() : 'gemini-2.0-flash'; const isPlaceholder = apiKey === 'PASTE_YOUR_GEMINI_API_KEY_HERE' || apiKey === ''; if (!apiKey || isPlaceholder) { @@ -2272,7 +2389,7 @@ async function scanRecipeFile(file) { // 3. Make direct call to Google Gemini Vision API const promptText = "You are a professional culinary transcriber. Analyze the image of a recipe (which could be a cookbook page, handwritten note, screenshot, or packaging) and extract it accurately. Fill in all fields as completely as possible. The response must be a JSON object matching this schema:\n{\n \"title\": \"Recipe Title\",\n \"description\": \"Brief description or review\",\n \"servings\": \"e.g., 4\",\n \"prepTime\": \"e.g., 15 mins\",\n \"cookTime\": \"e.g., 30 mins\",\n \"totalTime\": \"e.g., 45 mins\",\n \"ingredients\": [\"ingredient 1\", \"ingredient 2\"],\n \"instructions\": [\"step 1\", \"step 2\"]\n}\nDo not include any markdown styling (like ```json) or explanation. Return only the raw JSON string."; - const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${apiKey}`; + const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${geminiModel}:generateContent?key=${apiKey}`; const geminiResponse = await fetch(geminiUrl, { method: 'POST', @@ -2302,15 +2419,16 @@ async function scanRecipeFile(file) { if (!geminiResponse.ok) { const errorObj = await geminiResponse.json().catch(() => ({})); const status = geminiResponse.status; - let errMsg = 'Google Gemini API call failed.'; - if (status === 400) { - errMsg = 'Invalid parameters or model version. Please verify your settings.'; - } else if (status === 403) { + let errMsg = (errorObj.error && errorObj.error.message) + ? errorObj.error.message + : 'Google Gemini API call failed.'; + + if (status === 400 && (!errorObj.error || !errorObj.error.message)) { + errMsg = `Invalid parameters or model version (${geminiModel}). Please check your settings.`; + } else if (status === 403 && (!errorObj.error || !errorObj.error.message)) { errMsg = 'Access denied. The Google Gemini API key is invalid or inactive.'; - } else if (status === 429) { + } else if (status === 429 && (!errorObj.error || !errorObj.error.message)) { errMsg = 'Rate limit exceeded. You have made too many requests or hit your Gemini quota.'; - } else if (errorObj.error && errorObj.error.message) { - errMsg = `Gemini API Error: ${errorObj.error.message}`; } throw new Error(errMsg); } diff --git a/public/index.html b/public/index.html index 12da8e5..e6f4391 100644 --- a/public/index.html +++ b/public/index.html @@ -1013,11 +1013,11 @@

- Configure your system-wide AI credentials to enable physical recipe photo scanning. + Configure your system-wide Google Gemini credentials and model to enable recipe photo scanning and AI text polishing.

-
+
@@ -1026,8 +1026,25 @@
+ +
+
+ + +
+ + Powers both recipe photo scanning and AI text polishing. +
+
diff --git a/public/settings.json.template b/public/settings.json.template index b873803..88722aa 100644 --- a/public/settings.json.template +++ b/public/settings.json.template @@ -1,3 +1,4 @@ { - "geminiApiKey": "PASTE_YOUR_GEMINI_API_KEY_HERE" + "geminiApiKey": "PASTE_YOUR_GEMINI_API_KEY_HERE", + "geminiModel": "gemini-2.0-flash" } diff --git a/server.js b/server.js index e4bbf5e..b962dd0 100644 --- a/server.js +++ b/server.js @@ -80,7 +80,8 @@ db.prepare(` db.prepare(` CREATE TABLE IF NOT EXISTS settings ( - geminiApiKey TEXT DEFAULT '' + geminiApiKey TEXT DEFAULT '', + geminiModel TEXT DEFAULT 'gemini-2.0-flash' ) `).run(); @@ -190,11 +191,17 @@ try { try { db.prepare("ALTER TABLE backup_history ADD COLUMN userSummary TEXT NOT NULL DEFAULT '[]'").run(); } catch (e) {} +try { + db.prepare("ALTER TABLE settings ADD COLUMN geminiModel TEXT DEFAULT 'gemini-2.0-flash'").run(); +} catch (e) {} +try { + db.prepare("UPDATE settings SET geminiModel = 'gemini-2.0-flash' WHERE geminiModel IS NULL OR geminiModel = ''").run(); +} catch (e) {} // Initialize single settings record if empty const settingsCount = db.prepare('SELECT COUNT(*) as count FROM settings').get().count; if (settingsCount === 0) { - db.prepare('INSERT INTO settings (geminiApiKey) VALUES (?)').run(''); + db.prepare('INSERT INTO settings (geminiApiKey, geminiModel) VALUES (?, ?)').run('', 'gemini-2.0-flash'); } // Ensure default system_admin and admin user profiles exist @@ -1575,8 +1582,9 @@ async function scrapeTikTokRecipe(inputUrl) { 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 settingsRow = db.prepare('SELECT geminiApiKey, geminiModel FROM settings LIMIT 1').get(); const geminiKey = settingsRow?.geminiApiKey?.trim(); + const geminiModel = settingsRow?.geminiModel?.trim() || 'gemini-2.0-flash'; const isPlaceholder = !geminiKey || geminiKey === 'PASTE_YOUR_GEMINI_API_KEY_HERE'; if (isPlaceholder) { @@ -1612,7 +1620,7 @@ Return ONLY a valid JSON object matching this schema without markdown code block "instructions": ["Step 1", "Step 2"] }`; - const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${geminiKey}`; + const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${geminiModel}:generateContent?key=${geminiKey}`; const aiRes = await axios.post(geminiUrl, { contents: [{ parts: [{ text: promptText }] }], generationConfig: { responseMimeType: "application/json" } @@ -1638,11 +1646,12 @@ Return ONLY a valid JSON object matching this schema without markdown code block // API: Load System Settings & User Preferences app.get('/api/settings', requireAuth, (req, res) => { try { - const settings = db.prepare('SELECT geminiApiKey FROM settings LIMIT 1').get(); + const settings = db.prepare('SELECT geminiApiKey, geminiModel FROM settings LIMIT 1').get(); const userRow = db.prepare('SELECT defaultConsolidateLists FROM users WHERE id = ?').get(req.user.id); const defaultConsolidateLists = userRow ? userRow.defaultConsolidateLists !== 0 : true; res.json({ geminiApiKey: settings ? settings.geminiApiKey : '', + geminiModel: (settings && settings.geminiModel) ? settings.geminiModel : 'gemini-2.0-flash', defaultConsolidateLists }); } catch (e) { @@ -1653,19 +1662,23 @@ app.get('/api/settings', requireAuth, (req, res) => { // API: Save System Settings & User Preferences app.post('/api/settings', requireAuth, (req, res) => { - const { geminiApiKey, defaultConsolidateLists } = req.body; + const { geminiApiKey, geminiModel, defaultConsolidateLists } = req.body; try { if (geminiApiKey !== undefined) { db.prepare('UPDATE settings SET geminiApiKey = ?').run(geminiApiKey || ''); } + if (geminiModel !== undefined) { + db.prepare('UPDATE settings SET geminiModel = ?').run(geminiModel || 'gemini-2.0-flash'); + } if (typeof defaultConsolidateLists === 'boolean') { db.prepare('UPDATE users SET defaultConsolidateLists = ? WHERE id = ?').run(defaultConsolidateLists ? 1 : 0, req.user.id); req.user.defaultConsolidateLists = defaultConsolidateLists; } - const settings = db.prepare('SELECT geminiApiKey FROM settings LIMIT 1').get(); + const settings = db.prepare('SELECT geminiApiKey, geminiModel FROM settings LIMIT 1').get(); const userRow = db.prepare('SELECT defaultConsolidateLists FROM users WHERE id = ?').get(req.user.id); res.json({ geminiApiKey: settings ? settings.geminiApiKey : '', + geminiModel: (settings && settings.geminiModel) ? settings.geminiModel : 'gemini-2.0-flash', defaultConsolidateLists: userRow ? userRow.defaultConsolidateLists !== 0 : true }); } catch (e) { @@ -1674,6 +1687,55 @@ app.post('/api/settings', requireAuth, (req, res) => { } }); +// API: Discover available Gemini models using the saved or provided API key +app.post('/api/settings/discover-models', requireAuth, async (req, res) => { + let { apiKey } = req.body || {}; + if (!apiKey) { + const settingsRow = db.prepare('SELECT geminiApiKey FROM settings LIMIT 1').get(); + apiKey = settingsRow?.geminiApiKey?.trim(); + } + if (!apiKey || apiKey === 'PASTE_YOUR_GEMINI_API_KEY_HERE') { + return res.status(400).json({ error: 'Please enter a Gemini API Key first.' }); + } + + try { + const response = await axios.get(`https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`, { + timeout: 12000 + }); + + if (response.data && Array.isArray(response.data.models)) { + const models = response.data.models + .filter(m => Array.isArray(m.supportedGenerationMethods) && m.supportedGenerationMethods.includes('generateContent')) + .filter(m => !m.name.includes('embedding') && !m.name.includes('aqa') && !m.name.includes('imagen')) + .map(m => { + const modelId = m.name.replace(/^models\//, ''); + return { + id: modelId, + displayName: m.displayName || modelId, + description: m.description || '' + }; + }); + + // Sort models: Flash models first, then by ID + models.sort((a, b) => { + const aIsFlash = a.id.toLowerCase().includes('flash'); + const bIsFlash = b.id.toLowerCase().includes('flash'); + if (aIsFlash && !bIsFlash) return -1; + if (!aIsFlash && bIsFlash) return 1; + return a.id.localeCompare(b.id); + }); + + return res.json({ models }); + } else { + return res.status(500).json({ error: 'Unexpected response from Google Gemini API.' }); + } + } catch (err) { + console.error('Model discovery error:', err.response?.data?.error?.message || err.message); + const msg = err.response?.data?.error?.message || err.message; + return res.status(err.response?.status || 500).json({ error: `Failed to fetch models: ${msg}` }); + } +}); + // API: Update User Preferences directly app.put('/api/user/preferences', requireAuth, (req, res) => { const { defaultConsolidateLists } = req.body;