all repos — llm_aggregator @ 7b6943f1a3f252697b0e16d94c9635a5e064090b

A CLI tool to aggregate RSS feeds and summarise them with LLMs

feat: initial implementation of LLM RSS aggregator with DeepSeek summarisation

- Add core aggregation engine (`internal/aggregator/`)
  - Parse RSS/Atom/JSON feeds using `gofeed`
  - Extract article metadata (title, link, date, author)
  - Fallback to web scraping with `goquery` for minimal content
- Add content processing and filtering (`internal/processor/`)
  - Keyword-based include/exclude filtering
  - Sort by date, title, or source
  - Token estimation and context preparation
- Add DeepSeek API integration (`internal/llm/`)
  - OpenAI-compatible client for chat completions
  - Configurable model, max tokens, temperature
  - Support custom system prompts
- Add command-line interface (`internal/cli/`)
  - `go-arg` based argument parsing
  - Options for feeds file, prompt, filtering, output formats
  - Environment variable support for API key
- Add output formatting (`internal/output/`)
  - Plain text, GitHub‑flavoured markdown, and JSON outputs
  - Optional inclusion of original articles
- Add terminal user interface (`internal/tui/`)
  - Bubble Tea based progress bar with gradient colours
  - Live article counters and elapsed time
  - Keyboard controls (q/Ctrl+C to quit)
- Add runtime orchestration (`internal/runtime/`)
  - Pipeline execution (aggregate → process → summarise → output)
  - File output support
- Add project documentation
  - `README.md` with usage examples and dependencies
  - `CHANGELOG.md` for version 0.1.0
  - `LICENCE.txt` (EUPL 1.2)
  - Third‑party library READMEs in `docs/` for reference
- Add build configuration
  - `go.mod` with dependencies (gofeed, openai-go, bubbletea, lipgloss,
    go-arg, goquery)
  - `.goreleaser.yaml` for cross‑platform releases
  - `.gitignore` for Go binaries and test artifacts
Maxwell Jensen 85795372+maxwelljens@users.noreply.github.com
Tue, 21 Apr 2026 14:44:42 +0200
commit

7b6943f1a3f252697b0e16d94c9635a5e064090b

A .gitignore

@@ -0,0 +1,30 @@

+# Binaries for programs and plugins +llm_aggregator +*.exe +*.exe~ +*.dll +*.so +*.dylib +dist + +# Test binary, built with `go test -c` +*.test + +# Code coverage profiles and other test artifacts +*.out +coverage.* +*.coverprofile +profile.cov + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work +go.work.sum + +# env file +.env + +# Honk +.goosehints
A .goreleaser.yaml

@@ -0,0 +1,68 @@

+version: 2 + +builds: + - binary: llm_aggregator + env: + - CGO_ENABLED=0 + ldflags: + - -s -w -X main.buildDate={{.Date}} -X main.version={{.Version}} + goos: + - linux + - windows + - darwin + goarch: + - arm64 + - amd64 + - "386" + dir: cmd + +archives: + - formats: [tar.gz] + # this name template makes the OS and Arch compatible with the results of `uname`. + name_template: >- + {{ .ProjectName }}_ + {{- title .Os }}_ + {{- if eq .Arch "amd64" }}x86_64 + {{- else if eq .Arch "386" }}i386 + {{- else }}{{ .Arch }}{{ end }} + {{- if .Arm }}v{{ .Arm }}{{ end }} + # use zip for windows archives + format_overrides: + - goos: windows + formats: [zip] + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + +upx: + - # Templates: allowed. + enabled: true + + # Filter by build ID. + ids: [build1, build2] + + # Filter by GOOS. + goos: [linux, darwin] + + # Filter by GOARCH. + goarch: [arm, amd64] + + # Filter by GOARM. + goarm: [8] + + # Filter by GOAMD64. + goamd64: [v1] + + # Compress argument. + # Valid options are from '1' (faster) to '9' (better), and 'best'. + compress: best + + # Whether to try LZMA (slower). + lzma: true + + # Whether to try all methods and filters (slow). + brute: true
A CHANGELOG.md

@@ -0,0 +1,46 @@

+# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a +Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to +[Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.1.0] - 2026-04-21 + +### Added + +- Complete translation from Python prototype to Go implementation +- RSS/Atom feed parsing using `gofeed` library +- DeepSeek API integration via `openai-go` client, configured for `/chat/completions` endpoint +- Content filtering and processing with keyword‑based include/exclude, date filtering, and sorting +- Command‑line interface using `go‑arg` with automatic help and version flags +- Multiple output formats: plain text, GitHub‑flavoured markdown, and JSON +- Terminal user interface (TUI) built with `bubbletea` featuring: + - Animated progress bar with gradient colours (`#FF7CCB` to `#FDFF8C`) + - Live article counters (aggregated/processed) + - Elapsed time display + - Coloured status indicators using `lipgloss` styling + - Keyboard controls (q/Ctrl+C to quit) +- Web content extraction fallback using `goquery` when feed descriptions are minimal +- Configurable limits: articles per feed, maximum age, total articles +- Token estimation and API usage logging +- Environment variable support (`DEEPSEEK_API_KEY`) for authentication +- Example feeds file with technology, programming, and free software sources + +### Changed + +- Program structure organised into standard Go layout: `cmd/`, `internal/`, `pkg/` +- English spelling conventions maintained throughout (colour, initialise, summarise) +- Error handling improved with specific messages for common API failures (invalid key, rate limits, etc.) +- Progress reporting unified through a `ProgressContext` interface for both TUI and CLI modes + +### Fixed + +- Initial API endpoint mismatch (using `/responses` instead of `/chat/completions`) +- Nil pointer dereferences when handling optional feed metadata (author, dates) +- String repetition syntax errors in Go (replaced `"="*80` with `strings.Repeat`) +- CLI help/version flag handling to show information without requiring other arguments +- Type compatibility issues with `openai-go` v3 API (message parameters, token usage fields)
A LICENCE.txt

@@ -0,0 +1,287 @@

