Merge pull request #17 from R0m1k3/claude/dockerized-notes-todo-app-011CV1roGRbnm21wgtB2LMxP

UX: Résumés RSS par article + Optimisation 1920x1080
This commit is contained in:
LogiFlow authored and GitHub committed 2025-11-11 19:40:09 +01:00
commit 891c605865
4 files changed
+173 -84

No files matched your search

+43
View File
@@ -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 {
+3 -3
View File
@@ -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;
+56 -23
View File
@@ -1569,18 +1569,37 @@ function renderRssSummaries(summaries) {
return;
}
container.innerHTML = summaries.map(summary => `
<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>` : ''}
// 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 `
<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 class="rss-summary-content">${formatSummary(summary.summary)}</div>
</div>
`).join('');
`;
} else {
// Ancien format (résumé global)
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) {
@@ -1598,12 +1617,18 @@ function formatSummary(summary) {
// Listes numérotées
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>');
// Italique
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
html = html.replace(/\n\n/g, '<br><br>');
html = html.replace(/\n/g, '<br>');
@@ -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);
+71 -58
View File
@@ -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);