From 73391def6c574abfe40d63adcea50b434cd0204a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Nov 2025 18:37:40 +0000 Subject: [PATCH] =?UTF-8?q?UX:=20R=C3=A9sum=C3=A9s=20RSS=20par=20article?= =?UTF-8?q?=20+=20Optimisation=201920x1080?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Résumés RSS par article (100 mots max): - Modifié route POST /api/rss/summarize pour générer un résumé PAR article - Chaque résumé limité à 100 mots (max_tokens: 200) - Format: **Titre** + Résumé + 🔗 [Lire l'article](lien) - Résumés générés en parallèle pour meilleure performance - Supprimé requireAdmin pour accès à tous utilisateurs authentifiés - Timeout 30s par article au lieu de 60s global Frontend - Affichage résumés: - renderRssSummaries() : Détection auto ancien/nouveau format - formatSummary() : Support liens markdown [texte](url) - summarizeRss() : Gère tableau de résumés au lieu d'un seul - Styles CSS pour .rss-link (hover avec underline) - Nouveau style .rss-summary-article pour résumés individuels Optimisation pour écran 1920x1080: - max-width: 1400px → 1800px (utilise mieux l'espace horizontal) - todo-box-width: 340px → 420px (+80px de largeur) - Boxes TODO et RSS élargies de 23% - Grille: ~960px notes + 420px todo + 420px rss = 1800px - Layout optimisé pour grand écran CSS - Nouveaux styles: - .rss-link : liens cliquables avec hover effect - .rss-summary-article : carte compacte pour résumé individuel - Hover effects sur liens et cartes - Responsive maintenu pour petits écrans Impact utilisateur: ✓ Un résumé par article avec lien direct ✓ Plus concis (100 mots max vs résumé global) ✓ Interface plus large et spacieuse sur 1920x1080 ✓ Boxes TODO et RSS 23% plus larges ✓ Meilleure lisibilité des contenus Files: - routes/rss.routes.js: Nouveau système de résumés par article - public/js/complete-app.js: Rendu et gestion résumés - public/css/variables.css: Dimensions optimisées 1920x1080 - public/css/layout.css: Styles liens + résumés individuels --- public/css/layout.css | 43 +++++++++++++ public/css/variables.css | 6 +- public/js/complete-app.js | 79 ++++++++++++++++------- routes/rss.routes.js | 129 +++++++++++++++++++++----------------- 4 files changed, 173 insertions(+), 84 deletions(-) diff --git a/public/css/layout.css b/public/css/layout.css index 4225d62..2f1fbb7 100644 --- a/public/css/layout.css +++ b/public/css/layout.css @@ -551,6 +551,49 @@ body { font-style: italic; } +.rss-summary-content a.rss-link { + color: var(--color-accent); + text-decoration: none; + font-weight: var(--font-weight-medium); + transition: all var(--transition-fast); + border-bottom: 1px solid transparent; +} + +.rss-summary-content a.rss-link:hover { + border-bottom-color: var(--color-accent); + opacity: 0.8; +} + +/* Nouveau style pour résumés par article */ +.rss-summary-article { + padding: var(--spacing-md); + background: var(--color-background); + border-radius: var(--radius-md); + border: 1px solid var(--color-border); + margin-bottom: var(--spacing-md); + transition: all var(--transition-fast); +} + +.rss-summary-article:hover { + border-color: var(--color-accent); + box-shadow: var(--shadow-sm); +} + +.rss-summary-article .rss-summary-content { + font-size: var(--font-size-sm); + line-height: 1.6; +} + +.rss-summary-article .rss-summary-meta { + margin-top: var(--spacing-sm); + padding-top: var(--spacing-sm); + border-top: 1px solid var(--color-border); + display: flex; + gap: var(--spacing-md); + font-size: var(--font-size-xs); + color: var(--color-text-secondary); +} + /* Responsive */ @media (max-width: 1200px) { .content-container { diff --git a/public/css/variables.css b/public/css/variables.css index 34a160f..fd73f82 100644 --- a/public/css/variables.css +++ b/public/css/variables.css @@ -67,12 +67,12 @@ --transition-base: 0.25s cubic-bezier(0.4, 0, 0.2, 1); --transition-slow: 0.35s cubic-bezier(0.4, 0, 0.2, 1); - /* Dimensions */ + /* Dimensions - Optimisé pour 1920x1080 */ --header-height: 70px; - --max-width: 1400px; + --max-width: 1800px; --note-width: 320px; --note-expanded-width: 680px; - --todo-box-width: 340px; + --todo-box-width: 420px; /* Z-index */ --z-header: 100; diff --git a/public/js/complete-app.js b/public/js/complete-app.js index adde63e..f6f73a5 100644 --- a/public/js/complete-app.js +++ b/public/js/complete-app.js @@ -1569,18 +1569,37 @@ function renderRssSummaries(summaries) { 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)}` : ''} + // Nouvelle structure : afficher les résumés PAR ARTICLE avec titre + résumé + lien + container.innerHTML = summaries.map(summary => { + // Le résumé peut être soit l'ancien format (global) soit le nouveau (par article depuis DB) + // Si c'est un résumé DB, il contient déjà le titre et le lien formatés + if (summary.summary && summary.summary.includes('🔗')) { + // Ancien format sauvegardé en DB avec titre et lien + return ` +
+
${formatSummary(summary.summary)}
+
+ ${summary.created_at ? `${new Date(summary.created_at).toLocaleDateString('fr-FR', { hour: '2-digit', minute: '2-digit' })}` : ''} + ${summary.model ? `${escapeHtml(summary.model.split('/')[1] || summary.model)}` : ''} +
-
-
${formatSummary(summary.summary)}
-
- `).join(''); + `; + } else { + // Ancien format (résumé global) + return ` +
+
+
📰 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) { @@ -1598,12 +1617,18 @@ function formatSummary(summary) { // Listes numérotées html = html.replace(/\n\d+\. (.*?)(?=\n|$)/g, '
  • $1
  • '); - // Gras + // Gras (avant les liens pour éviter les conflits) html = html.replace(/\*\*(.*?)\*\*/g, '$1'); // Italique html = html.replace(/\*(.*?)\*/g, '$1'); + // Liens markdown [texte](url) - convertir en liens cliquables + html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); + + // Emoji lien avec texte - gérer 🔗 suivi d'espace + html = html.replace(/🔗\s*/g, '🔗 '); + // Sauts de ligne html = html.replace(/\n\n/g, '

    '); html = html.replace(/\n/g, '
    '); @@ -1628,21 +1653,29 @@ async function summarizeRss() { if (summaryEnabled) { // Si les résumés sont activés, juste recharger le bloc RSS - alert('Résumé généré avec succès !'); + alert(`${result.articles_count} résumés générés 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 - }); + // Sinon créer une note avec TOUS les résumés par article + if (result.summaries && result.summaries.length > 0) { + const allSummaries = result.summaries.map(s => + `**${s.title}**\n_${s.feed_title} - ${new Date(s.pub_date).toLocaleDateString('fr-FR')}_\n\n${s.summary}\n\n🔗 [Lire l'article](${s.link})` + ).join('\n\n---\n\n'); - alert('Résumé généré et sauvegardé dans une nouvelle note !'); - await loadNotes(); + const newNote = await api.post('/api/notes', { + title: `📰 Résumés RSS - ${new Date().toLocaleDateString('fr-FR')}`, + content: allSummaries + }); - // Expand the new note - state.expandedNoteId = newNote.id; - renderNotes(); + alert(`${result.articles_count} résumés générés et sauvegardés dans une nouvelle note !`); + await loadNotes(); + + // Expand the new note + state.expandedNoteId = newNote.id; + renderNotes(); + } else { + alert('Aucun résumé généré'); + } } } catch (error) { console.error('Erreur génération résumé RSS:', error); diff --git a/routes/rss.routes.js b/routes/rss.routes.js index 2eb7baf..aa91ce5 100644 --- a/routes/rss.routes.js +++ b/routes/rss.routes.js @@ -173,14 +173,13 @@ router.get('/articles', async (req, res) => { /** * POST /api/rss/summarize - * Générer un résumé des 5 derniers articles avec OpenRouter + * Générer un résumé PAR ARTICLE (100 mots max) avec lien */ -router.post('/summarize', requireAdmin, async (req, res) => { +router.post('/summarize', async (req, res) => { try { // Récupérer les paramètres const apiKey = await getOne('SELECT value FROM settings WHERE key = ?', ['openrouter_api_key']); const model = await getOne('SELECT value FROM settings WHERE key = ?', ['openrouter_model']); - const prompt = await getOne('SELECT value FROM settings WHERE key = ?', ['rss_summary_prompt']); if (!apiKey || !apiKey.value) { return res.status(400).json({ error: 'Clé API OpenRouter non configurée' }); @@ -189,10 +188,11 @@ router.post('/summarize', requireAdmin, async (req, res) => { // Récupérer les 5 derniers articles const articles = await getAll(` SELECT - a.title, a.description, a.link, a.pub_date, - f.title as feed_title + a.id, a.title, a.description, a.link, a.pub_date, a.content, + COALESCE(f.title, f.url) as feed_title FROM rss_articles a - JOIN rss_feeds f ON a.feed_id = f.id + LEFT JOIN rss_feeds f ON a.feed_id = f.id + WHERE a.pub_date IS NOT NULL ORDER BY a.pub_date DESC LIMIT 5 `); @@ -201,72 +201,85 @@ router.post('/summarize', requireAdmin, async (req, res) => { return res.status(400).json({ error: 'Aucun article à résumer' }); } - // Construire le prompt - const defaultPrompt = prompt?.value || `Tu es un assistant spécialisé dans la synthèse d'actualités. Analyse les articles suivants et crée un résumé structuré et informatif. - -Pour chaque article, identifie: -- Le sujet principal -- Les points clés -- L'importance de l'information - -Ensuite, génère un résumé global qui: -1. Regroupe les thèmes communs -2. Hiérarchise les informations par importance -3. Présente une vue d'ensemble claire et concise -4. Utilise un style professionnel mais accessible - -Format de sortie en Markdown avec des sections claires.`; - - const articlesText = articles.map((a, i) => - `## Article ${i + 1}: ${a.title}\n**Source**: ${a.feed_title}\n**Date**: ${new Date(a.pub_date).toLocaleDateString('fr-FR')}\n**Description**: ${a.description}\n**Lien**: ${a.link}\n` - ).join('\n---\n'); - - const fullPrompt = `${defaultPrompt}\n\n# Articles à résumer:\n\n${articlesText}`; - - // Appeler OpenRouter + // Générer un résumé pour CHAQUE article (en parallèle) const selectedModel = model?.value || 'openai/gpt-3.5-turbo'; + logger.info(`Génération de ${articles.length} résumés avec ${selectedModel}...`); - logger.info(`Génération du résumé avec le modèle ${selectedModel}...`); + const summaryPromises = articles.map(async (article) => { + const prompt = `Résume cet article en maximum 100 mots. Sois concis et informatif. - const response = await axios.post( - 'https://openrouter.ai/api/v1/chat/completions', - { - model: selectedModel, - messages: [ +Titre: ${article.title} +Source: ${article.feed_title} +Description: ${article.description || ''} +${article.content ? `Contenu: ${article.content.substring(0, 1000)}` : ''} + +Résumé (100 mots max):`; + + try { + const response = await axios.post( + 'https://openrouter.ai/api/v1/chat/completions', { - role: 'user', - content: fullPrompt + model: selectedModel, + messages: [ + { + role: 'user', + content: prompt + } + ], + max_tokens: 200 + }, + { + headers: { + 'Authorization': `Bearer ${apiKey.value}`, + 'HTTP-Referer': 'https://noteflow.app', + 'X-Title': 'NoteFlow RSS Summarizer', + 'Content-Type': 'application/json' + }, + timeout: 30000 } - ] - }, - { - headers: { - 'Authorization': `Bearer ${apiKey.value}`, - 'HTTP-Referer': 'https://noteflow.app', - 'X-Title': 'NoteFlow RSS Summarizer', - 'Content-Type': 'application/json' - }, - timeout: 60000 + ); + + const summary = response.data.choices[0].message.content; + + // Sauvegarder le résumé individuel + await runQuery( + 'INSERT INTO rss_summaries (summary, model, articles_count, created_at) VALUES (?, ?, ?, datetime("now"))', + [`**${article.title}**\n\n${summary}\n\n🔗 [Lire l'article](${article.link})`, selectedModel, 1] + ); + + return { + article_id: article.id, + title: article.title, + link: article.link, + feed_title: article.feed_title, + summary: summary, + pub_date: article.pub_date + }; + } catch (err) { + logger.error(`Erreur résumé article "${article.title}":`, err.message); + return { + article_id: article.id, + title: article.title, + link: article.link, + feed_title: article.feed_title, + summary: article.description || 'Résumé non disponible', + pub_date: article.pub_date, + error: true + }; } - ); + }); - const summary = response.data.choices[0].message.content; + const summaries = await Promise.all(summaryPromises); - // 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'); + logger.info(`${summaries.length} résumés générés avec succès`); res.json({ - summary, + summaries, model: selectedModel, articles_count: articles.length }); } catch (error) { - logger.error('Erreur lors de la génération du résumé:', error); + logger.error('Erreur lors de la génération des résumés:', error); if (error.response) { logger.error('Réponse OpenRouter:', error.response.data);