From 9748ecb1bb577bbefb9418d1f79dde748b1b33b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Nov 2025 17:03:44 +0000 Subject: [PATCH] =?UTF-8?q?Feature:=20Am=C3=A9lioration=20syst=C3=A8me=20R?= =?UTF-8?q?SS=20et=20OpenRouter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Récupération des vrais modèles depuis l'API OpenRouter - Table rss_summaries pour stocker les résumés générés - Limitation à 5 articles RSS au lieu de 10 - Route GET /api/rss/summaries pour récupérer les résumés - Sauvegarde automatique des résumés en base de données Frontend: - Affichage conditionnel: résumés si activé, sinon articles - Fonction renderRssSummaries() avec conversion Markdown→HTML - Formatage du résumé (titres, listes, gras, italique) - CSS complet pour .rss-summary avec typography - Bouton résumer: crée note ou recharge bloc selon config Comportement: - Si rss_summary_enabled=1: affiche les 5 derniers résumés - Sinon: affiche les 5 derniers articles - Résumés formatés avec Markdown dans le bloc RSS --- config/database.js | 11 +++++ public/css/layout.css | 67 ++++++++++++++++++++++++++ public/js/complete-app.js | 96 +++++++++++++++++++++++++++++++++----- routes/rss.routes.js | 98 +++++++++++++++++++++++++++++---------- 4 files changed, 236 insertions(+), 36 deletions(-) diff --git a/config/database.js b/config/database.js index ab552d7..e59f87b 100644 --- a/config/database.js +++ b/config/database.js @@ -126,6 +126,17 @@ function initDatabase() { ) `); + // Table rss_summaries (résumés générés) + db.run(` + CREATE TABLE IF NOT EXISTS rss_summaries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + summary TEXT NOT NULL, + model TEXT, + articles_count INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + // Créer les index pour la performance db.run('CREATE INDEX IF NOT EXISTS idx_notes_user ON notes(user_id)'); db.run('CREATE INDEX IF NOT EXISTS idx_note_todos ON note_todos(note_id)'); diff --git a/public/css/layout.css b/public/css/layout.css index 81fb042..4225d62 100644 --- a/public/css/layout.css +++ b/public/css/layout.css @@ -484,6 +484,73 @@ body { overflow: hidden; } +.rss-summary { + padding: var(--spacing-lg); + background: var(--color-background); + border-radius: var(--radius-md); + border: 1px solid var(--color-border); + margin-bottom: var(--spacing-md); +} + +.rss-summary-header { + margin-bottom: var(--spacing-md); + padding-bottom: var(--spacing-sm); + border-bottom: 1px solid var(--color-border); +} + +.rss-summary-title { + font-size: var(--font-size-base); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + margin-bottom: var(--spacing-xs); +} + +.rss-summary-meta { + display: flex; + gap: var(--spacing-md); + font-size: var(--font-size-xs); + color: var(--color-text-secondary); +} + +.rss-summary-content { + font-size: var(--font-size-sm); + color: var(--color-text-primary); + line-height: 1.6; +} + +.rss-summary-content h2 { + font-size: var(--font-size-lg); + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); + margin: var(--spacing-md) 0 var(--spacing-sm) 0; +} + +.rss-summary-content h3 { + font-size: var(--font-size-base); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + margin: var(--spacing-sm) 0 var(--spacing-xs) 0; +} + +.rss-summary-content ul, +.rss-summary-content ol { + margin: var(--spacing-sm) 0; + padding-left: var(--spacing-xl); +} + +.rss-summary-content li { + margin-bottom: var(--spacing-xs); +} + +.rss-summary-content strong { + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); +} + +.rss-summary-content em { + font-style: italic; +} + /* Responsive */ @media (max-width: 1200px) { .content-container { diff --git a/public/js/complete-app.js b/public/js/complete-app.js index ac1bd7e..ea42830 100644 --- a/public/js/complete-app.js +++ b/public/js/complete-app.js @@ -1486,8 +1486,19 @@ async function fetchRssArticles() { async function loadRssArticles() { try { - const articles = await api.get('/api/rss/articles'); - renderRssArticles(articles); + // Vérifier si les résumés sont activés + const settings = await api.get('/api/settings'); + const summaryEnabled = settings.rss_summary_enabled === '1' || settings.rss_summary_enabled === 'true'; + + if (summaryEnabled) { + // Charger les résumés + const summaries = await api.get('/api/rss/summaries'); + renderRssSummaries(summaries); + } else { + // Charger les articles normaux + const articles = await api.get('/api/rss/articles'); + renderRssArticles(articles); + } } catch (error) { console.error('Erreur chargement articles RSS:', error); } @@ -1514,6 +1525,57 @@ function renderRssArticles(articles) { `).join(''); } +function renderRssSummaries(summaries) { + const container = document.getElementById('rssArticlesList'); + if (!container) return; + + if (!summaries || summaries.length === 0) { + container.innerHTML = '

