nandi/gleanpublic Fork 0
422e1d0
Commits
Clone
git clone https://git.rickub.com/nandi/glean.git
git clone ssh://git@rickub.com/nandi/glean.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Improve language selector flow and fix daily digest dashboard issueUnverified

Julien Robert committed 2026-05-17T23:39:54+02:00 Browse files
422e1d0 parent: 867e4ef
modified docs/specs.md +17 -9
@@ -408,6 +408,7 @@ The `/articles` page is the main reading view:
408408 - **Share**: Share to Bluesky
409409 - **Keyboard navigation**: `j`/`k` to navigate, `l` to like, `m` to mark read (progressive enhancement via a small `<script>` block)
410410 - **Expanded view**: User setting that shows full article content inline on the articles page. Articles are automatically marked as read via `IntersectionObserver` after being visible for 3 seconds. YouTube videos are embedded inline. A duplicate like button appears at the bottom of each article. Configurable in profile settings.
411+- **Daily digest**: An AI-generated summary of unread articles, shown on the dashboard when enabled in profile settings. The LLM receives the titles and summaries of up to 50 unread articles and produces a grouped overview with linked references. A "Read" button marks all digest articles as read. Digests are cached per user for 24 hours and generated via singleflight to avoid duplicate LLM calls.
411412
412413 ### 4.7 Feed Discovery from Content
413414
@@ -745,7 +746,7 @@ Users can dismiss recommendations they don't want to see again:
745746 - Dismissed items are excluded from all future recommendation queries
746747 - Auto-dismiss: items shown ≥5 times over >5 days without action are auto-dismissed
747748
748-Impression tracking (`recommendation_impressions`) records how many times each recommendation was shown and whether the user acted on it.
749+Recommended articles use the same card template as regular articles, with an additional "Hide" button that dismisses the recommendation. Dismissing only removes that specific article from recommendations — it does not affect the user's likes or signal weights, and similar articles may still be recommended.
749750
750751 ### 7.6 Auto-Tuned Signal Weights
751752
@@ -888,11 +889,11 @@ The embedder uses the official `github.com/openai/openai-go` SDK with `option.Wi
888889
889890 ### 7.14 LLM Client (optional)
890891
891-When `GLEAN_LLM_BASE_URL` is configured, an LLM client is available for text classification tasks. It uses the same `github.com/openai/openai-go` SDK pointed at any OpenAI-compatible `/v1/chat/completions` endpoint.
892+When `GLEAN_LLM_BASE_URL` is configured, an LLM client is available for text classification and summarization tasks. It uses the same `github.com/openai/openai-go` SDK pointed at any OpenAI-compatible `/v1/chat/completions` endpoint.
892893
893-**Language detection**: The cron job calls `DetectLanguages` in batches of up to 100 articles per request. For each article, the title and summary (truncated to 500 characters) are sent with a prompt asking for ISO 639-1 codes. Results are written to `articles.language`. Articles that are already classified (non-empty `language`) are skipped. An empty response from the LLM defaults to English (`en`).
894+**Language detection**: The cron job calls `DetectLanguages` in batches of up to 100 articles per request. For each article, the title and summary (truncated to 500 characters) are sent with a prompt asking for ISO 639-1 codes. Results are written to `articles.language`. Articles that are already classified (non-empty `language`) are skipped. An empty response from the LLM defaults to `'unknown'` rather than a specific language, ensuring unclassifiable articles aren't miscategorized.
894895
895-Without an LLM, all articles remain at the default empty language value and language-based filtering is unavailable.
896+Without an LLM, all articles remain at the default empty language value and language-based filtering is unavailable. The daily digest feature also requires an LLM to generate summaries — without it, the digest is unavailable.
896897
897898 vec0 tables are created dynamically at startup with the configured dimension (`GLEAN_EMBED_DIMENSION`, default 1536):
898899
@@ -954,8 +955,11 @@ The server renders HTML fragments that htmx swaps into the page. No JSON API nee
954955 | `/library/{id}/delete` | POST | Delete an annotation |
955956 | `/stats` | GET | Application metrics and performance data (Prometheus, public) |
956957 | `/profile/{did}` | GET | Public profile: their feeds, likes, annotations |
957-| `/settings/languages` | POST | Save preferred recommendation languages (htmx, requires auth) |
958-| `/settings/expanded-view` | POST | Toggle expanded article view setting (htmx, requires auth) |
958+| `/settings/languages/{code}` | POST | Toggle a preferred recommendation language (htmx, requires auth) |
959+| `/settings/expanded-view` | POST | Toggle expanded article view setting (htmx, requires auth) |
960+| `/settings/digest-enabled` | POST | Toggle daily digest setting (htmx, requires auth) |
961+| `/digest` | GET | Daily digest fragment (LLM summary of unread articles, htmx partial) |
962+| `/digest/mark-read` | POST | Mark digest articles as read |
959963 | `/auth/login` | GET | Login page |
960964 | `/auth/register` | GET | Register with Eurosky (OAuth flow with hardcoded PDS) |
961965 | `/auth/resolve` | GET | Resolve handle to DID |
@@ -1004,7 +1008,8 @@ glean/
10041008 │ │ ├── social.go # Like, annotation queries
10051009 │ │ ├── follow.go # Follow queries
10061010 │ │ ├── oauth_store.go # OAuth session storage
1007-│ │ └── store.go # FeedStore adapter for scheduler
1011+│ │ ├── user_settings.go # User settings queries
1012+│ │ ├── store.go # FeedStore adapter for scheduler
10081013 │ ├── feed/
10091014 │ │ ├── parser.go # RSS/Atom/RDF/JSON feed parser
10101015 │ │ ├── fetcher.go # Scheduler with dedup + Fetcher
@@ -1016,7 +1021,7 @@ glean/
10161021 │ │ └── scraper.go # Full article content scraper
10171022 │ ├── metrics/
10181023 │ │ └── metrics.go # Prometheus metrics definitions
1019-│ ├── ai/
1024+│ ├── ml/
10201025 │ │ ├── embed.go # Embedder interface + OpenAI-compatible implementation + vector helpers
10211026 │ │ └── llm.go # TextModel interface + OpenAI-compatible LLM implementation
10221027 │ ├── cluster/
@@ -1040,7 +1045,9 @@ glean/
10401045 │ │ ├── stats_handler.go # Stats handler (Prometheus metrics display)
10411046 │ │ ├── index_handler.go # Landing page handler
10421047 │ │ ├── profile_handler.go # Public profile handler
1043-│ │ ├── settings_handler.go # User settings (language preferences)
1048+│ │ ├── settings_handler.go # User settings (language preferences, digest toggle)
1049+│ │ ├── digest_handler.go # Daily digest handler (LLM summary, mark-read)
1050+│ │ ├── recs_handler.go # Recommendation dismiss handlers
10441051 │ │ ├── terms_handler.go # Terms of service handler
10451052 │ │ ├── pagination.go # Pagination helpers
10461053 │ │ ├── middleware.go # Auth, logging, CSRF middleware
@@ -1189,3 +1196,4 @@ All PDS records are public. There is no notion of private data on the AT Protoco
11891196 ## 13. Future Considerations
11901197
11911198 - **Email digest**: Periodic email with top articles from subscribed feeds
1199+- **Digest personalization**: Feed the user's liked topics and reading patterns into the digest prompt for more targeted summaries
@@ -408,6 +408,7 @@ The `/articles` page is the main reading view:
408 - **Share**: Share to Bluesky408 - **Share**: Share to Bluesky
409 - **Keyboard navigation**: `j`/`k` to navigate, `l` to like, `m` to mark read (progressive enhancement via a small `<script>` block)409 - **Keyboard navigation**: `j`/`k` to navigate, `l` to like, `m` to mark read (progressive enhancement via a small `<script>` block)
410 - **Expanded view**: User setting that shows full article content inline on the articles page. Articles are automatically marked as read via `IntersectionObserver` after being visible for 3 seconds. YouTube videos are embedded inline. A duplicate like button appears at the bottom of each article. Configurable in profile settings.410 - **Expanded view**: User setting that shows full article content inline on the articles page. Articles are automatically marked as read via `IntersectionObserver` after being visible for 3 seconds. YouTube videos are embedded inline. A duplicate like button appears at the bottom of each article. Configurable in profile settings.
411+- **Daily digest**: An AI-generated summary of unread articles, shown on the dashboard when enabled in profile settings. The LLM receives the titles and summaries of up to 50 unread articles and produces a grouped overview with linked references. A "Read" button marks all digest articles as read. Digests are cached per user for 24 hours and generated via singleflight to avoid duplicate LLM calls.
411 412
412 ### 4.7 Feed Discovery from Content413 ### 4.7 Feed Discovery from Content
413 414
@@ -745,7 +746,7 @@ Users can dismiss recommendations they don't want to see again:
745 - Dismissed items are excluded from all future recommendation queries746 - Dismissed items are excluded from all future recommendation queries
746 - Auto-dismiss: items shown ≥5 times over >5 days without action are auto-dismissed747 - Auto-dismiss: items shown ≥5 times over >5 days without action are auto-dismissed
747 748
748-Impression tracking (`recommendation_impressions`) records how many times each recommendation was shown and whether the user acted on it.749+Recommended articles use the same card template as regular articles, with an additional "Hide" button that dismisses the recommendation. Dismissing only removes that specific article from recommendations — it does not affect the user's likes or signal weights, and similar articles may still be recommended.
749 750
750 ### 7.6 Auto-Tuned Signal Weights751 ### 7.6 Auto-Tuned Signal Weights
751 752
@@ -888,11 +889,11 @@ The embedder uses the official `github.com/openai/openai-go` SDK with `option.Wi
888 889
889 ### 7.14 LLM Client (optional)890 ### 7.14 LLM Client (optional)
890 891
891-When `GLEAN_LLM_BASE_URL` is configured, an LLM client is available for text classification tasks. It uses the same `github.com/openai/openai-go` SDK pointed at any OpenAI-compatible `/v1/chat/completions` endpoint.892+When `GLEAN_LLM_BASE_URL` is configured, an LLM client is available for text classification and summarization tasks. It uses the same `github.com/openai/openai-go` SDK pointed at any OpenAI-compatible `/v1/chat/completions` endpoint.
892 893
893-**Language detection**: The cron job calls `DetectLanguages` in batches of up to 100 articles per request. For each article, the title and summary (truncated to 500 characters) are sent with a prompt asking for ISO 639-1 codes. Results are written to `articles.language`. Articles that are already classified (non-empty `language`) are skipped. An empty response from the LLM defaults to English (`en`).894+**Language detection**: The cron job calls `DetectLanguages` in batches of up to 100 articles per request. For each article, the title and summary (truncated to 500 characters) are sent with a prompt asking for ISO 639-1 codes. Results are written to `articles.language`. Articles that are already classified (non-empty `language`) are skipped. An empty response from the LLM defaults to `'unknown'` rather than a specific language, ensuring unclassifiable articles aren't miscategorized.
894 895
895-Without an LLM, all articles remain at the default empty language value and language-based filtering is unavailable.896+Without an LLM, all articles remain at the default empty language value and language-based filtering is unavailable. The daily digest feature also requires an LLM to generate summaries — without it, the digest is unavailable.
896 897
897 vec0 tables are created dynamically at startup with the configured dimension (`GLEAN_EMBED_DIMENSION`, default 1536):898 vec0 tables are created dynamically at startup with the configured dimension (`GLEAN_EMBED_DIMENSION`, default 1536):
898 899
@@ -954,8 +955,11 @@ The server renders HTML fragments that htmx swaps into the page. No JSON API nee
954 | `/library/{id}/delete` | POST | Delete an annotation |955 | `/library/{id}/delete` | POST | Delete an annotation |
955 | `/stats` | GET | Application metrics and performance data (Prometheus, public) |956 | `/stats` | GET | Application metrics and performance data (Prometheus, public) |
956 | `/profile/{did}` | GET | Public profile: their feeds, likes, annotations |957 | `/profile/{did}` | GET | Public profile: their feeds, likes, annotations |
957-| `/settings/languages` | POST | Save preferred recommendation languages (htmx, requires auth) |958+| `/settings/languages/{code}` | POST | Toggle a preferred recommendation language (htmx, requires auth) |
958-| `/settings/expanded-view` | POST | Toggle expanded article view setting (htmx, requires auth) |959+| `/settings/expanded-view` | POST | Toggle expanded article view setting (htmx, requires auth) |
960+| `/settings/digest-enabled` | POST | Toggle daily digest setting (htmx, requires auth) |
961+| `/digest` | GET | Daily digest fragment (LLM summary of unread articles, htmx partial) |
962+| `/digest/mark-read` | POST | Mark digest articles as read |
959 | `/auth/login` | GET | Login page |963 | `/auth/login` | GET | Login page |
960 | `/auth/register` | GET | Register with Eurosky (OAuth flow with hardcoded PDS) |964 | `/auth/register` | GET | Register with Eurosky (OAuth flow with hardcoded PDS) |
961 | `/auth/resolve` | GET | Resolve handle to DID |965 | `/auth/resolve` | GET | Resolve handle to DID |
@@ -1004,7 +1008,8 @@ glean/
1004 │ │ ├── social.go # Like, annotation queries1008 │ │ ├── social.go # Like, annotation queries
1005 │ │ ├── follow.go # Follow queries1009 │ │ ├── follow.go # Follow queries
1006 │ │ ├── oauth_store.go # OAuth session storage1010 │ │ ├── oauth_store.go # OAuth session storage
1007-│ │ └── store.go # FeedStore adapter for scheduler1011+│ │ ├── user_settings.go # User settings queries
1012+│ │ ├── store.go # FeedStore adapter for scheduler
1008 │ ├── feed/1013 │ ├── feed/
1009 │ │ ├── parser.go # RSS/Atom/RDF/JSON feed parser1014 │ │ ├── parser.go # RSS/Atom/RDF/JSON feed parser
1010 │ │ ├── fetcher.go # Scheduler with dedup + Fetcher1015 │ │ ├── fetcher.go # Scheduler with dedup + Fetcher
@@ -1016,7 +1021,7 @@ glean/
1016 │ │ └── scraper.go # Full article content scraper1021 │ │ └── scraper.go # Full article content scraper
1017 │ ├── metrics/1022 │ ├── metrics/
1018 │ │ └── metrics.go # Prometheus metrics definitions1023 │ │ └── metrics.go # Prometheus metrics definitions
1019-│ ├── ai/1024+│ ├── ml/
1020 │ │ ├── embed.go # Embedder interface + OpenAI-compatible implementation + vector helpers1025 │ │ ├── embed.go # Embedder interface + OpenAI-compatible implementation + vector helpers
1021 │ │ └── llm.go # TextModel interface + OpenAI-compatible LLM implementation1026 │ │ └── llm.go # TextModel interface + OpenAI-compatible LLM implementation
1022 │ ├── cluster/1027 │ ├── cluster/
@@ -1040,7 +1045,9 @@ glean/
1040 │ │ ├── stats_handler.go # Stats handler (Prometheus metrics display)1045 │ │ ├── stats_handler.go # Stats handler (Prometheus metrics display)
1041 │ │ ├── index_handler.go # Landing page handler1046 │ │ ├── index_handler.go # Landing page handler
1042 │ │ ├── profile_handler.go # Public profile handler1047 │ │ ├── profile_handler.go # Public profile handler
1043-│ │ ├── settings_handler.go # User settings (language preferences)1048+│ │ ├── settings_handler.go # User settings (language preferences, digest toggle)
1049+│ │ ├── digest_handler.go # Daily digest handler (LLM summary, mark-read)
1050+│ │ ├── recs_handler.go # Recommendation dismiss handlers
1044 │ │ ├── terms_handler.go # Terms of service handler1051 │ │ ├── terms_handler.go # Terms of service handler
1045 │ │ ├── pagination.go # Pagination helpers1052 │ │ ├── pagination.go # Pagination helpers
1046 │ │ ├── middleware.go # Auth, logging, CSRF middleware1053 │ │ ├── middleware.go # Auth, logging, CSRF middleware
@@ -1189,3 +1196,4 @@ All PDS records are public. There is no notion of private data on the AT Protoco
1189 ## 13. Future Considerations1196 ## 13. Future Considerations
1190 1197
1191 - **Email digest**: Periodic email with top articles from subscribed feeds1198 - **Email digest**: Periodic email with top articles from subscribed feeds
1199+- **Digest personalization**: Feed the user's liked topics and reading patterns into the digest prompt for more targeted summaries
modified internal/server/server.go +1 -1
@@ -221,7 +221,7 @@ func (s *Server) setupRoutes() {
221221
222222 s.router.Route("/settings", func(r chi.Router) {
223223 r.Use(s.requireAuth)
224- r.Post("/languages", s.handleUpdateLanguages)
224+ r.Post("/languages/{code}", s.handleToggleLanguage)
225225 r.Post("/expanded-view", s.handleToggleExpandedView)
226226 r.Post("/digest-enabled", s.handleToggleDigestEnabled)
227227 })
@@ -221,7 +221,7 @@ func (s *Server) setupRoutes() {
221 221
222 s.router.Route("/settings", func(r chi.Router) {222 s.router.Route("/settings", func(r chi.Router) {
223 r.Use(s.requireAuth)223 r.Use(s.requireAuth)
224- r.Post("/languages", s.handleUpdateLanguages)224+ r.Post("/languages/{code}", s.handleToggleLanguage)
225 r.Post("/expanded-view", s.handleToggleExpandedView)225 r.Post("/expanded-view", s.handleToggleExpandedView)
226 r.Post("/digest-enabled", s.handleToggleDigestEnabled)226 r.Post("/digest-enabled", s.handleToggleDigestEnabled)
227 })227 })
modified internal/server/settings_handler.go +26 -10
@@ -2,16 +2,16 @@ package server
22
33 import (
44 "net/http"
5- "strings"
65
76 "pkg.rbrt.fr/glean/internal/ml"
87 )
98
10-func (s *Server) handleUpdateLanguages(w http.ResponseWriter, r *http.Request) {
9+func (s *Server) handleToggleLanguage(w http.ResponseWriter, r *http.Request) {
1110 user := currentUser(r)
1211
13- if err := r.ParseForm(); err != nil {
14- http.Error(w, err.Error(), http.StatusBadRequest)
12+ lang := r.PathValue("code")
13+ if lang == "" {
14+ http.Error(w, "missing language code", http.StatusBadRequest)
1515 return
1616 }
1717
@@ -19,16 +19,32 @@ func (s *Server) handleUpdateLanguages(w http.ResponseWriter, r *http.Request) {
1919 for _, known := range ml.KnownLanguages() {
2020 valid[known.Code] = true
2121 }
22+ if !valid[lang] {
23+ http.Error(w, "unknown language", http.StatusBadRequest)
24+ return
25+ }
2226
23- var filtered []string
24- for _, l := range r.Form["languages"] {
25- l = strings.TrimSpace(l)
26- if valid[l] {
27- filtered = append(filtered, l)
27+ current, err := s.dbs.Users.GetLanguages(r.Context(), user.DID)
28+ if err != nil {
29+ s.logger.Error("failed to get languages", "error", err)
30+ http.Error(w, err.Error(), http.StatusInternalServerError)
31+ return
32+ }
33+
34+ var updated []string
35+ found := false
36+ for _, l := range current {
37+ if l == lang {
38+ found = true
39+ continue
2840 }
41+ updated = append(updated, l)
42+ }
43+ if !found {
44+ updated = append(updated, lang)
2945 }
3046
31- if err := s.dbs.Users.UpdateLanguages(r.Context(), user.DID, filtered); err != nil {
47+ if err := s.dbs.Users.UpdateLanguages(r.Context(), user.DID, updated); err != nil {
3248 s.logger.Error("failed to update languages", "error", err)
3349 http.Error(w, err.Error(), http.StatusInternalServerError)
3450 return
@@ -2,16 +2,16 @@ package server
2 2
3 import (3 import (
4 "net/http"4 "net/http"
5- "strings"
6 5
7 "pkg.rbrt.fr/glean/internal/ml"6 "pkg.rbrt.fr/glean/internal/ml"
8 )7 )
9 8
10-func (s *Server) handleUpdateLanguages(w http.ResponseWriter, r *http.Request) {9+func (s *Server) handleToggleLanguage(w http.ResponseWriter, r *http.Request) {
11 user := currentUser(r)10 user := currentUser(r)
12 11
13- if err := r.ParseForm(); err != nil {12+ lang := r.PathValue("code")
14- http.Error(w, err.Error(), http.StatusBadRequest)13+ if lang == "" {
14+ http.Error(w, "missing language code", http.StatusBadRequest)
15 return15 return
16 }16 }
17 17
@@ -19,16 +19,32 @@ func (s *Server) handleUpdateLanguages(w http.ResponseWriter, r *http.Request) {
19 for _, known := range ml.KnownLanguages() {19 for _, known := range ml.KnownLanguages() {
20 valid[known.Code] = true20 valid[known.Code] = true
21 }21 }
22+ if !valid[lang] {
23+ http.Error(w, "unknown language", http.StatusBadRequest)
24+ return
25+ }
22 26
23- var filtered []string27+ current, err := s.dbs.Users.GetLanguages(r.Context(), user.DID)
24- for _, l := range r.Form["languages"] {28+ if err != nil {
25- l = strings.TrimSpace(l)29+ s.logger.Error("failed to get languages", "error", err)
26- if valid[l] {30+ http.Error(w, err.Error(), http.StatusInternalServerError)
27- filtered = append(filtered, l)31+ return
32+ }
33+
34+ var updated []string
35+ found := false
36+ for _, l := range current {
37+ if l == lang {
38+ found = true
39+ continue
28 }40 }
41+ updated = append(updated, l)
42+ }
43+ if !found {
44+ updated = append(updated, lang)
29 }45 }
30 46
31- if err := s.dbs.Users.UpdateLanguages(r.Context(), user.DID, filtered); err != nil {47+ if err := s.dbs.Users.UpdateLanguages(r.Context(), user.DID, updated); err != nil {
32 s.logger.Error("failed to update languages", "error", err)48 s.logger.Error("failed to update languages", "error", err)
33 http.Error(w, err.Error(), http.StatusInternalServerError)49 http.Error(w, err.Error(), http.StatusInternalServerError)
34 return50 return
modified internal/tmpl/dashboard.html +1 -1
@@ -82,7 +82,7 @@
8282 </div>
8383 {{end}}
8484
85-{{if .DigestEnabled}}
85+{{if and .DigestEnabled (gt .UnreadCount 0)}}
8686 <div id="digest" hx-get="/digest" hx-trigger="load" hx-swap="innerHTML" hx-indicator="#digest">
8787 <div class="mb-6">
8888 <h2 class="text-lg font-semibold text-spot-text mb-4">Daily digest</h2>
@@ -82,7 +82,7 @@
82 </div>82 </div>
83 {{end}}83 {{end}}
84 84
85-{{if .DigestEnabled}}85+{{if and .DigestEnabled (gt .UnreadCount 0)}}
86 <div id="digest" hx-get="/digest" hx-trigger="load" hx-swap="innerHTML" hx-indicator="#digest">86 <div id="digest" hx-get="/digest" hx-trigger="load" hx-swap="innerHTML" hx-indicator="#digest">
87 <div class="mb-6">87 <div class="mb-6">
88 <h2 class="text-lg font-semibold text-spot-text mb-4">Daily digest</h2>88 <h2 class="text-lg font-semibold text-spot-text mb-4">Daily digest</h2>
modified internal/tmpl/profile.html +6 -22
@@ -51,34 +51,18 @@
5151 </div>
5252 </form>
5353 <hr class="border-spot-divider">
54- <form id="lang-form" hx-post="/settings/languages" hx-swap="none" hx-on::after-request="location.reload()">
54+ <div>
5555 <p class="text-sm font-semibold text-spot-text mb-1">Recommendation languages</p>
5656 <p class="text-xs text-spot-secondary mb-4">Filter article recommendations to specific languages. Leave empty to show all.</p>
57- <div class="flex flex-wrap gap-1.5 mb-4">
57+ <div class="flex flex-wrap gap-1.5">
5858 {{range .AvailableLanguages}}
59- <label class="lang-pill cursor-pointer text-sm px-4 py-1.5 rounded-pill font-bold transition {{if containsString $.UserLanguages .Code}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}">
60- <input type="checkbox" name="languages" value="{{.Code}}" {{if containsString $.UserLanguages .Code}}checked{{end}} class="hidden">
61- {{.Name}}
62- </label>
59+ <form hx-post="/settings/languages/{{.Code}}" hx-swap="none" hx-on::after-request="location.reload()" class="inline">
60+ <button type="submit" class="cursor-pointer text-sm px-4 py-1.5 rounded-pill font-bold transition border-0 {{if containsString $.UserLanguages .Code}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}">{{.Name}}</button>
61+ </form>
6362 {{end}}
6463 </div>
65- <button type="submit" class="bg-spot-green text-white rounded-pill px-5 py-1.5 text-xs font-bold uppercase tracking-button hover:brightness-110 transition">Save</button>
66- </form>
64+ </div>
6765 </div>
68- <script>
69- document.querySelectorAll('#lang-form .lang-pill input[type=checkbox]').forEach(function(cb) {
70- cb.addEventListener('change', function() {
71- var pill = this.closest('.lang-pill');
72- if (this.checked) {
73- pill.classList.remove('bg-spot-hover', 'text-spot-secondary', 'hover:text-spot-text');
74- pill.classList.add('bg-spot-active-pill-bg', 'text-spot-active-pill-text');
75- } else {
76- pill.classList.remove('bg-spot-active-pill-bg', 'text-spot-active-pill-text');
77- pill.classList.add('bg-spot-hover', 'text-spot-secondary', 'hover:text-spot-text');
78- }
79- });
80- });
81- </script>
8266 {{end}}
8367
8468 <h2 class="text-sm font-bold text-spot-text uppercase tracking-wide mb-3">Feeds</h2>
@@ -51,34 +51,18 @@
51 </div>51 </div>
52 </form>52 </form>
53 <hr class="border-spot-divider">53 <hr class="border-spot-divider">
54- <form id="lang-form" hx-post="/settings/languages" hx-swap="none" hx-on::after-request="location.reload()">54+ <div>
55 <p class="text-sm font-semibold text-spot-text mb-1">Recommendation languages</p>55 <p class="text-sm font-semibold text-spot-text mb-1">Recommendation languages</p>
56 <p class="text-xs text-spot-secondary mb-4">Filter article recommendations to specific languages. Leave empty to show all.</p>56 <p class="text-xs text-spot-secondary mb-4">Filter article recommendations to specific languages. Leave empty to show all.</p>
57- <div class="flex flex-wrap gap-1.5 mb-4">57+ <div class="flex flex-wrap gap-1.5">
58 {{range .AvailableLanguages}}58 {{range .AvailableLanguages}}
59- <label class="lang-pill cursor-pointer text-sm px-4 py-1.5 rounded-pill font-bold transition {{if containsString $.UserLanguages .Code}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}">59+ <form hx-post="/settings/languages/{{.Code}}" hx-swap="none" hx-on::after-request="location.reload()" class="inline">
60- <input type="checkbox" name="languages" value="{{.Code}}" {{if containsString $.UserLanguages .Code}}checked{{end}} class="hidden">60+ <button type="submit" class="cursor-pointer text-sm px-4 py-1.5 rounded-pill font-bold transition border-0 {{if containsString $.UserLanguages .Code}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}">{{.Name}}</button>
61- {{.Name}}61+ </form>
62- </label>
63 {{end}}62 {{end}}
64 </div>63 </div>
65- <button type="submit" class="bg-spot-green text-white rounded-pill px-5 py-1.5 text-xs font-bold uppercase tracking-button hover:brightness-110 transition">Save</button>64+ </div>
66- </form>
67 </div>65 </div>
68- <script>
69- document.querySelectorAll('#lang-form .lang-pill input[type=checkbox]').forEach(function(cb) {
70- cb.addEventListener('change', function() {
71- var pill = this.closest('.lang-pill');
72- if (this.checked) {
73- pill.classList.remove('bg-spot-hover', 'text-spot-secondary', 'hover:text-spot-text');
74- pill.classList.add('bg-spot-active-pill-bg', 'text-spot-active-pill-text');
75- } else {
76- pill.classList.remove('bg-spot-active-pill-bg', 'text-spot-active-pill-text');
77- pill.classList.add('bg-spot-hover', 'text-spot-secondary', 'hover:text-spot-text');
78- }
79- });
80- });
81- </script>
82 {{end}}66 {{end}}
83 67
84 <h2 class="text-sm font-bold text-spot-text uppercase tracking-wide mb-3">Feeds</h2>68 <h2 class="text-sm font-bold text-spot-text uppercase tracking-wide mb-3">Feeds</h2>
modified readme.md +23 -22
@@ -13,6 +13,7 @@ Your subscriptions live as records on your PDS. You own them. If Glean goes away
1313 - [margin.at](https://margin.at) annotations displayed alongside glean annotations
1414 - A trending page showing what's popular across all users
1515 - Feed and people recommendations based on reading overlap
16+- Daily digest with an AI-generated summary of your unread articles
1617 - OPML import and export
1718 - Sign in with Bluesky / Atmosphere account — no new account needed
1819
@@ -51,28 +52,28 @@ Then open `http://localhost:8080`.
5152
5253 ## Configuration
5354
54-| Variable | Default | What it does |
55-| ---------------------------- | -------------------------- | ---------------------------------------------------------- |
56-| `GLEAN_SESSION_KEY` | _(required)_ | Secret key for signing session cookies (any random string) |
57-| `GLEAN_ADDR` | `:8080` | Listen address |
58-| `GLEAN_DB` | `glean.db` | SQLite base path (`_users`, `_articles`, `_recs` suffixes) |
59-| `GLEAN_JETSTREAM` | `wss://jetstream.glean.at` | Jetstream WebSocket URL |
60-| `GLEAN_SYNC_INTERVAL` | `8h` | PDS sync interval (Go duration: `24h`, `12h`, etc.) |
61-| `GLEAN_CLUSTER_INTERVAL` | `1h` | Cluster recomputation interval (Go duration) |
62-| `GLEAN_FETCH_INTERVAL` | `15m` | Feed fetch scheduler tick interval (Go duration) |
63-| `GLEAN_COLLECTION_DIR_URL` | _(empty)_ | Collection directory URL for startup backfill |
64-| `GLEAN_BACKFILL_CONCURRENCY` | `5` | Max concurrent backfill workers |
65-| `GLEAN_PLC_URL` | `https://didplc.glean.at` | PLC directory URL for DID resolution |
66-| `GLEAN_OAUTH_CLIENT_ID` | _(empty)_ | OAuth client metadata URL (leave empty for localhost dev) |
67-| `GLEAN_OAUTH_REDIRECT_URL` | _(empty)_ | OAuth redirect URL (leave empty for localhost dev) |
68-| `GLEAN_EMBED_BASE_URL` | _(empty)_ | Embeddings API base URL (recommended, see below) |
69-| `GLEAN_EMBED_API_KEY` | _(empty)_ | API key for the embeddings endpoint |
70-| `GLEAN_EMBED_MODEL` | `text-embedding-3-small` | Embedding model name |
71-| `GLEAN_EMBED_DIMENSION` | `1536` | Embedding vector dimension |
72-| `GLEAN_LLM_BASE_URL` | _(empty)_ | LLM API base URL for language detection (see below) |
73-| `GLEAN_LLM_API_KEY` | _(empty)_ | API key for the LLM endpoint |
74-| `GLEAN_LLM_MODEL` | `gpt-4o-mini` | LLM model name |
75-| `GLEAN_PPROF_ADDR` | _(empty)_ | Enable pprof profiling server (e.g. `:6060`, off by default) |
55+| Variable | Default | What it does |
56+| ---------------------------- | -------------------------- | ------------------------------------------------------------------------ |
57+| `GLEAN_SESSION_KEY` | _(required)_ | Secret key for signing session cookies (any random string) |
58+| `GLEAN_ADDR` | `:8080` | Listen address |
59+| `GLEAN_DB` | `glean.db` | SQLite base path (`_users`, `_articles`, `_recs` suffixes) |
60+| `GLEAN_JETSTREAM` | `wss://jetstream.glean.at` | Jetstream WebSocket URL |
61+| `GLEAN_SYNC_INTERVAL` | `8h` | PDS sync interval (Go duration: `24h`, `12h`, etc.) |
62+| `GLEAN_CLUSTER_INTERVAL` | `1h` | Cluster recomputation interval (Go duration) |
63+| `GLEAN_FETCH_INTERVAL` | `15m` | Feed fetch scheduler tick interval (Go duration) |
64+| `GLEAN_COLLECTION_DIR_URL` | _(empty)_ | Collection directory URL for startup backfill |
65+| `GLEAN_BACKFILL_CONCURRENCY` | `5` | Max concurrent backfill workers |
66+| `GLEAN_PLC_URL` | `https://didplc.glean.at` | PLC directory URL for DID resolution |
67+| `GLEAN_OAUTH_CLIENT_ID` | _(empty)_ | OAuth client metadata URL (leave empty for localhost dev) |
68+| `GLEAN_OAUTH_REDIRECT_URL` | _(empty)_ | OAuth redirect URL (leave empty for localhost dev) |
69+| `GLEAN_EMBED_BASE_URL` | _(empty)_ | Embeddings API base URL (recommended, see below) |
70+| `GLEAN_EMBED_API_KEY` | _(empty)_ | API key for the embeddings endpoint |
71+| `GLEAN_EMBED_MODEL` | `text-embedding-3-small` | Embedding model name |
72+| `GLEAN_EMBED_DIMENSION` | `1536` | Embedding vector dimension |
73+| `GLEAN_LLM_BASE_URL` | _(empty)_ | LLM API base URL for language detection and digest summaries (see below) |
74+| `GLEAN_LLM_API_KEY` | _(empty)_ | API key for the LLM endpoint |
75+| `GLEAN_LLM_MODEL` | `gpt-4o-mini` | LLM model name |
76+| `GLEAN_PPROF_ADDR` | _(empty)_ | Enable pprof profiling server (e.g. `:6060`, off by default) |
7677
7778 For production:
7879
@@ -13,6 +13,7 @@ Your subscriptions live as records on your PDS. You own them. If Glean goes away
13 - [margin.at](https://margin.at) annotations displayed alongside glean annotations13 - [margin.at](https://margin.at) annotations displayed alongside glean annotations
14 - A trending page showing what's popular across all users14 - A trending page showing what's popular across all users
15 - Feed and people recommendations based on reading overlap15 - Feed and people recommendations based on reading overlap
16+- Daily digest with an AI-generated summary of your unread articles
16 - OPML import and export17 - OPML import and export
17 - Sign in with Bluesky / Atmosphere account — no new account needed18 - Sign in with Bluesky / Atmosphere account — no new account needed
18 19
@@ -51,28 +52,28 @@ Then open `http://localhost:8080`.
51 52
52 ## Configuration53 ## Configuration
53 54
54-| Variable | Default | What it does |55+| Variable | Default | What it does |
55-| ---------------------------- | -------------------------- | ---------------------------------------------------------- |56+| ---------------------------- | -------------------------- | ------------------------------------------------------------------------ |
56-| `GLEAN_SESSION_KEY` | _(required)_ | Secret key for signing session cookies (any random string) |57+| `GLEAN_SESSION_KEY` | _(required)_ | Secret key for signing session cookies (any random string) |
57-| `GLEAN_ADDR` | `:8080` | Listen address |58+| `GLEAN_ADDR` | `:8080` | Listen address |
58-| `GLEAN_DB` | `glean.db` | SQLite base path (`_users`, `_articles`, `_recs` suffixes) |59+| `GLEAN_DB` | `glean.db` | SQLite base path (`_users`, `_articles`, `_recs` suffixes) |
59-| `GLEAN_JETSTREAM` | `wss://jetstream.glean.at` | Jetstream WebSocket URL |60+| `GLEAN_JETSTREAM` | `wss://jetstream.glean.at` | Jetstream WebSocket URL |
60-| `GLEAN_SYNC_INTERVAL` | `8h` | PDS sync interval (Go duration: `24h`, `12h`, etc.) |61+| `GLEAN_SYNC_INTERVAL` | `8h` | PDS sync interval (Go duration: `24h`, `12h`, etc.) |
61-| `GLEAN_CLUSTER_INTERVAL` | `1h` | Cluster recomputation interval (Go duration) |62+| `GLEAN_CLUSTER_INTERVAL` | `1h` | Cluster recomputation interval (Go duration) |
62-| `GLEAN_FETCH_INTERVAL` | `15m` | Feed fetch scheduler tick interval (Go duration) |63+| `GLEAN_FETCH_INTERVAL` | `15m` | Feed fetch scheduler tick interval (Go duration) |
63-| `GLEAN_COLLECTION_DIR_URL` | _(empty)_ | Collection directory URL for startup backfill |64+| `GLEAN_COLLECTION_DIR_URL` | _(empty)_ | Collection directory URL for startup backfill |
64-| `GLEAN_BACKFILL_CONCURRENCY` | `5` | Max concurrent backfill workers |65+| `GLEAN_BACKFILL_CONCURRENCY` | `5` | Max concurrent backfill workers |
65-| `GLEAN_PLC_URL` | `https://didplc.glean.at` | PLC directory URL for DID resolution |66+| `GLEAN_PLC_URL` | `https://didplc.glean.at` | PLC directory URL for DID resolution |
66-| `GLEAN_OAUTH_CLIENT_ID` | _(empty)_ | OAuth client metadata URL (leave empty for localhost dev) |67+| `GLEAN_OAUTH_CLIENT_ID` | _(empty)_ | OAuth client metadata URL (leave empty for localhost dev) |
67-| `GLEAN_OAUTH_REDIRECT_URL` | _(empty)_ | OAuth redirect URL (leave empty for localhost dev) |68+| `GLEAN_OAUTH_REDIRECT_URL` | _(empty)_ | OAuth redirect URL (leave empty for localhost dev) |
68-| `GLEAN_EMBED_BASE_URL` | _(empty)_ | Embeddings API base URL (recommended, see below) |69+| `GLEAN_EMBED_BASE_URL` | _(empty)_ | Embeddings API base URL (recommended, see below) |
69-| `GLEAN_EMBED_API_KEY` | _(empty)_ | API key for the embeddings endpoint |70+| `GLEAN_EMBED_API_KEY` | _(empty)_ | API key for the embeddings endpoint |
70-| `GLEAN_EMBED_MODEL` | `text-embedding-3-small` | Embedding model name |71+| `GLEAN_EMBED_MODEL` | `text-embedding-3-small` | Embedding model name |
71-| `GLEAN_EMBED_DIMENSION` | `1536` | Embedding vector dimension |72+| `GLEAN_EMBED_DIMENSION` | `1536` | Embedding vector dimension |
72-| `GLEAN_LLM_BASE_URL` | _(empty)_ | LLM API base URL for language detection (see below) |73+| `GLEAN_LLM_BASE_URL` | _(empty)_ | LLM API base URL for language detection and digest summaries (see below) |
73-| `GLEAN_LLM_API_KEY` | _(empty)_ | API key for the LLM endpoint |74+| `GLEAN_LLM_API_KEY` | _(empty)_ | API key for the LLM endpoint |
74-| `GLEAN_LLM_MODEL` | `gpt-4o-mini` | LLM model name |75+| `GLEAN_LLM_MODEL` | `gpt-4o-mini` | LLM model name |
75-| `GLEAN_PPROF_ADDR` | _(empty)_ | Enable pprof profiling server (e.g. `:6060`, off by default) |76+| `GLEAN_PPROF_ADDR` | _(empty)_ | Enable pprof profiling server (e.g. `:6060`, off by default) |
76 77
77 For production:78 For production:
78 79