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;
+}
+?>
+
+
+
= _t('ext.ai_summarizer.config.api_title') ?>
+
+
+
+
+
+
+
+
+
+
+
+
= _t('ext.ai_summarizer.config.summarization_title') ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
= _t('ext.ai_summarizer.config.conditions_title') ?>
+
+
+
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"
+}