diff --git a/xExtension-AiSummarizer/README.md b/xExtension-AiSummarizer/README.md new file mode 100644 index 0000000..0eb3bbf --- /dev/null +++ b/xExtension-AiSummarizer/README.md @@ -0,0 +1,200 @@ +# FreshRSS - AI Summarizer extension + +This FreshRSS extension automatically generates AI-powered summaries for RSS articles and displays them at the top of each article. It addresses [Feature Request #240](https://github.com/FreshRSS/Extensions/issues/240) by using OpenAI-compatible LLM APIs to create concise summaries that help readers quickly decide if an article is worth their time. + +## Features + +- **Automatic summarization**: Generates AI summaries displayed at the top of articles +- **OpenAI-compatible API support**: Works with OpenAI, Ollama, LocalAI, LiteLLM, and any OpenAI-compatible endpoint +- **Customizable prompts**: Full control over the summarization prompt with template placeholders +- **Multiple display styles**: Choose from blockquote, styled info box, or simple italic formatting +- **Smart caching**: Summaries are cached per entry to avoid redundant API calls +- **Conditional processing**: Filter which articles get summarized using FreshRSS Boolean search syntax +- **Unread-only mode**: Optionally only summarize unread articles to save API costs +- **Retry logic**: Automatic retry with exponential backoff for transient API failures +- **Content truncation**: Control token usage by limiting content length sent to the API + +## Requirements + +- FreshRSS 1.28.2+ +- An OpenAI-compatible LLM API endpoint (OpenAI, Ollama, etc.) + +## Installation + +1. Download or clone this extension into your FreshRSS `./extensions/` directory: + ```bash + cd /path/to/FreshRSS/extensions + git clone https://github.com/FreshRSS/Extensions.git + ``` + +2. Navigate to **Settings** → **Extensions** in FreshRSS + +3. Enable the **AI Summarizer** extension + +4. Click **Configure** to set up your API endpoint and preferences + +## Configuration + +### API Configuration + +| Setting | Default | Description | +|:--------|:--------|:------------| +| API URL | *(empty)* | OpenAI-compatible API base URL (e.g., `https://api.openai.com/v1` for OpenAI, `http://localhost:11434/v1` for Ollama) | +| API Key | *(empty)* | Bearer token for API authentication (optional for local APIs like Ollama) | +| Model | `gpt-4o-mini` | Model name (e.g., `gpt-4o-mini`, `llama3`, `gemini-pro`) | +| Timeout | `30` | HTTP request timeout in seconds (1–300) | + +### Summarization Settings + +| Setting | Default | Description | +|:--------|:--------|:------------| +| Enable AI summarization | Off | Master toggle for the extension | +| Summary display style | `blockquote` | How to display the summary: `blockquote` (default), `info-box` (styled with background), or `simple` (italic) | +| Only summarize unread articles | On | Skip summarization for articles you've already read to save API costs | +| Max content length | `8000` | Maximum characters for the `{content}` placeholder (0 = unlimited) | +| Max summary tokens | `512` | Maximum tokens for the summary response (0 = unlimited) | +| Max API retries | `2` | Number of retry attempts for failed API calls (0–5) | + +### Summarization Prompt Template + +The user prompt is a customizable template with placeholders that are replaced with article data: + +| Placeholder | Value | +|:------------|:------| +| `{title}` | Article title | +| `{content}` | Article content (HTML stripped, truncated if needed) | +| `{author}` | Article author(s) | +| `{url}` | Article link | +| `{feed_url}` | Feed URL | +| `{feed_name}` | Feed name | +| `{date}` | Article date | + +**Default prompt:** +``` +Summarize the following article in 2-3 sentences, focusing on the key points: + +Title: {title} +Content: {content} +``` + +### Conditions for Summarization + +**Search filters** (one per line): Only entries matching at least one filter are summarized. Uses [FreshRSS Boolean search syntax](https://freshrss.github.io/FreshRSS/en/users/10_filter.html). Leave empty to summarize all entries. + +Example filters: +``` +intitle:AI intitle:Machine Learning +author:TechCrunch +inurl:arxiv.org +``` + +## How It Works + +1. When an article is displayed (`EntryBeforeDisplay` hook), the extension checks: + - Is AI summarization enabled? + - Does the entry match the configured search filters? + - Is it unread (if "only unread" is enabled)? + - Is there a cached summary for this entry? + +2. If no cached summary exists: + - The user prompt template is populated with article data + - The LLM API is called with the system prompt and user prompt + - The returned summary is cached and prepended to the article content + +3. The summary is formatted according to the selected display style and shown at the top of the article + +## Example Display Styles + +### Blockquote (default) +``` +> 📝 AI Summary: This article discusses the latest advances in AI... +___ +[Original article content...] +``` + +### Info Box +``` +┌─────────────────────────────────────┐ +│ 📝 AI Summary: This article... │ +│ [Styled with blue background] │ +└─────────────────────────────────────┘ +[Original article content...] +``` + +### Simple +``` +Summary: This article discusses the latest advances in AI... +___ +[Original article content...] +``` + +## Using with Ollama (Local LLM) + +For privacy-conscious users or to avoid API costs, you can run Ollama locally: + +1. Install and start Ollama: https://ollama.ai/ +2. Pull a model: `ollama pull llama3` +3. Configure the extension: + - API URL: `http://localhost:11434/v1` + - API Key: *(leave empty)* + - Model: `llama3` + +## Performance Considerations + +- **Caching**: Summaries are cached per entry, so each article is only summarized once +- **Unread-only mode**: Enable "Only summarize unread articles" to avoid re-summarizing on every view +- **Content length**: Reduce `Max content length` to control token usage and costs +- **Search filters**: Use filters to only summarize high-value articles (e.g., from specific feeds) + +## Privacy + +- Article content is sent to the configured API endpoint +- Use a local LLM (Ollama, LocalAI) if you don't want to send article content to third-party APIs +- Summaries are stored in FreshRSS user configuration (not shared between users) + +## Troubleshooting + +**Summaries not appearing:** +- Check that "Enable AI summarization" is checked +- Verify your API URL and API Key are correct +- Check FreshRSS logs for API error messages +- Test your API endpoint with curl: `curl -X POST https://api.openai.com/v1/chat/completions -H "Authorization: Bearer YOUR_KEY" -H "Content-Type: application/json" -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"Hello"}]}'` + +**Slow performance:** +- Reduce `Max content length` to send less data to the API +- Enable "Only summarize unread articles" +- Use a faster/local model (e.g., Ollama) +- Reduce `Timeout` value (may cause failures for slow APIs) + +**High API costs:** +- Use a cheaper model (e.g., `gpt-4o-mini` instead of `gpt-4o`) +- Enable "Only summarize unread articles" +- Reduce `Max content length` +- Use search filters to only summarize important feeds +- Switch to a local LLM (Ollama, LocalAI) + +## Related Projects + +This extension complements other AI-powered FreshRSS extensions: +- [LLM Classification](https://github.com/FreshRSS/Extensions/tree/main/xExtension-LlmClassification) - Auto-tag articles using LLMs +- [freshrss-ai-assistant](https://github.com/cvlc/freshrss-ai-assistant) - Retitle, auto-tag, and generate category digests +- [xExtension-OllamaSummarizer](https://github.com/fspv/xExtension-OllamaSummarizer) - Summarize with Ollama (different approach using Chrome DevTools Protocol) + +## Changelog + +**v0.1** (2026-06-02) +- Initial release +- OpenAI-compatible API support +- Configurable display styles +- Per-entry caching +- Unread-only mode +- Search filters +- Retry logic with exponential backoff + +## Contributing + +This extension is part of the official FreshRSS Extensions repository. Contributions are welcome! Please submit issues and pull requests to https://github.com/FreshRSS/Extensions. + +## License + +This extension is licensed under the GNU Affero General Public License v3.0 (AGPL-3.0), the same license as FreshRSS. diff --git a/xExtension-AiSummarizer/configure.phtml b/xExtension-AiSummarizer/configure.phtml new file mode 100644 index 0000000..6a24e8c --- /dev/null +++ b/xExtension-AiSummarizer/configure.phtml @@ -0,0 +1,142 @@ +getUserConfigurationString('api_url') ?? ''; +$apiKey = $this->getUserConfigurationString('api_key') ?? ''; +$model = $this->getUserConfigurationString('model') ?? 'gpt-4o-mini'; +$maxContentLength = $this->getUserConfigurationInt('max_content_length') ?? 8000; +$timeout = $this->getUserConfigurationInt('timeout') ?? 30; +$maxTokens = $this->getUserConfigurationInt('max_tokens') ?? 512; +$maxRetries = $this->getUserConfigurationInt('max_retries') ?? 2; +$enableSummary = $this->getUserConfigurationBool('enable_summary') ?? false; +$summaryStyle = $this->getUserConfigurationString('summary_style') ?? 'blockquote'; +$onlyUnread = $this->getUserConfigurationBool('only_unread') ?? true; +$searchFilter = $this->getUserConfigurationString('search_filter') ?? ''; + +$defaultPrompt = 'Summarize the following article in 2-3 sentences, focusing on the key points: + +Title: {title} +Content: {content}'; + +if ($this->user_prompt === '') { + $this->user_prompt = $defaultPrompt; +} +?> + +
+

