ObsiViewer/server/integrations/gemini/gemini.routes.mjs
Bruno Charest 59d8a9f83a feat: add multi-select notes and Gemini AI integration
- 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
2025-11-04 09:54:03 -05:00

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' });
}
});