+ EUROPEAN UNION PUBLIC LICENCE v. 1.2 + EUPL © the European Union 2007, 2016 + +This European Union Public Licence (the ‘EUPL’) applies to the Work (as defined +below) which is provided under the terms of this Licence. Any use of the Work, +other than as authorised under this Licence is prohibited (to the extent such +use is covered by a right of the copyright holder of the Work). + +The Work is provided under the terms of this Licence when the Licensor (as +defined below) has placed the following notice immediately following the +copyright notice for the Work: + + Licensed under the EUPL + +or has expressed by any other means his willingness to license under the EUPL. + +1. Definitions + +In this Licence, the following terms have the following meaning: + +- ‘The Licence’: this Licence. + +- ‘The Original Work’: the work or software distributed or communicated by the + Licensor under this Licence, available as Source Code and also as Executable + Code as the case may be. + +- ‘Derivative Works’: the works or software that could be created by the + Licensee, based upon the Original Work or modifications thereof. This Licence + does not define the extent of modification or dependence on the Original Work + required in order to classify a work as a Derivative Work; this extent is + determined by copyright law applicable in the country mentioned in Article 15. + +- ‘The Work’: the Original Work or its Derivative Works. + +- ‘The Source Code’: the human-readable form of the Work which is the most + convenient for people to study and modify. + +- ‘The Executable Code’: any code which has generally been compiled and which is + meant to be interpreted by a computer as a program. + +- ‘The Licensor’: the natural or legal person that distributes or communicates + the Work under the Licence. + +- ‘Contributor(s)’: any natural or legal person who modifies the Work under the + Licence, or otherwise contributes to the creation of a Derivative Work. + +- ‘The Licensee’ or ‘You’: any natural or legal person who makes any usage of + the Work under the terms of the Licence. + +- ‘Distribution’ or ‘Communication’: any act of selling, giving, lending, + renting, distributing, communicating, transmitting, or otherwise making + available, online or offline, copies of the Work or providing access to its + essential functionalities at the disposal of any other natural or legal + person. + +2. Scope of the rights granted by the Licence + +The Licensor hereby grants You a worldwide, royalty-free, non-exclusive, +sublicensable licence to do the following, for the duration of copyright vested +in the Original Work: + +- use the Work in any circumstance and for all usage, +- reproduce the Work, +- modify the Work, and make Derivative Works based upon the Work, +- communicate to the public, including the right to make available or display + the Work or copies thereof to the public and perform publicly, as the case may + be, the Work, +- distribute the Work or copies thereof, +- lend and rent the Work or copies thereof, +- sublicense rights in the Work or copies thereof. + +Those rights can be exercised on any media, supports and formats, whether now +known or later invented, as far as the applicable law permits so. + +In the countries where moral rights apply, the Licensor waives his right to +exercise his moral right to the extent allowed by law in order to make effective +the licence of the economic rights here above listed. + +The Licensor grants to the Licensee royalty-free, non-exclusive usage rights to +any patents held by the Licensor, to the extent necessary to make use of the +rights granted on the Work under this Licence. + +3. Communication of the Source Code + +The Licensor may provide the Work either in its Source Code form, or as +Executable Code. If the Work is provided as Executable Code, the Licensor +provides in addition a machine-readable copy of the Source Code of the Work +along with each copy of the Work that the Licensor distributes or indicates, in +a notice following the copyright notice attached to the Work, a repository where +the Source Code is easily and freely accessible for as long as the Licensor +continues to distribute or communicate the Work. + +4. Limitations on copyright + +Nothing in this Licence is intended to deprive the Licensee of the benefits from +any exception or limitation to the exclusive rights of the rights owners in the +Work, of the exhaustion of those rights or of other applicable limitations +thereto. + +5. Obligations of the Licensee + +The grant of the rights mentioned above is subject to some restrictions and +obligations imposed on the Licensee. Those obligations are the following: + +Attribution right: The Licensee shall keep intact all copyright, patent or +trademarks notices and all notices that refer to the Licence and to the +disclaimer of warranties. The Licensee must include a copy of such notices and a +copy of the Licence with every copy of the Work he/she distributes or +communicates. The Licensee must cause any Derivative Work to carry prominent +notices stating that the Work has been modified and the date of modification. + +Copyleft clause: If the Licensee distributes or communicates copies of the +Original Works or Derivative Works, this Distribution or Communication will be +done under the terms of this Licence or of a later version of this Licence +unless the Original Work is expressly distributed only under this version of the +Licence — for example by communicating ‘EUPL v. 1.2 only’. The Licensee +(becoming Licensor) cannot offer or impose any additional terms or conditions on +the Work or Derivative Work that alter or restrict the terms of the Licence. + +Compatibility clause: If the Licensee Distributes or Communicates Derivative +Works or copies thereof based upon both the Work and another work licensed under +a Compatible Licence, this Distribution or Communication can be done under the +terms of this Compatible Licence. For the sake of this clause, ‘Compatible +Licence’ refers to the licences listed in the appendix attached to this Licence. +Should the Licensee's obligations under the Compatible Licence conflict with +his/her obligations under this Licence, the obligations of the Compatible +Licence shall prevail. + +Provision of Source Code: When distributing or communicating copies of the Work, +the Licensee will provide a machine-readable copy of the Source Code or indicate +a repository where this Source will be easily and freely available for as long +as the Licensee continues to distribute or communicate the Work. + +Legal Protection: This Licence does not grant permission to use the trade names, +trademarks, service marks, or names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and +reproducing the content of the copyright notice. + +6. Chain of Authorship + +The original Licensor warrants that the copyright in the Original Work granted +hereunder is owned by him/her or licensed to him/her and that he/she has the +power and authority to grant the Licence. + +Each Contributor warrants that the copyright in the modifications he/she brings +to the Work are owned by him/her or licensed to him/her and that he/she has the +power and authority to grant the Licence. + +Each time You accept the Licence, the original Licensor and subsequent +Contributors grant You a licence to their contributions to the Work, under the +terms of this Licence. + +7. Disclaimer of Warranty + +The Work is a work in progress, which is continuously improved by numerous +Contributors. It is not a finished work and may therefore contain defects or +‘bugs’ inherent to this type of development. + +For the above reason, the Work is provided under the Licence on an ‘as is’ basis +and without warranties of any kind concerning the Work, including without +limitation merchantability, fitness for a particular purpose, absence of defects +or errors, accuracy, non-infringement of intellectual property rights other than +copyright as stated in Article 6 of this Licence. + +This disclaimer of warranty is an essential part of the Licence and a condition +for the grant of any rights to the Work. + +8. Disclaimer of Liability + +Except in the cases of wilful misconduct or damages directly caused to natural +persons, the Licensor will in no event be liable for any direct or indirect, +material or moral, damages of any kind, arising out of the Licence or of the use +of the Work, including without limitation, damages for loss of goodwill, work +stoppage, computer failure or malfunction, loss of data or any commercial +damage, even if the Licensor has been advised of the possibility of such damage. +However, the Licensor will be liable under statutory product liability laws as +far such laws apply to the Work. + +9. Additional agreements + +While distributing the Work, You may choose to conclude an additional agreement, +defining obligations or services consistent with this Licence. However, if +accepting obligations, You may act only on your own behalf and on your sole +responsibility, not on behalf of the original Licensor or any other Contributor, +and only if You agree to indemnify, defend, and hold each Contributor harmless +for any liability incurred by, or claims asserted against such Contributor by +the fact You have accepted any warranty or additional liability. + +10. Acceptance of the Licence + +The provisions of this Licence can be accepted by clicking on an icon ‘I agree’ +placed under the bottom of a window displaying the text of this Licence or by +affirming consent in any other similar way, in accordance with the rules of +applicable law. Clicking on that icon indicates your clear and irrevocable +acceptance of this Licence and all of its terms and conditions. + +Similarly, you irrevocably accept this Licence and all of its terms and +conditions by exercising any rights granted to You by Article 2 of this Licence, +such as the use of the Work, the creation by You of a Derivative Work or the +Distribution or Communication by You of the Work or copies thereof. + +11. Information to the public + +In case of any Distribution or Communication of the Work by means of electronic +communication by You (for example, by offering to download the Work from a +remote location) the distribution channel or media (for example, a website) must +at least provide to the public the information requested by the applicable law +regarding the Licensor, the Licence and the way it may be accessible, concluded, +stored and reproduced by the Licensee. + +12. Termination of the Licence + +The Licence and the rights granted hereunder will terminate automatically upon +any breach by the Licensee of the terms of the Licence. + +Such a termination will not terminate the licences of any person who has +received the Work from the Licensee under the Licence, provided such persons +remain in full compliance with the Licence. + +13. Miscellaneous + +Without prejudice of Article 9 above, the Licence represents the complete +agreement between the Parties as to the Work. + +If any provision of the Licence is invalid or unenforceable under applicable +law, this will not affect the validity or enforceability of the Licence as a +whole. Such provision will be construed or reformed so as necessary to make it +valid and enforceable. + +The European Commission may publish other linguistic versions or new versions of +this Licence or updated versions of the Appendix, so far this is required and +reasonable, without reducing the scope of the rights granted by the Licence. New +versions of the Licence will be published with a unique version number. + +All linguistic versions of this Licence, approved by the European Commission, +have identical value. Parties can take advantage of the linguistic version of +their choice. + +14. Jurisdiction + +Without prejudice to specific agreement between parties, + +- any litigation resulting from the interpretation of this License, arising + between the European Union institutions, bodies, offices or agencies, as a + Licensor, and any Licensee, will be subject to the jurisdiction of the Court + of Justice of the European Union, as laid down in article 272 of the Treaty on + the Functioning of the European Union, + +- any litigation arising between other parties and resulting from the + interpretation of this License, will be subject to the exclusive jurisdiction + of the competent court where the Licensor resides or conducts its primary + business. + +15. Applicable Law + +Without prejudice to specific agreement between parties, + +- this Licence shall be governed by the law of the European Union Member State + where the Licensor has his seat, resides or has his registered office, + +- this licence shall be governed by Belgian law if the Licensor has no seat, + residence or registered office inside a European Union Member State. + +Appendix + +‘Compatible Licences’ according to Article 5 EUPL are: + +- GNU General Public License (GPL) v. 2, v. 3 +- GNU Affero General Public License (AGPL) v. 3 +- Open Software License (OSL) v. 2.1, v. 3.0 +- Eclipse Public License (EPL) v. 1.0 +- CeCILL v. 2.0, v. 2.1 +- Mozilla Public Licence (MPL) v. 2 +- GNU Lesser General Public Licence (LGPL) v. 2.1, v. 3 +- Creative Commons Attribution-ShareAlike v. 3.0 Unported (CC BY-SA 3.0) for + works other than software +- European Union Public Licence (EUPL) v. 1.1, v. 1.2 +- Québec Free and Open-Source Licence — Reciprocity (LiLiQ-R) or Strong + Reciprocity (LiLiQ-R+). + +The European Commission may update this Appendix to later versions of the above +licences without producing a new version of the EUPL, as long as they provide +the rights granted in Article 2 of this Licence and protect the covered Source +Code from exclusive appropriation. + +All other changes or additions to this Appendix require the production of a new +EUPL version.
A README.md

@@ -0,0 +1,162 @@