+ +
+ +
+ +

OpenAI-compatible API base URL (e.g., https://api.openai.com/v1, http://localhost:11434/v1 for Ollama)

+
+
+ +
+ +
+ +

Bearer token for API authentication (optional for local APIs like Ollama)

+
+
+ +
+ +
+ +

Model name (e.g., gpt-4o-mini, llama3, gemini-pro)

+
+
+ +
+ +
+ +

HTTP request timeout in seconds (1–300)

+
+
+
+ +
+

+ +
+ +

Master toggle for the extension

+
+ +
+ +
+ +

How to display the summary at the top of the article

+
+
+ +
+ +

Only generate summaries for unread articles

+
+ +
+ +
+ +

+ Template for the summarization prompt. Available placeholders:
+ {title}, {content}, {author}, {url}, + {feed_url}, {feed_name}, {date} +

+
+
+ +
+ +
+ +

Maximum characters for {content} placeholder (0 = unlimited)

+
+
+ +
+ +
+ +

Maximum tokens for the summary response (0 = unlimited)

+
+
+ +
+ +
+ +

Number of retry attempts for failed API calls (0–5)

+
+
+
+ +
+

+ +
+ +
+ +

+ Only summarize entries matching at least one filter (one per line).
+ Uses FreshRSS Boolean search syntax.
+ Leave empty to summarize all entries. +

+
+
+
diff --git a/xExtension-AiSummarizer/extension.php b/xExtension-AiSummarizer/extension.php new file mode 100644 index 0000000..4cfe8c0 --- /dev/null +++ b/xExtension-AiSummarizer/extension.php @@ -0,0 +1,355 @@ +registerTranslates(); + $this->registerHook(Minz_HookType::EntryBeforeDisplay, [$this, 'summarizeEntry']); + + if ($this->getUserConfigurationString('api_url') === null) { + $this->setUserConfigurationValue('api_url', ''); + } + if ($this->getUserConfigurationString('api_key') === null) { + $this->setUserConfigurationValue('api_key', ''); + } + if ($this->getUserConfigurationString('model') === null) { + $this->setUserConfigurationValue('model', self::DEFAULT_MODEL); + } + if ($this->getUserConfigurationInt('max_content_length') === null) { + $this->setUserConfigurationValue('max_content_length', self::DEFAULT_MAX_CONTENT_LENGTH); + } + if ($this->getUserConfigurationInt('timeout') === null) { + $this->setUserConfigurationValue('timeout', self::DEFAULT_TIMEOUT); + } + if ($this->getUserConfigurationBool('enable_summary') === null) { + $this->setUserConfigurationValue('enable_summary', false); + } + if ($this->getUserConfigurationInt('max_tokens') === null) { + $this->setUserConfigurationValue('max_tokens', self::DEFAULT_MAX_TOKENS); + } + if ($this->getUserConfigurationInt('max_retries') === null) { + $this->setUserConfigurationValue('max_retries', self::DEFAULT_MAX_RETRIES); + } + if ($this->getUserConfigurationString('search_filter') === null) { + $this->setUserConfigurationValue('search_filter', ''); + } + if ($this->getUserConfigurationString('summary_style') === null) { + $this->setUserConfigurationValue('summary_style', 'blockquote'); + } + if ($this->getUserConfigurationBool('only_unread') === null) { + $this->setUserConfigurationValue('only_unread', true); + } + } + + #[\Override] + public function handleConfigureAction(): void { + $this->registerTranslates(); + + if (Minz_Request::isPost()) { + $apiUrl = trim(Minz_Request::paramString('api_url', plaintext: true)); + $apiUrl = preg_replace('#/chat/completions/?$#i', '', $apiUrl) ?? $apiUrl; + $this->setUserConfigurationValue('api_url', $apiUrl); + $this->setUserConfigurationValue('api_key', + trim(Minz_Request::paramString('api_key', plaintext: true))); + $this->setUserConfigurationValue('model', trim(Minz_Request::paramString('model', plaintext: true))); + $userPrompt = trim(Minz_Request::paramString('user_prompt', plaintext: true)) + ?: 'Summarize the following article in 2-3 sentences, focusing on the key points:\n\nTitle: {title}\nContent: {content}'; + $this->saveFile(self::PROMPT_FILENAME, $userPrompt); + $this->setUserConfigurationValue('max_content_length', Minz_Request::paramInt('max_content_length')); + $this->setUserConfigurationValue('timeout', + Minz_Request::paramInt('timeout') ?: self::DEFAULT_TIMEOUT); + $this->setUserConfigurationValue('max_tokens', Minz_Request::paramInt('max_tokens')); + $this->setUserConfigurationValue('max_retries', + max(0, min(5, Minz_Request::paramInt('max_retries')))); + + $this->setUserConfigurationValue('enable_summary', + Minz_Request::paramBoolean('enable_summary')); + $this->setUserConfigurationValue('summary_style', + trim(Minz_Request::paramString('summary_style', plaintext: true))); + $this->setUserConfigurationValue('only_unread', + Minz_Request::paramBoolean('only_unread')); + + $this->setUserConfigurationValue('search_filter', + trim(Minz_Request::paramString('search_filter', plaintext: true))); + } + + $this->user_prompt = ''; + if ($this->hasFile(self::PROMPT_FILENAME)) { + $this->user_prompt = $this->getFile(self::PROMPT_FILENAME) ?? ''; + } + } + + /** + * Build the system prompt for summarization. + */ + public function getSystemPrompt(): string { + return <<<'PROMPT' + You are a summarization assistant. + Create a concise, informative summary of the article. + Focus on the main points and key takeaways. + Return only the summary text without any preamble or conclusion. + PROMPT; + } + + /** + * Build the user prompt by replacing placeholders with entry values. + */ + private function buildUserPrompt(FreshRSS_Entry $entry): string { + $template = $this->getFile(self::PROMPT_FILENAME) ?? ''; + if ($template === '') { + return ''; + } + + $content = strip_tags($entry->content()); + $maxLength = $this->getUserConfigurationInt('max_content_length') ?? self::DEFAULT_MAX_CONTENT_LENGTH; + if ($maxLength > 0 && mb_strlen($content) > $maxLength) { + $content = mb_substr($content, 0, $maxLength) . '…'; + } + + $feed = $entry->feed(); + + $replacements = [ + '{title}' => $entry->title(), + '{content}' => $content, + '{author}' => $entry->authors(true), + '{url}' => $entry->link(), + '{feed_url}' => $feed !== null ? $feed->url() : '', + '{feed_name}' => $feed !== null ? $feed->name() : '', + '{date}' => $entry->date(), + ]; + + return strtr($template, $replacements); + } + + /** + * Check whether the entry matches the configured search filter. + */ + private function entryMatchesSearchFilter(FreshRSS_Entry $entry): bool { + $filterStr = $this->getUserConfigurationString('search_filter') ?? ''; + if ($filterStr === '') { + return true; + } + $lines = array_filter(array_map('trim', explode("\n", $filterStr)), static fn(string $line) => $line !== ''); + foreach ($lines as $line) { + $booleanSearch = new FreshRSS_BooleanSearch($line); + if ($entry->matches($booleanSearch)) { + return true; + } + } + return false; + } + + /** + * Determine whether an HTTP failure is transient and worth retrying. + * @param array{fail:bool,status:int,error:string} $response + */ + private static function isRetryableFailure(array $response): bool { + if (!($response['fail'] ?? false)) { + return false; + } + if (($response['status'] ?? 0) === 0 && ($response['error'] ?? '') !== '') { + return true; + } + return in_array($response['status'] ?? 0, self::RETRYABLE_HTTP_STATUSES, true); + } + + /** + * Call the LLM API and return the summary. + * @throws Minz_PermissionDeniedException + */ + private function callLlm(string $systemPrompt, string $userPrompt): ?string { + $apiUrl = trim($this->getUserConfigurationString('api_url') ?? ''); + $apiKey = trim($this->getUserConfigurationString('api_key') ?? ''); + $model = trim($this->getUserConfigurationString('model') ?? '') ?: self::DEFAULT_MODEL; + $timeout = $this->getUserConfigurationInt('timeout') ?? self::DEFAULT_TIMEOUT; + + if ($apiUrl === '') { + return null; + } + + $url = rtrim($apiUrl, '/') . '/chat/completions'; + + $body = [ + 'model' => $model, + 'messages' => [ + ['role' => 'system', 'content' => $systemPrompt], + ['role' => 'user', 'content' => $userPrompt], + ], + ]; + + $maxTokens = $this->getUserConfigurationInt('max_tokens') ?? self::DEFAULT_MAX_TOKENS; + if ($maxTokens > 0) { + $body['max_completion_tokens'] = $maxTokens; + } + + $requestBody = json_encode($body, JSON_INVALID_UTF8_SUBSTITUTE | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + + if ($requestBody === false) { + Minz_Log::warning('AiSummarizer: Failed to encode request body'); + return null; + } + + $headers = [ + 'Content-Type: application/json', + 'Accept: application/json', + ]; + if ($apiKey !== '') { + $headers[] = 'Authorization: Bearer ' . $apiKey; + } + + $cachePath = CACHE_PATH . '/ai_summarizer_' . sha1($apiUrl . $requestBody) . '.json'; + + $maxRetries = $this->getUserConfigurationInt('max_retries') ?? self::DEFAULT_MAX_RETRIES; + $response = null; + + for ($attempt = 0; $attempt <= $maxRetries; $attempt++) { + $response = FreshRSS_http_Util::httpGet($url, $cachePath, type: 'json', curl_options: [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $requestBody, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_TIMEOUT => $timeout, + ]); + + if (!($response['fail'] ?? false) && ($response['body'] ?? '') !== '') { + break; // Success + } + + if ($attempt < $maxRetries && self::isRetryableFailure($response)) { + $delay = (int)pow(2, $attempt); // Exponential backoff: 1s, 2s, 4s... + Minz_Log::warning('AiSummarizer: API call failed (HTTP ' . ($response['status'] ?? 0) + . (($response['error'] ?? '') !== '' ? '; ' . ($response['error'] ?? '') : '') + . '), retry ' . ($attempt + 1) . '/' . $maxRetries . ' after ' . $delay . 's'); + sleep($delay); + @unlink($cachePath); + continue; + } + + Minz_Log::warning('AiSummarizer: API call failed for ' . $url + . ' (HTTP ' . ($response['status'] ?? 0) + . (($response['error'] ?? '') !== '' ? '; ' . ($response['error'] ?? '') : '') + . '), not retrying'); + return null; + } + + if ($response === null || ($response['fail'] ?? false) || !is_string($response['body'] ?? null) || ($response['body'] ?? '') === '') { + Minz_Log::warning('AiSummarizer: API call failed after ' . (1 + $maxRetries) . ' attempt(s) for ' . $url); + return null; + } + + $responseData = json_decode($response['body'], true); + if (!is_array($responseData)) { + Minz_Log::warning('AiSummarizer: Invalid JSON response from API'); + return null; + } + + $choices = $responseData['choices'] ?? null; + $content = is_array($choices) && is_array($choices[0] ?? null) && is_array($choices[0]['message'] ?? null) + ? ($choices[0]['message']['content'] ?? null) + : null; + if (!is_string($content)) { + Minz_Log::warning('AiSummarizer: Missing choices[0].message.content in API response'); + return null; + } + + return trim($content); + } + + /** + * Get cached summary for an entry or null if not cached. + */ + private function getCachedSummary(FreshRSS_Entry $entry): ?string { + $cacheKey = self::SUMMARY_CACHE_KEY_PREFIX . $entry->id(); + $cached = $this->getUserConfigurationString($cacheKey); + return $cached !== null && $cached !== '' ? $cached : null; + } + + /** + * Cache a summary for an entry. + */ + private function cacheSummary(FreshRSS_Entry $entry, string $summary): void { + $cacheKey = self::SUMMARY_CACHE_KEY_PREFIX . $entry->id(); + $this->setUserConfigurationValue($cacheKey, $summary); + } + + /** + * Format the summary according to the configured style. + */ + private function formatSummary(string $summary): string { + $style = $this->getUserConfigurationString('summary_style') ?? 'blockquote'; + $escaped = htmlspecialchars($summary, ENT_COMPAT, 'UTF-8'); + + return match($style) { + 'blockquote' => '
📝 AI Summary: ' . $escaped . '

