mirror of
https://github.com/R0m1k3/FlowReader.git
synced 2026-10-21 14:18:06 +02:00
feat(ia): implement Smart Digest and fix UI alignment
This commit is contained in:
1 parent
7e802df2a7
commit
5b219cf0e7
10 files changed
+281
-21
No files matched your search
+3
-1
@@ -46,6 +46,7 @@ func main() {
|
|||||||
// Initialize services
|
// Initialize services
|
||||||
authService := service.NewAuthService(userRepo, sessionRepo)
|
authService := service.NewAuthService(userRepo, sessionRepo)
|
||||||
feedService := service.NewFeedService(feedRepo)
|
feedService := service.NewFeedService(feedRepo)
|
||||||
|
aiService := service.NewAIService()
|
||||||
|
|
||||||
// Initialize WS Hub
|
// Initialize WS Hub
|
||||||
hub := ws.NewHub()
|
hub := ws.NewHub()
|
||||||
@@ -56,7 +57,7 @@ func main() {
|
|||||||
// Initialize handlers
|
// Initialize handlers
|
||||||
authHandler := handler.NewAuthHandler(authService)
|
authHandler := handler.NewAuthHandler(authService)
|
||||||
feedHandler := handler.NewFeedHandler(feedService, fetchService, authService)
|
feedHandler := handler.NewFeedHandler(feedService, fetchService, authService)
|
||||||
articleHandler := handler.NewArticleHandler(articleRepo, feedService, authService, hub)
|
articleHandler := handler.NewArticleHandler(articleRepo, feedService, authService, aiService, hub)
|
||||||
wsHandler := handler.NewWSHandler(hub, authService)
|
wsHandler := handler.NewWSHandler(hub, authService)
|
||||||
adminHandler := handler.NewAdminHandler(userRepo, authService)
|
adminHandler := handler.NewAdminHandler(userRepo, authService)
|
||||||
|
|
||||||
@@ -129,6 +130,7 @@ func main() {
|
|||||||
r.Post("/{id}/read", articleHandler.MarkRead)
|
r.Post("/{id}/read", articleHandler.MarkRead)
|
||||||
r.Delete("/{id}/read", articleHandler.MarkUnread)
|
r.Delete("/{id}/read", articleHandler.MarkUnread)
|
||||||
r.Post("/{id}/favorite", articleHandler.ToggleFavorite)
|
r.Post("/{id}/favorite", articleHandler.ToggleFavorite)
|
||||||
|
r.Post("/{id}/summarize", articleHandler.Summarize)
|
||||||
})
|
})
|
||||||
|
|
||||||
// WebSocket route
|
// WebSocket route
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type Article struct {
|
|||||||
URL string `json:"url,omitempty"`
|
URL string `json:"url,omitempty"`
|
||||||
Content string `json:"content,omitempty"`
|
Content string `json:"content,omitempty"`
|
||||||
Summary string `json:"summary,omitempty"`
|
Summary string `json:"summary,omitempty"`
|
||||||
|
AISummary string `json:"ai_summary,omitempty"`
|
||||||
Author string `json:"author,omitempty"`
|
Author string `json:"author,omitempty"`
|
||||||
ImageURL string `json:"image_url,omitempty"`
|
ImageURL string `json:"image_url,omitempty"`
|
||||||
PublishedAt *time.Time `json:"published_at,omitempty"`
|
PublishedAt *time.Time `json:"published_at,omitempty"`
|
||||||
@@ -43,4 +44,5 @@ type ArticleRepository interface {
|
|||||||
GetFavorites(userID uuid.UUID, limit, offset int) ([]*Article, error)
|
GetFavorites(userID uuid.UUID, limit, offset int) ([]*Article, error)
|
||||||
CountUnread(feedID uuid.UUID) (int, error)
|
CountUnread(feedID uuid.UUID) (int, error)
|
||||||
Search(userID uuid.UUID, query string, limit, offset int) ([]*Article, error)
|
Search(userID uuid.UUID, query string, limit, offset int) ([]*Article, error)
|
||||||
|
UpdateAISummary(id uuid.UUID, summary string) error
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
@@ -17,16 +18,18 @@ type ArticleHandler struct {
|
|||||||
articleRepo domain.ArticleRepository
|
articleRepo domain.ArticleRepository
|
||||||
feedService *service.FeedService
|
feedService *service.FeedService
|
||||||
authService *service.AuthService
|
authService *service.AuthService
|
||||||
|
aiService *service.AIService
|
||||||
sanitizer *utils.ContentSanitizer
|
sanitizer *utils.ContentSanitizer
|
||||||
hub *ws.Hub
|
hub *ws.Hub
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewArticleHandler creates a new article handler.
|
// NewArticleHandler creates a new article handler.
|
||||||
func NewArticleHandler(articleRepo domain.ArticleRepository, feedService *service.FeedService, authService *service.AuthService, hub *ws.Hub) *ArticleHandler {
|
func NewArticleHandler(articleRepo domain.ArticleRepository, feedService *service.FeedService, authService *service.AuthService, aiService *service.AIService, hub *ws.Hub) *ArticleHandler {
|
||||||
return &ArticleHandler{
|
return &ArticleHandler{
|
||||||
articleRepo: articleRepo,
|
articleRepo: articleRepo,
|
||||||
feedService: feedService,
|
feedService: feedService,
|
||||||
authService: authService,
|
authService: authService,
|
||||||
|
aiService: aiService,
|
||||||
sanitizer: utils.NewContentSanitizer(),
|
sanitizer: utils.NewContentSanitizer(),
|
||||||
hub: hub,
|
hub: hub,
|
||||||
}
|
}
|
||||||
@@ -388,3 +391,69 @@ func (h *ArticleHandler) Search(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
respondJSON(w, http.StatusOK, articles)
|
respondJSON(w, http.StatusOK, articles)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Summarize handles POST /api/v1/articles/{id}/summarize
|
||||||
|
func (h *ArticleHandler) Summarize(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, err := h.getUserFromRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusUnauthorized, "Not authenticated")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
articleID, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "Invalid article ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
article, err := h.articleRepo.GetByID(articleID)
|
||||||
|
if err != nil || article == nil {
|
||||||
|
respondError(w, http.StatusNotFound, "Article not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify feed ownership
|
||||||
|
_, err = h.feedService.GetFeed(article.FeedID, userID)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusForbidden, "Access denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If already summarized, return it
|
||||||
|
if article.AISummary != "" {
|
||||||
|
respondJSON(w, http.StatusOK, map[string]string{"summary": article.AISummary})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context for AI is title + content (or summary if content empty)
|
||||||
|
content := article.Content
|
||||||
|
if content == "" {
|
||||||
|
content = article.Summary
|
||||||
|
}
|
||||||
|
|
||||||
|
aiInput := fmt.Sprintf("Titre: %s\n\nContenu: %s", article.Title, content)
|
||||||
|
|
||||||
|
// Summary generation (can be slow, but for this demo/small app we do it synchronously
|
||||||
|
// or we could use WS to notify when done. Here we follow the simple POST -> String pattern).
|
||||||
|
summary, err := h.aiService.Summarize(r.Context(), aiInput)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "Failed to generate summary: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to DB
|
||||||
|
if err := h.articleRepo.UpdateAISummary(articleID, summary); err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "Failed to persist summary")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast update via WebSocket
|
||||||
|
if h.hub != nil {
|
||||||
|
h.hub.Broadcast("article_updated", map[string]interface{}{
|
||||||
|
"id": articleID,
|
||||||
|
"ai_summary": summary,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(w, http.StatusOK, map[string]string{"summary": summary})
|
||||||
|
}
|
||||||
@@ -27,8 +27,8 @@ func (r *ArticleRepository) Create(article *domain.Article) error {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
INSERT INTO articles (id, feed_id, guid, title, url, content, summary, author, image_url, published_at, created_at)
|
INSERT INTO articles (id, feed_id, guid, title, url, content, summary, ai_summary, author, image_url, published_at, created_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||||
ON CONFLICT (feed_id, guid) DO NOTHING
|
ON CONFLICT (feed_id, guid) DO NOTHING
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -40,6 +40,7 @@ func (r *ArticleRepository) Create(article *domain.Article) error {
|
|||||||
nullString(article.URL),
|
nullString(article.URL),
|
||||||
nullString(article.Content),
|
nullString(article.Content),
|
||||||
nullString(article.Summary),
|
nullString(article.Summary),
|
||||||
|
nullString(article.AISummary),
|
||||||
nullString(article.Author),
|
nullString(article.Author),
|
||||||
nullString(article.ImageURL),
|
nullString(article.ImageURL),
|
||||||
article.PublishedAt,
|
article.PublishedAt,
|
||||||
@@ -59,8 +60,8 @@ func (r *ArticleRepository) CreateBatch(articles []*domain.Article) error {
|
|||||||
|
|
||||||
batch := &pgx.Batch{}
|
batch := &pgx.Batch{}
|
||||||
query := `
|
query := `
|
||||||
INSERT INTO articles (id, feed_id, guid, title, url, content, summary, author, image_url, published_at, created_at)
|
INSERT INTO articles (id, feed_id, guid, title, url, content, summary, ai_summary, author, image_url, published_at, created_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||||
ON CONFLICT (feed_id, guid) DO NOTHING
|
ON CONFLICT (feed_id, guid) DO NOTHING
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -73,6 +74,7 @@ func (r *ArticleRepository) CreateBatch(articles []*domain.Article) error {
|
|||||||
nullString(article.URL),
|
nullString(article.URL),
|
||||||
nullString(article.Content),
|
nullString(article.Content),
|
||||||
nullString(article.Summary),
|
nullString(article.Summary),
|
||||||
|
nullString(article.AISummary),
|
||||||
nullString(article.Author),
|
nullString(article.Author),
|
||||||
nullString(article.ImageURL),
|
nullString(article.ImageURL),
|
||||||
article.PublishedAt,
|
article.PublishedAt,
|
||||||
@@ -97,7 +99,7 @@ func (r *ArticleRepository) GetByID(id uuid.UUID) (*domain.Article, error) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.author,
|
SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.ai_summary, a.author,
|
||||||
a.image_url, a.published_at, a.is_read, a.is_favorite, a.read_at, a.created_at,
|
a.image_url, a.published_at, a.is_read, a.is_favorite, a.read_at, a.created_at,
|
||||||
f.title as feed_title
|
f.title as feed_title
|
||||||
FROM articles a
|
FROM articles a
|
||||||
@@ -121,7 +123,7 @@ func (r *ArticleRepository) GetByFeedID(feedID uuid.UUID, limit, offset int) ([]
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.author,
|
SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.ai_summary, a.author,
|
||||||
a.image_url, a.published_at, a.is_read, a.is_favorite, a.read_at, a.created_at,
|
a.image_url, a.published_at, a.is_read, a.is_favorite, a.read_at, a.created_at,
|
||||||
f.title as feed_title
|
f.title as feed_title
|
||||||
FROM articles a
|
FROM articles a
|
||||||
@@ -147,7 +149,7 @@ func (r *ArticleRepository) GetByUserID(userID uuid.UUID, limit, offset int, unr
|
|||||||
var query string
|
var query string
|
||||||
if unreadOnly {
|
if unreadOnly {
|
||||||
query = `
|
query = `
|
||||||
SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.author,
|
SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.ai_summary, a.author,
|
||||||
a.image_url, a.published_at, a.is_read, a.is_favorite, a.read_at, a.created_at,
|
a.image_url, a.published_at, a.is_read, a.is_favorite, a.read_at, a.created_at,
|
||||||
f.title as feed_title
|
f.title as feed_title
|
||||||
FROM articles a
|
FROM articles a
|
||||||
@@ -158,7 +160,7 @@ func (r *ArticleRepository) GetByUserID(userID uuid.UUID, limit, offset int, unr
|
|||||||
`
|
`
|
||||||
} else {
|
} else {
|
||||||
query = `
|
query = `
|
||||||
SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.author,
|
SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.ai_summary, a.author,
|
||||||
a.image_url, a.published_at, a.is_read, a.is_favorite, a.read_at, a.created_at,
|
a.image_url, a.published_at, a.is_read, a.is_favorite, a.read_at, a.created_at,
|
||||||
f.title as feed_title
|
f.title as feed_title
|
||||||
FROM articles a
|
FROM articles a
|
||||||
@@ -183,7 +185,7 @@ func (r *ArticleRepository) GetByGUID(feedID uuid.UUID, guid string) (*domain.Ar
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.author,
|
SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.ai_summary, a.author,
|
||||||
a.image_url, a.published_at, a.is_read, a.is_favorite, a.read_at, a.created_at,
|
a.image_url, a.published_at, a.is_read, a.is_favorite, a.read_at, a.created_at,
|
||||||
f.title as feed_title
|
f.title as feed_title
|
||||||
FROM articles a
|
FROM articles a
|
||||||
@@ -269,7 +271,7 @@ func (r *ArticleRepository) GetFavorites(userID uuid.UUID, limit, offset int) ([
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.author,
|
SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.ai_summary, a.author,
|
||||||
a.image_url, a.published_at, a.is_read, a.is_favorite, a.read_at, a.created_at,
|
a.image_url, a.published_at, a.is_read, a.is_favorite, a.read_at, a.created_at,
|
||||||
f.title as feed_title
|
f.title as feed_title
|
||||||
FROM articles a
|
FROM articles a
|
||||||
@@ -305,7 +307,7 @@ func (r *ArticleRepository) CountUnread(feedID uuid.UUID) (int, error) {
|
|||||||
// scanArticle scans a single article row.
|
// scanArticle scans a single article row.
|
||||||
func (r *ArticleRepository) scanArticle(row pgx.Row) (*domain.Article, error) {
|
func (r *ArticleRepository) scanArticle(row pgx.Row) (*domain.Article, error) {
|
||||||
var article domain.Article
|
var article domain.Article
|
||||||
var url, content, summary, author, imageURL, feedTitle *string
|
var url, content, summary, aiSummary, author, imageURL, feedTitle *string
|
||||||
var publishedAt, readAt *time.Time
|
var publishedAt, readAt *time.Time
|
||||||
|
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
@@ -316,6 +318,7 @@ func (r *ArticleRepository) scanArticle(row pgx.Row) (*domain.Article, error) {
|
|||||||
&url,
|
&url,
|
||||||
&content,
|
&content,
|
||||||
&summary,
|
&summary,
|
||||||
|
&aiSummary,
|
||||||
&author,
|
&author,
|
||||||
&imageURL,
|
&imageURL,
|
||||||
&publishedAt,
|
&publishedAt,
|
||||||
@@ -339,6 +342,9 @@ func (r *ArticleRepository) scanArticle(row pgx.Row) (*domain.Article, error) {
|
|||||||
if summary != nil {
|
if summary != nil {
|
||||||
article.Summary = *summary
|
article.Summary = *summary
|
||||||
}
|
}
|
||||||
|
if aiSummary != nil {
|
||||||
|
article.AISummary = *aiSummary
|
||||||
|
}
|
||||||
if author != nil {
|
if author != nil {
|
||||||
article.Author = *author
|
article.Author = *author
|
||||||
}
|
}
|
||||||
@@ -359,7 +365,7 @@ func (r *ArticleRepository) scanArticles(rows pgx.Rows) ([]*domain.Article, erro
|
|||||||
var articles []*domain.Article
|
var articles []*domain.Article
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var article domain.Article
|
var article domain.Article
|
||||||
var url, content, summary, author, imageURL, feedTitle *string
|
var url, content, summary, aiSummary, author, imageURL, feedTitle *string
|
||||||
var publishedAt, readAt *time.Time
|
var publishedAt, readAt *time.Time
|
||||||
|
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
@@ -370,6 +376,7 @@ func (r *ArticleRepository) scanArticles(rows pgx.Rows) ([]*domain.Article, erro
|
|||||||
&url,
|
&url,
|
||||||
&content,
|
&content,
|
||||||
&summary,
|
&summary,
|
||||||
|
&aiSummary,
|
||||||
&author,
|
&author,
|
||||||
&imageURL,
|
&imageURL,
|
||||||
&publishedAt,
|
&publishedAt,
|
||||||
@@ -393,6 +400,9 @@ func (r *ArticleRepository) scanArticles(rows pgx.Rows) ([]*domain.Article, erro
|
|||||||
if summary != nil {
|
if summary != nil {
|
||||||
article.Summary = *summary
|
article.Summary = *summary
|
||||||
}
|
}
|
||||||
|
if aiSummary != nil {
|
||||||
|
article.AISummary = *aiSummary
|
||||||
|
}
|
||||||
if author != nil {
|
if author != nil {
|
||||||
article.Author = *author
|
article.Author = *author
|
||||||
}
|
}
|
||||||
@@ -417,7 +427,7 @@ func (r *ArticleRepository) Search(userID uuid.UUID, query string, limit, offset
|
|||||||
|
|
||||||
// Use plainto_tsquery or websearch_to_tsquery for natural language search
|
// Use plainto_tsquery or websearch_to_tsquery for natural language search
|
||||||
sql := `
|
sql := `
|
||||||
SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.author,
|
SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.ai_summary, a.author,
|
||||||
a.image_url, a.published_at, a.is_read, a.is_favorite, a.read_at, a.created_at,
|
a.image_url, a.published_at, a.is_read, a.is_favorite, a.read_at, a.created_at,
|
||||||
f.title as feed_title,
|
f.title as feed_title,
|
||||||
ts_rank_cd(a.tsv, websearch_to_tsquery('french', $2)) as rank
|
ts_rank_cd(a.tsv, websearch_to_tsquery('french', $2)) as rank
|
||||||
@@ -442,7 +452,7 @@ func (r *ArticleRepository) scanArticlesWithRank(rows pgx.Rows) ([]*domain.Artic
|
|||||||
var articles []*domain.Article
|
var articles []*domain.Article
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var article domain.Article
|
var article domain.Article
|
||||||
var url, content, summary, author, imageURL, feedTitle *string
|
var url, content, summary, aiSummary, author, imageURL, feedTitle *string
|
||||||
var publishedAt, readAt *time.Time
|
var publishedAt, readAt *time.Time
|
||||||
var rank float32
|
var rank float32
|
||||||
|
|
||||||
@@ -454,6 +464,7 @@ func (r *ArticleRepository) scanArticlesWithRank(rows pgx.Rows) ([]*domain.Artic
|
|||||||
&url,
|
&url,
|
||||||
&content,
|
&content,
|
||||||
&summary,
|
&summary,
|
||||||
|
&aiSummary,
|
||||||
&author,
|
&author,
|
||||||
&imageURL,
|
&imageURL,
|
||||||
&publishedAt,
|
&publishedAt,
|
||||||
@@ -478,6 +489,9 @@ func (r *ArticleRepository) scanArticlesWithRank(rows pgx.Rows) ([]*domain.Artic
|
|||||||
if summary != nil {
|
if summary != nil {
|
||||||
article.Summary = *summary
|
article.Summary = *summary
|
||||||
}
|
}
|
||||||
|
if aiSummary != nil {
|
||||||
|
article.AISummary = *aiSummary
|
||||||
|
}
|
||||||
if author != nil {
|
if author != nil {
|
||||||
article.Author = *author
|
article.Author = *author
|
||||||
}
|
}
|
||||||
@@ -496,6 +510,17 @@ func (r *ArticleRepository) scanArticlesWithRank(rows pgx.Rows) ([]*domain.Artic
|
|||||||
return articles, nil
|
return articles, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateAISummary updates the AI-generated summary of an article.
|
||||||
|
func (r *ArticleRepository) UpdateAISummary(id uuid.UUID, summary string) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
query := `UPDATE articles SET ai_summary = $2 WHERE id = $1`
|
||||||
|
_, err := r.pool.Exec(ctx, query, id, summary)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("updating AI summary: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// nullString returns nil if string is empty.
|
// nullString returns nil if string is empty.
|
||||||
func nullString(s string) *string {
|
func nullString(s string) *string {
|
||||||
if s == "" {
|
if s == "" {
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AIService handles interactions with AI providers (OpenRouter).
|
||||||
|
type AIService struct {
|
||||||
|
apiKey string
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAIService creates a new AI service.
|
||||||
|
func NewAIService() *AIService {
|
||||||
|
return &AIService{
|
||||||
|
apiKey: os.Getenv("OPENROUTER_API_KEY"),
|
||||||
|
client: &http.Client{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenRouterRequest represents the request body for OpenRouter.
|
||||||
|
type OpenRouterRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Messages []Message `json:"messages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message represents a message in the conversation.
|
||||||
|
type Message struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenRouterResponse represents the response body from OpenRouter.
|
||||||
|
type OpenRouterResponse struct {
|
||||||
|
Choices []struct {
|
||||||
|
Message Message `json:"message"`
|
||||||
|
} `json:"choices"`
|
||||||
|
Error *struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
} `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summarize generates a concise summary of the given content.
|
||||||
|
func (s *AIService) Summarize(ctx context.Context, content string) (string, error) {
|
||||||
|
if s.apiKey == "" {
|
||||||
|
return "", fmt.Errorf("OPENROUTER_API_KEY not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt := fmt.Sprintf("Résume l'article suivant en 3 à 5 phrases percutantes. Sois direct et informatif :\n\n%s", content)
|
||||||
|
|
||||||
|
reqBody := OpenRouterRequest{
|
||||||
|
Model: "google/gemini-2.0-flash-001", // Économique et performant
|
||||||
|
Messages: []Message{
|
||||||
|
{Role: "user", Content: prompt},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonData, err := json.Marshal(reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("marshaling request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", "https://openrouter.ai/api/v1/chat/completions", bytes.NewBuffer(jsonData))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("creating request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+s.apiKey)
|
||||||
|
req.Header.Set("HTTP-Referer", "https://github.com/michael/flowreader") // Optionnel pour OpenRouter
|
||||||
|
|
||||||
|
resp, err := s.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("sending request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("reading response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var orResp OpenRouterResponse
|
||||||
|
if err := json.Unmarshal(body, &orResp); err != nil {
|
||||||
|
return "", fmt.Errorf("unmarshaling response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if orResp.Error != nil {
|
||||||
|
return "", fmt.Errorf("OpenRouter error: %s", orResp.Error.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(orResp.Choices) == 0 {
|
||||||
|
return "", fmt.Errorf("no summary generated")
|
||||||
|
}
|
||||||
|
|
||||||
|
return orResp.Choices[0].Message.Content, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Migration: 006_add_ai_summary
|
||||||
|
-- Description: Add ai_summary column to articles table
|
||||||
|
|
||||||
|
ALTER TABLE articles ADD COLUMN IF NOT EXISTS ai_summary TEXT;
|
||||||
@@ -17,6 +17,7 @@ export interface Article {
|
|||||||
url?: string;
|
url?: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
summary?: string;
|
summary?: string;
|
||||||
|
ai_summary?: string;
|
||||||
author?: string;
|
author?: string;
|
||||||
image_url?: string;
|
image_url?: string;
|
||||||
published_at?: string;
|
published_at?: string;
|
||||||
@@ -118,4 +119,12 @@ export const articlesApi = {
|
|||||||
});
|
});
|
||||||
return handleResponse<Article[]>(response);
|
return handleResponse<Article[]>(response);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async summarize(id: string): Promise<{ summary: string }> {
|
||||||
|
const response = await fetch(`${API_BASE}/articles/${id}/summarize`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
return handleResponse(response);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useSwipeable } from 'react-swipeable';
|
import { useSwipeable } from 'react-swipeable';
|
||||||
import type { Article } from '../api/articles';
|
import { type Article, articlesApi } from '../api/articles';
|
||||||
|
|
||||||
interface ArticleCardProps {
|
interface ArticleCardProps {
|
||||||
article: Article;
|
article: Article;
|
||||||
@@ -12,6 +12,7 @@ interface ArticleCardProps {
|
|||||||
export function ArticleCard({ article, onClick, onToggleRead, onToggleFavorite }: ArticleCardProps) {
|
export function ArticleCard({ article, onClick, onToggleRead, onToggleFavorite }: ArticleCardProps) {
|
||||||
const [swipeOffset, setSwipeOffset] = useState(0);
|
const [swipeOffset, setSwipeOffset] = useState(0);
|
||||||
const [isSwiping, setIsSwiping] = useState(false);
|
const [isSwiping, setIsSwiping] = useState(false);
|
||||||
|
const [isSummarizing, setIsSummarizing] = useState(false);
|
||||||
|
|
||||||
const handlers = useSwipeable({
|
const handlers = useSwipeable({
|
||||||
onSwiping: (event) => {
|
onSwiping: (event) => {
|
||||||
@@ -45,6 +46,22 @@ export function ArticleCard({ article, onClick, onToggleRead, onToggleFavorite }
|
|||||||
setIsSwiping(false);
|
setIsSwiping(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSummarize = async (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (article.ai_summary || isSummarizing) return;
|
||||||
|
|
||||||
|
setIsSummarizing(true);
|
||||||
|
try {
|
||||||
|
await articlesApi.summarize(article.id);
|
||||||
|
// The article will be updated via WebSocket broadcast
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to summarize:', error);
|
||||||
|
alert('Erreur lors de la génération du résumé. Vérifiez votre clé API OpenRouter.');
|
||||||
|
} finally {
|
||||||
|
setIsSummarizing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative overflow-hidden rounded-xl">
|
<div className="relative overflow-hidden rounded-xl">
|
||||||
{/* Action Layers behind the card */}
|
{/* Action Layers behind the card */}
|
||||||
@@ -132,7 +149,14 @@ export function ArticleCard({ article, onClick, onToggleRead, onToggleFavorite }
|
|||||||
{article.title}
|
{article.title}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{article.summary && (
|
{article.ai_summary ? (
|
||||||
|
<div className="bg-nature/10 border-l-2 border-nature p-3 rounded-r-lg animate-fade-in relative group/summary">
|
||||||
|
<span className="absolute -top-2 -left-1 text-lg">✨</span>
|
||||||
|
<p className="text-paper-white text-sm leading-relaxed font-reading italic">
|
||||||
|
{article.ai_summary}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : article.summary && (
|
||||||
<p className="text-paper-muted text-sm leading-relaxed line-clamp-3 font-reading opacity-80 decoration-nature/0 group-hover:opacity-100 transition-opacity">
|
<p className="text-paper-muted text-sm leading-relaxed line-clamp-3 font-reading opacity-80 decoration-nature/0 group-hover:opacity-100 transition-opacity">
|
||||||
{article.summary.replace(/<[^>]*>?/gm, '').substring(0, 160)}...
|
{article.summary.replace(/<[^>]*>?/gm, '').substring(0, 160)}...
|
||||||
</p>
|
</p>
|
||||||
@@ -164,6 +188,24 @@ export function ArticleCard({ article, onClick, onToggleRead, onToggleFavorite }
|
|||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.382-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.382-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSummarize}
|
||||||
|
disabled={isSummarizing || !!article.ai_summary}
|
||||||
|
className={`p-1.5 rounded-full transition-all duration-300 flex items-center justify-center ${article.ai_summary
|
||||||
|
? 'text-nature bg-nature/10 opacity-50 cursor-default'
|
||||||
|
: isSummarizing
|
||||||
|
? 'text-nature animate-pulse'
|
||||||
|
: 'text-paper-muted hover:text-nature hover:bg-nature/10'}`}
|
||||||
|
title={article.ai_summary ? "Résumé généré" : "Générer un résumé IA (Smart Digest)"}
|
||||||
|
>
|
||||||
|
{isSummarizing ? (
|
||||||
|
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm leading-none">✨</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className="text-[10px] text-paper-muted/60 font-medium tracking-wide">
|
<span className="text-[10px] text-paper-muted/60 font-medium tracking-wide">
|
||||||
|
|||||||
@@ -80,10 +80,10 @@ export function Sidebar({ onSelectFeed, selectedFeedId }: SidebarProps) {
|
|||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsAddModalOpen(true)}
|
onClick={() => setIsAddModalOpen(true)}
|
||||||
className="w-8 h-8 flex items-center justify-center rounded-full border border-nature/20 text-nature hover:bg-nature hover:text-white transition-all duration-300 shadow-sm"
|
className="w-8 h-8 flex items-center justify-center rounded-full border border-nature/20 text-nature hover:bg-nature/10 hover:text-nature-light active:scale-95 transition-all duration-300 shadow-sm"
|
||||||
title="Ajouter un flux"
|
title="Ajouter un flux"
|
||||||
>
|
>
|
||||||
<span className="text-xl leading-none">+</span>
|
<span className="text-xl leading-none" style={{ marginBottom: '1px' }}>+</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function ThemeToggle() {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
className="p-2 rounded-lg border border-nature/10 text-nature hover:bg-nature/5 transition-all duration-300"
|
className="w-8 h-8 flex items-center justify-center rounded-full border border-nature/10 text-nature hover:bg-nature/5 transition-all duration-300"
|
||||||
title={theme === 'light' ? 'Activer le mode sombre' : 'Activer le mode clair'}
|
title={theme === 'light' ? 'Activer le mode sombre' : 'Activer le mode clair'}
|
||||||
>
|
>
|
||||||
{theme === 'light' ? (
|
{theme === 'light' ? (
|
||||||
|
|||||||
Reference in new issue
Block a user