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:
2025-11-04 09:54:03 -05:00
parent 79e80fd798
commit 59d8a9f83a
56 changed files with 7135 additions and 307 deletions
+12 -2
View File
@@ -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 {};