mirror of
https://github.com/R0m1k3/noteflow.git
synced 2026-10-24 07:38:17 +02:00
UX: Résumés RSS par article + Optimisation 1920x1080
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
This commit is contained in:
4 files changed
+173
-84
No files matched your search
@@ -551,6 +551,49 @@ body {
|
|||||||
font-style: italic;
|
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 */
|
/* Responsive */
|
||||||
@media (max-width: 1200px) {
|
@media (max-width: 1200px) {
|
||||||
.content-container {
|
.content-container {
|
||||||
|
|||||||
@@ -67,12 +67,12 @@
|
|||||||
--transition-base: 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
--transition-base: 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
--transition-slow: 0.35s 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;
|
--header-height: 70px;
|
||||||
--max-width: 1400px;
|
--max-width: 1800px;
|
||||||
--note-width: 320px;
|
--note-width: 320px;
|
||||||
--note-expanded-width: 680px;
|
--note-expanded-width: 680px;
|
||||||
--todo-box-width: 340px;
|
--todo-box-width: 420px;
|
||||||
|
|
||||||
/* Z-index */
|
/* Z-index */
|
||||||
--z-header: 100;
|
--z-header: 100;
|
||||||
|
|||||||
+56
-23
@@ -1569,18 +1569,37 @@ function renderRssSummaries(summaries) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = summaries.map(summary => `
|
// Nouvelle structure : afficher les résumés PAR ARTICLE avec titre + résumé + lien
|
||||||
<div class="rss-summary">
|
container.innerHTML = summaries.map(summary => {
|
||||||
<div class="rss-summary-header">
|
// Le résumé peut être soit l'ancien format (global) soit le nouveau (par article depuis DB)
|
||||||
<div class="rss-summary-title">📰 Résumé ${new Date(summary.created_at).toLocaleDateString('fr-FR')}</div>
|
// Si c'est un résumé DB, il contient déjà le titre et le lien formatés
|
||||||
<div class="rss-summary-meta">
|
if (summary.summary && summary.summary.includes('🔗')) {
|
||||||
<span>${summary.articles_count} articles</span>
|
// Ancien format sauvegardé en DB avec titre et lien
|
||||||
${summary.model ? `<span>${escapeHtml(summary.model.split('/')[1] || summary.model)}</span>` : ''}
|
return `
|
||||||
|
<div class="rss-summary rss-summary-article">
|
||||||
|
<div class="rss-summary-content">${formatSummary(summary.summary)}</div>
|
||||||
|
<div class="rss-summary-meta">
|
||||||
|
${summary.created_at ? `<span>${new Date(summary.created_at).toLocaleDateString('fr-FR', { hour: '2-digit', minute: '2-digit' })}</span>` : ''}
|
||||||
|
${summary.model ? `<span>${escapeHtml(summary.model.split('/')[1] || summary.model)}</span>` : ''}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
`;
|
||||||
<div class="rss-summary-content">${formatSummary(summary.summary)}</div>
|
} else {
|
||||||
</div>
|
// Ancien format (résumé global)
|
||||||
`).join('');
|
return `
|
||||||
|
<div class="rss-summary">
|
||||||
|
<div class="rss-summary-header">
|
||||||
|
<div class="rss-summary-title">📰 Résumé ${new Date(summary.created_at).toLocaleDateString('fr-FR')}</div>
|
||||||
|
<div class="rss-summary-meta">
|
||||||
|
<span>${summary.articles_count} articles</span>
|
||||||
|
${summary.model ? `<span>${escapeHtml(summary.model.split('/')[1] || summary.model)}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rss-summary-content">${formatSummary(summary.summary)}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSummary(summary) {
|
function formatSummary(summary) {
|
||||||
@@ -1598,12 +1617,18 @@ function formatSummary(summary) {
|
|||||||
// Listes numérotées
|
// Listes numérotées
|
||||||
html = html.replace(/\n\d+\. (.*?)(?=\n|$)/g, '<li>$1</li>');
|
html = html.replace(/\n\d+\. (.*?)(?=\n|$)/g, '<li>$1</li>');
|
||||||
|
|
||||||
// Gras
|
// Gras (avant les liens pour éviter les conflits)
|
||||||
html = html.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
|
html = html.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
|
||||||
|
|
||||||
// Italique
|
// Italique
|
||||||
html = html.replace(/\*(.*?)\*/g, '<em>$1</em>');
|
html = html.replace(/\*(.*?)\*/g, '<em>$1</em>');
|
||||||
|
|
||||||
|
// Liens markdown [texte](url) - convertir en liens cliquables
|
||||||
|
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" class="rss-link">$1</a>');
|
||||||
|
|
||||||
|
// Emoji lien avec texte - gérer 🔗 suivi d'espace
|
||||||
|
html = html.replace(/🔗\s*/g, '🔗 ');
|
||||||
|
|
||||||
// Sauts de ligne
|
// Sauts de ligne
|
||||||
html = html.replace(/\n\n/g, '<br><br>');
|
html = html.replace(/\n\n/g, '<br><br>');
|
||||||
html = html.replace(/\n/g, '<br>');
|
html = html.replace(/\n/g, '<br>');
|
||||||
@@ -1628,21 +1653,29 @@ async function summarizeRss() {
|
|||||||
|
|
||||||
if (summaryEnabled) {
|
if (summaryEnabled) {
|
||||||
// Si les résumés sont activés, juste recharger le bloc RSS
|
// 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();
|
await loadRssArticles();
|
||||||
} else {
|
} else {
|
||||||
// Sinon créer une note avec le résumé
|
// Sinon créer une note avec TOUS les résumés par article
|
||||||
const newNote = await api.post('/api/notes', {
|
if (result.summaries && result.summaries.length > 0) {
|
||||||
title: `📰 Résumé RSS - ${new Date().toLocaleDateString('fr-FR')}`,
|
const allSummaries = result.summaries.map(s =>
|
||||||
content: result.summary
|
`**${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 !');
|
const newNote = await api.post('/api/notes', {
|
||||||
await loadNotes();
|
title: `📰 Résumés RSS - ${new Date().toLocaleDateString('fr-FR')}`,
|
||||||
|
content: allSummaries
|
||||||
|
});
|
||||||
|
|
||||||
// Expand the new note
|
alert(`${result.articles_count} résumés générés et sauvegardés dans une nouvelle note !`);
|
||||||
state.expandedNoteId = newNote.id;
|
await loadNotes();
|
||||||
renderNotes();
|
|
||||||
|
// Expand the new note
|
||||||
|
state.expandedNoteId = newNote.id;
|
||||||
|
renderNotes();
|
||||||
|
} else {
|
||||||
|
alert('Aucun résumé généré');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur génération résumé RSS:', error);
|
console.error('Erreur génération résumé RSS:', error);
|
||||||
|
|||||||
+71
-58
@@ -173,14 +173,13 @@ router.get('/articles', async (req, res) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/rss/summarize
|
* 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 {
|
try {
|
||||||
// Récupérer les paramètres
|
// Récupérer les paramètres
|
||||||
const apiKey = await getOne('SELECT value FROM settings WHERE key = ?', ['openrouter_api_key']);
|
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 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) {
|
if (!apiKey || !apiKey.value) {
|
||||||
return res.status(400).json({ error: 'Clé API OpenRouter non configurée' });
|
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
|
// Récupérer les 5 derniers articles
|
||||||
const articles = await getAll(`
|
const articles = await getAll(`
|
||||||
SELECT
|
SELECT
|
||||||
a.title, a.description, a.link, a.pub_date,
|
a.id, a.title, a.description, a.link, a.pub_date, a.content,
|
||||||
f.title as feed_title
|
COALESCE(f.title, f.url) as feed_title
|
||||||
FROM rss_articles a
|
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
|
ORDER BY a.pub_date DESC
|
||||||
LIMIT 5
|
LIMIT 5
|
||||||
`);
|
`);
|
||||||
@@ -201,72 +201,85 @@ router.post('/summarize', requireAdmin, async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'Aucun article à résumer' });
|
return res.status(400).json({ error: 'Aucun article à résumer' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Construire le prompt
|
// Générer un résumé pour CHAQUE article (en parallèle)
|
||||||
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
|
|
||||||
const selectedModel = model?.value || 'openai/gpt-3.5-turbo';
|
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(
|
Titre: ${article.title}
|
||||||
'https://openrouter.ai/api/v1/chat/completions',
|
Source: ${article.feed_title}
|
||||||
{
|
Description: ${article.description || ''}
|
||||||
model: selectedModel,
|
${article.content ? `Contenu: ${article.content.substring(0, 1000)}` : ''}
|
||||||
messages: [
|
|
||||||
|
Résumé (100 mots max):`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post(
|
||||||
|
'https://openrouter.ai/api/v1/chat/completions',
|
||||||
{
|
{
|
||||||
role: 'user',
|
model: selectedModel,
|
||||||
content: fullPrompt
|
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
|
||||||
}
|
}
|
||||||
]
|
);
|
||||||
},
|
|
||||||
{
|
const summary = response.data.choices[0].message.content;
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${apiKey.value}`,
|
// Sauvegarder le résumé individuel
|
||||||
'HTTP-Referer': 'https://noteflow.app',
|
await runQuery(
|
||||||
'X-Title': 'NoteFlow RSS Summarizer',
|
'INSERT INTO rss_summaries (summary, model, articles_count, created_at) VALUES (?, ?, ?, datetime("now"))',
|
||||||
'Content-Type': 'application/json'
|
[`**${article.title}**\n\n${summary}\n\n🔗 [Lire l'article](${article.link})`, selectedModel, 1]
|
||||||
},
|
);
|
||||||
timeout: 60000
|
|
||||||
|
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
|
logger.info(`${summaries.length} résumés générés avec succès`);
|
||||||
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({
|
res.json({
|
||||||
summary,
|
summaries,
|
||||||
model: selectedModel,
|
model: selectedModel,
|
||||||
articles_count: articles.length
|
articles_count: articles.length
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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) {
|
if (error.response) {
|
||||||
logger.error('Réponse OpenRouter:', error.response.data);
|
logger.error('Réponse OpenRouter:', error.response.data);
|
||||||
|
|||||||
Reference in new issue
Block a user