// ═══════════════════════════════════════════════ // gemini-client.js — Lumière Skin // ═══════════════════════════════════════════════ // Chống cháy tiết kiệm chi phí nhưng tự động: // - Mặc định TỰ THỬ Gemini nếu còn daily limit và có key hợp lệ. // - Nếu Gemini lỗi 429/quá tải/hết limit → tự chuyển key khác → cuối cùng fallback local. // - Không cần bật/tắt thủ công khi demo. Có thể tắt bằng LumiereAPI.disable() nếu cần. const GEMINI_TEXT_MODEL = 'gemini-2.5-flash-lite'; const GEMINI_VISION_MODEL = 'gemini-2.5-flash'; // Key chính + backup. // Dán thêm 2–3 key thật vào các dòng Backup bên dưới khi cần demo. // KHÔNG để key rỗng/placeholder không hợp lệ ở trạng thái bật, vì nó sẽ tạo lỗi 400/403. const GEMINI_API_KEYS = [ { name: 'Main key', key: 'AIzaSyCxlOBmUNN1viq0OqvsK-i-hKnGo1IYR2I' }, { name: 'Backup key 1', key: 'AIzaSyDRGykguZIvly6_dpF5L16-pGeOVlPidZw' }, { name: 'Backup key 2', key: 'AQ.Ab8RN6LuclOpFlJHbBW2uqj7YY-JjL_UhrLd37GwSj3teqNl9Q' }, { name: 'Backup key 3', key: 'AIzaSyBRKTKHweUohf330TRJi8G31WL0-FmApE0' }, { name: 'Backup key 4', key: 'AQ.Ab8RN6KJDOIjLQC9ZdrxpJscbP-RF9hYr1D2r5gYgDuYOHaTfw' }, { name: 'Backup key 5', key: 'AQ.Ab8RN6IJfoVq5JGLQN6q2obtlcruNzYI0-pS8YVcPM63Mm8gTw' }, { name: 'Backup key 6', key: 'AQ.Ab8RN6JHmx8nsVp9U0P1pRuQsBhb8iCdU5-zSHXfyRJObbc-ig' }, { name: 'Backup key 7', key: 'AQ.Ab8RN6I5vWfburr5MI_Ez6DRNffm2wL2pCj4aCXMRrnsHZcDOA' }, ]; const API_STATE = { localKeysStorage: 'lumiere_gemini_keys', enabledStorage: 'lumiere_use_gemini', activeIndexStorage: 'lumiere_gemini_active_key', dailyLimitStorage: 'lumiere_api_daily_limit', usagePrefix: 'lumiere_api_usage_', keyCooldownMs: 5 * 60 * 1000, maxKeysPerCall: 4, keyCooldownUntil: new Map(), }; function todayKey() { return new Date().toISOString().slice(0, 10); } function readJSON(key, fallback) { try { const raw = localStorage.getItem(key); return raw ? JSON.parse(raw) : fallback; } catch { return fallback; } } function writeJSON(key, value) { localStorage.setItem(key, JSON.stringify(value)); } function normalizeKeyItem(item, idx = 0) { if (!item) return null; if (typeof item === 'string') { const key = item.trim(); return key ? { name: `Local key ${idx + 1}`, key } : null; } const key = String(item.key || '').trim(); if (!key) return null; return { name: item.name || `Gemini key ${idx + 1}`, key, project: item.project || '' }; } function getAllApiKeys() { const localKeys = readJSON(API_STATE.localKeysStorage, []); const merged = [...GEMINI_API_KEYS, ...localKeys] .map(normalizeKeyItem) .filter(Boolean); // tránh duplicate key const seen = new Set(); return merged.filter(k => { if (seen.has(k.key)) return false; seen.add(k.key); return true; }); } function getActiveIndex() { const keys = getAllApiKeys(); const raw = Number(localStorage.getItem(API_STATE.activeIndexStorage) || 0); if (!Number.isFinite(raw) || raw < 0 || raw >= keys.length) return 0; return raw; } function setActiveIndex(index) { const keys = getAllApiKeys(); const safeIndex = Math.max(0, Math.min(Number(index) || 0, Math.max(keys.length - 1, 0))); localStorage.setItem(API_STATE.activeIndexStorage, String(safeIndex)); return safeIndex; } function isApiEnabled() { // Auto mode: mặc định bật Gemini để demo mượt hơn. // Chỉ tắt nếu user/chúng ta chủ động gọi LumiereAPI.disable(). const stored = localStorage.getItem(API_STATE.enabledStorage); if (stored === 'false') return false; if (stored === 'true') return true; if (window.LUMIERE_USE_GEMINI === false) return false; return true; } function setApiEnabled(value) { localStorage.setItem(API_STATE.enabledStorage, value ? 'true' : 'false'); window.LUMIERE_USE_GEMINI = !!value; } function getDailyLimit() { return Number(localStorage.getItem(API_STATE.dailyLimitStorage) || 25); } function getUsageKey() { return API_STATE.usagePrefix + todayKey(); } function getUsage() { const data = readJSON(getUsageKey(), { calls: 0, errors: 0, lastAt: null }); return { calls: data.calls || 0, errors: data.errors || 0, lastAt: data.lastAt || null }; } function bumpUsage(type = 'calls') { const data = getUsage(); data[type] = (data[type] || 0) + 1; data.lastAt = new Date().toISOString(); writeJSON(getUsageKey(), data); return data; } function canSpendOneCall() { const limit = getDailyLimit(); if (!limit || limit < 0) return true; return getUsage().calls < limit; } function makeGeminiUrl(model, key) { return `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${encodeURIComponent(key)}`; } function getCandidateKeyIndexes() { const keys = getAllApiKeys(); if (!keys.length) return []; const start = getActiveIndex(); const indexes = []; for (let i = 0; i < keys.length; i++) indexes.push((start + i) % keys.length); return indexes.slice(0, Math.min(API_STATE.maxKeysPerCall, keys.length)); } function markKeyCooldown(index) { API_STATE.keyCooldownUntil.set(index, Date.now() + API_STATE.keyCooldownMs); } function isKeyCoolingDown(index) { const until = API_STATE.keyCooldownUntil.get(index) || 0; return Date.now() < until; } // Public helper để demo / đổi key nhanh trên GitHub Pages mà không cần backend. window.LumiereAPI = { enable() { setApiEnabled(true); console.log('[LumiereAPI] Gemini enabled'); return this.status(); }, disable() { setApiEnabled(false); console.log('[LumiereAPI] Gemini disabled. Local fallback mode is active.'); return this.status(); }, auto() { localStorage.removeItem(API_STATE.enabledStorage); window.LUMIERE_USE_GEMINI = undefined; console.log('[LumiereAPI] Auto mode enabled: Gemini will be tried first, then fallback local.'); return this.status(); }, status() { const keys = getAllApiKeys(); return { enabled: isApiEnabled(), textModel: GEMINI_TEXT_MODEL, visionModel: GEMINI_VISION_MODEL, activeIndex: getActiveIndex(), activeKeyName: keys[getActiveIndex()]?.name || null, keyCount: keys.length, keys: keys.map((k, i) => ({ index: i, name: k.name, project: k.project || '', ending: k.key.slice(-6), coolingDown: isKeyCoolingDown(i) })), dailyLimit: getDailyLimit(), todayUsage: getUsage() }; }, setDailyLimit(n) { localStorage.setItem(API_STATE.dailyLimitStorage, String(Number(n) || 0)); return this.status(); }, resetUsage() { localStorage.removeItem(getUsageKey()); return this.status(); }, setActive(index) { setActiveIndex(index); return this.status(); }, nextKey() { const keys = getAllApiKeys(); if (!keys.length) return this.status(); setActiveIndex((getActiveIndex() + 1) % keys.length); return this.status(); }, setLocalKeys(keys) { const cleaned = (Array.isArray(keys) ? keys : [keys]).map(normalizeKeyItem).filter(Boolean); writeJSON(API_STATE.localKeysStorage, cleaned); setActiveIndex(0); return this.status(); }, addLocalKey(key, name = 'Backup key') { const keys = readJSON(API_STATE.localKeysStorage, []); keys.push({ name, key }); writeJSON(API_STATE.localKeysStorage, keys); return this.status(); }, clearLocalKeys() { localStorage.removeItem(API_STATE.localKeysStorage); setActiveIndex(0); return this.status(); } }; // Default: AUTO. Gemini được thử trước nếu còn limit; lỗi thì các trang sẽ fallback local. window.LUMIERE_USE_GEMINI = isApiEnabled(); // ── Helpers ── const sleep = ms => new Promise(r => setTimeout(r, ms)); // ── Debounce: tránh spam API ── let _lastCall = 0; const MIN_GAP = 1200; async function debounce() { const gap = Date.now() - _lastCall; if (gap < MIN_GAP) await sleep(MIN_GAP - gap); _lastCall = Date.now(); } // ── Core fetch với multi-key rotation ── async function callGemini({ parts, temperature = 0.7, maxTokens = 1024, isVision = false }) { if (!isApiEnabled()) { throw new Error('Gemini API đang tắt để tiết kiệm chi phí. Dùng LumiereAPI.enable() nếu muốn bật.'); } const keys = getAllApiKeys(); if (!keys.length) throw new Error('Chưa cấu hình Gemini API key.'); await debounce(); const body = { contents: [{ parts }], generationConfig: { temperature, maxOutputTokens: maxTokens } }; const model = isVision ? GEMINI_VISION_MODEL : GEMINI_TEXT_MODEL; const candidates = getCandidateKeyIndexes(); let lastError = null; for (const index of candidates) { if (isKeyCoolingDown(index)) continue; if (!canSpendOneCall()) { throw new Error(`Đã đạt giới hạn API demo hôm nay (${getDailyLimit()} calls). Đang dùng fallback local để tránh tốn phí.`); } const keyInfo = keys[index]; const url = makeGeminiUrl(model, keyInfo.key); try { bumpUsage('calls'); const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); console.log(`[Gemini] key ${index} (${keyInfo.name}) → status ${res.status}`); if (res.status === 429) { bumpUsage('errors'); markKeyCooldown(index); console.warn(`[Gemini] key ${index} bị 429, chuyển key khác nếu có.`); lastError = new Error('429 Too Many Requests'); continue; } if (res.status === 403 || res.status === 400) { bumpUsage('errors'); markKeyCooldown(index); const err = await res.text(); console.warn(`[Gemini] key ${index} lỗi ${res.status}:`, err.slice(0, 160)); lastError = new Error(`Gemini ${res.status}: ${err.slice(0, 120)}`); continue; } if (!res.ok) { bumpUsage('errors'); const err = await res.text(); lastError = new Error(`Gemini ${res.status}: ${err.slice(0, 120)}`); console.error('[Gemini] Error:', err); continue; } const data = await res.json(); const candidate = data.candidates?.[0]; const finishReason = candidate?.finishReason; if (finishReason === 'MAX_TOKENS') { console.warn('[Gemini] Output bị cắt do MAX_TOKENS — tăng maxTokens nếu cần'); } if (!candidate?.content?.parts?.[0]?.text) { lastError = new Error('Gemini trả về response rỗng'); console.error('[Gemini] Empty response:', data); continue; } setActiveIndex(index); return candidate.content.parts[0].text.trim(); } catch (e) { bumpUsage('errors'); lastError = e; console.warn(`[Gemini] key ${index} fetch failed:`, e); markKeyCooldown(index); continue; } } throw lastError || new Error('Gemini đang quá tải hoặc tất cả API key đang bị giới hạn.'); } // ══════════════════════════════════════════════ // 1. CHATBOT — AI Concierge // ══════════════════════════════════════════════ window.GeminiChat = { history: [], async send(userMessage, skinProfile = {}) { const profile = [ `Loại da: ${skinProfile.skinType || 'chưa xác định'}`, `Skin Score: ${skinProfile.lastScore || '?'}/100`, skinProfile.concerns?.length ? `Vấn đề: ${skinProfile.concerns.join(', ')}` : '', skinProfile.avoid?.length ? `Tránh: ${skinProfile.avoid.join(', ')}` : '', ].filter(Boolean).join(' | '); const histText = this.history.slice(-8).join('\n'); const prompt = `Bạn là Lumière AI Concierge — trợ lý hỗ trợ chăm sóc da cá nhân hóa cho người dùng phổ thông. Thông tin người dùng hiện có: ${profile} Vai trò và giới hạn: - Hỗ trợ skincare hằng ngày, không chẩn đoán bệnh da liễu và không thay thế bác sĩ. - Không khẳng định nguyên nhân chắc chắn khi người dùng chỉ mô tả triệu chứng. - Không tự yêu cầu upload ảnh, trừ khi người dùng chủ động yêu cầu phân tích ảnh, scan da hoặc chấm điểm da. - Ưu tiên lời khuyên an toàn, dễ thực hiện và phù hợp với profile nếu đã có dữ liệu. Cách trả lời: 1. Nếu người dùng hỏi về triệu chứng như ngứa, đỏ, rát, bong tróc hoặc nổi mẩn: - Giải thích ngắn gọn rằng có thể liên quan đến kích ứng, khô da, thời tiết hoặc sản phẩm đang dùng. - Đưa ra 2-4 việc nên làm ngay. - Nêu rõ dấu hiệu nào cần gặp bác sĩ. - Hỏi thêm 1 câu để cá nhân hóa tốt hơn. 2. Nếu người dùng hỏi về ingredient hoặc sản phẩm: - Giải thích công dụng chính. - Cảnh báo nguy cơ kích ứng nếu có. - Liên hệ với loại da hoặc concerns trong profile. 3. Nếu người dùng hỏi về routine: - Gợi ý routine sáng/tối đơn giản. - Không đề xuất quá nhiều treatment cùng lúc. 4. Nếu người dùng yêu cầu phân tích ảnh hoặc chấm điểm da: - Hướng dẫn upload ảnh rõ, đủ sáng và không dùng filter. Yêu cầu đầu ra: - Trả lời bằng tiếng Việt tự nhiên, thân thiện. - Độ dài khoảng 120 đến 220 từ khi câu hỏi cần giải thích. - Có thể dùng xuống dòng và dấu gạch đầu dòng cho dễ đọc. - Không quảng cáo sản phẩm cụ thể. - Không cam kết hiệu quả tuyệt đối. ${histText ? `Lịch sử hội thoại gần đây:\n${histText}\n` : ''} Người dùng: ${userMessage} Lumière:`; // maxTokens 1024 đủ cho ~600 từ tiếng Việt — không bị cắt const reply = await callGemini({ parts: [{ text: prompt }], temperature: 0.45, maxTokens: 1024, }); this.history.push(`User: ${userMessage}`); this.history.push(`Lumière: ${reply}`); // Giới hạn history 20 turns để tránh prompt quá dài if (this.history.length > 20) this.history = this.history.slice(-20); return reply; }, reset() { this.history = []; } }; // ══════════════════════════════════════════════ // 2. INGREDIENT CHECK // ══════════════════════════════════════════════ window.GeminiIngredient = { async analyze(ingredientText, skinProfile = {}) { const avoid = (skinProfile.avoid || []).join(', ') || 'không có'; const prompt = `Phân tích thành phần mỹ phẩm sau và trả về JSON THUẦN (không markdown, không giải thích). Thành phần: ${ingredientText} Thành phần cần tránh của user: ${avoid} JSON format: { "safetyScore": <0-100>, "keyBenefits": ["benefit1", "benefit2"], "irritants": [{"name": "...", "reason": "...", "level": "low|medium|high"}], "userSpecificWarnings": ["cảnh báo nếu có thành phần trong avoid list"], "summary": "tóm tắt ngắn tiếng Việt" }`; const raw = await callGemini({ parts: [{ text: prompt }], temperature: 0.2, maxTokens: 1024, }); try { return JSON.parse(raw.replace(/```json|```/g, '').trim()); } catch { return { safetyScore: 70, keyBenefits: [], irritants: [], userSpecificWarnings: [], summary: raw }; } } }; // ══════════════════════════════════════════════ // 3. ROUTINE GENERATION // ══════════════════════════════════════════════ window.GeminiRoutine = { async generate(skinType, concerns = [], skinScore = null, avoid = []) { const prompt = `Tạo routine skincare cá nhân hóa và trả về JSON THUẦN (không markdown, không giải thích). Thông tin: Loại da: ${skinType} | Vấn đề: ${concerns.join(', ') || 'duy trì'} | Score: ${skinScore || '?'}/100 | Tránh: ${avoid.join(', ') || 'không có'} JSON format: { "morning": [{"step": 1, "name": "...", "product": "...", "tip": "..."}], "evening": [{"step": 1, "name": "...", "product": "...", "tip": "..."}], "weekly": [{"frequency": "...", "name": "...", "tip": "..."}], "notes": ["lưu ý 1", "lưu ý 2"] }`; const raw = await callGemini({ parts: [{ text: prompt }], temperature: 0.4, maxTokens: 1500, }); try { return JSON.parse(raw.replace(/```json|```/g, '').trim()); } catch { console.error('[Routine] JSON parse failed:', raw.slice(0, 200)); return null; } } }; // ══════════════════════════════════════════════ // 4. SKIN IMAGE ANALYSIS — Gemini Vision // ══════════════════════════════════════════════ window.GeminiVision = { async analyze(imageFile) { // Validate file const MAX_SIZE = 10 * 1024 * 1024; const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp']; if (!ALLOWED_TYPES.includes(imageFile.type)) { throw new Error('Chỉ hỗ trợ ảnh JPG, PNG hoặc WEBP'); } if (imageFile.size > MAX_SIZE) { throw new Error('Ảnh quá lớn — tối đa 10MB'); } // Convert to base64 const base64 = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result.split(',')[1]); reader.onerror = reject; reader.readAsDataURL(imageFile); }); const prompt = `Phân tích da khuôn mặt theo 3 thang y khoa. Trả về JSON THUẦN. Thang đo: - acneGrade: IGA Scale 0-4 (0=không mụn, 1=rất nhẹ, 2=nhẹ, 3=trung bình, 4=nặng) - redness: Erythema Index (low/medium/high) - texture: Skin Roughness (smooth/rough) User thường là người Châu Á Fitzpatrick III-IV. JSON: { "acneGrade": 0, "redness": "low", "texture": "smooth", "confidence": 0.9, "imageQuality": "good|fair|poor", "notes": "nhận xét ngắn tiếng Việt" }`; const raw = await callGemini({ parts: [ { inlineData: { mimeType: imageFile.type, data: base64 } }, { text: prompt } ], temperature: 0.1, maxTokens: 512, isVision: true }); if (!raw) throw new Error('Gemini Vision không trả về kết quả'); let result; try { result = JSON.parse(raw.replace(/```json|```/g, '').trim()); } catch (e) { console.error('[Vision] JSON parse failed:', raw); throw new Error('AI trả về kết quả không đúng định dạng — vui lòng thử lại'); } if (result.imageQuality === 'poor') { throw new Error('Ảnh chất lượng kém — vui lòng chụp lại với ánh sáng tự nhiên, nhìn thẳng vào camera'); } // Tính Skin Score const p = { acne: [0, 10, 30, 55, 55][result.acneGrade] ?? 0, redness: ({ low:0, medium:15, high:30 })[result.redness] ?? 0, texture: ({ smooth:0, rough:20 })[result.texture] ?? 0, }; result.score = Math.max(0, 100 - p.acne - p.redness - p.texture); result.penalties = p; result.label = result.score >= 90 ? 'Xuất sắc ✦' : result.score >= 75 ? 'Rất tốt' : result.score >= 55 ? 'Trung bình' : result.score >= 35 ? 'Cần cải thiện' : 'Nghiêm trọng'; return result; } };