+<h1 align="center">llm_aggregator</h1> + +<p align="center"> + <strong>A CLI tool to aggregate RSS feeds and summarise them with LLMs</strong> +</p> + +![Codeberg Release](https://img.shields.io/gitea/v/release/maxwelljensen/llm_aggregator?gitea_url=https%3A%2F%2Fcodeberg.org&style=for-the-badge) +![Codeberg License](assets/eupl-12-badge.svg) + +--- + +## What is `llm_aggregator`? + +`llm_aggregator` is a command‑line utility that fetches articles from multiple +RSS feeds, filters and processes the content, and sends it to an LLM through +OpenAI-compatible API to generate a concise summary or analysis. It’s designed +for keeping up with news and articles from your favourite sources without +having to read dozens or hundreds of individual posts. + +The tool includes a WIP terminal user interface (TUI) built with +`bubbletea` and `lipgloss` that shows a live progress bar, article counts, and +elapsed time while the aggregation runs. + +## How do I use `llm_aggregator`? + + llm_aggregator --feeds-file FEEDS-FILE --prompt PROMPT [OPTIONS]... + +By default, `llm_aggregator` reads a list of RSS feed URLs, fetches the +articles, filters them by date and keywords, and sends a summary request to +Deepseek. The resulting output is printed to the terminal in your chosen format +(text, markdown, or JSON). + +### Basic Options + + --feeds-file FILE Path to file containing RSS feed URLs (one per line) + --prompt PROMPT User prompt for summarisation/analysis + --api-key KEY Deepseek API key (default: read from DEEPSEEK_API_KEY env var) + --model MODEL Deepseek model to use (default: deepseek-chat) + --max-tokens N Maximum tokens in response (default: 4000) + --output FORMAT Output format: text, markdown, or json (default: text) + --output-file FILE Write output to FILE instead of stdout + --tui Enable TUI interface with progress bar + --verbose, -v Enable verbose logging + --help, -h Show this help message and exit + --version Show version information and exit + +### Filtering & Processing + + --max-articles-per-feed N Maximum articles to fetch from each feed (default: 10) + --max-days-old N Only include articles from the last N days (0 for all) (default: 7) + --max-total-articles N Maximum total articles to process (default: 20) + --include-keywords LIST Comma-separated list of keywords to include (case‑insensitive) + --exclude-keywords LIST Comma-separated list of keywords to exclude (case‑insensitive) + --include-articles Include original articles in JSON output + +### Deepseek Configuration + + --temperature VALUE Sampling temperature (0.0 to 1.0) (default: 0.7) + --system-prompt TEXT Custom system prompt for Deepseek + +### Examples + +```bash +# Basic usage: summarise tech news from a list of feeds +llm_aggregator --feeds-file feeds.txt \ +--prompt "What are the latest AI-related trends in free software?" + +# With TUI progress bar +llm_aggregator --feeds-file feeds.txt --prompt "Summarise tech news" --tui + +# Output to a JSON file with included articles +llm_aggregator --feeds-file feeds.txt --prompt "Analyse AI developments" \ + --output json --output-file analysis.json --include-articles + +# Filter by keywords (only include articles about Linux or open source) +llm_aggregator --feeds-file feeds.txt --prompt "Linux news" \ + --include-keywords linux,opensource --max-days-old 3 + +# Use a custom Deepseek model and higher token limit +llm_aggregator --feeds-file feeds.txt --prompt "Code analysis" \ + --model deepseek-coder --max-tokens 8000 + +# Show version information +llm_aggregator --version + +# Show help message +llm_aggregator --help +``` + +## How does `llm_aggregator` work? + +`llm_aggregator` performs the following steps for each run: + +1. Parse command‑line arguments +2. Read the feeds file: a plain text file containing one RSS/Atom feed URL per + line. +3. Fetch and parse each feed. RSS, Atom, and JSON Feed formats are supported. +4. **Extract article content**: for each feed entry, the tool extracts the + title, link, publication date, author, and description. If the feed provides + only a snippet, it can optionally fetch the full webpage using `goquery` to + extract the main content. +5. **Filter and sort articles**: articles are filtered by age (configurable + with `--max-days-old`), optionally filtered by keywords (include/exclude), + and sorted by date, title, or source. +6. Prepare the prompt with selected articles, formatted into a context + string that is sent to the LLM along with the user’s custom prompt. +7. Call the OpenAI API via the `openai‑go` client. +8. **Format and output the result**: the AI’s response is printed in the chosen + format (plain text, GitHub‑flavoured markdown, or JSON). If JSON output is + selected, the original articles can be included alongside the summary. + +When the `--tui` flag is used, the entire process is wrapped in a `bubbletea` +TUI that shows a colourful progress bar, live article counters, and elapsed +time (WIP). + +## Dependencies + +`llm_aggregator` is written in Go and uses the following libraries: + +* [`gofeed`](https://github.com/mmcdole/gofeed): a robust RSS/Atom/JSON feed parser +* [`openai‑go`](https://github.com/openai/openai-go): the official OpenAI Go + SDK, configured to work with Deepseek’s compatible API +* [`bubbletea`](https://github.com/charmbracelet/bubbletea): a TUI framework + for building terminal applications +* [`lipgloss`](https://github.com/charmbracelet/lipgloss): a library for + styling terminal output (colours, borders, alignment) +* [`go‑arg`](https://github.com/alexflint/go-arg): struct‑based argument + parsing with automatic help and version flags +* [`goquery`](https://github.com/PuerkitoBio/goquery): a jQuery‑like HTML + scraping library (used as a fallback when feed content is minimal) + +## How do I build `llm_aggregator`? + +`llm_aggregator` can be built with a standard Go toolchain: + + go build ./cmd/llm_aggregator.go + +## Configuration file + +A configuration file is not yet implemented; all options are passed via +command‑line arguments or environment variables. The API key can be provided +either with `--api-key` or by setting the `DEEPSEEK_API_KEY` environment +variable. + +## Example feeds file + +Create a file named `feeds.txt` with your favourite RSS feeds, one per line. +For example: + + https://news.ycombinator.com/rss + https://lwn.net/headlines/newrss + https://opensource.com/feed + https://www.phoronix.com/rss.php + +Then run: + + llm_aggregator --feeds-file feeds.txt --prompt "Summarise the top tech stories" + +## Licence + +This project is licensed under [European Union Public Licence +1.2](https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12).
A assets/eupl-12-badge.svg

@@ -0,0 +1,1 @@

+<svg xmlns="http://www.w3.org/2000/svg" width="175.75" height="28" role="img" aria-label="LICENCE: EUPL 1.2"><title>LICENCE: EUPL 1.2</title><a target="_blank" href="https://interoperable-europe.ec.europa.eu/collection/eupl/eupl-text-eupl-12"><g shape-rendering="crispEdges"><rect width="92.75" height="28" fill="#555"/><rect x="92.75" width="83" height="28" fill="#4c1"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="100"><image x="9" y="7" width="14" height="14" href="data:image/svg+xml;base64,PHN2ZyBmaWxsPSJ3aGl0ZXNtb2tlIiByb2xlPSJpbWciIHZpZXdCb3g9IjAgMCAyNCAyNCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48dGl0bGU+RXVyb3BlYW4gVW5pb248L3RpdGxlPjxwYXRoIGQ9Ik0xMS4zNzMgMS45NCAxMC4zNiAxLjJoMS4yNTNMMTIgMGwuMzg3IDEuMmgxLjI1M2wtMS4wMTMuNzQuMzg2IDEuMjA3TDEyIDIuNGwtMS4wMTMuNzQ3Wm0xLjI1NCAyMC44Ni4zODYgMS4yTDEyIDIzLjI2bC0xLjAxMy43NC4zODYtMS4yLTEuMDEzLS43NGgxLjI1M0wxMiAyMC44NTNsLjM4NyAxLjIwN2gxLjI1M1pNMS42NCAxMi44bC0xLjAxMy43NDcuMzg2LTEuMkwwIDExLjYyN2gxLjI1M2wuMzg3LTEuMi4zODcgMS4yaDEuMjZsLTEuMDIuNzQ2LjM4NiAxLjItMS4wMTMtLjc0NlptNS44MDctOS40NjcuMzg2IDEuMkw2LjgyIDMuOGwtMS4wMTMuNzQuMzg2LTEuMkw1LjE4IDIuNmgxLjI1M2wuMzg3LTEuMi4zODcgMS4ySDguNDZabS00Ljc4IDMuMDguMzg2LTEuMi4zOTQgMS4yaDEuMjJsLTEuMDE0Ljc0Ny4zODcgMS4yLTEuMDItLjc0N0wyIDguMzZsLjM4Ny0xLjItMS4wMTQtLjc0N1pNMS4zODcgMTYuODRoMS4yOGwuMzg2LTEuMi4zOTQgMS4yaDEuMjJsLTEuMDE0Ljc0Ny4zODcgMS4yLTEuMDItLjc0LTEuMDIuNzQuMzg3LTEuMi0xLjAxNC0uNzQ3Wm00LjgwNiA0LjU2LTEuMDEzLS43MzNoMS4yNTNsLjM4Ny0xLjIuMzg3IDEuMkg4LjQ2bC0xLjAxMy43MzMuMzg2IDEuMi0xLjAxMy0uNzQtMS4wMTMuNzRabTE2Ljc5NC05LjAyNy4zODYgMS4yLTEuMDEzLS43NDYtMS4wMjcuNzQ2LjM4Ny0xLjItMS4wMi0uNzQ2SDIybC4zODctMS4yLjM4NiAxLjJIMjRabS02LjQzNC05LjA0TDE1LjU0IDIuNmgxLjI1M2wuMzg3LTEuMi4zODcgMS4yaDEuMjUzbC0xLjAxMy43MzMuMzg2IDEuMkwxNy4xOCAzLjhsLTEuMDEzLjc0LjM4Ni0xLjJabTQgMy4wNzQuMzk0LTEuMi4zODYgMS4yaDEuMjU0bC0uOTg3Ljc1My4zODcgMS4yLTEuMDE0LS43NDctMS4wMi43NDcuMzg3LTEuMi0xLjAwNy0uNzQ3Wm0uNzggMTAuNDMzaDEuMjU0bC0uOTg3Ljc0Ny4zODcgMS4yLTEuMDE0LS43NC0xLjAyLjc0LjM4Ny0xLjItMS4wMDctLjc0N2gxLjI1NGwuMzkzLTEuMi4zODcgMS4yem0tMi41MTMgMy44MjctMS4wMTMuNzMzLjM4NiAxLjItMS4wMTMtLjc0LTEuMDEzLjc0LjM4Ni0xLjItMS4wMTMtLjczM2gxLjI1M2wuMzg3LTEuMi4zODcgMS4yeiIvPjwvc3ZnPg=="/><text transform="scale(.1)" x="548.75" y="175" textLength="517.5" fill="#fff">LICENCE</text><text transform="scale(.1)" x="1342.5" y="175" textLength="590" fill="#fff" font-weight="bold">EUPL 1.2</text></g></a></svg>
A cmd/llm_aggregator.go

@@ -0,0 +1,175 @@

+package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + + "llm_aggregator/internal/cli" + "llm_aggregator/internal/runtime" + "llm_aggregator/internal/tui" + + tea "github.com/charmbracelet/bubbletea" +) + +var ( + version string + buildDate string +) + +func main() { + cli.BuildDate = buildDate + cli.Version = version + // Parse command line arguments + args, err := cli.ParseArgs() + if err != nil { + fmt.Fprintf(os.Stderr, "Error parsing arguments: %v\n", err) + os.Exit(1) + } + + // Create runtime configuration + rt := runtime.NewRuntime() + rt.FeedsFile = args.FeedsFile + rt.MaxArticlesPerFeed = args.MaxArticlesPerFeed + rt.MaxDaysOld = args.MaxDaysOld + rt.MaxTotalArticles = args.MaxTotalArticles + rt.IncludeKeywords = cli.ParseKeywords(args.IncludeKeywords) + rt.ExcludeKeywords = cli.ParseKeywords(args.ExcludeKeywords) + rt.APIKey = args.APIKey + if rt.APIKey == "" { + rt.APIKey = os.Getenv("DEEPSEEK_API_KEY") + } + rt.Model = args.Model + rt.MaxTokens = args.MaxTokens + rt.Temperature = args.Temperature + rt.Prompt = args.Prompt + rt.SystemPrompt = args.SystemPrompt + rt.Output = args.Output + rt.OutputFile = args.OutputFile + rt.IncludeArticles = args.IncludeArticles + + // Validate API key + if rt.APIKey == "" { + fmt.Fprintln(os.Stderr, "Error: DeepSeek API key is required. Set via --api-key or DEEPSEEK_API_KEY environment variable.") + os.Exit(1) + } + + // Run with TUI if requested + if args.TUI { + runWithTUI(rt) + } else { + runWithoutTUI(rt, args.Verbose) + } +} + +func runWithTUI(rt *runtime.Runtime) { + // Create TUI model + m := tui.New() + p := tea.NewProgram(m, tea.WithAltScreen()) + + // Run TUI in goroutine + done := make(chan error) + go func() { + if _, err := p.Run(); err != nil { + done <- err + return + } + done <- nil + }() + + // Run aggregation in another goroutine + go func() { + // Create context for cancellation + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Set up signal handling + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + + // Handle signals + go func() { + <-sigCh + p.Send(tui.Error("Operation cancelled")) + cancel() + }() + + // Update TUI status + p.Send(tui.Step(1, fmt.Sprintf("Aggregating feeds from: %s", rt.FeedsFile))) + + // Execute the runtime + err := rt.Execute(ctx) + if err != nil { + p.Send(tui.Error(err.Error())) + return + } + + // Update TUI status + p.Send(tui.StepWithCounts(5, "Formatting output", len(rt.Articles), len(rt.Articles))) + + // Write output + if rt.OutputFile != "" { + if err := rt.WriteOutputToFile(); err != nil { + p.Send(tui.Error(fmt.Sprintf("Error writing output: %v", err))) + return + } + p.Send(tui.Step(6, fmt.Sprintf("Output written to: %s", rt.OutputFile))) + } else { + if err := rt.WriteOutput(os.Stdout); err != nil { + p.Send(tui.Error(fmt.Sprintf("Error writing output: %v", err))) + return + } + p.Send(tui.Step(6, "Output complete")) + } + + // Mark as done + p.Send(tui.Step(7, "Processing completed successfully")) + }() + + // Wait for TUI to complete + if err := <-done; err != nil { + fmt.Fprintf(os.Stderr, "TUI error: %v\n", err) + os.Exit(1) + } +} + +func runWithoutTUI(rt *runtime.Runtime, verbose bool) { + if verbose { + fmt.Printf("Aggregating feeds from: %s\n", rt.FeedsFile) + } + + // Execute the runtime + err := rt.Execute(context.Background()) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + if verbose { + fmt.Printf("Aggregated %d articles\n", len(rt.Articles)) + fmt.Printf("Requesting summary from DeepSeek with model: %s\n", rt.Model) + } + + // Write output + if rt.OutputFile != "" { + if err := rt.WriteOutputToFile(); err != nil { + fmt.Fprintf(os.Stderr, "Error writing output: %v\n", err) + os.Exit(1) + } + if verbose { + fmt.Printf("Output written to: %s\n", rt.OutputFile) + } + } else { + if err := rt.WriteOutput(os.Stdout); err != nil { + fmt.Fprintf(os.Stderr, "Error writing output: %v\n", err) + os.Exit(1) + } + } + + if verbose { + fmt.Println("Processing completed successfully") + } +} +
A go.mod

@@ -0,0 +1,49 @@

+module llm_aggregator + +go 1.25.8 + +require ( + github.com/PuerkitoBio/goquery v1.8.0 + github.com/alexflint/go-arg v1.6.1 + github.com/mmcdole/gofeed v1.3.0 + github.com/openai/openai-go/v3 v3.32.0 +) + +require ( + github.com/alexflint/go-scalar v1.2.0 // indirect + github.com/andybalholm/cascadia v1.3.1 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/harmonica v0.2.0 // indirect + github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect +)
A go.sum

@@ -0,0 +1,115 @@

+github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U= +github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI= +github.com/alexflint/go-arg v1.6.1 h1:uZogJ6VDBjcuosydKgvYYRhh9sRCusjOvoOLZopBlnA= +github.com/alexflint/go-arg v1.6.1/go.mod h1:nQ0LFYftLJ6njcaee0sU+G0iS2+2XJQfA8I062D0LGc= +github.com/alexflint/go-scalar v1.2.0 h1:WR7JPKkeNpnYIOfHRa7ivM21aWAdHD0gEWHCx+WQBRw= +github.com/alexflint/go-scalar v1.2.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o= +github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c= +github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4= +github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE= +github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 h1:Zr92CAlFhy2gL+V1F+EyIuzbQNbSgP4xhTODZtrXUtk= +github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23/go.mod h1:v+25+lT2ViuQ7mVxcncQ8ch1URund48oH+jhjiwEgS8= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/openai/openai-go/v3 v3.32.0 h1:aHp/3wkX1W6jB8zTtf9xV0aK0qPFSVDqS7AHmlJ4hXs= +github.com/openai/openai-go/v3 v3.32.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
A internal/aggregator/aggregator.go

@@ -0,0 +1,311 @@

+package aggregator + +import ( + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + "llm_aggregator/internal/progress" + + "github.com/PuerkitoBio/goquery" + "github.com/mmcdole/gofeed" +) + +// FeedAggregator aggregates articles from multiple RSS feeds. +type FeedAggregator struct { + maxArticlesPerFeed int + maxDaysOld int + maxContentLength int + client *http.Client + userAgent string + progressCtx *progress.Context +} + +// NewFeedAggregator creates a new FeedAggregator with the specified options. +func NewFeedAggregator(maxArticlesPerFeed, maxDaysOld, maxContentLength int) *FeedAggregator { + return NewFeedAggregatorWithProgress(maxArticlesPerFeed, maxDaysOld, maxContentLength, nil) +} + +// NewFeedAggregatorWithProgress creates a new FeedAggregator with progress context. +func NewFeedAggregatorWithProgress(maxArticlesPerFeed, maxDaysOld, maxContentLength int, progressCtx *progress.Context) *FeedAggregator { + return &FeedAggregator{ + maxArticlesPerFeed: maxArticlesPerFeed, + maxDaysOld: maxDaysOld, + maxContentLength: maxContentLength, + client: &http.Client{ + Timeout: 30 * time.Second, + }, + userAgent: "LLM-Aggregator/0.1.0 (+https://codeberg.org/maxwelljensen/llm-aggregator)", + progressCtx: progressCtx, + } +} + +// ParseFeedsFromFile parses RSS feeds from a file containing one URL per line. +func (fa *FeedAggregator) ParseFeedsFromFile(filePath string) ([]*Article, error) { + articles := []*Article{} + + file, err := os.Open(filePath) + if err != nil { + return nil, fmt.Errorf("failed to open feeds file: %w", err) + } + defer file.Close() + + content, err := io.ReadAll(file) + if err != nil { + return nil, fmt.Errorf("failed to read feeds file: %w", err) + } + + lines := strings.Split(string(content), "\n") + feedURLs := []string{} + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" && !strings.HasPrefix(line, "#") { + feedURLs = append(feedURLs, line) + } + } + + if fa.progressCtx != nil { + fa.progressCtx.Logf("Found %d feed URLs in %s", len(feedURLs), filePath) + } else { + fmt.Printf("Found %d feed URLs in %s\n", len(feedURLs), filePath) + } + + for i, feedURL := range feedURLs { + if fa.progressCtx != nil { + fa.progressCtx.Logf("Processing feed %d/%d: %s", i+1, len(feedURLs), feedURL) + } else { + fmt.Printf("Processing feed %d/%d: %s\n", i+1, len(feedURLs), feedURL) + } + feedArticles, err := fa.ParseFeed(feedURL) + if err != nil { + if fa.progressCtx != nil { + fa.progressCtx.Warningf("Failed to parse feed %s: %v", feedURL, err) + } else { + fmt.Printf("Warning: Failed to parse feed %s: %v\n", feedURL, err) + } + continue + } + articles = append(articles, feedArticles...) + } + + return articles, nil +} + +// ParseFeed parses a single RSS feed and extracts articles. +func (fa *FeedAggregator) ParseFeed(feedURL string) ([]*Article, error) { + articles := []*Article{} + + fp := gofeed.NewParser() + feed, err := fp.ParseURL(feedURL) + if err != nil { + return nil, fmt.Errorf("failed to parse feed URL %s: %w", feedURL, err) + } + + feedTitle := feed.Title + if feedTitle == "" { + feedTitle = feedURL + } + + if fa.progressCtx != nil { + fa.progressCtx.Logf("Parsing feed: %s (%d entries)", feedTitle, len(feed.Items)) + } else { + fmt.Printf("Parsing feed: %s (%d entries)\n", feedTitle, len(feed.Items)) + } + + var cutoffTime time.Time + if fa.maxDaysOld > 0 { + cutoffTime = time.Now().Add(-time.Duration(fa.maxDaysOld) * 24 * time.Hour) + } + + maxItems := min(fa.maxArticlesPerFeed, len(feed.Items)) + + for i, item := range feed.Items[:maxItems] { + article, err := fa.extractArticle(item, feedTitle, cutoffTime) + if err != nil { + if fa.progressCtx != nil { + fa.progressCtx.Warningf("Failed to extract article %d from %s: %v", i, feedURL, err) + } else { + fmt.Printf("Warning: Failed to extract article %d from %s: %v\n", i, feedURL, err) + } + continue + } + if article != nil { + articles = append(articles, article) + } + } + + return articles, nil +} + +func (fa *FeedAggregator) extractArticle(item *gofeed.Item, feedTitle string, cutoffTime time.Time) (*Article, error) { + // Extract metadata + title := item.Title + if title == "" { + title = "Untitled" + } + + link := item.Link + if link == "" { + return nil, nil + } + + // Parse publication date + published := fa.parsePublishedDate(item) + + // Check if article is too old + if !cutoffTime.IsZero() && !published.IsZero() && published.Before(cutoffTime) { + if fa.progressCtx != nil { + fa.progressCtx.Debugf("Skipping old article: %s (%s)", title, published.Format("2006-01-02")) + } else { + fmt.Printf("Skipping old article: %s (%s)\n", title, published.Format("2006-01-02")) + } + return nil, nil + } + + // Extract author + author := "" + if item.Author != nil { + author = item.Author.Name + if author == "" { + author = item.Author.Email + } + } + if author == "" && item.DublinCoreExt != nil && len(item.DublinCoreExt.Creator) > 0 { + author = item.DublinCoreExt.Creator[0] + } + + // Get content + content := fa.extractContent(item, link) + + // Truncate content if too long + if len(content) > fa.maxContentLength { + content = content[:fa.maxContentLength] + "... [truncated]" + } + + return &Article{ + Title: title, + Link: link, + Content: content, + Published: published, + Author: author, + SourceFeed: feedTitle, + }, nil +} + +func (fa *FeedAggregator) parsePublishedDate(item *gofeed.Item) time.Time { + if item.PublishedParsed != nil { + return *item.PublishedParsed + } + if item.UpdatedParsed != nil { + return *item.UpdatedParsed + } + // Try to parse from string + if item.Published != "" { + if t, err := time.Parse(time.RFC1123, item.Published); err == nil { + return t + } + if t, err := time.Parse(time.RFC1123Z, item.Published); err == nil { + return t + } + if t, err := time.Parse(time.RFC3339, item.Published); err == nil { + return t + } + } + return time.Time{} +} + +func (fa *FeedAggregator) extractContent(item *gofeed.Item, link string) string { + // Priority: Content -> Description -> Fetch webpage + content := "" + + // Try to get content from item + if item.Content != "" { + content = item.Content + } else if item.Description != "" { + content = item.Description + } + + // If still no content or very short, fetch webpage + if content == "" || len(content) < 100 { + fetchedContent, err := fa.fetchWebpageContent(link) + if err == nil && fetchedContent != "" { + content = fetchedContent + } + } + + return content +} + +func (fa *FeedAggregator) fetchWebpageContent(url string) (string, error) { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return "", err + } + req.Header.Set("User-Agent", fa.userAgent) + + resp, err := fa.client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("HTTP status %d", resp.StatusCode) + } + + doc, err := goquery.NewDocumentFromReader(resp.Body) + if err != nil { + return "", err + } + + // Remove script and style elements + doc.Find("script, style, nav, footer, header").Remove() + + // Try to find article content + articleSelectors := []string{ + "article", + "main", + ".article-content", + ".post-content", + ".entry-content", + "#content", + ".content", + } + + for _, selector := range articleSelectors { + articleElement := doc.Find(selector).First() + if articleElement.Length() > 0 { + text := articleElement.Text() + text = strings.Join(strings.Fields(text), " ") // Normalise whitespace + if len(text) > 200 { + return text, nil + } + } + } + + // Fallback: get all paragraphs + paragraphs := doc.Find("p") + if paragraphs.Length() > 0 { + var texts []string + paragraphs.Each(func(i int, s *goquery.Selection) { + texts = append(texts, strings.TrimSpace(s.Text())) + }) + text := strings.Join(texts, " ") + if len(text) > 200 { + return text, nil + } + } + + // Last resort: get all text + text := doc.Text() + text = strings.Join(strings.Fields(text), " ") + if len(text) > 200 { + return text, nil + } + + return "", nil +} +
A internal/aggregator/article.go

@@ -0,0 +1,38 @@

+package aggregator + +import "time" + +// Article represents an article extracted from an RSS feed. +type Article struct { + Title string `json:"title"` + Link string `json:"link"` + Content string `json:"content"` + Published time.Time `json:"published"` + Author string `json:"author,omitempty"` + SourceFeed string `json:"source_feed,omitempty"` + Summary string `json:"summary,omitempty"` +} + +// ToMap converts article to a map for serialisation. +func (a *Article) ToMap() map[string]any { + content := a.Content + if len(content) > 500 { + content = content[:500] + "... [truncated]" + } + + result := map[string]any{ + "title": a.Title, + "link": a.Link, + "content": content, + "author": a.Author, + "source_feed": a.SourceFeed, + "summary": a.Summary, + } + + if !a.Published.IsZero() { + result["published"] = a.Published.Format(time.RFC3339) + } + + return result +} +
A internal/cli/args.go

@@ -0,0 +1,118 @@

+package cli + +import ( + "fmt" + "os" + "strings" + + "github.com/alexflint/go-arg" +) + +var ( + Version string + BuildDate string +) + +// Args represents command-line arguments. +type Args struct { + FeedsFile string `arg:"--feeds-file,required" help:"Path to file containing RSS feed URLs (one per line)"` + Prompt string `arg:"--prompt,required" help:"User prompt for summarisation/analysis"` + + // Feed aggregation options + MaxArticlesPerFeed int `arg:"--max-articles-per-feed" help:"Maximum articles to fetch from each feed" default:"10"` + MaxDaysOld int `arg:"--max-days-old" help:"Only include articles from the last N days (0 for all)" default:"7"` + MaxTotalArticles int `arg:"--max-total-articles" help:"Maximum total articles to process" default:"20"` + + // Content filtering + IncludeKeywords string `arg:"--include-keywords" help:"Comma-separated list of keywords to include (case-insensitive)"` + ExcludeKeywords string `arg:"--exclude-keywords" help:"Comma-separated list of keywords to exclude (case-insensitive)"` + + // Deepseek API options + APIKey string `arg:"--api-key" help:"Deepseek API key (default: read from DEEPSEEK_API_KEY env var)"` + Model string `arg:"--model" help:"Deepseek model to use" default:"deepseek-chat"` + MaxTokens int `arg:"--max-tokens" help:"Maximum tokens in response" default:"4000"` + Temperature float64 `arg:"--temperature" help:"Sampling temperature (0.0 to 1.0)" default:"0.7"` + + // Output options + Output string `arg:"--output" help:"Output format" default:"text" choice:"text,json,markdown"` + OutputFile string `arg:"--output-file" help:"Write output to file (default: stdout)"` + IncludeArticles bool `arg:"--include-articles" help:"Include original articles in JSON output"` + + // System options + SystemPrompt string `arg:"--system-prompt" help:"Custom system prompt for Deepseek"` + TUI bool `arg:"--tui" help:"Enable TUI interface with progress bar"` + Verbose bool `arg:"-v,--verbose" help:"Enable verbose logging"` + ShowVersion bool `arg:"--version" help:"Show version"` +} + +// Version returns the version string. +func (Args) Version() string { + return fmt.Sprintf("llm_aggregator v%s (built %s)", Version, BuildDate) +} + +// Description returns the program description. +func (Args) Description() string { + return `LLM Aggregator - Aggregate RSS feeds and summarise with Deepseek API + +Examples: + # Basic usage with prompts + llm_aggregator --feeds-file feeds.txt --prompt "What are the latest trends in free software?" + + # With custom Deepseek model + llm_aggregator --feeds-file feeds.txt --prompt "Summarise tech news" --model deepseek-coder + + # Output to JSON file + llm_aggregator --feeds-file feeds.txt --prompt "Analyse AI developments" --output json --output-file analysis.json + + # Filter by keywords + llm_aggregator --feeds-file feeds.txt --prompt "Linux news" --include-keywords linux,opensource + +Environment Variables: + DEEPSEEK_API_KEY: Your Deepseek API key (required if not provided via --api-key)` +} + +// ParseKeywords parses comma-separated keywords string into list. +func ParseKeywords(keywordString string) []string { + if keywordString == "" { + return nil + } + keywords := strings.Split(keywordString, ",") + result := make([]string, 0, len(keywords)) + for _, kw := range keywords { + if trimmed := strings.TrimSpace(kw); trimmed != "" { + result = append(result, trimmed) + } + } + return result +} + +// ParseArgs parses command line arguments. +func ParseArgs() (*Args, error) { + var args Args + parser, err := arg.NewParser(arg.Config{ + Program: "llm_aggregator", + }, &args) + if err != nil { + return nil, err + } + + // Handle help and version flags before checking required fields + if len(os.Args) > 1 { + if os.Args[1] == "-h" || os.Args[1] == "--help" { + parser.WriteHelp(os.Stdout) + os.Exit(0) + } + if os.Args[1] == "--version" { + fmt.Printf("llm_aggregator v%s (built %s)", Version, BuildDate) + os.Exit(0) + } + } + + err = parser.Parse(os.Args[1:]) + if err != nil { + return nil, err + } + + return &args, nil +} +
A internal/config/config.go

@@ -0,0 +1,8 @@

+package config + +// QuietMode suppresses stdout output when true (e.g., when TUI is active). +var QuietMode = false + +// VerboseMode enables debug output when true. +var VerboseMode = false +
A internal/llm/deepseek.go

@@ -0,0 +1,231 @@

+package llm + +import ( + "context" + "fmt" + "os" + "strings" + "time" + + "github.com/openai/openai-go/v3" + "github.com/openai/openai-go/v3/option" +) + +// DeepseekClient is a client for interacting with Deepseek API. +type DeepseekClient struct { + client openai.Client + model string + maxTokens int + temperature float64 +} + +// NewDeepseekClient creates a new Deepseek client. +// apiKey: Deepseek API key (or read from DEEPSEEK_API_KEY env var) +// baseURL: API base URL (defaults to "https://api.deepseek.com") +// model: Model to use (defaults to "deepseek-chat") +// maxTokens: Maximum tokens in response (defaults to 4000) +// temperature: Sampling temperature (0.0 to 1.0, defaults to 0.7) +func NewDeepseekClient(apiKey, baseURL, model string, maxTokens int, temperature float64) (*DeepseekClient, error) { + // Get API key from parameter or environment variable + if apiKey == "" { + apiKey = os.Getenv("DEEPSEEK_API_KEY") + } + if apiKey == "" || strings.TrimSpace(apiKey) == "" { + return nil, fmt.Errorf( + "Deepseek API key is required. " + + "Set DEEPSEEK_API_KEY environment variable or pass apiKey parameter", + ) + } + + // Set defaults + if baseURL == "" { + baseURL = "https://api.deepseek.com" + } + if model == "" { + model = "deepseek-chat" + } + if maxTokens == 0 { + maxTokens = 4000 + } + if temperature == 0 { + temperature = 0.7 + } + + // Create OpenAI client configured for Deepseek + client := openai.NewClient( + option.WithAPIKey(apiKey), + option.WithBaseURL(baseURL), + ) + + fmt.Printf("Initialised Deepseek client with model: %s\n", model) + + return &DeepseekClient{ + client: client, + model: model, + maxTokens: maxTokens, + temperature: temperature, + }, nil +} + +// SummariseArticles summarises a list of articles based on user prompt. +// articles: List of article maps +// userPrompt: User's query/summarisation request +// systemPrompt: Optional system prompt (defaults to helpful assistant) +func (dc *DeepseekClient) SummariseArticles( + articles []map[string]any, + userPrompt string, + systemPrompt string, +) (string, error) { + if len(articles) == 0 { + return "No articles to summarise.", nil + } + + // Prepare context from articles + context := dc.prepareContext(articles) + + // Create messages for chat completion API + messages := dc.createMessages(context, userPrompt, systemPrompt) + + // Call API with messages + return dc.callAPIWithMessages(messages) +} + +func (dc *DeepseekClient) prepareContext(articles []map[string]any) string { + contextParts := []string{} + + for i, article := range articles { + contextParts = append(contextParts, fmt.Sprintf("--- ARTICLE %d ---", i+1)) + contextParts = append(contextParts, fmt.Sprintf("Title: %s", article["title"])) + + if source, ok := article["source_feed"].(string); ok && source != "" { + contextParts = append(contextParts, fmt.Sprintf("Source: %s", source)) + } + + if published, ok := article["published"]; ok { + switch pub := published.(type) { + case time.Time: + if !pub.IsZero() { + contextParts = append(contextParts, fmt.Sprintf("Published: %s", pub.Format(time.RFC3339))) + } + case string: + contextParts = append(contextParts, fmt.Sprintf("Published: %s", pub)) + default: + contextParts = append(contextParts, fmt.Sprintf("Published: %v", pub)) + } + } + + if author, ok := article["author"].(string); ok && author != "" { + contextParts = append(contextParts, fmt.Sprintf("Author: %s", author)) + } + + if link, ok := article["link"].(string); ok && link != "" { + contextParts = append(contextParts, fmt.Sprintf("Link: %s", link)) + } + + if content, ok := article["content"].(string); ok && content != "" { + // Truncate very long content + maxContentLen := 3000 + if len(content) > maxContentLen { + content = content[:maxContentLen] + "... [truncated]" + } + contextParts = append(contextParts, fmt.Sprintf("Content: %s", content)) + } + + contextParts = append(contextParts, "") // Empty line between articles + } + + return strings.Join(contextParts, "\n") +} + +func (dc *DeepseekClient) createMessages(context, userPrompt, systemPrompt string) []openai.ChatCompletionMessageParamUnion { + if systemPrompt == "" { + systemPrompt = `You are an expert analyst and summariser. +You analyse content from multiple sources and provide +concise, insightful summaries based on user requests. +Focus on key points, trends, and important information.` + } + + // Combine context with user prompt + fullUserContent := fmt.Sprintf(`Here are articles from various RSS feeds: + +%s + +User request: %s + +Please provide a comprehensive summary/analysis addressing the user's request. +Focus on key insights, trends, and important information from the articles. +If relevant, note any patterns, contradictions, or notable developments.`, + context, userPrompt) + + messages := []openai.ChatCompletionMessageParamUnion{ + openai.SystemMessage(systemPrompt), + openai.UserMessage(fullUserContent), + } + + return messages +} + +func (dc *DeepseekClient) callAPIWithMessages(messages []openai.ChatCompletionMessageParamUnion) (string, error) { + ctx := context.Background() + + fmt.Printf("Calling Deepseek API with model: %s\n", dc.model) + + response, err := dc.client.Chat.Completions.New(ctx, openai.ChatCompletionNewParams{ + Model: dc.model, + Messages: messages, + MaxTokens: openai.Int(int64(dc.maxTokens)), + Temperature: openai.Float(dc.temperature), + }) + + if err != nil { + // Provide more specific error messages + errStr := err.Error() + if strings.Contains(errStr, "401") { + return "", fmt.Errorf("invalid API key. Please check your Deepseek API key") + } else if strings.Contains(errStr, "429") { + return "", fmt.Errorf("rate limit exceeded. Please try again later") + } else if strings.Contains(errStr, "500") { + return "", fmt.Errorf("Deepseek API server error. Please try again later") + } else if strings.Contains(errStr, "404") { + return "", fmt.Errorf("API endpoint not found. Please check the base URL and endpoint. Deepseek uses /chat/completions") + } + return "", fmt.Errorf("failed to connect to Deepseek API: %w", err) + } + + // Extract text from response + if len(response.Choices) == 0 { + return "", fmt.Errorf("no response choices returned from API") + } + + outputText := response.Choices[0].Message.Content + + // Print token usage + fmt.Printf( + "Deepseek API response: %d prompt tokens, %d completion tokens\n", + response.Usage.PromptTokens, + response.Usage.CompletionTokens, + ) + + return outputText, nil +} + +// AnalyseWithCustomPrompt analyses articles with custom system and user prompts. +func (dc *DeepseekClient) AnalyseWithCustomPrompt( + articles []map[string]any, + systemPrompt string, + userPrompt string, +) (string, error) { + context := dc.prepareContext(articles) + + // Create messages for chat completion API + messages := []openai.ChatCompletionMessageParamUnion{ + openai.SystemMessage(systemPrompt), + openai.UserMessage(fmt.Sprintf(`Here are articles from various RSS feeds: + +%s + +%s`, context, userPrompt)), + } + + return dc.callAPIWithMessages(messages) +}
A internal/output/formatter.go

@@ -0,0 +1,253 @@

+package output + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "llm_aggregator/internal/aggregator" +) + +// Formatter formats output in different formats. +type Formatter struct { + formatType string +} + +// NewFormatter creates a new formatter with the specified format. +func NewFormatter(formatType string) (*Formatter, error) { + formatType = strings.ToLower(formatType) + if formatType != "text" && formatType != "markdown" && formatType != "json" { + return nil, fmt.Errorf("unsupported format: %s", formatType) + } + return &Formatter{formatType: formatType}, nil +} + +// FormatData formats data according to the specified format. +func (f *Formatter) FormatData(data map[string]any) (string, error) { + switch f.formatType { + case "json": + return f.formatJSON(data) + case "markdown": + return f.formatMarkdown(data) + default: // text + return f.formatText(data) + } +} + +func (f *Formatter) formatJSON(data map[string]any) (string, error) { + bytes, err := json.MarshalIndent(data, "", " ") + if err != nil { + return "", fmt.Errorf("failed to marshal JSON: %w", err) + } + return string(bytes), nil +} + +func (f *Formatter) formatMarkdown(data map[string]any) (string, error) { + var lines []string + + // Title + title := getString(data, "title", "LLM Aggregator Summary") + lines = append(lines, fmt.Sprintf("# %s", title)) + lines = append(lines, "") + + // Metadata + lines = append(lines, "## Metadata") + lines = append(lines, "") + + prompt := getString(data, "prompt", "Unknown") + model := getString(data, "model", "Unknown") + articlesCount := getInt(data, "articles_count", 0) + timestamp := getString(data, "timestamp", time.Now().Format(time.RFC3339)) + + lines = append(lines, fmt.Sprintf("- **Prompt**: %s", prompt)) + lines = append(lines, fmt.Sprintf("- **Model**: %s", model)) + lines = append(lines, fmt.Sprintf("- **Articles Analysed**: %d", articlesCount)) + lines = append(lines, fmt.Sprintf("- **Generated**: %s", timestamp)) + lines = append(lines, "") + + // Summary + lines = append(lines, "## Summary") + lines = append(lines, "") + summary := getString(data, "summary", "No summary available.") + lines = append(lines, summary) + lines = append(lines, "") + + // Articles (if included) + if articles, ok := data["articles"].([]map[string]any); ok && len(articles) > 0 { + lines = append(lines, "## Articles Analysed") + lines = append(lines, "") + + for i, article := range articles { + lines = append(lines, fmt.Sprintf("### Article %d: %s", i+1, article["title"])) + lines = append(lines, "") + + if source, ok := article["source_feed"].(string); ok && source != "" { + lines = append(lines, fmt.Sprintf("**Source**: %s", source)) + } + + if published, ok := article["published"]; ok { + switch pub := published.(type) { + case time.Time: + if !pub.IsZero() { + lines = append(lines, fmt.Sprintf("**Published**: %s", pub.Format("2006-01-02 15:04"))) + } + case string: + lines = append(lines, fmt.Sprintf("**Published**: %s", pub)) + } + } + + if author, ok := article["author"].(string); ok && author != "" { + lines = append(lines, fmt.Sprintf("**Author**: %s", author)) + } + + if link, ok := article["link"].(string); ok && link != "" { + lines = append(lines, fmt.Sprintf("**Link**: [%s](%s)", link, link)) + } + + lines = append(lines, "") + } + } + + return strings.Join(lines, "\n"), nil +} + +func (f *Formatter) formatText(data map[string]any) (string, error) { + var lines []string + + // Title/Header + title := getString(data, "title", "LLM Aggregator Summary") + lines = append(lines, strings.Repeat("=", 80)) + lines = append(lines, centerText(title, 80)) + lines = append(lines, strings.Repeat("=", 80)) + lines = append(lines, "") + + // Metadata + lines = append(lines, "METADATA") + lines = append(lines, strings.Repeat("-", 40)) + + prompt := getString(data, "prompt", "Unknown") + model := getString(data, "model", "Unknown") + articlesCount := getInt(data, "articles_count", 0) + timestamp := getString(data, "timestamp", time.Now().Format(time.RFC3339)) + + lines = append(lines, fmt.Sprintf("Prompt: %s", prompt)) + lines = append(lines, fmt.Sprintf("Model: %s", model)) + lines = append(lines, fmt.Sprintf("Articles Analysed: %d", articlesCount)) + lines = append(lines, fmt.Sprintf("Generated: %s", timestamp)) + lines = append(lines, "") + + // Summary + lines = append(lines, "SUMMARY") + lines = append(lines, strings.Repeat("-", 40)) + lines = append(lines, "") + summary := getString(data, "summary", "No summary available.") + lines = append(lines, summary) + lines = append(lines, "") + + // Articles (if included) + if articles, ok := data["articles"].([]map[string]any); ok && len(articles) > 0 { + lines = append(lines, "ARTICLES ANALYSED") + lines = append(lines, strings.Repeat("-", 40)) + lines = append(lines, "") + + for i, article := range articles { + lines = append(lines, fmt.Sprintf("[Article %d]", i+1)) + lines = append(lines, fmt.Sprintf("Title: %s", article["title"])) + + if source, ok := article["source_feed"].(string); ok && source != "" { + lines = append(lines, fmt.Sprintf("Source: %s", source)) + } + + if published, ok := article["published"]; ok { + switch pub := published.(type) { + case time.Time: + if !pub.IsZero() { + lines = append(lines, fmt.Sprintf("Published: %s", pub.Format("2006-01-02 15:04"))) + } + case string: + lines = append(lines, fmt.Sprintf("Published: %s", pub)) + } + } + + if author, ok := article["author"].(string); ok && author != "" { + lines = append(lines, fmt.Sprintf("Author: %s", author)) + } + + if link, ok := article["link"].(string); ok && link != "" { + lines = append(lines, fmt.Sprintf("Link: %s", link)) + } + + // Show preview of content + if content, ok := article["content"].(string); ok && content != "" { + preview := content + if len(preview) > 200 { + preview = preview[:200] + "..." + } + lines = append(lines, fmt.Sprintf("Preview: %s", preview)) + } + + lines = append(lines, "") + } + } + + // Footer + lines = append(lines, strings.Repeat("=", 80)) + lines = append(lines, centerText("End of Summary", 80)) + lines = append(lines, strings.Repeat("=", 80)) + + return strings.Join(lines, "\n"), nil +} + +// Helper functions +func getString(data map[string]any, key, defaultValue string) string { + if val, ok := data[key].(string); ok { + return val + } + return defaultValue +} + +func getInt(data map[string]any, key string, defaultValue int) int { + if val, ok := data[key].(int); ok { + return val + } + if val, ok := data[key].(float64); ok { + return int(val) + } + return defaultValue +} + +func centerText(text string, width int) string { + if len(text) >= width { + return text + } + padding := (width - len(text)) / 2 + return strings.Repeat(" ", padding) + text + strings.Repeat(" ", width-padding-len(text)) +} + +// FormatArticleList formats a list of articles. +func FormatArticleList(articles []*aggregator.Article, formatType string, includeContent bool) (string, error) { + // Convert articles to maps + articleMaps := make([]map[string]any, len(articles)) + for i, article := range articles { + articleMap := article.ToMap() + if !includeContent { + delete(articleMap, "content") + } + articleMaps[i] = articleMap + } + + formatter, err := NewFormatter(formatType) + if err != nil { + return "", err + } + + data := map[string]any{ + "title": fmt.Sprintf("Articles List - %d articles", len(articles)), + "articles": articleMaps, + "articles_count": len(articles), + } + + return formatter.FormatData(data) +} +
A internal/processor/processor.go

@@ -0,0 +1,301 @@

+package processor + +import ( + "fmt" + "sort" + "strings" + "time" + + "llm_aggregator/internal/aggregator" +) + +// ContentProcessor processes and prepares aggregated content for LLM analysis. +type ContentProcessor struct { + maxTotalArticles int + maxContentPerArticle int + filterKeywords []string + excludeKeywords []string +} + +// NewContentProcessor creates a new ContentProcessor with the specified options. +func NewContentProcessor(maxTotalArticles, maxContentPerArticle int, filterKeywords, excludeKeywords []string) *ContentProcessor { + // Convert keywords to lowercase for case-insensitive matching + filterLower := make([]string, len(filterKeywords)) + for i, kw := range filterKeywords { + filterLower[i] = strings.ToLower(kw) + } + + excludeLower := make([]string, len(excludeKeywords)) + for i, kw := range excludeKeywords { + excludeLower[i] = strings.ToLower(kw) + } + + return &ContentProcessor{ + maxTotalArticles: maxTotalArticles, + maxContentPerArticle: maxContentPerArticle, + filterKeywords: filterLower, + excludeKeywords: excludeLower, + } +} + +// ProcessArticles processes articles: filter, sort, and prepare for LLM. +func (cp *ContentProcessor) ProcessArticles(articles []*aggregator.Article, sortBy string, reverse bool) []map[string]any { + if len(articles) == 0 { + fmt.Println("Warning: No articles to process") + return []map[string]any{} + } + + // Filter articles + filteredArticles := cp.filterArticles(articles) + + // Sort articles + sortedArticles := cp.sortArticles(filteredArticles, sortBy, reverse) + + // Limit total articles + if len(sortedArticles) > cp.maxTotalArticles { + fmt.Printf("Limiting articles from %d to %d\n", len(sortedArticles), cp.maxTotalArticles) + sortedArticles = sortedArticles[:cp.maxTotalArticles] + } + + // Prepare articles for LLM + processed := cp.prepareForLLM(sortedArticles) + + fmt.Printf("Processed %d articles (from %d original)\n", len(processed), len(articles)) + + return processed +} + +func (cp *ContentProcessor) filterArticles(articles []*aggregator.Article) []*aggregator.Article { + if len(cp.filterKeywords) == 0 && len(cp.excludeKeywords) == 0 { + return articles + } + + filtered := []*aggregator.Article{} + + for _, article := range articles { + include := true + + // Check if article should be excluded + if len(cp.excludeKeywords) > 0 { + articleText := strings.ToLower(article.Title + " " + article.Content) + for _, keyword := range cp.excludeKeywords { + if strings.Contains(articleText, keyword) { + fmt.Printf("Excluding article due to keyword '%s': %s\n", keyword, article.Title) + include = false + break + } + } + } + + // Check if article should be included (only if we have inclusion filters) + if include && len(cp.filterKeywords) > 0 { + articleText := strings.ToLower(article.Title + " " + article.Content) + include = false + for _, keyword := range cp.filterKeywords { + if strings.Contains(articleText, keyword) { + include = true + break + } + } + } + + if include { + filtered = append(filtered, article) + } + } + + fmt.Printf( + "Filtered %d articles to %d (inclusion: %v, exclusion: %v)\n", + len(articles), len(filtered), cp.filterKeywords, cp.excludeKeywords, + ) + + return filtered +} + +func (cp *ContentProcessor) sortArticles(articles []*aggregator.Article, sortBy string, reverse bool) []*aggregator.Article { + if len(articles) == 0 { + return articles + } + + // Create a copy to avoid modifying the original slice + sortedArticles := make([]*aggregator.Article, len(articles)) + copy(sortedArticles, articles) + + // Define sort functions + switch strings.ToLower(sortBy) { + case "date": + sort.Slice(sortedArticles, func(i, j int) bool { + iTime := sortedArticles[i].Published + jTime := sortedArticles[j].Published + if iTime.IsZero() { + iTime = time.Time{} + } + if jTime.IsZero() { + jTime = time.Time{} + } + if reverse { + return iTime.After(jTime) + } + return iTime.Before(jTime) + }) + case "title": + sort.Slice(sortedArticles, func(i, j int) bool { + iTitle := strings.ToLower(sortedArticles[i].Title) + jTitle := strings.ToLower(sortedArticles[j].Title) + if reverse { + return iTitle > jTitle + } + return iTitle < jTitle + }) + case "source": + sort.Slice(sortedArticles, func(i, j int) bool { + iSource := strings.ToLower(sortedArticles[i].SourceFeed) + jSource := strings.ToLower(sortedArticles[j].SourceFeed) + if reverse { + return iSource > jSource + } + return iSource < jSource + }) + default: + // Default to date sorting + sort.Slice(sortedArticles, func(i, j int) bool { + iTime := sortedArticles[i].Published + jTime := sortedArticles[j].Published + if iTime.IsZero() { + iTime = time.Time{} + } + if jTime.IsZero() { + jTime = time.Time{} + } + if reverse { + return iTime.After(jTime) + } + return iTime.Before(jTime) + }) + } + + return sortedArticles +} + +func (cp *ContentProcessor) prepareForLLM(articles []*aggregator.Article) []map[string]any { + processed := make([]map[string]any, len(articles)) + + for i, article := range articles { + // Process content + content := article.Content + if len(content) > cp.maxContentPerArticle { + content = content[:cp.maxContentPerArticle] + "... [truncated]" + } + + // Create map + articleMap := map[string]any{ + "title": article.Title, + "link": article.Link, + "content": content, + "author": article.Author, + "source_feed": article.SourceFeed, + "summary": article.Summary, + } + + if !article.Published.IsZero() { + articleMap["published"] = article.Published + } + + processed[i] = articleMap + } + + return processed +} + +// EstimateTokenCount estimates token count for articles (rough approximation). +// Note: This is a rough estimate (1 token ≈ 4 characters for English). +// Actual tokenisation may vary. +func (cp *ContentProcessor) EstimateTokenCount(articles []map[string]any) int { + totalChars := 0 + + for _, article := range articles { + for _, field := range []string{"title", "content", "author", "source_feed"} { + if val, ok := article[field]; ok && val != nil { + if str, ok := val.(string); ok { + totalChars += len(str) + } + } + } + } + + // Rough estimate: 1 token ≈ 4 characters for English + estimatedTokens := totalChars / 4 + + fmt.Printf("Estimated tokens: %d (~%d chars)\n", estimatedTokens, totalChars) + + return estimatedTokens +} + +// CreateConciseContext creates a concise context from articles, respecting token limits. +func (cp *ContentProcessor) CreateConciseContext(articles []map[string]any, maxTotalTokens int) string { + if len(articles) == 0 { + return "No articles available." + } + + contextParts := []string{} + currentTokens := 0 + + for i, article := range articles { + // Create article summary + articleText := fmt.Sprintf("Article %d: %s\n", i+1, article["title"]) + + // Add source if available + if source, ok := article["source_feed"].(string); ok && source != "" { + articleText += fmt.Sprintf("Source: %s\n", source) + } + + // Add publication date if available + if published, ok := article["published"]; ok { + switch pub := published.(type) { + case time.Time: + if !pub.IsZero() { + articleText += fmt.Sprintf("Published: %s\n", pub.Format("2006-01-02")) + } + case string: + articleText += fmt.Sprintf("Published: %s\n", pub) + } + } + + // Add content (truncated if needed) + content := "" + if c, ok := article["content"].(string); ok { + content = c + } + + maxContentTokens := maxTotalTokens / len(articles) // Rough allocation + maxContentChars := maxContentTokens * 4 + + if len(content) > maxContentChars { + content = content[:maxContentChars] + "... [truncated]" + } + + articleText += fmt.Sprintf("Content: %s\n", content) + + // Estimate tokens for this article + articleTokens := len(articleText) / 4 + + // Check if adding this article would exceed limit + if currentTokens+articleTokens > maxTotalTokens { + fmt.Printf( + "Reached token limit (%d). Included %d of %d articles.\n", + maxTotalTokens, i, len(articles), + ) + break + } + + contextParts = append(contextParts, articleText) + currentTokens += articleTokens + } + + context := strings.Join(contextParts, "\n---\n") + + fmt.Printf("Created context with %d articles, ~%d tokens\n", len(contextParts), currentTokens) + + return context +} +
A internal/progress/progress.go

@@ -0,0 +1,90 @@

+package progress + +import ( + "fmt" + "io" +) + +// Logger provides logging interface for the aggregator +type Logger interface { + Logf(format string, args ...any) + Warningf(format string, args ...any) + Debugf(format string, args ...any) +} + +// Progress provides progress reporting interface for TUI +type Progress interface { + Logger + SetStage(stage string) + SetSubStage(status string) + SetArticleCount(total, processed int) +} + +// SimpleLogger implements Logger with io.Writer output +type SimpleLogger struct { + writer io.Writer + debug bool +} + +// NewSimpleLogger creates a new SimpleLogger +func NewSimpleLogger(writer io.Writer, debug bool) *SimpleLogger { + return &SimpleLogger{ + writer: writer, + debug: debug, + } +} + +func (sl *SimpleLogger) Logf(format string, args ...any) { + fmt.Fprintf(sl.writer, format+"\n", args...) +} + +func (sl *SimpleLogger) Warningf(format string, args ...any) { + fmt.Fprintf(sl.writer, "Warning: "+format+"\n", args...) +} + +func (sl *SimpleLogger) Debugf(format string, args ...any) { + if sl.debug { + fmt.Fprintf(sl.writer, "Debug: "+format+"\n", args...) + } +} + +// NoopLogger implements Logger with no output +type NoopLogger struct{} + +func (nl *NoopLogger) Logf(format string, args ...any) {} +func (nl *NoopLogger) Warningf(format string, args ...any) {} +func (nl *NoopLogger) Debugf(format string, args ...any) {} + +// Context provides progress context for components +type Context struct { + logger Logger +} + +// NewContext creates a new progress context +func NewContext(logger Logger) *Context { + return &Context{ + logger: logger, + } +} + +// Logf logs a message +func (c *Context) Logf(format string, args ...any) { + if c.logger != nil { + c.logger.Logf(format, args...) + } +} + +// Warningf logs a warning +func (c *Context) Warningf(format string, args ...any) { + if c.logger != nil { + c.logger.Warningf(format, args...) + } +} + +// Debugf logs a debug message +func (c *Context) Debugf(format string, args ...any) { + if c.logger != nil { + c.logger.Debugf(format, args...) + } +} +
A internal/runtime/runtime.go

@@ -0,0 +1,161 @@

+package runtime + +import ( + "context" + "fmt" + "io" + "os" + "time" + + "llm_aggregator/internal/aggregator" + "llm_aggregator/internal/llm" + "llm_aggregator/internal/output" + "llm_aggregator/internal/processor" +) + +// Runtime holds the execution context for the aggregator +type Runtime struct { + // Configuration + FeedsFile string + MaxArticlesPerFeed int + MaxDaysOld int + MaxTotalArticles int + IncludeKeywords []string + ExcludeKeywords []string + APIKey string + Model string + MaxTokens int + Temperature float64 + Prompt string + SystemPrompt string + Output string + OutputFile string + IncludeArticles bool + + // State + Articles []map[string]any + Summary string + Error error +} + +// NewRuntime creates a new runtime with default values +func NewRuntime() *Runtime { + return &Runtime{ + MaxArticlesPerFeed: 10, + MaxDaysOld: 7, + MaxTotalArticles: 50, + Model: "deepseek-chat", + MaxTokens: 2000, + Temperature: 0.7, + Output: "text", + SystemPrompt: "You are a helpful assistant that summarises news articles.", + } +} + +// Execute runs the full aggregation pipeline +func (r *Runtime) Execute(ctx context.Context) error { + // Step 1: Aggregate feeds + feedAgg := aggregator.NewFeedAggregator( + r.MaxArticlesPerFeed, + r.MaxDaysOld, + 5000, // max content length + ) + + articles, err := feedAgg.ParseFeedsFromFile(r.FeedsFile) + if err != nil { + return fmt.Errorf("error aggregating feeds: %w", err) + } + + if len(articles) == 0 { + return fmt.Errorf("no articles found") + } + + // Step 2: Process content + contentProc := processor.NewContentProcessor( + r.MaxTotalArticles, + 3000, // max content per article + r.IncludeKeywords, + r.ExcludeKeywords, + ) + + processedArticles := contentProc.ProcessArticles(articles, "date", true) + + if len(processedArticles) == 0 { + return fmt.Errorf("no articles passed filtering") + } + + r.Articles = processedArticles + + // Step 3: Initialise Deepseek client + deepseek, err := llm.NewDeepseekClient( + r.APIKey, + "", // default base URL + r.Model, + r.MaxTokens, + r.Temperature, + ) + if err != nil { + return fmt.Errorf("error initialising Deepseek client: %w", err) + } + + // Step 4: Get summary from Deepseek + summary, err := deepseek.SummariseArticles( + processedArticles, + r.Prompt, + r.SystemPrompt, + ) + if err != nil { + return fmt.Errorf("error getting summary from Deepseek: %w", err) + } + + r.Summary = summary + return nil +} + +// WriteOutput writes the formatted output to the specified writer +func (r *Runtime) WriteOutput(writer io.Writer) error { + formatter, err := output.NewFormatter(r.Output) + if err != nil { + return fmt.Errorf("error creating formatter: %w", err) + } + + outputData := map[string]any{ + "title": fmt.Sprintf("LLM Aggregator Summary - %s", time.Now().Format("2006-01-02 15:04")), + "prompt": r.Prompt, + "model": r.Model, + "articles_count": len(r.Articles), + "summary": r.Summary, + "timestamp": time.Now().Format(time.RFC3339), + } + + if r.IncludeArticles { + outputData["articles"] = r.Articles + } + + formattedOutput, err := formatter.FormatData(outputData) + if err != nil { + return fmt.Errorf("error formatting output: %w", err) + } + + if _, err := fmt.Fprint(writer, formattedOutput); err != nil { + return fmt.Errorf("error writing output: %w", err) + } + + return nil +} + +// WriteOutputToFile writes the output to the specified file +func (r *Runtime) WriteOutputToFile() error { + if r.OutputFile == "" { + return r.WriteOutput(os.Stdout) + } + + file, err := os.Create(r.OutputFile) + if err != nil { + return fmt.Errorf("error creating output file: %w", err) + } + defer file.Close() + + return r.WriteOutput(file) +} +
A internal/tui/model.go

@@ -0,0 +1,237 @@

+package tui + +import ( + "fmt" + "strings" + "time" + + "github.com/charmbracelet/bubbles/progress" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +const ( + padding = 2 + maxWidth = 80 +) + +var ( + subtle = lipgloss.AdaptiveColor{Light: "#D9DCCF", Dark: "#383838"} + highlight = lipgloss.AdaptiveColor{Light: "#874BFD", Dark: "#7D56F4"} + special = lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"} + + titleStyle = lipgloss.NewStyle(). + MarginLeft(2). + MarginRight(2). + Padding(0, 1). + Italic(true). + Foreground(lipgloss.Color("#FFF7DB")). + SetString("LLM Aggregator") + + infoStyle = lipgloss.NewStyle(). + Foreground(subtle). + Italic(true) + + statusStyle = lipgloss.NewStyle(). + Foreground(highlight). + Bold(true) + + progressBarStyle = lipgloss.NewStyle(). + Foreground(special) + + stepNames = []string{ + "Initialising", + "Aggregating feeds", + "Processing articles", + "Connecting to DeepSeek", + "Getting summary", + "Formatting output", + "Complete", + } +) + +type progressMsg float64 + +type Model struct { + progress progress.Model + currentStep int + totalSteps int + status string + subStatus string + startTime time.Time + width int + height int + done bool + errorMsg string + articlesCount int + processedCount int +} + +func New() *Model { + prog := progress.New( + progress.WithScaledGradient("#FF7CCB", "#FDFF8C"), + progress.WithWidth(40), + progress.WithoutPercentage(), + ) + + return &Model{ + progress: prog, + currentStep: 0, + totalSteps: len(stepNames) - 1, // Last step is "Complete" + status: "Initialising...", + subStatus: "Loading configuration", + startTime: time.Now(), + } +} + +func (m *Model) Init() tea.Cmd { + return nil +} + +func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + } + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.progress.Width = msg.Width - padding*2 - 4 + if m.progress.Width > maxWidth { + m.progress.Width = maxWidth + } + case progressMsg: + progress := float64(msg) + if progress >= 1.0 { + m.done = true + return m, tea.Quit + } + cmd := m.progress.SetPercent(float64(progress)) + return m, cmd + case StepMsg: + if msg.Step >= 0 && msg.Step < len(stepNames) { + m.currentStep = msg.Step + m.status = stepNames[msg.Step] + if msg.Status != "" { + m.subStatus = msg.Status + } + if msg.ArticlesCount > 0 { + m.articlesCount = msg.ArticlesCount + } + if msg.ProcessedCount > 0 { + m.processedCount = msg.ProcessedCount + } + } + case ErrorMsg: + m.errorMsg = msg.Error + m.status = "Error" + return m, tea.Quit + } + + return m, nil +} + +func (m *Model) View() string { + if m.width == 0 { + return "Initialising..." + } + + var sb strings.Builder + + // Title + sb.WriteString(titleStyle.Render("🤖 LLM RSS Aggregator")) + sb.WriteString("\n\n") + + // Progress bar + progressPercent := float64(m.currentStep) / float64(m.totalSteps) + progressView := m.progress.ViewAs(progressPercent) + sb.WriteString(progressBarStyle.Render(progressView)) + sb.WriteString("\n\n") + + // Status + sb.WriteString(statusStyle.Render("Status: ")) + sb.WriteString(m.status) + sb.WriteString("\n") + + // Sub-status + if m.subStatus != "" { + sb.WriteString(infoStyle.Render(" → ")) + sb.WriteString(m.subStatus) + sb.WriteString("\n") + } + + // Statistics + if m.articlesCount > 0 { + sb.WriteString(infoStyle.Render(fmt.Sprintf("Articles: %d", m.articlesCount))) + sb.WriteString(" ") + } + if m.processedCount > 0 { + sb.WriteString(infoStyle.Render(fmt.Sprintf("Processed: %d", m.processedCount))) + sb.WriteString("\n") + } + + // Elapsed time + elapsed := time.Since(m.startTime).Round(time.Second) + sb.WriteString(infoStyle.Render(fmt.Sprintf("Elapsed: %v", elapsed))) + sb.WriteString("\n\n") + + // Error message (if any) + if m.errorMsg != "" { + errorStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FF0000")). + Bold(true) + sb.WriteString(errorStyle.Render("Error: ")) + sb.WriteString(m.errorMsg) + sb.WriteString("\n\n") + } + + // Help text + if !m.done && m.errorMsg == "" { + sb.WriteString(infoStyle.Render("Press 'q' or Ctrl+C to quit")) + } else if m.done { + completeStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#43BF6D")). + Bold(true) + sb.WriteString(completeStyle.Render("✓ Processing complete!")) + sb.WriteString("\n") + } + + return sb.String() +} + +// Messages for updating the TUI +type StepMsg struct { + Step int + Status string + ArticlesCount int + ProcessedCount int +} + +type ErrorMsg struct { + Error string +} + +func Step(step int, status string) tea.Cmd { + return func() tea.Msg { + return StepMsg{Step: step, Status: status} + } +} + +func StepWithCounts(step int, status string, articles, processed int) tea.Cmd { + return func() tea.Msg { + return StepMsg{ + Step: step, + Status: status, + ArticlesCount: articles, + ProcessedCount: processed, + } + } +} + +func Error(err string) tea.Cmd { + return func() tea.Msg { + return ErrorMsg{Error: err} + } +}
A internal/tui/tui.go

@@ -0,0 +1,147 @@

+package tui + +import ( + "context" + "fmt" + "time" + + "llm_aggregator/internal/progress" + "llm_aggregator/internal/runtime" + + tea "github.com/charmbracelet/bubbletea" +) + +// TUIProgress implements the progress.Progress interface for TUI +type TUIProgress struct { + program *tea.Program + model *Model +} + +// NewTUIProgress creates a new TUIProgress +func NewTUIProgress() *TUIProgress { + model := New() + p := tea.NewProgram(model, tea.WithAltScreen()) + return &TUIProgress{ + program: p, + model: model, + } +} + +// Logf logs a message to the TUI +func (tp *TUIProgress) Logf(format string, args ...interface{}) { + tp.program.Send(StepWithCounts(tp.model.currentStep, fmt.Sprintf(format, args...), + tp.model.articlesCount, tp.model.processedCount)) +} + +// Warningf logs a warning to the TUI +func (tp *TUIProgress) Warningf(format string, args ...interface{}) { + tp.Logf("Warning: "+format, args...) +} + +// Debugf logs a debug message to the TUI +func (tp *TUIProgress) Debugf(format string, args ...interface{}) { + // Debug messages not shown in TUI by default +} + +// SetStage sets the current stage +func (tp *TUIProgress) SetStage(stage string) { + tp.program.Send(Step(tp.model.currentStep, stage)) +} + +// SetSubStage sets the sub-stage status +func (tp *TUIProgress) SetSubStage(status string) { + tp.program.Send(Step(tp.model.currentStep, status)) +} + +// SetArticleCount sets the article counts +func (tp *TUIProgress) SetArticleCount(total, processed int) { + tp.model.articlesCount = total + tp.model.processedCount = processed +} + +// Run runs the TUI +func (tp *TUIProgress) Run() error { + _, err := tp.program.Run() + return err +} + +// Context returns a progress.Context for the TUI +func (tp *TUIProgress) Context() *progress.Context { + return progress.NewContext(tp) +} + +// RunWithRuntime runs the TUI with a runtime +func RunWithRuntime(rt *runtime.Runtime) error { + // Create TUI progress + tp := NewTUIProgress() + + // Run TUI in goroutine + tuiDone := make(chan error) + go func() { + tuiDone <- tp.Run() + }() + + // Run aggregation in another goroutine + runDone := make(chan error) + go func() { + // Create context for cancellation + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Update TUI status + tp.program.Send(Step(1, fmt.Sprintf("Aggregating feeds from: %s", rt.FeedsFile))) + + // TODO: Configure runtime with progress context + + // Execute the runtime + err := rt.Execute(ctx) + if err != nil { + tp.program.Send(Error(err.Error())) + runDone <- err + return + } + + // Update TUI status + tp.program.Send(StepWithCounts(5, "Formatting output", len(rt.Articles), len(rt.Articles))) + + // Write output + if rt.OutputFile != "" { + if err := rt.WriteOutputToFile(); err != nil { + tp.program.Send(Error(fmt.Sprintf("Error writing output: %v", err))) + runDone <- err + return + } + tp.program.Send(Step(6, fmt.Sprintf("Output written to: %s", rt.OutputFile))) + } else { + if err := rt.WriteOutputToFile(); err != nil { + tp.program.Send(Error(fmt.Sprintf("Error writing output: %v", err))) + runDone <- err + return + } + tp.program.Send(Step(6, "Output complete")) + } + + // Mark as done + tp.program.Send(Step(7, "Processing completed successfully")) + time.Sleep(2 * time.Second) // Show success message briefly + tp.program.Send(tea.Quit()) + runDone <- nil + }() + + // Wait for either TUI or run to complete + select { + case err := <-tuiDone: + return err + case err := <-runDone: + if err != nil { + return err + } + // Wait for TUI to quit gracefully + select { + case err := <-tuiDone: + return err + case <-time.After(5 * time.Second): + return fmt.Errorf("TUI did not exit gracefully") + } + } +}