mirror of
https://gitlab.com/Zer0interrupt/recipe-manager.git
synced 2026-11-01 23:24:11 -05:00
Support dynamic Gemini AI model selection with model discovery and default to gemini-2.0-flash
This commit is contained in:
parent
a098ea3bca
commit
311c03136c
4 changed files with 217 additions and 19 deletions
134
public/app.js
134
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1013,11 +1013,11 @@
|
|||
</div>
|
||||
|
||||
<p style="font-size: 0.85rem; line-height: 1.4; color: var(--text-muted); margin-bottom: 16px;">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<form id="settings-api-form" style="margin-bottom: 8px;">
|
||||
<div class="form-group mb-4">
|
||||
<div class="form-group mb-3">
|
||||
<label for="settings-gemini-key" style="font-size: 0.85rem; margin-bottom: 6px; display: block; font-weight: 500;">Google Gemini API Key</label>
|
||||
<div class="input-wrapper" style="position: relative; display: flex; align-items: center;">
|
||||
<input type="password" id="settings-gemini-key" placeholder="Enter your Gemini API key..." autocomplete="off" style="width: 100%; padding-right: 40px; height: 40px; border-radius: var(--border-radius-md); border: 1px solid var(--border-color); background: rgba(0,0,0,0.2); color: var(--text-main); padding-left: 12px;">
|
||||
|
|
@ -1026,8 +1026,25 @@
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-4">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
|
||||
<label for="settings-gemini-model" style="font-size: 0.85rem; display: block; font-weight: 500;">AI Model</label>
|
||||
<button type="button" id="btn-discover-models" class="btn btn-secondary btn-sm" style="font-size: 0.75rem; padding: 2px 10px; height: 28px; display: inline-flex; align-items: center; gap: 5px; cursor: pointer;" title="Query Google API for available models on your key">
|
||||
<i data-lucide="refresh-cw" style="width: 13px; height: 13px;"></i>
|
||||
<span>Discover Models</span>
|
||||
</button>
|
||||
</div>
|
||||
<select id="settings-gemini-model" style="width: 100%; height: 40px; border-radius: var(--border-radius-md); border: 1px solid var(--border-color); background: var(--bg-card, #1e2430); color: var(--text-main); padding-left: 12px; padding-right: 12px; font-size: 0.85rem; cursor: pointer;">
|
||||
<option value="gemini-2.0-flash">Gemini 2.0 Flash (Recommended)</option>
|
||||
<option value="gemini-1.5-flash">Gemini 1.5 Flash</option>
|
||||
<option value="gemini-1.5-pro">Gemini 1.5 Pro</option>
|
||||
</select>
|
||||
<span id="models-status-hint" style="font-size: 0.75rem; color: var(--text-muted); margin-top: 4px; display: block;">Powers both recipe photo scanning and AI text polishing.</span>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary" style="width: 100%; height: 40px;">
|
||||
<i data-lucide="save"></i> <span>Save Credentials</span>
|
||||
<i data-lucide="save"></i> <span>Save Settings</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
{
|
||||
"geminiApiKey": "PASTE_YOUR_GEMINI_API_KEY_HERE"
|
||||
"geminiApiKey": "PASTE_YOUR_GEMINI_API_KEY_HERE",
|
||||
"geminiModel": "gemini-2.0-flash"
|
||||
}
|
||||
|
|
|
|||
76
server.js
76
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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue