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
This commit is contained in:
+12
-2
@@ -39,6 +39,7 @@ import {
|
||||
setupRenameFileEndpoint,
|
||||
setupMoveNoteEndpoint
|
||||
} from './index-phase3-patch.mjs';
|
||||
import geminiRoutes from './integrations/gemini/gemini.routes.mjs';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
@@ -567,8 +568,14 @@ app.use(cors({
|
||||
credentials: true
|
||||
}));
|
||||
|
||||
// Exposer les fichiers de la voûte pour un accès direct si nécessaire
|
||||
app.use('/vault', express.static(vaultDir));
|
||||
// Exposer les fichiers de la voûte pour un accès direct, y compris les dotfiles (ex: .test, .obsidian)
|
||||
// Utiliser fallthrough:false pour renvoyer un 404 au lieu de tomber sur le fallback Angular index.html
|
||||
app.use('/vault', express.static(vaultDir, {
|
||||
dotfiles: 'allow',
|
||||
fallthrough: false,
|
||||
index: false,
|
||||
etag: true
|
||||
}));
|
||||
|
||||
// ---------- Settings API ----------
|
||||
app.get('/api/settings', (req, res) => {
|
||||
@@ -700,6 +707,9 @@ app.get('/api/health', (req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// Gemini Integration endpoints
|
||||
app.use('/api/integrations/gemini', geminiRoutes);
|
||||
|
||||
app.get('/api/vault/events', (req, res) => {
|
||||
res.set({
|
||||
'Content-Type': 'text/event-stream',
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
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();
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, it, beforeEach, afterEach } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import { GeminiHealthService } from './gemini.health.service.mjs';
|
||||
|
||||
describe('GeminiHealthService', () => {
|
||||
let service;
|
||||
let originalEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
// Sauvegarder l'environnement original
|
||||
originalEnv = { ...process.env };
|
||||
service = new GeminiHealthService();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restaurer l'environnement
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
describe('getStatus()', () => {
|
||||
it('should return NOT_CONFIGURED when GEMINI_API_KEY is missing', () => {
|
||||
delete process.env.GEMINI_API_KEY;
|
||||
const status = service.getStatus();
|
||||
|
||||
assert.strictEqual(status.status, 'NOT_CONFIGURED');
|
||||
assert.strictEqual(status.details.reason, 'missing_key');
|
||||
assert.strictEqual(status.details.httpCode, null);
|
||||
});
|
||||
|
||||
it('should return NOT_CONFIGURED when GEMINI_API_KEY is empty string', () => {
|
||||
process.env.GEMINI_API_KEY = '';
|
||||
const status = service.getStatus();
|
||||
|
||||
assert.strictEqual(status.status, 'NOT_CONFIGURED');
|
||||
assert.strictEqual(status.details.reason, 'missing_key');
|
||||
});
|
||||
|
||||
it('should return NOT_CONFIGURED when GEMINI_API_KEY is only whitespace', () => {
|
||||
process.env.GEMINI_API_KEY = ' ';
|
||||
const status = service.getStatus();
|
||||
|
||||
assert.strictEqual(status.status, 'NOT_CONFIGURED');
|
||||
assert.strictEqual(status.details.reason, 'missing_key');
|
||||
});
|
||||
|
||||
it('should return CONFIGURED_UNVERIFIED when key is present but never tested', () => {
|
||||
process.env.GEMINI_API_KEY = 'test-key';
|
||||
service.tested = false;
|
||||
|
||||
const status = service.getStatus();
|
||||
|
||||
assert.strictEqual(status.status, 'CONFIGURED_UNVERIFIED');
|
||||
assert.strictEqual(status.details.reason, 'not_tested');
|
||||
assert.strictEqual(status.details.httpCode, null);
|
||||
});
|
||||
|
||||
it('should return cached status when already tested', () => {
|
||||
process.env.GEMINI_API_KEY = 'test-key';
|
||||
service.tested = true;
|
||||
service.cache = {
|
||||
status: 'WORKING',
|
||||
lastCheckedAt: '2025-01-01T00:00:00.000Z',
|
||||
details: { reason: 'ok', httpCode: 200 }
|
||||
};
|
||||
|
||||
const status = service.getStatus();
|
||||
|
||||
assert.strictEqual(status.status, 'WORKING');
|
||||
assert.strictEqual(status.details.reason, 'ok');
|
||||
assert.strictEqual(status.details.httpCode, 200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canRunTest()', () => {
|
||||
it('should allow test on first call', () => {
|
||||
const result = service.canRunTest('127.0.0.1');
|
||||
|
||||
assert.strictEqual(result.allowed, true);
|
||||
});
|
||||
|
||||
it('should block test if called too soon', () => {
|
||||
const clientIp = '127.0.0.1';
|
||||
service.lastTestByIp.set(clientIp, Date.now());
|
||||
|
||||
const result = service.canRunTest(clientIp);
|
||||
|
||||
assert.strictEqual(result.allowed, false);
|
||||
assert.strictEqual(result.reason, 'rate_limited');
|
||||
assert.ok(result.waitSeconds > 0);
|
||||
});
|
||||
|
||||
it('should allow test after minimum interval', (t, done) => {
|
||||
const clientIp = '127.0.0.1';
|
||||
// Simuler un test ancien (31 secondes)
|
||||
service.lastTestByIp.set(clientIp, Date.now() - 31000);
|
||||
|
||||
const result = service.canRunTest(clientIp);
|
||||
|
||||
assert.strictEqual(result.allowed, true);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset()', () => {
|
||||
it('should reset all state to initial values', () => {
|
||||
// Modifier l'état
|
||||
service.tested = true;
|
||||
service.cache = {
|
||||
status: 'WORKING',
|
||||
lastCheckedAt: '2025-01-01T00:00:00.000Z',
|
||||
details: { reason: 'ok', httpCode: 200 }
|
||||
};
|
||||
service.lastTestByIp.set('127.0.0.1', Date.now());
|
||||
|
||||
// Reset
|
||||
service.reset();
|
||||
|
||||
// Vérifier
|
||||
assert.strictEqual(service.tested, false);
|
||||
assert.strictEqual(service.cache.status, 'CONFIGURED_UNVERIFIED');
|
||||
assert.strictEqual(service.cache.lastCheckedAt, null);
|
||||
assert.strictEqual(service.cache.details.reason, 'not_tested');
|
||||
assert.strictEqual(service.lastTestByIp.size, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runLiveTest() - edge cases', () => {
|
||||
it('should return NOT_CONFIGURED if key is missing', async () => {
|
||||
delete process.env.GEMINI_API_KEY;
|
||||
|
||||
const result = await service.runLiveTest('127.0.0.1');
|
||||
|
||||
assert.strictEqual(result.status, 'NOT_CONFIGURED');
|
||||
assert.strictEqual(result.details.reason, 'missing_key');
|
||||
assert.strictEqual(service.tested, false);
|
||||
});
|
||||
|
||||
it('should respect rate limiting', async () => {
|
||||
process.env.GEMINI_API_KEY = 'test-key';
|
||||
service.lastTestByIp.set('127.0.0.1', Date.now());
|
||||
service.cache = {
|
||||
status: 'WORKING',
|
||||
lastCheckedAt: '2025-01-01T00:00:00.000Z',
|
||||
details: { reason: 'ok', httpCode: 200 }
|
||||
};
|
||||
|
||||
const result = await service.runLiveTest('127.0.0.1');
|
||||
|
||||
assert.strictEqual(result.details.reason, 'rate_limited');
|
||||
assert.ok(result.details.waitSeconds);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,375 @@
|
||||
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' });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* @typedef {'NOT_CONFIGURED' | 'CONFIGURED_UNVERIFIED' | 'WORKING' | 'INVALID'} GeminiStatus
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {'missing_key' | 'not_tested' | 'ok' | 'auth_error' | 'network_error' | 'rate_limited' | 'unexpected_response'} GeminiStatusReason
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} GeminiStatusDetails
|
||||
* @property {GeminiStatusReason} reason
|
||||
* @property {number | null} httpCode
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} GeminiStatusResponse
|
||||
* @property {GeminiStatus} status
|
||||
* @property {string | null} lastCheckedAt
|
||||
* @property {GeminiStatusDetails} details
|
||||
*/
|
||||
|
||||
export {};
|
||||
Reference in New Issue
Block a user