', + 'info-box' => '
📝 AI Summary: ' . $escaped . '
', + 'simple' => '

Summary: ' . $escaped . '


', + default => '
📝 AI Summary: ' . $escaped . '

', + }; + } + + /** + * Hook for EntryBeforeDisplay: add AI summary to entry content. + * @throws Minz_PermissionDeniedException + */ + public function summarizeEntry(FreshRSS_Entry $entry): FreshRSS_Entry { + $enableSummary = $this->getUserConfigurationBool('enable_summary') ?? false; + $apiUrl = $this->getUserConfigurationString('api_url') ?? ''; + if (!$enableSummary || $apiUrl === '' || !$this->hasFile(self::PROMPT_FILENAME)) { + return $entry; + } + + // Skip read entries if configured + $onlyUnread = $this->getUserConfigurationBool('only_unread') ?? true; + if ($onlyUnread && $entry->isRead()) { + return $entry; + } + + if (!$this->entryMatchesSearchFilter($entry)) { + return $entry; + } + + // Check cache first + $cachedSummary = $this->getCachedSummary($entry); + if ($cachedSummary !== null) { + $entry->_content($this->formatSummary($cachedSummary) . $entry->content()); + return $entry; + } + + // Generate summary + $systemPrompt = $this->getSystemPrompt(); + $userPrompt = $this->buildUserPrompt($entry); + if ($userPrompt === '') { + return $entry; + } + + $summary = $this->callLlm($systemPrompt, $userPrompt); + if ($summary === null || $summary === '') { + return $entry; + } + + // Cache and prepend summary + $this->cacheSummary($entry, $summary); + $entry->_content($this->formatSummary($summary) . $entry->content()); + + return $entry; + } +} diff --git a/xExtension-AiSummarizer/i18n/en/ext.php b/xExtension-AiSummarizer/i18n/en/ext.php new file mode 100644 index 0000000..e6d5564 --- /dev/null +++ b/xExtension-AiSummarizer/i18n/en/ext.php @@ -0,0 +1,23 @@ + [ + 'config' => [ + 'api_title' => 'API Configuration', + 'api_url' => 'API URL', + 'api_key' => 'API Key', + 'model' => 'Model', + 'timeout' => 'Timeout', + 'summarization_title' => 'Summarization Settings', + 'enable_summary' => 'Enable AI summarization', + 'summary_style' => 'Summary display style', + 'only_unread' => 'Only summarize unread articles', + 'user_prompt' => 'Summarization prompt template', + 'max_content_length' => 'Max content length', + 'max_tokens' => 'Max summary tokens', + 'max_retries' => 'Max API retries', + 'conditions_title' => 'Conditions for Summarization', + 'search_filter' => 'Search filters', + ], + ], +]; diff --git a/xExtension-AiSummarizer/metadata.json b/xExtension-AiSummarizer/metadata.json new file mode 100644 index 0000000..ea058fe --- /dev/null +++ b/xExtension-AiSummarizer/metadata.json @@ -0,0 +1,8 @@ +{ + "name": "AI Summarizer", + "author": "FreshRSS Contributors", + "description": "Automatically generate AI summaries for RSS articles using OpenAI-compatible LLM APIs", + "version": 0.1, + "entrypoint": "AiSummarizer", + "type": "user" +}