Add daily digest featureUnverified
0be7f42 parent: 4bd7c6a modified
internal/db/article.go +27 -0 | @@ -318,6 +318,33 @@ func (s *ArticleStore) MarkAllSubscribedRead(ctx context.Context, userDID string | ||
| 318 | 318 | return err |
| 319 | 319 | } |
| 320 | 320 | |
| 321 | +func (s *ArticleStore) MarkArticlesRead(ctx context.Context, userDID string, ids []int64) error { | |
| 322 | + if len(ids) == 0 { | |
| 323 | + return nil | |
| 324 | + } | |
| 325 | + tx, err := s.db.BeginTx(ctx, nil) | |
| 326 | + if err != nil { | |
| 327 | + return err | |
| 328 | + } | |
| 329 | + defer tx.Rollback() | |
| 330 | + stmt, err := tx.PrepareContext(ctx, ` | |
| 331 | + INSERT INTO articles.read_state (user_did, article_id, is_read, read_at) | |
| 332 | + VALUES (?, ?, 1, CURRENT_TIMESTAMP) | |
| 333 | + ON CONFLICT(user_did, article_id) DO UPDATE SET | |
| 334 | + is_read = 1, read_at = CURRENT_TIMESTAMP | |
| 335 | + `) | |
| 336 | + if err != nil { | |
| 337 | + return err | |
| 338 | + } | |
| 339 | + defer stmt.Close() | |
| 340 | + for _, id := range ids { | |
| 341 | + if _, err := stmt.ExecContext(ctx, userDID, id); err != nil { | |
| 342 | + return err | |
| 343 | + } | |
| 344 | + } | |
| 345 | + return tx.Commit() | |
| 346 | +} | |
| 347 | + | |
| 321 | 348 | func (s *ArticleStore) GetReadState(ctx context.Context, userDID string, articleID int64) (*ReadState, error) { |
| 322 | 349 | rs := &ReadState{} |
| 323 | 350 | err := s.db.QueryRowContext(ctx, ` |
| @@ -318,6 +318,33 @@ func (s *ArticleStore) MarkAllSubscribedRead(ctx context.Context, userDID string | |||
| 318 | return err | 318 | return err |
| 319 | } | 319 | } |
| 320 | 320 | ||
| 321 | +func (s *ArticleStore) MarkArticlesRead(ctx context.Context, userDID string, ids []int64) error { | ||
| 322 | + if len(ids) == 0 { | ||
| 323 | + return nil | ||
| 324 | + } | ||
| 325 | + tx, err := s.db.BeginTx(ctx, nil) | ||
| 326 | + if err != nil { | ||
| 327 | + return err | ||
| 328 | + } | ||
| 329 | + defer tx.Rollback() | ||
| 330 | + stmt, err := tx.PrepareContext(ctx, ` | ||
| 331 | + INSERT INTO articles.read_state (user_did, article_id, is_read, read_at) | ||
| 332 | + VALUES (?, ?, 1, CURRENT_TIMESTAMP) | ||
| 333 | + ON CONFLICT(user_did, article_id) DO UPDATE SET | ||
| 334 | + is_read = 1, read_at = CURRENT_TIMESTAMP | ||
| 335 | + `) | ||
| 336 | + if err != nil { | ||
| 337 | + return err | ||
| 338 | + } | ||
| 339 | + defer stmt.Close() | ||
| 340 | + for _, id := range ids { | ||
| 341 | + if _, err := stmt.ExecContext(ctx, userDID, id); err != nil { | ||
| 342 | + return err | ||
| 343 | + } | ||
| 344 | + } | ||
| 345 | + return tx.Commit() | ||
| 346 | +} | ||
| 347 | + | ||
| 321 | func (s *ArticleStore) GetReadState(ctx context.Context, userDID string, articleID int64) (*ReadState, error) { | 348 | func (s *ArticleStore) GetReadState(ctx context.Context, userDID string, articleID int64) (*ReadState, error) { |
| 322 | rs := &ReadState{} | 349 | rs := &ReadState{} |
| 323 | err := s.db.QueryRowContext(ctx, ` | 350 | err := s.db.QueryRowContext(ctx, ` |
modified
internal/db/db.go +1 -0 | @@ -257,6 +257,7 @@ var usersSchema = []string{ | ||
| 257 | 257 | did TEXT PRIMARY KEY, |
| 258 | 258 | languages TEXT, |
| 259 | 259 | expanded_view BOOLEAN NOT NULL DEFAULT 0, |
| 260 | + digest_enabled BOOLEAN NOT NULL DEFAULT 0, | |
| 260 | 261 | updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP |
| 261 | 262 | )`, |
| 262 | 263 | |
| @@ -257,6 +257,7 @@ var usersSchema = []string{ | |||
| 257 | did TEXT PRIMARY KEY, | 257 | did TEXT PRIMARY KEY, |
| 258 | languages TEXT, | 258 | languages TEXT, |
| 259 | expanded_view BOOLEAN NOT NULL DEFAULT 0, | 259 | expanded_view BOOLEAN NOT NULL DEFAULT 0, |
| 260 | + digest_enabled BOOLEAN NOT NULL DEFAULT 0, | ||
| 260 | updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | 261 | updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP |
| 261 | )`, | 262 | )`, |
| 262 | 263 | ||
modified
internal/db/migrations.go +20 -1 | @@ -13,7 +13,7 @@ func init() { | ||
| 13 | 13 | } |
| 14 | 14 | |
| 15 | 15 | // SchemaVersion must be incremented each time a migration is added to the migrations slice (used so that fresh dbs skip running migrations). |
| 16 | -const SchemaVersion = 6 | |
| 16 | +const SchemaVersion = 7 | |
| 17 | 17 | |
| 18 | 18 | type migration struct { |
| 19 | 19 | id int |
| @@ -52,6 +52,11 @@ var migrations = []migration{ | ||
| 52 | 52 | name: "drop_redundant_follows_user_index", |
| 53 | 53 | run: migrateDropFollowsUserIndex, |
| 54 | 54 | }, |
| 55 | + { | |
| 56 | + id: 7, | |
| 57 | + name: "user_settings_digest_enabled", | |
| 58 | + run: migrateUserSettingsDigestEnabled, | |
| 59 | + }, | |
| 55 | 60 | } |
| 56 | 61 | |
| 57 | 62 | func runMigrations(db *DB) error { |
| @@ -254,6 +259,20 @@ func migrateUserSettingsExpandedView(db *DB) error { | ||
| 254 | 259 | return nil |
| 255 | 260 | } |
| 256 | 261 | |
| 262 | +func migrateUserSettingsDigestEnabled(db *DB) error { | |
| 263 | + var colCount int | |
| 264 | + err := db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('user_settings') WHERE name='digest_enabled'").Scan(&colCount) | |
| 265 | + if err != nil { | |
| 266 | + return fmt.Errorf("check user_settings.digest_enabled column: %w", err) | |
| 267 | + } | |
| 268 | + if colCount == 0 { | |
| 269 | + if _, err := db.Exec("ALTER TABLE user_settings ADD COLUMN digest_enabled BOOLEAN NOT NULL DEFAULT 0"); err != nil { | |
| 270 | + return fmt.Errorf("add user_settings.digest_enabled: %w", err) | |
| 271 | + } | |
| 272 | + } | |
| 273 | + return nil | |
| 274 | +} | |
| 275 | + | |
| 257 | 276 | func migrateDropFollowsUserIndex(db *DB) error { |
| 258 | 277 | for _, idx := range []string{ |
| 259 | 278 | "idx_follows_user", |
| @@ -13,7 +13,7 @@ func init() { | |||
| 13 | } | 13 | } |
| 14 | 14 | ||
| 15 | // SchemaVersion must be incremented each time a migration is added to the migrations slice (used so that fresh dbs skip running migrations). | 15 | // SchemaVersion must be incremented each time a migration is added to the migrations slice (used so that fresh dbs skip running migrations). |
| 16 | -const SchemaVersion = 6 | 16 | +const SchemaVersion = 7 |
| 17 | 17 | ||
| 18 | type migration struct { | 18 | type migration struct { |
| 19 | id int | 19 | id int |
| @@ -52,6 +52,11 @@ var migrations = []migration{ | |||
| 52 | name: "drop_redundant_follows_user_index", | 52 | name: "drop_redundant_follows_user_index", |
| 53 | run: migrateDropFollowsUserIndex, | 53 | run: migrateDropFollowsUserIndex, |
| 54 | }, | 54 | }, |
| 55 | + { | ||
| 56 | + id: 7, | ||
| 57 | + name: "user_settings_digest_enabled", | ||
| 58 | + run: migrateUserSettingsDigestEnabled, | ||
| 59 | + }, | ||
| 55 | } | 60 | } |
| 56 | 61 | ||
| 57 | func runMigrations(db *DB) error { | 62 | func runMigrations(db *DB) error { |
| @@ -254,6 +259,20 @@ func migrateUserSettingsExpandedView(db *DB) error { | |||
| 254 | return nil | 259 | return nil |
| 255 | } | 260 | } |
| 256 | 261 | ||
| 262 | +func migrateUserSettingsDigestEnabled(db *DB) error { | ||
| 263 | + var colCount int | ||
| 264 | + err := db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('user_settings') WHERE name='digest_enabled'").Scan(&colCount) | ||
| 265 | + if err != nil { | ||
| 266 | + return fmt.Errorf("check user_settings.digest_enabled column: %w", err) | ||
| 267 | + } | ||
| 268 | + if colCount == 0 { | ||
| 269 | + if _, err := db.Exec("ALTER TABLE user_settings ADD COLUMN digest_enabled BOOLEAN NOT NULL DEFAULT 0"); err != nil { | ||
| 270 | + return fmt.Errorf("add user_settings.digest_enabled: %w", err) | ||
| 271 | + } | ||
| 272 | + } | ||
| 273 | + return nil | ||
| 274 | +} | ||
| 275 | + | ||
| 257 | func migrateDropFollowsUserIndex(db *DB) error { | 276 | func migrateDropFollowsUserIndex(db *DB) error { |
| 258 | for _, idx := range []string{ | 277 | for _, idx := range []string{ |
| 259 | "idx_follows_user", | 278 | "idx_follows_user", |
modified
internal/db/user_settings.go +16 -6 | @@ -7,22 +7,24 @@ import ( | ||
| 7 | 7 | ) |
| 8 | 8 | |
| 9 | 9 | type UserSettings struct { |
| 10 | - DID string | |
| 11 | - Languages []string | |
| 12 | - ExpandedView bool | |
| 10 | + DID string | |
| 11 | + Languages []string | |
| 12 | + ExpandedView bool | |
| 13 | + DigestEnabled bool | |
| 13 | 14 | } |
| 14 | 15 | |
| 15 | 16 | func (s *UserStore) GetSettings(ctx context.Context, did string) (*UserSettings, error) { |
| 16 | 17 | var langs sql.NullString |
| 17 | 18 | var expandedView bool |
| 18 | - err := s.db.QueryRowContext(ctx, `SELECT languages, COALESCE(expanded_view, 0) FROM user_settings WHERE did = ?`, did).Scan(&langs, &expandedView) | |
| 19 | + var digestEnabled bool | |
| 20 | + err := s.db.QueryRowContext(ctx, `SELECT languages, COALESCE(expanded_view, 0), COALESCE(digest_enabled, 1) FROM user_settings WHERE did = ?`, did).Scan(&langs, &expandedView, &digestEnabled) | |
| 19 | 21 | if err != nil { |
| 20 | 22 | if err == sql.ErrNoRows { |
| 21 | - return &UserSettings{DID: did}, nil | |
| 23 | + return &UserSettings{DID: did, DigestEnabled: false}, nil | |
| 22 | 24 | } |
| 23 | 25 | return nil, err |
| 24 | 26 | } |
| 25 | - us := &UserSettings{DID: did, ExpandedView: expandedView} | |
| 27 | + us := &UserSettings{DID: did, ExpandedView: expandedView, DigestEnabled: digestEnabled} | |
| 26 | 28 | if langs.Valid && langs.String != "" { |
| 27 | 29 | us.Languages = strings.Split(langs.String, ",") |
| 28 | 30 | } |
| @@ -56,3 +58,11 @@ func (s *UserStore) SetExpandedView(ctx context.Context, did string, enabled boo | ||
| 56 | 58 | `, did, enabled) |
| 57 | 59 | return err |
| 58 | 60 | } |
| 61 | + | |
| 62 | +func (s *UserStore) SetDigestEnabled(ctx context.Context, did string, enabled bool) error { | |
| 63 | + _, err := s.db.ExecContext(ctx, ` | |
| 64 | + INSERT INTO user_settings (did, digest_enabled, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) | |
| 65 | + ON CONFLICT(did) DO UPDATE SET digest_enabled = excluded.digest_enabled, updated_at = CURRENT_TIMESTAMP | |
| 66 | + `, did, enabled) | |
| 67 | + return err | |
| 68 | +} | |
| @@ -7,22 +7,24 @@ import ( | |||
| 7 | ) | 7 | ) |
| 8 | 8 | ||
| 9 | type UserSettings struct { | 9 | type UserSettings struct { |
| 10 | - DID string | 10 | + DID string |
| 11 | - Languages []string | 11 | + Languages []string |
| 12 | - ExpandedView bool | 12 | + ExpandedView bool |
| 13 | + DigestEnabled bool | ||
| 13 | } | 14 | } |
| 14 | 15 | ||
| 15 | func (s *UserStore) GetSettings(ctx context.Context, did string) (*UserSettings, error) { | 16 | func (s *UserStore) GetSettings(ctx context.Context, did string) (*UserSettings, error) { |
| 16 | var langs sql.NullString | 17 | var langs sql.NullString |
| 17 | var expandedView bool | 18 | var expandedView bool |
| 18 | - err := s.db.QueryRowContext(ctx, `SELECT languages, COALESCE(expanded_view, 0) FROM user_settings WHERE did = ?`, did).Scan(&langs, &expandedView) | 19 | + var digestEnabled bool |
| 20 | + err := s.db.QueryRowContext(ctx, `SELECT languages, COALESCE(expanded_view, 0), COALESCE(digest_enabled, 1) FROM user_settings WHERE did = ?`, did).Scan(&langs, &expandedView, &digestEnabled) | ||
| 19 | if err != nil { | 21 | if err != nil { |
| 20 | if err == sql.ErrNoRows { | 22 | if err == sql.ErrNoRows { |
| 21 | - return &UserSettings{DID: did}, nil | 23 | + return &UserSettings{DID: did, DigestEnabled: false}, nil |
| 22 | } | 24 | } |
| 23 | return nil, err | 25 | return nil, err |
| 24 | } | 26 | } |
| 25 | - us := &UserSettings{DID: did, ExpandedView: expandedView} | 27 | + us := &UserSettings{DID: did, ExpandedView: expandedView, DigestEnabled: digestEnabled} |
| 26 | if langs.Valid && langs.String != "" { | 28 | if langs.Valid && langs.String != "" { |
| 27 | us.Languages = strings.Split(langs.String, ",") | 29 | us.Languages = strings.Split(langs.String, ",") |
| 28 | } | 30 | } |
| @@ -56,3 +58,11 @@ func (s *UserStore) SetExpandedView(ctx context.Context, did string, enabled boo | |||
| 56 | `, did, enabled) | 58 | `, did, enabled) |
| 57 | return err | 59 | return err |
| 58 | } | 60 | } |
| 61 | + | ||
| 62 | +func (s *UserStore) SetDigestEnabled(ctx context.Context, did string, enabled bool) error { | ||
| 63 | + _, err := s.db.ExecContext(ctx, ` | ||
| 64 | + INSERT INTO user_settings (did, digest_enabled, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) | ||
| 65 | + ON CONFLICT(did) DO UPDATE SET digest_enabled = excluded.digest_enabled, updated_at = CURRENT_TIMESTAMP | ||
| 66 | + `, did, enabled) | ||
| 67 | + return err | ||
| 68 | +} | ||
modified
internal/ml/llm.go +52 -0 | @@ -11,6 +11,7 @@ import ( | ||
| 11 | 11 | |
| 12 | 12 | type TextModel interface { |
| 13 | 13 | DetectLanguages(ctx context.Context, texts []string) ([]string, error) |
| 14 | + GenerateDigest(ctx context.Context, articleTitles []string, trendingTopics []string) (title, body string, err error) | |
| 14 | 15 | } |
| 15 | 16 | |
| 16 | 17 | type llm struct { |
| @@ -76,3 +77,54 @@ func (c *llm) DetectLanguages(ctx context.Context, texts []string) ([]string, er | ||
| 76 | 77 | } |
| 77 | 78 | return result, nil |
| 78 | 79 | } |
| 80 | + | |
| 81 | +func (c *llm) GenerateDigest(ctx context.Context, articleTitles []string, trendingTopics []string) (string, string, error) { | |
| 82 | + var b strings.Builder | |
| 83 | + b.WriteString(`You are the editor of a sharply written daily briefing for a tech-savvy reader. Write a digest that groups the day's articles into thematic sections. | |
| 84 | + | |
| 85 | +Rules: | |
| 86 | +- First line must be a newspaper-style headline (max 8 words) that captures the dominant theme or tension across the articles. Be specific — name the biggest topic, company, or conflict. Never use meta-phrases like "Daily digest", "Today's briefing", or "Your daily roundup". Just the raw headline, no quotes, no prefix. | |
| 87 | +- Then a blank line, then the body. | |
| 88 | +- Organize into 3-5 thematic sections. Each section has: | |
| 89 | + - A bold inline heading wrapped in <strong> tags (2-4 words, e.g. <strong>Open source</strong>). | |
| 90 | + - Followed by a colon and 2-3 sentences weaving the relevant articles into a narrative. | |
| 91 | +- When you mention an article from the numbered list, append its number in brackets right after, like: "Nvidia raised prices on the RTX 5090 [1]." Do this for every article you reference. | |
| 92 | +- Be specific — name companies, projects, and technologies. Vague summaries are useless. | |
| 93 | +- Use a confident, conversational tone. Short sentences. No filler, no hedging. | |
| 94 | +- End with a one-line note on the most interesting or under-the-radar article. | |
| 95 | +- Use <p> tags to separate sections. Each <p> contains exactly one theme. | |
| 96 | +- Don't write a theme if there aren't at least 2 articles for it. Pick the most interesting and diverse themes. | |
| 97 | + | |
| 98 | +`) | |
| 99 | + | |
| 100 | + fmt.Fprintf(&b, "Here are the %d most recent unread articles (each has a title, optional feed name in brackets, URL, and content excerpt):\n\n", len(articleTitles)) | |
| 101 | + for i, t := range articleTitles { | |
| 102 | + if i >= 50 { | |
| 103 | + break | |
| 104 | + } | |
| 105 | + fmt.Fprintf(&b, "%d. %s\n", i+1, t) | |
| 106 | + } | |
| 107 | + if len(trendingTopics) > 0 { | |
| 108 | + b.WriteString("\nTrending in the reader's network:\n") | |
| 109 | + for _, t := range trendingTopics { | |
| 110 | + fmt.Fprintf(&b, "- %s\n", t) | |
| 111 | + } | |
| 112 | + } | |
| 113 | + | |
| 114 | + resp, err := c.client.Chat.Completions.New(ctx, openai.ChatCompletionNewParams{ | |
| 115 | + Model: c.model, | |
| 116 | + Messages: []openai.ChatCompletionMessageParamUnion{ | |
| 117 | + openai.UserMessage(b.String()), | |
| 118 | + }, | |
| 119 | + Temperature: openai.Float(0.7), | |
| 120 | + }) | |
| 121 | + if err != nil { | |
| 122 | + return "", "", err | |
| 123 | + } | |
| 124 | + | |
| 125 | + content := strings.TrimSpace(resp.Choices[0].Message.Content) | |
| 126 | + title, body, _ := strings.Cut(content, "\n") | |
| 127 | + title = strings.TrimSpace(title) | |
| 128 | + body = strings.TrimSpace(body) | |
| 129 | + return title, body, nil | |
| 130 | +} | |
| @@ -11,6 +11,7 @@ import ( | |||
| 11 | 11 | ||
| 12 | type TextModel interface { | 12 | type TextModel interface { |
| 13 | DetectLanguages(ctx context.Context, texts []string) ([]string, error) | 13 | DetectLanguages(ctx context.Context, texts []string) ([]string, error) |
| 14 | + GenerateDigest(ctx context.Context, articleTitles []string, trendingTopics []string) (title, body string, err error) | ||
| 14 | } | 15 | } |
| 15 | 16 | ||
| 16 | type llm struct { | 17 | type llm struct { |
| @@ -76,3 +77,54 @@ func (c *llm) DetectLanguages(ctx context.Context, texts []string) ([]string, er | |||
| 76 | } | 77 | } |
| 77 | return result, nil | 78 | return result, nil |
| 78 | } | 79 | } |
| 80 | + | ||
| 81 | +func (c *llm) GenerateDigest(ctx context.Context, articleTitles []string, trendingTopics []string) (string, string, error) { | ||
| 82 | + var b strings.Builder | ||
| 83 | + b.WriteString(`You are the editor of a sharply written daily briefing for a tech-savvy reader. Write a digest that groups the day's articles into thematic sections. | ||
| 84 | + | ||
| 85 | +Rules: | ||
| 86 | +- First line must be a newspaper-style headline (max 8 words) that captures the dominant theme or tension across the articles. Be specific — name the biggest topic, company, or conflict. Never use meta-phrases like "Daily digest", "Today's briefing", or "Your daily roundup". Just the raw headline, no quotes, no prefix. | ||
| 87 | +- Then a blank line, then the body. | ||
| 88 | +- Organize into 3-5 thematic sections. Each section has: | ||
| 89 | + - A bold inline heading wrapped in <strong> tags (2-4 words, e.g. <strong>Open source</strong>). | ||
| 90 | + - Followed by a colon and 2-3 sentences weaving the relevant articles into a narrative. | ||
| 91 | +- When you mention an article from the numbered list, append its number in brackets right after, like: "Nvidia raised prices on the RTX 5090 [1]." Do this for every article you reference. | ||
| 92 | +- Be specific — name companies, projects, and technologies. Vague summaries are useless. | ||
| 93 | +- Use a confident, conversational tone. Short sentences. No filler, no hedging. | ||
| 94 | +- End with a one-line note on the most interesting or under-the-radar article. | ||
| 95 | +- Use <p> tags to separate sections. Each <p> contains exactly one theme. | ||
| 96 | +- Don't write a theme if there aren't at least 2 articles for it. Pick the most interesting and diverse themes. | ||
| 97 | + | ||
| 98 | +`) | ||
| 99 | + | ||
| 100 | + fmt.Fprintf(&b, "Here are the %d most recent unread articles (each has a title, optional feed name in brackets, URL, and content excerpt):\n\n", len(articleTitles)) | ||
| 101 | + for i, t := range articleTitles { | ||
| 102 | + if i >= 50 { | ||
| 103 | + break | ||
| 104 | + } | ||
| 105 | + fmt.Fprintf(&b, "%d. %s\n", i+1, t) | ||
| 106 | + } | ||
| 107 | + if len(trendingTopics) > 0 { | ||
| 108 | + b.WriteString("\nTrending in the reader's network:\n") | ||
| 109 | + for _, t := range trendingTopics { | ||
| 110 | + fmt.Fprintf(&b, "- %s\n", t) | ||
| 111 | + } | ||
| 112 | + } | ||
| 113 | + | ||
| 114 | + resp, err := c.client.Chat.Completions.New(ctx, openai.ChatCompletionNewParams{ | ||
| 115 | + Model: c.model, | ||
| 116 | + Messages: []openai.ChatCompletionMessageParamUnion{ | ||
| 117 | + openai.UserMessage(b.String()), | ||
| 118 | + }, | ||
| 119 | + Temperature: openai.Float(0.7), | ||
| 120 | + }) | ||
| 121 | + if err != nil { | ||
| 122 | + return "", "", err | ||
| 123 | + } | ||
| 124 | + | ||
| 125 | + content := strings.TrimSpace(resp.Choices[0].Message.Content) | ||
| 126 | + title, body, _ := strings.Cut(content, "\n") | ||
| 127 | + title = strings.TrimSpace(title) | ||
| 128 | + body = strings.TrimSpace(body) | ||
| 129 | + return title, body, nil | ||
| 130 | +} | ||
modified
internal/server/dashboard_handler.go +4 -0 | @@ -125,6 +125,9 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { | ||
| 125 | 125 | } |
| 126 | 126 | } |
| 127 | 127 | |
| 128 | + settings, _ := s.dbs.Users.GetSettings(ctx, user.DID) | |
| 129 | + digestEnabled := settings != nil && settings.DigestEnabled | |
| 130 | + | |
| 128 | 131 | s.render(w, r, "dashboard.html", map[string]any{ |
| 129 | 132 | "User": user, |
| 130 | 133 | "SubscriptionCount": subCount, |
| @@ -137,6 +140,7 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { | ||
| 137 | 140 | "PersonalTrending": personalTrending, |
| 138 | 141 | "GlobalTrending": globalTrending, |
| 139 | 142 | "Now": time.Now(), |
| 143 | + "DigestEnabled": digestEnabled, | |
| 140 | 144 | }) |
| 141 | 145 | } |
| 142 | 146 | |
| @@ -125,6 +125,9 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { | |||
| 125 | } | 125 | } |
| 126 | } | 126 | } |
| 127 | 127 | ||
| 128 | + settings, _ := s.dbs.Users.GetSettings(ctx, user.DID) | ||
| 129 | + digestEnabled := settings != nil && settings.DigestEnabled | ||
| 130 | + | ||
| 128 | s.render(w, r, "dashboard.html", map[string]any{ | 131 | s.render(w, r, "dashboard.html", map[string]any{ |
| 129 | "User": user, | 132 | "User": user, |
| 130 | "SubscriptionCount": subCount, | 133 | "SubscriptionCount": subCount, |
| @@ -137,6 +140,7 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { | |||
| 137 | "PersonalTrending": personalTrending, | 140 | "PersonalTrending": personalTrending, |
| 138 | "GlobalTrending": globalTrending, | 141 | "GlobalTrending": globalTrending, |
| 139 | "Now": time.Now(), | 142 | "Now": time.Now(), |
| 143 | + "DigestEnabled": digestEnabled, | ||
| 140 | }) | 144 | }) |
| 141 | } | 145 | } |
| 142 | 146 | ||
added
internal/server/digest_handler.go +286 -0 | new file mode 100644 | ||
| @@ -0,0 +1,286 @@ | ||
| 1 | +package server | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "fmt" | |
| 6 | + "net/http" | |
| 7 | + "regexp" | |
| 8 | + "strconv" | |
| 9 | + "strings" | |
| 10 | + "sync" | |
| 11 | + "time" | |
| 12 | + | |
| 13 | + "golang.org/x/sync/errgroup" | |
| 14 | + "golang.org/x/sync/singleflight" | |
| 15 | + | |
| 16 | + "pkg.rbrt.fr/glean/internal/db" | |
| 17 | +) | |
| 18 | + | |
| 19 | +type digestCtx struct { | |
| 20 | + Title string | |
| 21 | + Summary string | |
| 22 | + Excerpt string | |
| 23 | + ArticleIDs []int64 | |
| 24 | + GeneratedAt time.Time | |
| 25 | + CSRFToken string | |
| 26 | + Consumed bool | |
| 27 | +} | |
| 28 | + | |
| 29 | +type digestCacheEntry struct { | |
| 30 | + html string | |
| 31 | + expiry time.Time | |
| 32 | + articleIDs []int64 | |
| 33 | + consumed bool | |
| 34 | +} | |
| 35 | + | |
| 36 | +var ( | |
| 37 | + digestCache sync.Map | |
| 38 | + digestFlight singleflight.Group | |
| 39 | +) | |
| 40 | + | |
| 41 | +const digestTTL = 24 * time.Hour | |
| 42 | + | |
| 43 | +var refRe = regexp.MustCompile(`\[(\d+)\]`) | |
| 44 | + | |
| 45 | +func linkifyRefs(html string, articles []*db.Article) string { | |
| 46 | + return refRe.ReplaceAllStringFunc(html, func(match string) string { | |
| 47 | + n, err := strconv.Atoi(match[1 : len(match)-1]) | |
| 48 | + if err != nil || n < 1 || n > len(articles) { | |
| 49 | + return match | |
| 50 | + } | |
| 51 | + a := articles[n-1] | |
| 52 | + return fmt.Sprintf(`<a href="/articles/%d" class="text-spot-green hover:underline">[%d]</a>`, a.ID, n) | |
| 53 | + }) | |
| 54 | +} | |
| 55 | + | |
| 56 | +func (s *Server) buildDigestData(ctx context.Context, user *db.User) *digestCtx { | |
| 57 | + var ( | |
| 58 | + articles []*db.Article | |
| 59 | + userLangs []string | |
| 60 | + ) | |
| 61 | + | |
| 62 | + g, gCtx := errgroup.WithContext(ctx) | |
| 63 | + | |
| 64 | + g.Go(func() error { | |
| 65 | + var err error | |
| 66 | + articles, err = s.dbs.Articles.ListUnreadArticles(gCtx, user.DID, "", 50, 0) | |
| 67 | + return err | |
| 68 | + }) | |
| 69 | + | |
| 70 | + g.Go(func() error { | |
| 71 | + var err error | |
| 72 | + userLangs, err = s.dbs.Users.GetLanguages(gCtx, user.DID) | |
| 73 | + return err | |
| 74 | + }) | |
| 75 | + | |
| 76 | + if err := g.Wait(); err != nil { | |
| 77 | + s.logger.Warn("digest data error", "error", err, "did", user.DID) | |
| 78 | + return nil | |
| 79 | + } | |
| 80 | + | |
| 81 | + if len(articles) == 0 { | |
| 82 | + return nil | |
| 83 | + } | |
| 84 | + | |
| 85 | + trending, err := s.engine.GetPersonalTrending(ctx, user.DID, userLangs, 5, 0) | |
| 86 | + if err != nil { | |
| 87 | + s.logger.Warn("failed to get trending for digest", "error", err, "did", user.DID) | |
| 88 | + } | |
| 89 | + | |
| 90 | + var articleEntries []string | |
| 91 | + for _, a := range articles { | |
| 92 | + var parts []string | |
| 93 | + parts = append(parts, a.Title) | |
| 94 | + if a.FeedTitle != "" { | |
| 95 | + parts = append(parts, "["+a.FeedTitle+"]") | |
| 96 | + } | |
| 97 | + if a.URL.Valid && a.URL.String != "" { | |
| 98 | + parts = append(parts, a.URL.String) | |
| 99 | + } | |
| 100 | + if content := digestArticleContent(a); content != "" { | |
| 101 | + parts = append(parts, content) | |
| 102 | + } | |
| 103 | + articleEntries = append(articleEntries, strings.Join(parts, " ")) | |
| 104 | + } | |
| 105 | + | |
| 106 | + trendingTopics := make([]string, len(trending)) | |
| 107 | + for i, t := range trending { | |
| 108 | + trendingTopics[i] = t.Title | |
| 109 | + } | |
| 110 | + | |
| 111 | + title, summary, err := s.llm.GenerateDigest(ctx, articleEntries, trendingTopics) | |
| 112 | + if err != nil { | |
| 113 | + s.logger.Warn("failed to generate digest", "error", err, "did", user.DID) | |
| 114 | + return nil | |
| 115 | + } | |
| 116 | + | |
| 117 | + summary = linkifyRefs(summary, articles) | |
| 118 | + | |
| 119 | + return &digestCtx{ | |
| 120 | + Title: title, | |
| 121 | + Summary: summary, | |
| 122 | + Excerpt: excerptFromHTML(summary), | |
| 123 | + ArticleIDs: func() []int64 { | |
| 124 | + ids := make([]int64, len(articles)) | |
| 125 | + for i, a := range articles { | |
| 126 | + ids[i] = a.ID | |
| 127 | + } | |
| 128 | + return ids | |
| 129 | + }(), | |
| 130 | + GeneratedAt: time.Now(), | |
| 131 | + } | |
| 132 | +} | |
| 133 | + | |
| 134 | +const digestContentLimit = 500 | |
| 135 | + | |
| 136 | +func digestArticleContent(a *db.Article) string { | |
| 137 | + src := a.FullContent | |
| 138 | + if !src.Valid || src.String == "" { | |
| 139 | + src = a.Content | |
| 140 | + } | |
| 141 | + if !src.Valid || src.String == "" { | |
| 142 | + src = a.Summary | |
| 143 | + } | |
| 144 | + if !src.Valid || src.String == "" { | |
| 145 | + return "" | |
| 146 | + } | |
| 147 | + text := plainText(src.String) | |
| 148 | + runes := []rune(text) | |
| 149 | + if len(runes) > digestContentLimit { | |
| 150 | + runes = runes[:digestContentLimit] | |
| 151 | + } | |
| 152 | + return string(runes) | |
| 153 | +} | |
| 154 | + | |
| 155 | +func excerptFromHTML(html string) string { | |
| 156 | + text := plainText(html) | |
| 157 | + if len(text) > 200 { | |
| 158 | + text = text[:200] | |
| 159 | + if idx := strings.LastIndex(text, ". "); idx > 0 { | |
| 160 | + text = text[:idx+1] | |
| 161 | + } | |
| 162 | + } | |
| 163 | + return text | |
| 164 | +} | |
| 165 | + | |
| 166 | +func (s *Server) handleDigest(w http.ResponseWriter, r *http.Request) { | |
| 167 | + user := currentUser(r) | |
| 168 | + ctx := r.Context() | |
| 169 | + | |
| 170 | + settings, err := s.dbs.Users.GetSettings(ctx, user.DID) | |
| 171 | + if err != nil { | |
| 172 | + s.logger.Warn("failed to get settings for digest", "error", err, "did", user.DID) | |
| 173 | + } | |
| 174 | + | |
| 175 | + if settings != nil && !settings.DigestEnabled { | |
| 176 | + w.WriteHeader(http.StatusNoContent) | |
| 177 | + return | |
| 178 | + } | |
| 179 | + | |
| 180 | + if s.llm == nil { | |
| 181 | + w.WriteHeader(http.StatusNoContent) | |
| 182 | + return | |
| 183 | + } | |
| 184 | + | |
| 185 | + unreadCount, _ := s.dbs.Articles.GetUnreadCount(ctx, user.DID, "") | |
| 186 | + if unreadCount == 0 { | |
| 187 | + w.WriteHeader(http.StatusNoContent) | |
| 188 | + return | |
| 189 | + } | |
| 190 | + | |
| 191 | + key := user.DID | |
| 192 | + if cached, ok := digestCache.Load(key); ok { | |
| 193 | + entry := cached.(*digestCacheEntry) | |
| 194 | + if time.Now().Before(entry.expiry) { | |
| 195 | + if entry.consumed { | |
| 196 | + s.renderDigest(w, &digestCtx{Consumed: true}) | |
| 197 | + } else { | |
| 198 | + w.Header().Set("Content-Type", "text/html") | |
| 199 | + w.Write([]byte(entry.html)) | |
| 200 | + } | |
| 201 | + return | |
| 202 | + } | |
| 203 | + digestCache.Delete(key) | |
| 204 | + } | |
| 205 | + | |
| 206 | + result, _, _ := digestFlight.Do(key, func() (any, error) { | |
| 207 | + d := s.buildDigestData(ctx, user) | |
| 208 | + if d == nil { | |
| 209 | + return nil, nil | |
| 210 | + } | |
| 211 | + | |
| 212 | + if cookie, err := r.Cookie("glean_csrf"); err == nil { | |
| 213 | + d.CSRFToken = cookie.Value | |
| 214 | + } | |
| 215 | + | |
| 216 | + var buf strings.Builder | |
| 217 | + if err := s.templates.ExecuteTemplate(&buf, "partials/digest.html", d); err != nil { | |
| 218 | + s.logger.Error("digest template error", "error", err) | |
| 219 | + return nil, nil | |
| 220 | + } | |
| 221 | + | |
| 222 | + html := buf.String() | |
| 223 | + digestCache.Store(key, &digestCacheEntry{ | |
| 224 | + html: html, | |
| 225 | + expiry: time.Now().Add(digestTTL), | |
| 226 | + articleIDs: d.ArticleIDs, | |
| 227 | + }) | |
| 228 | + | |
| 229 | + return html, nil | |
| 230 | + }) | |
| 231 | + | |
| 232 | + if result == nil { | |
| 233 | + w.WriteHeader(http.StatusNoContent) | |
| 234 | + return | |
| 235 | + } | |
| 236 | + | |
| 237 | + w.Header().Set("Content-Type", "text/html") | |
| 238 | + w.Write([]byte(result.(string))) | |
| 239 | +} | |
| 240 | + | |
| 241 | +func (s *Server) renderDigest(w http.ResponseWriter, d *digestCtx) { | |
| 242 | + var buf strings.Builder | |
| 243 | + if err := s.templates.ExecuteTemplate(&buf, "partials/digest.html", d); err != nil { | |
| 244 | + s.logger.Error("digest template error", "error", err) | |
| 245 | + w.WriteHeader(http.StatusNoContent) | |
| 246 | + return | |
| 247 | + } | |
| 248 | + w.Header().Set("Content-Type", "text/html") | |
| 249 | + w.Write([]byte(buf.String())) | |
| 250 | +} | |
| 251 | + | |
| 252 | +func (s *Server) handleDigestMarkRead(w http.ResponseWriter, r *http.Request) { | |
| 253 | + user := currentUser(r) | |
| 254 | + | |
| 255 | + if err := r.ParseForm(); err != nil { | |
| 256 | + http.Error(w, err.Error(), http.StatusBadRequest) | |
| 257 | + return | |
| 258 | + } | |
| 259 | + | |
| 260 | + var ids []int64 | |
| 261 | + for _, idStr := range r.Form["ids"] { | |
| 262 | + id, err := strconv.ParseInt(idStr, 10, 64) | |
| 263 | + if err != nil { | |
| 264 | + continue | |
| 265 | + } | |
| 266 | + ids = append(ids, id) | |
| 267 | + } | |
| 268 | + | |
| 269 | + if len(ids) == 0 { | |
| 270 | + w.WriteHeader(http.StatusNoContent) | |
| 271 | + return | |
| 272 | + } | |
| 273 | + | |
| 274 | + if err := s.dbs.Articles.MarkArticlesRead(r.Context(), user.DID, ids); err != nil { | |
| 275 | + s.logger.Error("failed to mark digest articles read", "error", err) | |
| 276 | + http.Error(w, err.Error(), http.StatusInternalServerError) | |
| 277 | + return | |
| 278 | + } | |
| 279 | + | |
| 280 | + digestCache.Store(user.DID, &digestCacheEntry{ | |
| 281 | + expiry: time.Now().Add(digestTTL), | |
| 282 | + consumed: true, | |
| 283 | + }) | |
| 284 | + | |
| 285 | + s.renderDigest(w, &digestCtx{Consumed: true}) | |
| 286 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,286 @@ | |||
| 1 | +package server | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "fmt" | ||
| 6 | + "net/http" | ||
| 7 | + "regexp" | ||
| 8 | + "strconv" | ||
| 9 | + "strings" | ||
| 10 | + "sync" | ||
| 11 | + "time" | ||
| 12 | + | ||
| 13 | + "golang.org/x/sync/errgroup" | ||
| 14 | + "golang.org/x/sync/singleflight" | ||
| 15 | + | ||
| 16 | + "pkg.rbrt.fr/glean/internal/db" | ||
| 17 | +) | ||
| 18 | + | ||
| 19 | +type digestCtx struct { | ||
| 20 | + Title string | ||
| 21 | + Summary string | ||
| 22 | + Excerpt string | ||
| 23 | + ArticleIDs []int64 | ||
| 24 | + GeneratedAt time.Time | ||
| 25 | + CSRFToken string | ||
| 26 | + Consumed bool | ||
| 27 | +} | ||
| 28 | + | ||
| 29 | +type digestCacheEntry struct { | ||
| 30 | + html string | ||
| 31 | + expiry time.Time | ||
| 32 | + articleIDs []int64 | ||
| 33 | + consumed bool | ||
| 34 | +} | ||
| 35 | + | ||
| 36 | +var ( | ||
| 37 | + digestCache sync.Map | ||
| 38 | + digestFlight singleflight.Group | ||
| 39 | +) | ||
| 40 | + | ||
| 41 | +const digestTTL = 24 * time.Hour | ||
| 42 | + | ||
| 43 | +var refRe = regexp.MustCompile(`\[(\d+)\]`) | ||
| 44 | + | ||
| 45 | +func linkifyRefs(html string, articles []*db.Article) string { | ||
| 46 | + return refRe.ReplaceAllStringFunc(html, func(match string) string { | ||
| 47 | + n, err := strconv.Atoi(match[1 : len(match)-1]) | ||
| 48 | + if err != nil || n < 1 || n > len(articles) { | ||
| 49 | + return match | ||
| 50 | + } | ||
| 51 | + a := articles[n-1] | ||
| 52 | + return fmt.Sprintf(`<a href="/articles/%d" class="text-spot-green hover:underline">[%d]</a>`, a.ID, n) | ||
| 53 | + }) | ||
| 54 | +} | ||
| 55 | + | ||
| 56 | +func (s *Server) buildDigestData(ctx context.Context, user *db.User) *digestCtx { | ||
| 57 | + var ( | ||
| 58 | + articles []*db.Article | ||
| 59 | + userLangs []string | ||
| 60 | + ) | ||
| 61 | + | ||
| 62 | + g, gCtx := errgroup.WithContext(ctx) | ||
| 63 | + | ||
| 64 | + g.Go(func() error { | ||
| 65 | + var err error | ||
| 66 | + articles, err = s.dbs.Articles.ListUnreadArticles(gCtx, user.DID, "", 50, 0) | ||
| 67 | + return err | ||
| 68 | + }) | ||
| 69 | + | ||
| 70 | + g.Go(func() error { | ||
| 71 | + var err error | ||
| 72 | + userLangs, err = s.dbs.Users.GetLanguages(gCtx, user.DID) | ||
| 73 | + return err | ||
| 74 | + }) | ||
| 75 | + | ||
| 76 | + if err := g.Wait(); err != nil { | ||
| 77 | + s.logger.Warn("digest data error", "error", err, "did", user.DID) | ||
| 78 | + return nil | ||
| 79 | + } | ||
| 80 | + | ||
| 81 | + if len(articles) == 0 { | ||
| 82 | + return nil | ||
| 83 | + } | ||
| 84 | + | ||
| 85 | + trending, err := s.engine.GetPersonalTrending(ctx, user.DID, userLangs, 5, 0) | ||
| 86 | + if err != nil { | ||
| 87 | + s.logger.Warn("failed to get trending for digest", "error", err, "did", user.DID) | ||
| 88 | + } | ||
| 89 | + | ||
| 90 | + var articleEntries []string | ||
| 91 | + for _, a := range articles { | ||
| 92 | + var parts []string | ||
| 93 | + parts = append(parts, a.Title) | ||
| 94 | + if a.FeedTitle != "" { | ||
| 95 | + parts = append(parts, "["+a.FeedTitle+"]") | ||
| 96 | + } | ||
| 97 | + if a.URL.Valid && a.URL.String != "" { | ||
| 98 | + parts = append(parts, a.URL.String) | ||
| 99 | + } | ||
| 100 | + if content := digestArticleContent(a); content != "" { | ||
| 101 | + parts = append(parts, content) | ||
| 102 | + } | ||
| 103 | + articleEntries = append(articleEntries, strings.Join(parts, " ")) | ||
| 104 | + } | ||
| 105 | + | ||
| 106 | + trendingTopics := make([]string, len(trending)) | ||
| 107 | + for i, t := range trending { | ||
| 108 | + trendingTopics[i] = t.Title | ||
| 109 | + } | ||
| 110 | + | ||
| 111 | + title, summary, err := s.llm.GenerateDigest(ctx, articleEntries, trendingTopics) | ||
| 112 | + if err != nil { | ||
| 113 | + s.logger.Warn("failed to generate digest", "error", err, "did", user.DID) | ||
| 114 | + return nil | ||
| 115 | + } | ||
| 116 | + | ||
| 117 | + summary = linkifyRefs(summary, articles) | ||
| 118 | + | ||
| 119 | + return &digestCtx{ | ||
| 120 | + Title: title, | ||
| 121 | + Summary: summary, | ||
| 122 | + Excerpt: excerptFromHTML(summary), | ||
| 123 | + ArticleIDs: func() []int64 { | ||
| 124 | + ids := make([]int64, len(articles)) | ||
| 125 | + for i, a := range articles { | ||
| 126 | + ids[i] = a.ID | ||
| 127 | + } | ||
| 128 | + return ids | ||
| 129 | + }(), | ||
| 130 | + GeneratedAt: time.Now(), | ||
| 131 | + } | ||
| 132 | +} | ||
| 133 | + | ||
| 134 | +const digestContentLimit = 500 | ||
| 135 | + | ||
| 136 | +func digestArticleContent(a *db.Article) string { | ||
| 137 | + src := a.FullContent | ||
| 138 | + if !src.Valid || src.String == "" { | ||
| 139 | + src = a.Content | ||
| 140 | + } | ||
| 141 | + if !src.Valid || src.String == "" { | ||
| 142 | + src = a.Summary | ||
| 143 | + } | ||
| 144 | + if !src.Valid || src.String == "" { | ||
| 145 | + return "" | ||
| 146 | + } | ||
| 147 | + text := plainText(src.String) | ||
| 148 | + runes := []rune(text) | ||
| 149 | + if len(runes) > digestContentLimit { | ||
| 150 | + runes = runes[:digestContentLimit] | ||
| 151 | + } | ||
| 152 | + return string(runes) | ||
| 153 | +} | ||
| 154 | + | ||
| 155 | +func excerptFromHTML(html string) string { | ||
| 156 | + text := plainText(html) | ||
| 157 | + if len(text) > 200 { | ||
| 158 | + text = text[:200] | ||
| 159 | + if idx := strings.LastIndex(text, ". "); idx > 0 { | ||
| 160 | + text = text[:idx+1] | ||
| 161 | + } | ||
| 162 | + } | ||
| 163 | + return text | ||
| 164 | +} | ||
| 165 | + | ||
| 166 | +func (s *Server) handleDigest(w http.ResponseWriter, r *http.Request) { | ||
| 167 | + user := currentUser(r) | ||
| 168 | + ctx := r.Context() | ||
| 169 | + | ||
| 170 | + settings, err := s.dbs.Users.GetSettings(ctx, user.DID) | ||
| 171 | + if err != nil { | ||
| 172 | + s.logger.Warn("failed to get settings for digest", "error", err, "did", user.DID) | ||
| 173 | + } | ||
| 174 | + | ||
| 175 | + if settings != nil && !settings.DigestEnabled { | ||
| 176 | + w.WriteHeader(http.StatusNoContent) | ||
| 177 | + return | ||
| 178 | + } | ||
| 179 | + | ||
| 180 | + if s.llm == nil { | ||
| 181 | + w.WriteHeader(http.StatusNoContent) | ||
| 182 | + return | ||
| 183 | + } | ||
| 184 | + | ||
| 185 | + unreadCount, _ := s.dbs.Articles.GetUnreadCount(ctx, user.DID, "") | ||
| 186 | + if unreadCount == 0 { | ||
| 187 | + w.WriteHeader(http.StatusNoContent) | ||
| 188 | + return | ||
| 189 | + } | ||
| 190 | + | ||
| 191 | + key := user.DID | ||
| 192 | + if cached, ok := digestCache.Load(key); ok { | ||
| 193 | + entry := cached.(*digestCacheEntry) | ||
| 194 | + if time.Now().Before(entry.expiry) { | ||
| 195 | + if entry.consumed { | ||
| 196 | + s.renderDigest(w, &digestCtx{Consumed: true}) | ||
| 197 | + } else { | ||
| 198 | + w.Header().Set("Content-Type", "text/html") | ||
| 199 | + w.Write([]byte(entry.html)) | ||
| 200 | + } | ||
| 201 | + return | ||
| 202 | + } | ||
| 203 | + digestCache.Delete(key) | ||
| 204 | + } | ||
| 205 | + | ||
| 206 | + result, _, _ := digestFlight.Do(key, func() (any, error) { | ||
| 207 | + d := s.buildDigestData(ctx, user) | ||
| 208 | + if d == nil { | ||
| 209 | + return nil, nil | ||
| 210 | + } | ||
| 211 | + | ||
| 212 | + if cookie, err := r.Cookie("glean_csrf"); err == nil { | ||
| 213 | + d.CSRFToken = cookie.Value | ||
| 214 | + } | ||
| 215 | + | ||
| 216 | + var buf strings.Builder | ||
| 217 | + if err := s.templates.ExecuteTemplate(&buf, "partials/digest.html", d); err != nil { | ||
| 218 | + s.logger.Error("digest template error", "error", err) | ||
| 219 | + return nil, nil | ||
| 220 | + } | ||
| 221 | + | ||
| 222 | + html := buf.String() | ||
| 223 | + digestCache.Store(key, &digestCacheEntry{ | ||
| 224 | + html: html, | ||
| 225 | + expiry: time.Now().Add(digestTTL), | ||
| 226 | + articleIDs: d.ArticleIDs, | ||
| 227 | + }) | ||
| 228 | + | ||
| 229 | + return html, nil | ||
| 230 | + }) | ||
| 231 | + | ||
| 232 | + if result == nil { | ||
| 233 | + w.WriteHeader(http.StatusNoContent) | ||
| 234 | + return | ||
| 235 | + } | ||
| 236 | + | ||
| 237 | + w.Header().Set("Content-Type", "text/html") | ||
| 238 | + w.Write([]byte(result.(string))) | ||
| 239 | +} | ||
| 240 | + | ||
| 241 | +func (s *Server) renderDigest(w http.ResponseWriter, d *digestCtx) { | ||
| 242 | + var buf strings.Builder | ||
| 243 | + if err := s.templates.ExecuteTemplate(&buf, "partials/digest.html", d); err != nil { | ||
| 244 | + s.logger.Error("digest template error", "error", err) | ||
| 245 | + w.WriteHeader(http.StatusNoContent) | ||
| 246 | + return | ||
| 247 | + } | ||
| 248 | + w.Header().Set("Content-Type", "text/html") | ||
| 249 | + w.Write([]byte(buf.String())) | ||
| 250 | +} | ||
| 251 | + | ||
| 252 | +func (s *Server) handleDigestMarkRead(w http.ResponseWriter, r *http.Request) { | ||
| 253 | + user := currentUser(r) | ||
| 254 | + | ||
| 255 | + if err := r.ParseForm(); err != nil { | ||
| 256 | + http.Error(w, err.Error(), http.StatusBadRequest) | ||
| 257 | + return | ||
| 258 | + } | ||
| 259 | + | ||
| 260 | + var ids []int64 | ||
| 261 | + for _, idStr := range r.Form["ids"] { | ||
| 262 | + id, err := strconv.ParseInt(idStr, 10, 64) | ||
| 263 | + if err != nil { | ||
| 264 | + continue | ||
| 265 | + } | ||
| 266 | + ids = append(ids, id) | ||
| 267 | + } | ||
| 268 | + | ||
| 269 | + if len(ids) == 0 { | ||
| 270 | + w.WriteHeader(http.StatusNoContent) | ||
| 271 | + return | ||
| 272 | + } | ||
| 273 | + | ||
| 274 | + if err := s.dbs.Articles.MarkArticlesRead(r.Context(), user.DID, ids); err != nil { | ||
| 275 | + s.logger.Error("failed to mark digest articles read", "error", err) | ||
| 276 | + http.Error(w, err.Error(), http.StatusInternalServerError) | ||
| 277 | + return | ||
| 278 | + } | ||
| 279 | + | ||
| 280 | + digestCache.Store(user.DID, &digestCacheEntry{ | ||
| 281 | + expiry: time.Now().Add(digestTTL), | ||
| 282 | + consumed: true, | ||
| 283 | + }) | ||
| 284 | + | ||
| 285 | + s.renderDigest(w, &digestCtx{Consumed: true}) | ||
| 286 | +} | ||
modified
internal/server/profile_handler.go +1 -0 | @@ -111,5 +111,6 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) { | ||
| 111 | 111 | "UserLanguages": userLangs, |
| 112 | 112 | "AvailableLanguages": ml.KnownLanguages(), |
| 113 | 113 | "ExpandedView": userSettings.ExpandedView, |
| 114 | + "DigestEnabled": userSettings.DigestEnabled, | |
| 114 | 115 | }) |
| 115 | 116 | } |
| @@ -111,5 +111,6 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) { | |||
| 111 | "UserLanguages": userLangs, | 111 | "UserLanguages": userLangs, |
| 112 | "AvailableLanguages": ml.KnownLanguages(), | 112 | "AvailableLanguages": ml.KnownLanguages(), |
| 113 | "ExpandedView": userSettings.ExpandedView, | 113 | "ExpandedView": userSettings.ExpandedView, |
| 114 | + "DigestEnabled": userSettings.DigestEnabled, | ||
| 114 | }) | 115 | }) |
| 115 | } | 116 | } |
modified
internal/server/server.go +17 -1 | @@ -28,6 +28,7 @@ import ( | ||
| 28 | 28 | "pkg.rbrt.fr/glean/internal/feed" |
| 29 | 29 | "pkg.rbrt.fr/glean/internal/feedback" |
| 30 | 30 | "pkg.rbrt.fr/glean/internal/metrics" |
| 31 | + "pkg.rbrt.fr/glean/internal/ml" | |
| 31 | 32 | "pkg.rbrt.fr/glean/internal/scraper" |
| 32 | 33 | "pkg.rbrt.fr/glean/internal/tmpl" |
| 33 | 34 | "pkg.rbrt.fr/glean/static" |
| @@ -70,12 +71,22 @@ type Server struct { | ||
| 70 | 71 | engine *cluster.Engine |
| 71 | 72 | feedback *feedback.Service |
| 72 | 73 | scraper *scraper.Scraper |
| 74 | + llm ml.TextModel | |
| 73 | 75 | clientID string |
| 74 | 76 | callbackURL string |
| 75 | 77 | sessionKey []byte |
| 76 | 78 | } |
| 77 | 79 | |
| 78 | -func New(dbs *db.Store, clientID, callbackURL, addr string, scheduler *feed.Scheduler, fetcher *feed.Fetcher, engine *cluster.Engine, logger *slog.Logger, sessionKey []byte) *Server { | |
| 80 | +func New( | |
| 81 | + dbs *db.Store, | |
| 82 | + clientID, callbackURL, addr string, | |
| 83 | + scheduler *feed.Scheduler, | |
| 84 | + fetcher *feed.Fetcher, | |
| 85 | + engine *cluster.Engine, | |
| 86 | + logger *slog.Logger, | |
| 87 | + sessionKey []byte, | |
| 88 | + textModel ml.TextModel, | |
| 89 | +) *Server { | |
| 79 | 90 | oauthStore := db.NewOAuthStore(dbs) |
| 80 | 91 | |
| 81 | 92 | var config oauth.ClientConfig |
| @@ -102,6 +113,7 @@ func New(dbs *db.Store, clientID, callbackURL, addr string, scheduler *feed.Sche | ||
| 102 | 113 | engine: engine, |
| 103 | 114 | feedback: feedback.NewService(dbs.SQLDB()), |
| 104 | 115 | scraper: scraper.New(logger), |
| 116 | + llm: textModel, | |
| 105 | 117 | clientID: clientID, |
| 106 | 118 | callbackURL: callbackURL, |
| 107 | 119 | sessionKey: sessionKey, |
| @@ -211,8 +223,12 @@ func (s *Server) setupRoutes() { | ||
| 211 | 223 | r.Use(s.requireAuth) |
| 212 | 224 | r.Post("/languages", s.handleUpdateLanguages) |
| 213 | 225 | r.Post("/expanded-view", s.handleToggleExpandedView) |
| 226 | + r.Post("/digest-enabled", s.handleToggleDigestEnabled) | |
| 214 | 227 | }) |
| 215 | 228 | |
| 229 | + s.router.With(s.requireAuth).Get("/digest", s.handleDigest) | |
| 230 | + s.router.With(s.requireAuth).Post("/digest/mark-read", s.handleDigestMarkRead) | |
| 231 | + | |
| 216 | 232 | s.router.Get("/auth/login", s.handleAuthLogin) |
| 217 | 233 | s.router.Get("/auth/register", s.handleAuthRegister) |
| 218 | 234 | s.router.Get("/auth/resolve", s.handleAuthResolve) |
| @@ -28,6 +28,7 @@ import ( | |||
| 28 | "pkg.rbrt.fr/glean/internal/feed" | 28 | "pkg.rbrt.fr/glean/internal/feed" |
| 29 | "pkg.rbrt.fr/glean/internal/feedback" | 29 | "pkg.rbrt.fr/glean/internal/feedback" |
| 30 | "pkg.rbrt.fr/glean/internal/metrics" | 30 | "pkg.rbrt.fr/glean/internal/metrics" |
| 31 | + "pkg.rbrt.fr/glean/internal/ml" | ||
| 31 | "pkg.rbrt.fr/glean/internal/scraper" | 32 | "pkg.rbrt.fr/glean/internal/scraper" |
| 32 | "pkg.rbrt.fr/glean/internal/tmpl" | 33 | "pkg.rbrt.fr/glean/internal/tmpl" |
| 33 | "pkg.rbrt.fr/glean/static" | 34 | "pkg.rbrt.fr/glean/static" |
| @@ -70,12 +71,22 @@ type Server struct { | |||
| 70 | engine *cluster.Engine | 71 | engine *cluster.Engine |
| 71 | feedback *feedback.Service | 72 | feedback *feedback.Service |
| 72 | scraper *scraper.Scraper | 73 | scraper *scraper.Scraper |
| 74 | + llm ml.TextModel | ||
| 73 | clientID string | 75 | clientID string |
| 74 | callbackURL string | 76 | callbackURL string |
| 75 | sessionKey []byte | 77 | sessionKey []byte |
| 76 | } | 78 | } |
| 77 | 79 | ||
| 78 | -func New(dbs *db.Store, clientID, callbackURL, addr string, scheduler *feed.Scheduler, fetcher *feed.Fetcher, engine *cluster.Engine, logger *slog.Logger, sessionKey []byte) *Server { | 80 | +func New( |
| 81 | + dbs *db.Store, | ||
| 82 | + clientID, callbackURL, addr string, | ||
| 83 | + scheduler *feed.Scheduler, | ||
| 84 | + fetcher *feed.Fetcher, | ||
| 85 | + engine *cluster.Engine, | ||
| 86 | + logger *slog.Logger, | ||
| 87 | + sessionKey []byte, | ||
| 88 | + textModel ml.TextModel, | ||
| 89 | +) *Server { | ||
| 79 | oauthStore := db.NewOAuthStore(dbs) | 90 | oauthStore := db.NewOAuthStore(dbs) |
| 80 | 91 | ||
| 81 | var config oauth.ClientConfig | 92 | var config oauth.ClientConfig |
| @@ -102,6 +113,7 @@ func New(dbs *db.Store, clientID, callbackURL, addr string, scheduler *feed.Sche | |||
| 102 | engine: engine, | 113 | engine: engine, |
| 103 | feedback: feedback.NewService(dbs.SQLDB()), | 114 | feedback: feedback.NewService(dbs.SQLDB()), |
| 104 | scraper: scraper.New(logger), | 115 | scraper: scraper.New(logger), |
| 116 | + llm: textModel, | ||
| 105 | clientID: clientID, | 117 | clientID: clientID, |
| 106 | callbackURL: callbackURL, | 118 | callbackURL: callbackURL, |
| 107 | sessionKey: sessionKey, | 119 | sessionKey: sessionKey, |
| @@ -211,8 +223,12 @@ func (s *Server) setupRoutes() { | |||
| 211 | r.Use(s.requireAuth) | 223 | r.Use(s.requireAuth) |
| 212 | r.Post("/languages", s.handleUpdateLanguages) | 224 | r.Post("/languages", s.handleUpdateLanguages) |
| 213 | r.Post("/expanded-view", s.handleToggleExpandedView) | 225 | r.Post("/expanded-view", s.handleToggleExpandedView) |
| 226 | + r.Post("/digest-enabled", s.handleToggleDigestEnabled) | ||
| 214 | }) | 227 | }) |
| 215 | 228 | ||
| 229 | + s.router.With(s.requireAuth).Get("/digest", s.handleDigest) | ||
| 230 | + s.router.With(s.requireAuth).Post("/digest/mark-read", s.handleDigestMarkRead) | ||
| 231 | + | ||
| 216 | s.router.Get("/auth/login", s.handleAuthLogin) | 232 | s.router.Get("/auth/login", s.handleAuthLogin) |
| 217 | s.router.Get("/auth/register", s.handleAuthRegister) | 233 | s.router.Get("/auth/register", s.handleAuthRegister) |
| 218 | s.router.Get("/auth/resolve", s.handleAuthResolve) | 234 | s.router.Get("/auth/resolve", s.handleAuthResolve) |
modified
internal/server/settings_handler.go +20 -0 | @@ -57,3 +57,23 @@ func (s *Server) handleToggleExpandedView(w http.ResponseWriter, r *http.Request | ||
| 57 | 57 | w.Header().Set("HX-Redirect", "/profile/"+user.DID) |
| 58 | 58 | w.WriteHeader(http.StatusOK) |
| 59 | 59 | } |
| 60 | + | |
| 61 | +func (s *Server) handleToggleDigestEnabled(w http.ResponseWriter, r *http.Request) { | |
| 62 | + user := currentUser(r) | |
| 63 | + | |
| 64 | + if err := r.ParseForm(); err != nil { | |
| 65 | + http.Error(w, err.Error(), http.StatusBadRequest) | |
| 66 | + return | |
| 67 | + } | |
| 68 | + | |
| 69 | + enabled := r.FormValue("digest_enabled") == "1" | |
| 70 | + | |
| 71 | + if err := s.dbs.Users.SetDigestEnabled(r.Context(), user.DID, enabled); err != nil { | |
| 72 | + s.logger.Error("failed to update digest enabled", "error", err) | |
| 73 | + http.Error(w, err.Error(), http.StatusInternalServerError) | |
| 74 | + return | |
| 75 | + } | |
| 76 | + | |
| 77 | + w.Header().Set("HX-Redirect", "/profile/"+user.DID) | |
| 78 | + w.WriteHeader(http.StatusOK) | |
| 79 | +} | |
| @@ -57,3 +57,23 @@ func (s *Server) handleToggleExpandedView(w http.ResponseWriter, r *http.Request | |||
| 57 | w.Header().Set("HX-Redirect", "/profile/"+user.DID) | 57 | w.Header().Set("HX-Redirect", "/profile/"+user.DID) |
| 58 | w.WriteHeader(http.StatusOK) | 58 | w.WriteHeader(http.StatusOK) |
| 59 | } | 59 | } |
| 60 | + | ||
| 61 | +func (s *Server) handleToggleDigestEnabled(w http.ResponseWriter, r *http.Request) { | ||
| 62 | + user := currentUser(r) | ||
| 63 | + | ||
| 64 | + if err := r.ParseForm(); err != nil { | ||
| 65 | + http.Error(w, err.Error(), http.StatusBadRequest) | ||
| 66 | + return | ||
| 67 | + } | ||
| 68 | + | ||
| 69 | + enabled := r.FormValue("digest_enabled") == "1" | ||
| 70 | + | ||
| 71 | + if err := s.dbs.Users.SetDigestEnabled(r.Context(), user.DID, enabled); err != nil { | ||
| 72 | + s.logger.Error("failed to update digest enabled", "error", err) | ||
| 73 | + http.Error(w, err.Error(), http.StatusInternalServerError) | ||
| 74 | + return | ||
| 75 | + } | ||
| 76 | + | ||
| 77 | + w.Header().Set("HX-Redirect", "/profile/"+user.DID) | ||
| 78 | + w.WriteHeader(http.StatusOK) | ||
| 79 | +} | ||
modified
internal/tmpl/dashboard.html +36 -0 | @@ -38,6 +38,24 @@ | ||
| 38 | 38 | </div> |
| 39 | 39 | {{end}} |
| 40 | 40 | |
| 41 | +{{if .DigestEnabled}} | |
| 42 | +<div id="digest" hx-get="/digest" hx-trigger="load" hx-swap="innerHTML" hx-indicator="#digest"> | |
| 43 | + <div class="mb-6"> | |
| 44 | + <h2 class="text-lg font-semibold text-spot-text mb-4">Daily digest</h2> | |
| 45 | + <div class="animate-pulse bg-spot-surface rounded-xl px-5 py-4"> | |
| 46 | + <div class="flex items-start gap-2.5"> | |
| 47 | + <div class="w-4 h-4 bg-spot-hover rounded shrink-0 mt-0.5"></div> | |
| 48 | + <div class="flex-1 space-y-2"> | |
| 49 | + <div class="h-4 bg-spot-hover rounded w-2/3"></div> | |
| 50 | + <div class="h-3 bg-spot-hover rounded w-full"></div> | |
| 51 | + <div class="h-3 bg-spot-hover rounded w-4/5"></div> | |
| 52 | + </div> | |
| 53 | + </div> | |
| 54 | + </div> | |
| 55 | + </div> | |
| 56 | +</div> | |
| 57 | +{{end}} | |
| 58 | + | |
| 41 | 59 | <div class="mb-8"> |
| 42 | 60 | <div class="flex items-center justify-between mb-4"> |
| 43 | 61 | <h2 class="text-lg font-semibold text-spot-text">Unread articles</h2> |
| @@ -64,6 +82,24 @@ | ||
| 64 | 82 | </div> |
| 65 | 83 | {{end}} |
| 66 | 84 | |
| 85 | +{{if .DigestEnabled}} | |
| 86 | +<div id="digest" hx-get="/digest" hx-trigger="load" hx-swap="innerHTML" hx-indicator="#digest"> | |
| 87 | + <div class="mb-6"> | |
| 88 | + <h2 class="text-lg font-semibold text-spot-text mb-4">Daily digest</h2> | |
| 89 | + <div class="animate-pulse bg-spot-surface rounded-xl px-5 py-4"> | |
| 90 | + <div class="flex items-start gap-2.5"> | |
| 91 | + <div class="w-4 h-4 bg-spot-hover rounded shrink-0 mt-0.5"></div> | |
| 92 | + <div class="flex-1 space-y-2"> | |
| 93 | + <div class="h-4 bg-spot-hover rounded w-2/3"></div> | |
| 94 | + <div class="h-3 bg-spot-hover rounded w-full"></div> | |
| 95 | + <div class="h-3 bg-spot-hover rounded w-4/5"></div> | |
| 96 | + </div> | |
| 97 | + </div> | |
| 98 | + </div> | |
| 99 | + </div> | |
| 100 | +</div> | |
| 101 | +{{end}} | |
| 102 | + | |
| 67 | 103 | <div class="mb-8"> |
| 68 | 104 | <h2 class="text-lg font-semibold text-spot-text mb-4">Unread articles</h2> |
| 69 | 105 | <div class="bg-spot-surface rounded-2xl py-14 px-6 text-center"> |
| @@ -38,6 +38,24 @@ | |||
| 38 | </div> | 38 | </div> |
| 39 | {{end}} | 39 | {{end}} |
| 40 | 40 | ||
| 41 | +{{if .DigestEnabled}} | ||
| 42 | +<div id="digest" hx-get="/digest" hx-trigger="load" hx-swap="innerHTML" hx-indicator="#digest"> | ||
| 43 | + <div class="mb-6"> | ||
| 44 | + <h2 class="text-lg font-semibold text-spot-text mb-4">Daily digest</h2> | ||
| 45 | + <div class="animate-pulse bg-spot-surface rounded-xl px-5 py-4"> | ||
| 46 | + <div class="flex items-start gap-2.5"> | ||
| 47 | + <div class="w-4 h-4 bg-spot-hover rounded shrink-0 mt-0.5"></div> | ||
| 48 | + <div class="flex-1 space-y-2"> | ||
| 49 | + <div class="h-4 bg-spot-hover rounded w-2/3"></div> | ||
| 50 | + <div class="h-3 bg-spot-hover rounded w-full"></div> | ||
| 51 | + <div class="h-3 bg-spot-hover rounded w-4/5"></div> | ||
| 52 | + </div> | ||
| 53 | + </div> | ||
| 54 | + </div> | ||
| 55 | + </div> | ||
| 56 | +</div> | ||
| 57 | +{{end}} | ||
| 58 | + | ||
| 41 | <div class="mb-8"> | 59 | <div class="mb-8"> |
| 42 | <div class="flex items-center justify-between mb-4"> | 60 | <div class="flex items-center justify-between mb-4"> |
| 43 | <h2 class="text-lg font-semibold text-spot-text">Unread articles</h2> | 61 | <h2 class="text-lg font-semibold text-spot-text">Unread articles</h2> |
| @@ -64,6 +82,24 @@ | |||
| 64 | </div> | 82 | </div> |
| 65 | {{end}} | 83 | {{end}} |
| 66 | 84 | ||
| 85 | +{{if .DigestEnabled}} | ||
| 86 | +<div id="digest" hx-get="/digest" hx-trigger="load" hx-swap="innerHTML" hx-indicator="#digest"> | ||
| 87 | + <div class="mb-6"> | ||
| 88 | + <h2 class="text-lg font-semibold text-spot-text mb-4">Daily digest</h2> | ||
| 89 | + <div class="animate-pulse bg-spot-surface rounded-xl px-5 py-4"> | ||
| 90 | + <div class="flex items-start gap-2.5"> | ||
| 91 | + <div class="w-4 h-4 bg-spot-hover rounded shrink-0 mt-0.5"></div> | ||
| 92 | + <div class="flex-1 space-y-2"> | ||
| 93 | + <div class="h-4 bg-spot-hover rounded w-2/3"></div> | ||
| 94 | + <div class="h-3 bg-spot-hover rounded w-full"></div> | ||
| 95 | + <div class="h-3 bg-spot-hover rounded w-4/5"></div> | ||
| 96 | + </div> | ||
| 97 | + </div> | ||
| 98 | + </div> | ||
| 99 | + </div> | ||
| 100 | +</div> | ||
| 101 | +{{end}} | ||
| 102 | + | ||
| 67 | <div class="mb-8"> | 103 | <div class="mb-8"> |
| 68 | <h2 class="text-lg font-semibold text-spot-text mb-4">Unread articles</h2> | 104 | <h2 class="text-lg font-semibold text-spot-text mb-4">Unread articles</h2> |
| 69 | <div class="bg-spot-surface rounded-2xl py-14 px-6 text-center"> | 105 | <div class="bg-spot-surface rounded-2xl py-14 px-6 text-center"> |
added
internal/tmpl/partials/digest.html +46 -0 | new file mode 100644 | ||
| @@ -0,0 +1,46 @@ | ||
| 1 | +{{define "partials/digest.html"}} | |
| 2 | +<div id="digest" class="mb-8"> | |
| 3 | + <h2 class="text-lg font-semibold text-spot-text mb-4">Daily digest</h2> | |
| 4 | + <article class="bg-spot-surface rounded-xl px-5 py-4 shadow-spot{{if not .Consumed}} border-l border-spot-green/80{{end}}"> | |
| 5 | + {{if .Consumed}} | |
| 6 | + <p class="text-sm text-spot-secondary">That's all for today. Come back tomorrow for a fresh digest.</p> | |
| 7 | + {{else}} | |
| 8 | + <div class="flex items-start justify-between gap-4"> | |
| 9 | + <div class="min-w-0 flex-1"> | |
| 10 | + <button onclick="var b=document.getElementById('digest-body');var h=document.getElementById('digest-header');var i=document.getElementById('digest-chevron');if(b.classList.contains('hidden')){b.classList.remove('hidden');h.classList.add('hidden');i.style.transform=''}else{b.classList.add('hidden');h.classList.remove('hidden');i.style.transform='rotate(-90deg)'}" | |
| 11 | + class="text-left w-full"> | |
| 12 | + <span id="digest-chevron" class="inline-block mr-1.5 transition-transform rotate(-90deg)"> | |
| 13 | + <svg class="w-3.5 h-3.5 text-spot-secondary" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5"/></svg> | |
| 14 | + </span> | |
| 15 | + <span class="font-bold text-[17px] text-spot-text hover:text-spot-green transition leading-snug">{{.Title}}</span> | |
| 16 | + </button> | |
| 17 | + <div id="digest-header"> | |
| 18 | + <div class="text-xs text-spot-secondary mt-2 flex items-center gap-1.5"> | |
| 19 | + <svg class="w-3.5 h-3.5 text-spot-green shrink-0" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25"/></svg> | |
| 20 | + <span class="font-medium">AI summary</span> | |
| 21 | + <span class="text-spot-muted">·</span> | |
| 22 | + <span>{{.GeneratedAt.Format "Jan 2, 2006 15:04"}}</span> | |
| 23 | + <span class="text-spot-muted">·</span> | |
| 24 | + <span>{{len .ArticleIDs}} articles</span> | |
| 25 | + </div> | |
| 26 | + <p class="text-sm text-spot-secondary mt-2.5 line-clamp-2 leading-relaxed">{{.Excerpt}}</p> | |
| 27 | + </div> | |
| 28 | + </div> | |
| 29 | + <div class="shrink-0 pt-0.5"> | |
| 30 | + <form hx-post="/digest/mark-read" hx-confirm="Mark these articles as read?" hx-target="#digest" hx-swap="outerHTML"> | |
| 31 | + {{csrfInput .CSRFToken}} | |
| 32 | + {{range .ArticleIDs}}<input type="hidden" name="ids" value="{{.}}">{{end}} | |
| 33 | + <button type="submit" class="group inline-flex items-center justify-center gap-1 text-[10px] text-spot-text hover:text-spot-green hover:bg-spot-green/15 uppercase tracking-button px-2 py-0.5 rounded-pill bg-spot-hover transition"> | |
| 34 | + <svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg> | |
| 35 | + <span>Read</span> | |
| 36 | + </button> | |
| 37 | + </form> | |
| 38 | + </div> | |
| 39 | + </div> | |
| 40 | + <div id="digest-body" class="hidden"> | |
| 41 | + <div class="text-sm text-spot-text leading-relaxed space-y-3 border-t border-spot-divider pt-4 mt-3 [&_p]:mb-3 [&_p:last-child]:mb-0 [&_strong]:text-spot-text [&_strong]:font-semibold [&_strong]:text-[13px] [&_strong]:uppercase [&_strong]:tracking-wide">{{sanitizeHTML .Summary}}</div> | |
| 42 | + </div> | |
| 43 | + {{end}} | |
| 44 | + </article> | |
| 45 | +</div> | |
| 46 | +{{end}} | |
| new file mode 100644 | |||
| @@ -0,0 +1,46 @@ | |||
| 1 | +{{define "partials/digest.html"}} | ||
| 2 | +<div id="digest" class="mb-8"> | ||
| 3 | + <h2 class="text-lg font-semibold text-spot-text mb-4">Daily digest</h2> | ||
| 4 | + <article class="bg-spot-surface rounded-xl px-5 py-4 shadow-spot{{if not .Consumed}} border-l border-spot-green/80{{end}}"> | ||
| 5 | + {{if .Consumed}} | ||
| 6 | + <p class="text-sm text-spot-secondary">That's all for today. Come back tomorrow for a fresh digest.</p> | ||
| 7 | + {{else}} | ||
| 8 | + <div class="flex items-start justify-between gap-4"> | ||
| 9 | + <div class="min-w-0 flex-1"> | ||
| 10 | + <button onclick="var b=document.getElementById('digest-body');var h=document.getElementById('digest-header');var i=document.getElementById('digest-chevron');if(b.classList.contains('hidden')){b.classList.remove('hidden');h.classList.add('hidden');i.style.transform=''}else{b.classList.add('hidden');h.classList.remove('hidden');i.style.transform='rotate(-90deg)'}" | ||
| 11 | + class="text-left w-full"> | ||
| 12 | + <span id="digest-chevron" class="inline-block mr-1.5 transition-transform rotate(-90deg)"> | ||
| 13 | + <svg class="w-3.5 h-3.5 text-spot-secondary" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5"/></svg> | ||
| 14 | + </span> | ||
| 15 | + <span class="font-bold text-[17px] text-spot-text hover:text-spot-green transition leading-snug">{{.Title}}</span> | ||
| 16 | + </button> | ||
| 17 | + <div id="digest-header"> | ||
| 18 | + <div class="text-xs text-spot-secondary mt-2 flex items-center gap-1.5"> | ||
| 19 | + <svg class="w-3.5 h-3.5 text-spot-green shrink-0" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25"/></svg> | ||
| 20 | + <span class="font-medium">AI summary</span> | ||
| 21 | + <span class="text-spot-muted">·</span> | ||
| 22 | + <span>{{.GeneratedAt.Format "Jan 2, 2006 15:04"}}</span> | ||
| 23 | + <span class="text-spot-muted">·</span> | ||
| 24 | + <span>{{len .ArticleIDs}} articles</span> | ||
| 25 | + </div> | ||
| 26 | + <p class="text-sm text-spot-secondary mt-2.5 line-clamp-2 leading-relaxed">{{.Excerpt}}</p> | ||
| 27 | + </div> | ||
| 28 | + </div> | ||
| 29 | + <div class="shrink-0 pt-0.5"> | ||
| 30 | + <form hx-post="/digest/mark-read" hx-confirm="Mark these articles as read?" hx-target="#digest" hx-swap="outerHTML"> | ||
| 31 | + {{csrfInput .CSRFToken}} | ||
| 32 | + {{range .ArticleIDs}}<input type="hidden" name="ids" value="{{.}}">{{end}} | ||
| 33 | + <button type="submit" class="group inline-flex items-center justify-center gap-1 text-[10px] text-spot-text hover:text-spot-green hover:bg-spot-green/15 uppercase tracking-button px-2 py-0.5 rounded-pill bg-spot-hover transition"> | ||
| 34 | + <svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg> | ||
| 35 | + <span>Read</span> | ||
| 36 | + </button> | ||
| 37 | + </form> | ||
| 38 | + </div> | ||
| 39 | + </div> | ||
| 40 | + <div id="digest-body" class="hidden"> | ||
| 41 | + <div class="text-sm text-spot-text leading-relaxed space-y-3 border-t border-spot-divider pt-4 mt-3 [&_p]:mb-3 [&_p:last-child]:mb-0 [&_strong]:text-spot-text [&_strong]:font-semibold [&_strong]:text-[13px] [&_strong]:uppercase [&_strong]:tracking-wide">{{sanitizeHTML .Summary}}</div> | ||
| 42 | + </div> | ||
| 43 | + {{end}} | ||
| 44 | + </article> | ||
| 45 | +</div> | ||
| 46 | +{{end}} | ||
modified
internal/tmpl/profile.html +13 -0 | @@ -25,6 +25,19 @@ | ||
| 25 | 25 | {{if eq .ProfileUser.DID .CurrentUserDID}} |
| 26 | 26 | <h2 class="text-sm font-bold text-spot-text uppercase tracking-wide mb-3">Settings</h2> |
| 27 | 27 | <div class="bg-spot-surface rounded-xl p-5 mb-6 space-y-5"> |
| 28 | + <form hx-post="/settings/digest-enabled" hx-swap="none" hx-on::after-request="location.reload()"> | |
| 29 | + <input type="hidden" name="digest_enabled" value="{{if .DigestEnabled}}0{{else}}1{{end}}"> | |
| 30 | + <div class="flex items-center justify-between"> | |
| 31 | + <div> | |
| 32 | + <p class="text-sm font-semibold text-spot-text mb-0.5">Daily digest</p> | |
| 33 | + <p class="text-xs text-spot-secondary">Show an AI-generated summary of your 50 most recent unread articles on the dashboard.</p> | |
| 34 | + </div> | |
| 35 | + <button type="submit" class="relative inline-flex h-6 w-11 shrink-0 items-center rounded-xl border-0 p-0 cursor-pointer transition-colors appearance-none {{if .DigestEnabled}}bg-spot-green{{else}}bg-spot-hover{{end}}" aria-label="Toggle daily digest"> | |
| 36 | + <span class="pointer-events-none inline-block h-4 w-4 rounded-xl bg-white shadow-sm transition-transform {{if .DigestEnabled}}translate-x-6{{else}}translate-x-1{{end}}"></span> | |
| 37 | + </button> | |
| 38 | + </div> | |
| 39 | + </form> | |
| 40 | + <hr class="border-spot-divider"> | |
| 28 | 41 | <form hx-post="/settings/expanded-view" hx-swap="none" hx-on::after-request="location.reload()"> |
| 29 | 42 | <input type="hidden" name="expanded_view" value="{{if .ExpandedView}}0{{else}}1{{end}}"> |
| 30 | 43 | <div class="flex items-center justify-between"> |
| @@ -25,6 +25,19 @@ | |||
| 25 | {{if eq .ProfileUser.DID .CurrentUserDID}} | 25 | {{if eq .ProfileUser.DID .CurrentUserDID}} |
| 26 | <h2 class="text-sm font-bold text-spot-text uppercase tracking-wide mb-3">Settings</h2> | 26 | <h2 class="text-sm font-bold text-spot-text uppercase tracking-wide mb-3">Settings</h2> |
| 27 | <div class="bg-spot-surface rounded-xl p-5 mb-6 space-y-5"> | 27 | <div class="bg-spot-surface rounded-xl p-5 mb-6 space-y-5"> |
| 28 | + <form hx-post="/settings/digest-enabled" hx-swap="none" hx-on::after-request="location.reload()"> | ||
| 29 | + <input type="hidden" name="digest_enabled" value="{{if .DigestEnabled}}0{{else}}1{{end}}"> | ||
| 30 | + <div class="flex items-center justify-between"> | ||
| 31 | + <div> | ||
| 32 | + <p class="text-sm font-semibold text-spot-text mb-0.5">Daily digest</p> | ||
| 33 | + <p class="text-xs text-spot-secondary">Show an AI-generated summary of your 50 most recent unread articles on the dashboard.</p> | ||
| 34 | + </div> | ||
| 35 | + <button type="submit" class="relative inline-flex h-6 w-11 shrink-0 items-center rounded-xl border-0 p-0 cursor-pointer transition-colors appearance-none {{if .DigestEnabled}}bg-spot-green{{else}}bg-spot-hover{{end}}" aria-label="Toggle daily digest"> | ||
| 36 | + <span class="pointer-events-none inline-block h-4 w-4 rounded-xl bg-white shadow-sm transition-transform {{if .DigestEnabled}}translate-x-6{{else}}translate-x-1{{end}}"></span> | ||
| 37 | + </button> | ||
| 38 | + </div> | ||
| 39 | + </form> | ||
| 40 | + <hr class="border-spot-divider"> | ||
| 28 | <form hx-post="/settings/expanded-view" hx-swap="none" hx-on::after-request="location.reload()"> | 41 | <form hx-post="/settings/expanded-view" hx-swap="none" hx-on::after-request="location.reload()"> |
| 29 | <input type="hidden" name="expanded_view" value="{{if .ExpandedView}}0{{else}}1{{end}}"> | 42 | <input type="hidden" name="expanded_view" value="{{if .ExpandedView}}0{{else}}1{{end}}"> |
| 30 | <div class="flex items-center justify-between"> | 43 | <div class="flex items-center justify-between"> |
modified
main.go +1 -1 | @@ -104,7 +104,7 @@ func main() { | ||
| 104 | 104 | engine := cluster.NewEngine(dbs.SQLDB(), dbs.Articles, embedder, llm, feedback.NewService(dbs.SQLDB()), logger, *clusterInterval, cluster.DefaultConfig()) |
| 105 | 105 | |
| 106 | 106 | fetcher := feed.NewFetcher(siteFetcher) |
| 107 | - srv := server.New(dbs, clientID, callbackURL, *addr, scheduler, fetcher, engine, logger, []byte(sessionKey)) | |
| 107 | + srv := server.New(dbs, clientID, callbackURL, *addr, scheduler, fetcher, engine, logger, []byte(sessionKey), llm) | |
| 108 | 108 | |
| 109 | 109 | cron := cluster.NewCron(engine, *clusterInterval, logger, dbs) |
| 110 | 110 | |
| @@ -104,7 +104,7 @@ func main() { | |||
| 104 | engine := cluster.NewEngine(dbs.SQLDB(), dbs.Articles, embedder, llm, feedback.NewService(dbs.SQLDB()), logger, *clusterInterval, cluster.DefaultConfig()) | 104 | engine := cluster.NewEngine(dbs.SQLDB(), dbs.Articles, embedder, llm, feedback.NewService(dbs.SQLDB()), logger, *clusterInterval, cluster.DefaultConfig()) |
| 105 | 105 | ||
| 106 | fetcher := feed.NewFetcher(siteFetcher) | 106 | fetcher := feed.NewFetcher(siteFetcher) |
| 107 | - srv := server.New(dbs, clientID, callbackURL, *addr, scheduler, fetcher, engine, logger, []byte(sessionKey)) | 107 | + srv := server.New(dbs, clientID, callbackURL, *addr, scheduler, fetcher, engine, logger, []byte(sessionKey), llm) |
| 108 | 108 | ||
| 109 | cron := cluster.NewCron(engine, *clusterInterval, logger, dbs) | 109 | cron := cluster.NewCron(engine, *clusterInterval, logger, dbs) |
| 110 | 110 | ||