- Implemented multi-selection for notes with Ctrl+click, long-press, and keyboard shortcuts (Ctrl+A, Escape) - Added Gemini API integration with environment configuration and routes - Enhanced code block UI with improved copy feedback animation and visual polish - Added sort order toggle (asc/desc) for note lists with persistent state
282 lines
8.2 KiB
JavaScript
282 lines
8.2 KiB
JavaScript
import './gemini.types.mjs';
|
|
|
|
/**
|
|
* Service de vérification de santé de l'API Gemini
|
|
* Gère l'état de la clé API et les tests de connectivité
|
|
*/
|
|
export class GeminiHealthService {
|
|
constructor() {
|
|
/** @type {GeminiStatusResponse} */
|
|
this.cache = {
|
|
status: 'CONFIGURED_UNVERIFIED',
|
|
lastCheckedAt: null,
|
|
details: {
|
|
reason: 'not_tested',
|
|
httpCode: null
|
|
}
|
|
};
|
|
|
|
/** @type {boolean} */
|
|
this.tested = false;
|
|
|
|
/** @type {number} - TTL du cache en millisecondes (5 minutes) */
|
|
this.CACHE_TTL_MS = 5 * 60 * 1000;
|
|
|
|
/** @type {number} - Timeout pour les requêtes API (8 secondes) */
|
|
this.REQUEST_TIMEOUT_MS = 8000;
|
|
|
|
/** @type {Map<string, number>} - Anti-spam: dernier appel par IP */
|
|
this.lastTestByIp = new Map();
|
|
|
|
/** @type {number} - Délai minimum entre deux tests (30 secondes) */
|
|
this.MIN_TEST_INTERVAL_MS = 30 * 1000;
|
|
}
|
|
|
|
/**
|
|
* Obtient le statut actuel de la clé API Gemini
|
|
* @returns {GeminiStatusResponse}
|
|
*/
|
|
getStatus() {
|
|
const apiKey = process.env.GEMINI_API_KEY;
|
|
|
|
// Cas 1: Clé non configurée
|
|
if (!apiKey || apiKey.trim() === '') {
|
|
return {
|
|
status: 'NOT_CONFIGURED',
|
|
lastCheckedAt: this.cache.lastCheckedAt,
|
|
details: {
|
|
reason: 'missing_key',
|
|
httpCode: null
|
|
}
|
|
};
|
|
}
|
|
|
|
// Cas 2: Clé configurée mais jamais testée
|
|
if (!this.tested) {
|
|
return {
|
|
status: 'CONFIGURED_UNVERIFIED',
|
|
lastCheckedAt: this.cache.lastCheckedAt,
|
|
details: {
|
|
reason: 'not_tested',
|
|
httpCode: null
|
|
}
|
|
};
|
|
}
|
|
|
|
// Cas 3 & 4: Retourner le cache (WORKING ou INVALID)
|
|
return this.cache;
|
|
}
|
|
|
|
/**
|
|
* Vérifie si un test peut être exécuté (anti-spam)
|
|
* @param {string} clientIp - IP du client
|
|
* @returns {{ allowed: boolean, reason?: string, waitSeconds?: number }}
|
|
*/
|
|
canRunTest(clientIp) {
|
|
const now = Date.now();
|
|
const lastTest = this.lastTestByIp.get(clientIp);
|
|
|
|
if (lastTest) {
|
|
const elapsed = now - lastTest;
|
|
if (elapsed < this.MIN_TEST_INTERVAL_MS) {
|
|
const waitSeconds = Math.ceil((this.MIN_TEST_INTERVAL_MS - elapsed) / 1000);
|
|
return {
|
|
allowed: false,
|
|
reason: 'rate_limited',
|
|
waitSeconds
|
|
};
|
|
}
|
|
}
|
|
|
|
return { allowed: true };
|
|
}
|
|
|
|
/**
|
|
* Exécute un test live de la clé API Gemini
|
|
* @param {string} clientIp - IP du client (pour anti-spam)
|
|
* @returns {Promise<GeminiStatusResponse>}
|
|
*/
|
|
async runLiveTest(clientIp = 'unknown') {
|
|
const apiKey = process.env.GEMINI_API_KEY;
|
|
const now = new Date().toISOString();
|
|
|
|
// Vérifier si la clé est configurée
|
|
if (!apiKey || apiKey.trim() === '') {
|
|
this.cache = {
|
|
status: 'NOT_CONFIGURED',
|
|
lastCheckedAt: now,
|
|
details: {
|
|
reason: 'missing_key',
|
|
httpCode: null
|
|
}
|
|
};
|
|
this.tested = false;
|
|
return this.cache;
|
|
}
|
|
|
|
// Anti-spam
|
|
const testAllowed = this.canRunTest(clientIp);
|
|
if (!testAllowed.allowed) {
|
|
// Retourner le cache existant avec un message
|
|
return {
|
|
...this.cache,
|
|
details: {
|
|
...this.cache.details,
|
|
reason: 'rate_limited',
|
|
waitSeconds: testAllowed.waitSeconds
|
|
}
|
|
};
|
|
}
|
|
|
|
// Marquer le timestamp du test
|
|
this.lastTestByIp.set(clientIp, Date.now());
|
|
|
|
try {
|
|
// Appel minimal à l'API Gemini: liste des modèles
|
|
const baseUrl = process.env.GEMINI_API_BASE || 'https://generativelanguage.googleapis.com';
|
|
const preferredApiVersion = (process.env.GEMINI_API_VERSION || 'v1').trim(); // 'v1' ou 'v1beta'
|
|
const buildUrl = (ver) => `${baseUrl}/${ver}/models`;
|
|
|
|
console.log('[GeminiHealth] Testing API key connectivity...');
|
|
|
|
// Créer un controller pour le timeout
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), this.REQUEST_TIMEOUT_MS);
|
|
|
|
// 1er essai: version préférée
|
|
let response = await fetch(buildUrl(preferredApiVersion), {
|
|
method: 'GET',
|
|
signal: controller.signal,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'x-goog-api-key': apiKey
|
|
}
|
|
});
|
|
|
|
// Fallback automatique: si 400, retenter avec l'autre version (v1 <-> v1beta)
|
|
if (response.status === 400) {
|
|
const altVersion = preferredApiVersion === 'v1' ? 'v1beta' : 'v1';
|
|
console.warn(`[GeminiHealth] 400 on ${preferredApiVersion}, retrying with ${altVersion}...`);
|
|
response = await fetch(buildUrl(altVersion), {
|
|
method: 'GET',
|
|
signal: controller.signal,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'x-goog-api-key': apiKey
|
|
}
|
|
});
|
|
}
|
|
|
|
clearTimeout(timeoutId);
|
|
|
|
// Analyser la réponse
|
|
if (response.ok) {
|
|
// Vérifier que la réponse contient des modèles
|
|
const data = await response.json();
|
|
const hasModels = data.models && Array.isArray(data.models) && data.models.length > 0;
|
|
|
|
if (hasModels) {
|
|
console.log(`[GeminiHealth] ✅ API key is WORKING (${data.models.length} models available)`);
|
|
this.cache = {
|
|
status: 'WORKING',
|
|
lastCheckedAt: now,
|
|
details: {
|
|
reason: 'ok',
|
|
httpCode: response.status
|
|
}
|
|
};
|
|
} else {
|
|
console.warn('[GeminiHealth] ⚠️ API key valid but unexpected response structure');
|
|
this.cache = {
|
|
status: 'INVALID',
|
|
lastCheckedAt: now,
|
|
details: {
|
|
reason: 'unexpected_response',
|
|
httpCode: response.status
|
|
}
|
|
};
|
|
}
|
|
} else if (response.status === 401 || response.status === 403) {
|
|
let errMsg = 'authentication failed';
|
|
try { const err = await response.json(); errMsg = err?.error?.message || errMsg; } catch {}
|
|
console.error('[GeminiHealth] ❌ API key is INVALID:', errMsg);
|
|
this.cache = {
|
|
status: 'INVALID',
|
|
lastCheckedAt: now,
|
|
details: {
|
|
reason: 'auth_error',
|
|
httpCode: response.status,
|
|
message: errMsg
|
|
}
|
|
};
|
|
} else if (response.status === 429) {
|
|
let errMsg = 'rate limited';
|
|
try { const err = await response.json(); errMsg = err?.error?.message || errMsg; } catch {}
|
|
console.error('[GeminiHealth] ❌ API rate limited:', errMsg);
|
|
this.cache = {
|
|
status: 'INVALID',
|
|
lastCheckedAt: now,
|
|
details: {
|
|
reason: 'rate_limited',
|
|
httpCode: response.status,
|
|
message: errMsg
|
|
}
|
|
};
|
|
} else {
|
|
let errMsg = 'unexpected_response';
|
|
try { const err = await response.json(); errMsg = err?.error?.message || errMsg; } catch {}
|
|
console.error(`[GeminiHealth] ❌ Unexpected HTTP status: ${response.status}`, errMsg);
|
|
this.cache = {
|
|
status: 'INVALID',
|
|
lastCheckedAt: now,
|
|
details: {
|
|
reason: 'unexpected_response',
|
|
httpCode: response.status,
|
|
message: errMsg
|
|
}
|
|
};
|
|
}
|
|
|
|
this.tested = true;
|
|
return this.cache;
|
|
|
|
} catch (error) {
|
|
console.error('[GeminiHealth] ❌ Network error during API test:', error.message);
|
|
|
|
// Timeout ou erreur réseau
|
|
const isTimeout = error.name === 'AbortError' || error.message.includes('timeout');
|
|
|
|
this.cache = {
|
|
status: 'INVALID',
|
|
lastCheckedAt: now,
|
|
details: {
|
|
reason: isTimeout ? 'network_error' : 'network_error',
|
|
httpCode: null
|
|
}
|
|
};
|
|
|
|
this.tested = true;
|
|
return this.cache;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Réinitialise le cache (utile pour les tests)
|
|
*/
|
|
reset() {
|
|
this.cache = {
|
|
status: 'CONFIGURED_UNVERIFIED',
|
|
lastCheckedAt: null,
|
|
details: {
|
|
reason: 'not_tested',
|
|
httpCode: null
|
|
}
|
|
};
|
|
this.tested = false;
|
|
this.lastTestByIp.clear();
|
|
}
|
|
}
|
|
|
|
// Singleton global
|
|
export const geminiHealthService = new GeminiHealthService();
|