Aucun résumé pour le moment

'; + return; + } + + container.innerHTML = summaries.map(summary => ` +
+
+
📰 Résumé ${new Date(summary.created_at).toLocaleDateString('fr-FR')}
+
+ ${summary.articles_count} articles + ${summary.model ? `${escapeHtml(summary.model.split('/')[1] || summary.model)}` : ''} +
+
+
${formatSummary(summary.summary)}
+
+ `).join(''); +} + +function formatSummary(summary) { + // Convertir le Markdown de base en HTML + let html = escapeHtml(summary); + + // Titres + html = html.replace(/\n## (.*?)\n/g, '

$1

'); + html = html.replace(/\n# (.*?)\n/g, '

$1

'); + + // Listes à puces + html = html.replace(/\n- (.*?)(?=\n|$)/g, '
  • $1
  • '); + html = html.replace(/(
  • .*<\/li>)/s, ''); + + // Listes numérotées + html = html.replace(/\n\d+\. (.*?)(?=\n|$)/g, '
  • $1
  • '); + + // Gras + html = html.replace(/\*\*(.*?)\*\*/g, '$1'); + + // Italique + html = html.replace(/\*(.*?)\*/g, '$1'); + + // Sauts de ligne + html = html.replace(/\n\n/g, '

    '); + html = html.replace(/\n/g, '
    '); + + return html; +} + async function summarizeRss() { const btn = document.getElementById('summarizeRssBtn'); if (!btn) return; @@ -1525,18 +1587,28 @@ async function summarizeRss() { try { const result = await api.post('/api/rss/summarize', {}); - // Create a new note with the summary - const newNote = await api.post('/api/notes', { - title: `📰 Résumé RSS - ${new Date().toLocaleDateString('fr-FR')}`, - content: result.summary - }); + // Vérifier si les résumés sont activés + const settings = await api.get('/api/settings'); + const summaryEnabled = settings.rss_summary_enabled === '1' || settings.rss_summary_enabled === 'true'; - alert('Résumé généré et sauvegardé dans une nouvelle note !'); - await loadNotes(); + if (summaryEnabled) { + // Si les résumés sont activés, juste recharger le bloc RSS + alert('Résumé généré avec succès !'); + await loadRssArticles(); + } else { + // Sinon créer une note avec le résumé + const newNote = await api.post('/api/notes', { + title: `📰 Résumé RSS - ${new Date().toLocaleDateString('fr-FR')}`, + content: result.summary + }); - // Expand the new note - state.expandedNoteId = newNote.id; - renderNotes(); + alert('Résumé généré et sauvegardé dans une nouvelle note !'); + await loadNotes(); + + // Expand the new note + state.expandedNoteId = newNote.id; + renderNotes(); + } } catch (error) { console.error('Erreur génération résumé RSS:', error); if (error.message && error.message.includes('400')) { diff --git a/routes/rss.routes.js b/routes/rss.routes.js index a882668..bd4e138 100644 --- a/routes/rss.routes.js +++ b/routes/rss.routes.js @@ -170,7 +170,7 @@ router.post('/fetch', requireAdmin, async (req, res) => { /** * GET /api/rss/articles - * Récupérer les 10 derniers articles + * Récupérer les 5 derniers articles */ router.get('/articles', async (req, res) => { try { @@ -181,7 +181,7 @@ router.get('/articles', async (req, res) => { FROM rss_articles a JOIN rss_feeds f ON a.feed_id = f.id ORDER BY a.pub_date DESC - LIMIT 10 + LIMIT 5 `); res.json(articles || []); @@ -193,7 +193,7 @@ router.get('/articles', async (req, res) => { /** * POST /api/rss/summarize - * Générer un résumé des 10 derniers articles avec OpenRouter + * Générer un résumé des 5 derniers articles avec OpenRouter */ router.post('/summarize', requireAdmin, async (req, res) => { try { @@ -206,7 +206,7 @@ router.post('/summarize', requireAdmin, async (req, res) => { return res.status(400).json({ error: 'Clé API OpenRouter non configurée' }); } - // Récupérer les 10 derniers articles + // Récupérer les 5 derniers articles const articles = await getAll(` SELECT a.title, a.description, a.link, a.pub_date, @@ -214,7 +214,7 @@ router.post('/summarize', requireAdmin, async (req, res) => { FROM rss_articles a JOIN rss_feeds f ON a.feed_id = f.id ORDER BY a.pub_date DESC - LIMIT 10 + LIMIT 5 `); if (articles.length === 0) { @@ -272,7 +272,13 @@ Format de sortie en Markdown avec des sections claires.`; const summary = response.data.choices[0].message.content; - logger.info('Résumé généré avec succès'); + // Sauvegarder le résumé en base de données + await runQuery( + 'INSERT INTO rss_summaries (summary, model, articles_count) VALUES (?, ?, ?)', + [summary, selectedModel, articles.length] + ); + + logger.info('Résumé généré et sauvegardé avec succès'); res.json({ summary, @@ -295,30 +301,74 @@ Format de sortie en Markdown avec des sections claires.`; }); /** - * GET /api/rss/models - * Récupérer la liste des modèles OpenRouter + * GET /api/rss/summaries + * Récupérer les 5 derniers résumés */ -router.get('/models', requireAdmin, async (req, res) => { +router.get('/summaries', async (req, res) => { try { - // Liste des modèles populaires pour le résumé - const models = [ - { id: 'openai/gpt-4-turbo', name: 'GPT-4 Turbo', provider: 'OpenAI' }, - { id: 'openai/gpt-4', name: 'GPT-4', provider: 'OpenAI' }, - { id: 'openai/gpt-3.5-turbo', name: 'GPT-3.5 Turbo', provider: 'OpenAI' }, - { id: 'anthropic/claude-3-opus', name: 'Claude 3 Opus', provider: 'Anthropic' }, - { id: 'anthropic/claude-3-sonnet', name: 'Claude 3 Sonnet', provider: 'Anthropic' }, - { id: 'anthropic/claude-3-haiku', name: 'Claude 3 Haiku', provider: 'Anthropic' }, - { id: 'google/gemini-pro', name: 'Gemini Pro', provider: 'Google' }, - { id: 'meta-llama/llama-3-70b-instruct', name: 'Llama 3 70B', provider: 'Meta' }, - { id: 'mistralai/mistral-large', name: 'Mistral Large', provider: 'Mistral AI' }, - { id: 'mistralai/mixtral-8x7b-instruct', name: 'Mixtral 8x7B', provider: 'Mistral AI' } - ]; + const summaries = await getAll(` + SELECT id, summary, model, articles_count, created_at + FROM rss_summaries + ORDER BY created_at DESC + LIMIT 5 + `); - res.json(models); + res.json(summaries || []); } catch (error) { - logger.error('Erreur lors de la récupération des modèles:', error); + logger.error('Erreur lors de la récupération des résumés:', error); res.status(500).json({ error: 'Erreur serveur' }); } }); +/** + * GET /api/rss/models + * Récupérer la liste des modèles OpenRouter depuis l'API + */ +router.get('/models', requireAdmin, async (req, res) => { + try { + // Récupérer les modèles depuis l'API OpenRouter + const response = await axios.get('https://openrouter.ai/api/v1/models', { + headers: { + 'HTTP-Referer': 'https://noteflow.app', + 'X-Title': 'NoteFlow' + }, + timeout: 10000 + }); + + // Transformer les données en format simplifié + const models = response.data.data.map(model => ({ + id: model.id, + name: model.name || model.id, + provider: model.id.split('/')[0] || 'Unknown', + context_length: model.context_length, + pricing: model.pricing + })); + + // Trier par provider puis par nom + models.sort((a, b) => { + if (a.provider !== b.provider) { + return a.provider.localeCompare(b.provider); + } + return a.name.localeCompare(b.name); + }); + + res.json(models); + } catch (error) { + logger.error('Erreur lors de la récupération des modèles:', error); + + // Fallback sur une liste de modèles populaires si l'API échoue + const fallbackModels = [ + { id: 'openai/gpt-4-turbo', name: 'GPT-4 Turbo', provider: 'openai' }, + { id: 'openai/gpt-3.5-turbo', name: 'GPT-3.5 Turbo', provider: 'openai' }, + { id: 'anthropic/claude-3-opus', name: 'Claude 3 Opus', provider: 'anthropic' }, + { id: 'anthropic/claude-3-sonnet', name: 'Claude 3 Sonnet', provider: 'anthropic' }, + { id: 'google/gemini-pro', name: 'Gemini Pro', provider: 'google' }, + { id: 'meta-llama/llama-3-70b-instruct', name: 'Llama 3 70B', provider: 'meta-llama' }, + { id: 'mistralai/mistral-large', name: 'Mistral Large', provider: 'mistralai' } + ]; + + res.json(fallbackModels); + } +}); + module.exports = router;