- 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
376 lines
15 KiB
JavaScript
376 lines
15 KiB
JavaScript
import express, { Router } from 'express';
|
|
import { geminiHealthService } from './gemini.health.service.mjs';
|
|
|
|
const router = Router();
|
|
router.use(express.json());
|
|
|
|
// --- Helpers ---------------------------------------------------------------
|
|
|
|
async function listModels(baseUrl, ver, apiKey) {
|
|
const url = `${baseUrl}/${ver}/models?key=${encodeURIComponent(apiKey)}`;
|
|
const r = await fetch(url, {
|
|
method: 'GET',
|
|
headers: { 'Content-Type': 'application/json', 'x-goog-api-key': apiKey }
|
|
});
|
|
if (!r.ok) {
|
|
// Return empty list on error; caller will try fallbacks
|
|
return [];
|
|
}
|
|
const data = await r.json().catch(() => ({}));
|
|
const items = Array.isArray(data?.models) ? data.models : [];
|
|
return items.map(m => ({
|
|
name: typeof m?.name === 'string' ? m.name.replace(/^models\//, '') : '',
|
|
raw: m
|
|
})).filter(m => !!m.name);
|
|
}
|
|
|
|
function scoreModelName(name) {
|
|
// Prefer 1.5 flash latest > 1.5 flash > 1.5 pro latest > 1.5 pro > gemini-pro > others
|
|
const n = name.toLowerCase();
|
|
if (n.includes('1.5') && n.includes('flash') && n.includes('latest')) return 100;
|
|
if (n.includes('1.5') && n.includes('flash')) return 95;
|
|
if (n.includes('1.5') && n.includes('pro') && n.includes('latest')) return 90;
|
|
if (n.includes('1.5') && n.includes('pro')) return 85;
|
|
if (n === 'gemini-pro' || n.startsWith('gemini-pro')) return 80;
|
|
return 10;
|
|
}
|
|
|
|
function normalizeRequestedModel(reqModel) {
|
|
const base = String(reqModel || '').trim();
|
|
return base.replace(/^models\//, '') || 'gemini-1.5-flash';
|
|
}
|
|
|
|
async function resolveModel(baseUrl, apiKey, preferredVer, requestedModel) {
|
|
const versions = preferredVer === 'v1' ? ['v1', 'v1beta'] : ['v1beta', 'v1'];
|
|
const want = normalizeRequestedModel(requestedModel);
|
|
for (const ver of versions) {
|
|
const models = await listModels(baseUrl, ver, apiKey);
|
|
if (!models.length) continue;
|
|
// Try exact (with/without -latest) and common variants
|
|
const candidates = new Map();
|
|
for (const m of models) {
|
|
candidates.set(m.name, m);
|
|
}
|
|
const exact = candidates.get(want) || candidates.get(`${want}-latest`) || candidates.get(want.replace(/-latest$/i, ''));
|
|
if (exact) {
|
|
return { ver, model: exact.name };
|
|
}
|
|
// Otherwise pick best scored
|
|
const sorted = models
|
|
.map(m => ({ ...m, score: scoreModelName(m.name) }))
|
|
.sort((a, b) => b.score - a.score);
|
|
if (sorted.length) {
|
|
return { ver, model: sorted[0].name };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* GET /api/integrations/gemini/status
|
|
* Retourne le statut actuel de la clé API Gemini
|
|
*/
|
|
router.get('/status', (req, res) => {
|
|
try {
|
|
const status = geminiHealthService.getStatus();
|
|
res.set({
|
|
'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
|
|
'Pragma': 'no-cache',
|
|
'Expires': '0'
|
|
});
|
|
res.json(status);
|
|
} catch (error) {
|
|
console.error('[Gemini Routes] Error getting status:', error);
|
|
res.status(500).json({
|
|
status: 'INVALID',
|
|
lastCheckedAt: null,
|
|
details: {
|
|
reason: 'unexpected_response',
|
|
httpCode: 500
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/integrations/gemini/test
|
|
* Exécute un test live de la clé API Gemini
|
|
*/
|
|
router.post('/test', async (req, res) => {
|
|
try {
|
|
// Récupérer l'IP du client pour l'anti-spam
|
|
const clientIp = req.ip || req.connection.remoteAddress || 'unknown';
|
|
|
|
// Vérifier le rate limiting avant d'exécuter le test
|
|
const canTest = geminiHealthService.canRunTest(clientIp);
|
|
if (!canTest.allowed) {
|
|
return res.status(429).json({
|
|
status: geminiHealthService.cache.status,
|
|
lastCheckedAt: geminiHealthService.cache.lastCheckedAt,
|
|
details: {
|
|
reason: 'rate_limited',
|
|
httpCode: 429,
|
|
message: `Veuillez patienter ${canTest.waitSeconds} secondes avant de retester`,
|
|
waitSeconds: canTest.waitSeconds
|
|
}
|
|
});
|
|
}
|
|
|
|
const result = await geminiHealthService.runLiveTest(clientIp);
|
|
res.set({
|
|
'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
|
|
'Pragma': 'no-cache',
|
|
'Expires': '0'
|
|
});
|
|
res.json(result);
|
|
} catch (error) {
|
|
console.error('[Gemini Routes] Error running test:', error);
|
|
res.status(500).json({
|
|
status: 'INVALID',
|
|
lastCheckedAt: new Date().toISOString(),
|
|
details: {
|
|
reason: 'unexpected_response',
|
|
httpCode: 500
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
/**
|
|
* DELETE /api/integrations/gemini/cache
|
|
* Réinitialise le cache (utile pour les tests/debug)
|
|
*/
|
|
router.delete('/cache', (req, res) => {
|
|
try {
|
|
geminiHealthService.reset();
|
|
res.set({
|
|
'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
|
|
'Pragma': 'no-cache',
|
|
'Expires': '0'
|
|
});
|
|
res.json({
|
|
success: true,
|
|
message: 'Cache réinitialisé'
|
|
});
|
|
} catch (error) {
|
|
console.error('[Gemini Routes] Error resetting cache:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: 'Failed to reset cache'
|
|
});
|
|
}
|
|
});
|
|
|
|
export default router;
|
|
|
|
// --- Below: AI utility endpoints ---
|
|
|
|
/**
|
|
* POST /api/integrations/gemini/summarize
|
|
* Body: { text: string, title?: string, maxChars?: number, model?: string, language?: string }
|
|
* Returns: { success: boolean, summary?: string, model: string, duration: number, error?: string }
|
|
*/
|
|
router.post('/summarize', async (req, res) => {
|
|
try {
|
|
const apiKey = process.env.GEMINI_API_KEY;
|
|
if (!apiKey) return res.status(400).json({ success: false, error: 'Gemini API key not configured' });
|
|
|
|
const baseUrl = process.env.GEMINI_API_BASE || 'https://generativelanguage.googleapis.com';
|
|
const preferredVer = (process.env.GEMINI_API_VERSION || 'v1').trim();
|
|
const requestedModel = (req.body?.model || process.env.GEMINI_DEFAULT_MODEL || 'gemini-1.5-flash').toString();
|
|
const text = (req.body?.text || '').toString();
|
|
const title = (req.body?.title || '').toString();
|
|
const maxChars = Number(req.body?.maxChars || 240);
|
|
const language = (req.body?.language || 'fr').toString();
|
|
if (!text) return res.status(400).json({ success: false, error: 'Missing text' });
|
|
|
|
const start = Date.now();
|
|
const prompt = [
|
|
`Tu es un assistant qui résume des notes Obsidian de façon concise.`,
|
|
`Objectif: produire 1 à 2 phrases (${maxChars} caractères max) en ${language}.`,
|
|
title ? `Titre: ${title}` : null,
|
|
`Contenu: ${text.substring(0, 8000)}`
|
|
].filter(Boolean).join('\n\n');
|
|
|
|
const body = {
|
|
contents: [
|
|
{ role: 'user', parts: [{ text: prompt }] }
|
|
]
|
|
};
|
|
|
|
// Resolve a compatible model/version first
|
|
const resolved = await resolveModel(baseUrl, apiKey, preferredVer, requestedModel);
|
|
if (resolved) {
|
|
const { ver, model } = resolved;
|
|
const url = `${baseUrl}/${ver}/models/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(apiKey)}`;
|
|
const r = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'x-goog-api-key': apiKey },
|
|
body: JSON.stringify(body)
|
|
});
|
|
const duration = Date.now() - start;
|
|
if (!r.ok) {
|
|
let msg = `HTTP ${r.status}`;
|
|
let payload = null;
|
|
try { payload = await r.json(); msg = payload?.error?.message || msg; } catch { try { msg = await r.text(); } catch {} }
|
|
return res.status(502).json({ success: false, error: msg, httpStatus: r.status, model, version: ver, duration, details: payload });
|
|
}
|
|
const data = await r.json().catch(() => ({}));
|
|
const summary = data?.candidates?.[0]?.content?.parts?.map(p => p?.text || '').join(' ').trim() || '';
|
|
if (!summary) return res.status(502).json({ success: false, error: 'Empty response', model, version: ver, duration });
|
|
return res.json({ success: true, summary, model, version: ver, duration });
|
|
}
|
|
|
|
// Fallback: previous retry logic
|
|
const versionsToTry = preferredVer === 'v1' ? ['v1', 'v1beta'] : ['v1beta', 'v1'];
|
|
const baseModel = requestedModel.replace(/-latest$/i, '');
|
|
const modelsToTry = Array.from(new Set([
|
|
requestedModel,
|
|
baseModel,
|
|
`${baseModel}-latest`,
|
|
'gemini-1.5-flash',
|
|
'gemini-1.5-pro'
|
|
]));
|
|
|
|
let lastErr = null;
|
|
for (const ver of versionsToTry) {
|
|
for (const model of modelsToTry) {
|
|
const url = `${baseUrl}/${ver}/models/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(apiKey)}`;
|
|
const r = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'x-goog-api-key': apiKey },
|
|
body: JSON.stringify(body)
|
|
});
|
|
const duration = Date.now() - start;
|
|
if (!r.ok) {
|
|
try {
|
|
const payload = await r.json();
|
|
const msg = payload?.error?.message || `HTTP ${r.status}`;
|
|
lastErr = { msg, payload, status: r.status, ver, model, duration };
|
|
continue;
|
|
} catch {
|
|
const txt = await r.text().catch(() => '');
|
|
lastErr = { msg: txt || `HTTP ${r.status}`, payload: null, status: r.status, ver, model, duration };
|
|
continue;
|
|
}
|
|
}
|
|
const data = await r.json().catch(() => ({}));
|
|
const summary = data?.candidates?.[0]?.content?.parts?.map(p => p?.text || '').join(' ').trim() || '';
|
|
if (!summary) {
|
|
lastErr = { msg: 'Empty response', payload: data, status: r.status, ver, model, duration };
|
|
continue;
|
|
}
|
|
return res.json({ success: true, summary, model, version: ver, duration });
|
|
}
|
|
}
|
|
console.warn('[Gemini summarize] All attempts failed:', lastErr);
|
|
return res.status(502).json({ success: false, error: lastErr?.msg || 'Upstream error', httpStatus: lastErr?.status, tried: { versions: versionsToTry, models: modelsToTry }, details: lastErr?.payload });
|
|
} catch (error) {
|
|
console.error('[Gemini Routes] summarize error:', error);
|
|
return res.status(500).json({ success: false, error: 'Internal error' });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/integrations/gemini/description
|
|
* Alias de /summarize avec un prompt orienté "description de frontmatter".
|
|
*/
|
|
router.post('/description', async (req, res) => {
|
|
try {
|
|
const apiKey = process.env.GEMINI_API_KEY;
|
|
if (!apiKey) return res.status(400).json({ success: false, error: 'Gemini API key not configured' });
|
|
|
|
const baseUrl = process.env.GEMINI_API_BASE || 'https://generativelanguage.googleapis.com';
|
|
const preferredVer = (process.env.GEMINI_API_VERSION || 'v1beta').trim();
|
|
const requestedModel = (req.body?.model || 'gemini-1.5-flash-latest').toString();
|
|
const text = (req.body?.text || '').toString();
|
|
const title = (req.body?.title || '').toString();
|
|
const maxChars = Number(req.body?.maxChars || 140);
|
|
const language = (req.body?.language || 'fr').toString();
|
|
if (!text) return res.status(400).json({ success: false, error: 'Missing text' });
|
|
|
|
const start = Date.now();
|
|
const prompt = [
|
|
`Rédige une description frontmatter concise (une seule phrase) en ${language}.`,
|
|
`Maximum ${maxChars} caractères, sans balises, sans mise en forme.`,
|
|
title ? `Titre: ${title}` : null,
|
|
`Contenu: ${text.substring(0, 8000)}`
|
|
].filter(Boolean).join('\n\n');
|
|
|
|
const body = {
|
|
contents: [
|
|
{ role: 'user', parts: [{ text: prompt }] }
|
|
]
|
|
};
|
|
|
|
// Resolve a compatible model/version first
|
|
const resolved = await resolveModel(baseUrl, apiKey, preferredVer, requestedModel);
|
|
if (resolved) {
|
|
const { ver, model } = resolved;
|
|
const url = `${baseUrl}/${ver}/models/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(apiKey)}`;
|
|
const r = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'x-goog-api-key': apiKey },
|
|
body: JSON.stringify(body)
|
|
});
|
|
const duration = Date.now() - start;
|
|
if (!r.ok) {
|
|
let msg = `HTTP ${r.status}`;
|
|
let payload = null;
|
|
try { payload = await r.json(); msg = payload?.error?.message || msg; } catch { try { msg = await r.text(); } catch {} }
|
|
return res.status(502).json({ success: false, error: msg, httpStatus: r.status, model, version: ver, duration, details: payload });
|
|
}
|
|
const data = await r.json().catch(() => ({}));
|
|
const description = data?.candidates?.[0]?.content?.parts?.map(p => p?.text || '').join(' ').trim() || '';
|
|
if (!description) return res.status(502).json({ success: false, error: 'Empty response', model, version: ver, duration });
|
|
return res.json({ success: true, description, model, version: ver, duration });
|
|
}
|
|
|
|
// Fallback: basic retry logic
|
|
const versionsToTry = preferredVer === 'v1' ? ['v1', 'v1beta'] : ['v1beta', 'v1'];
|
|
const baseModel = requestedModel.replace(/-latest$/i, '');
|
|
const modelsToTry = Array.from(new Set([
|
|
requestedModel,
|
|
baseModel,
|
|
`${baseModel}-latest`,
|
|
'gemini-1.5-flash',
|
|
'gemini-1.5-pro'
|
|
]));
|
|
|
|
let lastErr = null;
|
|
for (const ver of versionsToTry) {
|
|
for (const model of modelsToTry) {
|
|
const url = `${baseUrl}/${ver}/models/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(apiKey)}`;
|
|
const r = await fetch(url, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'x-goog-api-key': apiKey }, body: JSON.stringify(body)
|
|
});
|
|
const duration = Date.now() - start;
|
|
if (!r.ok) {
|
|
try {
|
|
const payload = await r.json();
|
|
const msg = payload?.error?.message || `HTTP ${r.status}`;
|
|
lastErr = { msg, payload, status: r.status, ver, model, duration };
|
|
continue;
|
|
} catch {
|
|
const txt = await r.text().catch(() => '');
|
|
lastErr = { msg: txt || `HTTP ${r.status}`, payload: null, status: r.status, ver, model, duration };
|
|
continue;
|
|
}
|
|
}
|
|
const data = await r.json().catch(() => ({}));
|
|
const description = data?.candidates?.[0]?.content?.parts?.map(p => p?.text || '').join(' ').trim() || '';
|
|
if (!description) {
|
|
lastErr = { msg: 'Empty response', payload: data, status: r.status, ver, model, duration };
|
|
continue;
|
|
}
|
|
return res.json({ success: true, description, model, version: ver, duration });
|
|
}
|
|
}
|
|
console.warn('[Gemini description] All attempts failed:', lastErr);
|
|
return res.status(502).json({ success: false, error: lastErr?.msg || 'Upstream error', httpStatus: lastErr?.status, tried: { versions: versionsToTry, models: modelsToTry }, details: lastErr?.payload });
|
|
} catch (error) {
|
|
console.error('[Gemini Routes] description error:', error);
|
|
return res.status(500).json({ success: false, error: 'Internal error' });
|
|
}
|
|
});
|