From 807854f35ea081f0c30eca645e2dee526a5a15e8 Mon Sep 17 00:00:00 2001 From: "Barry (Will Curran's AI)" Date: Tue, 2 Jun 2026 07:04:38 -0700 Subject: [PATCH] feat: Add AI Summarizer extension Implements feature request #240 - AI-powered article summarization This extension automatically generates AI summaries for RSS articles and displays them at the top of each article, helping readers quickly decide if content is worth their time. Features: - OpenAI-compatible API support (OpenAI, Ollama, LocalAI, etc.) - Customizable summarization prompts with template placeholders - Multiple display styles (blockquote, info box, simple) - Per-entry caching to avoid redundant API calls - Unread-only mode to save API costs - Search filters using FreshRSS Boolean search syntax - Retry logic with exponential backoff for transient failures - Content truncation for token usage control Uses EntryBeforeDisplay hook to prepend summaries to article content. Built on the same architecture as xExtension-LlmClassification. Addresses: FreshRSS/Extensions#240 Co-Authored-By: Claude Sonnet 4.5 --- xExtension-AiSummarizer/README.md | 200 +++++++++++++ xExtension-AiSummarizer/configure.phtml | 142 ++++++++++ xExtension-AiSummarizer/extension.php | 355 ++++++++++++++++++++++++ xExtension-AiSummarizer/i18n/en/ext.php | 23 ++ xExtension-AiSummarizer/metadata.json | 8 + 5 files changed, 728 insertions(+) create mode 100644 xExtension-AiSummarizer/README.md create mode 100644 xExtension-AiSummarizer/configure.phtml create mode 100644 xExtension-AiSummarizer/extension.php create mode 100644 xExtension-AiSummarizer/i18n/en/ext.php create mode 100644 xExtension-AiSummarizer/metadata.json 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; +} +?> + + + + + + 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" +}