From cea31fed3871c7b3b59eb2198a395a366af20ec7 Mon Sep 17 00:00:00 2001 From: fujie Date: Sun, 15 Mar 2026 17:45:42 +0800 Subject: [PATCH] feat(batch-install-plugins): initial release v1.0.0 Add Batch Install Plugins from GitHub tool with: - One-click installation of plugins from GitHub repositories - Smart plugin discovery with metadata extraction and validation - Confirmation dialog with plugin list preview - Selective installation with keyword-based filtering - Smart fallback: auto-retry with localhost:8080 on connection failure - Enhanced debugging with frontend and backend logging - 120-second confirmation timeout for user convenience - Async httpx client for non-blocking I/O - Complete i18n support across 11 languages - Event emitter handling with fallback support - Timeout guards on frontend JavaScript execution - Filtered list consistency for confirmation and installation - Auto-exclusion of tool itself from batch operations - 6 regression tests with 100% pass rate Documentation includes: - English and Chinese READMEs with flow diagrams - Popular repository examples (iChristGit, Haervwe, Classic298, suurt8ll) - Mirrored docs for official documentation site - Plugin index entries in both languages - Comprehensive release notes (v1.0.0.md and v1.0.0_CN.md) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../tools/batch-install-plugins-tool.md | 139 ++ .../tools/batch-install-plugins-tool.zh.md | 139 ++ docs/plugins/tools/index.md | 1 + docs/plugins/tools/index.zh.md | 1 + plugins/tools/batch-install-plugins/README.md | 137 ++ .../tools/batch-install-plugins/README_CN.md | 137 ++ .../batch_install_plugins.py | 1262 +++++++++++++++++ plugins/tools/batch-install-plugins/v1.0.0.md | 67 + .../tools/batch-install-plugins/v1.0.0_CN.md | 67 + .../tools/test_batch_install_plugins.py | 302 ++++ 10 files changed, 2252 insertions(+) create mode 100644 docs/plugins/tools/batch-install-plugins-tool.md create mode 100644 docs/plugins/tools/batch-install-plugins-tool.zh.md create mode 100644 plugins/tools/batch-install-plugins/README.md create mode 100644 plugins/tools/batch-install-plugins/README_CN.md create mode 100644 plugins/tools/batch-install-plugins/batch_install_plugins.py create mode 100644 plugins/tools/batch-install-plugins/v1.0.0.md create mode 100644 plugins/tools/batch-install-plugins/v1.0.0_CN.md create mode 100644 tests/plugins/tools/test_batch_install_plugins.py diff --git a/docs/plugins/tools/batch-install-plugins-tool.md b/docs/plugins/tools/batch-install-plugins-tool.md new file mode 100644 index 0000000..2442006 --- /dev/null +++ b/docs/plugins/tools/batch-install-plugins-tool.md @@ -0,0 +1,139 @@ +# Batch Install Plugins from GitHub + +**Author:** [Fu-Jie](https://github.com/Fu-Jie) | **Version:** 1.0.0 | **Project:** [OpenWebUI Extensions](https://github.com/Fu-Jie/openwebui-extensions) | **License:** MIT + +--- + +One-click batch install plugins from GitHub repositories to your OpenWebUI instance. + +## Key Features + +- **One-Click Install**: Install all plugins with a single command +- **Auto-Update**: Automatically updates previously installed plugins +- **GitHub Support**: Install plugins from any GitHub repository +- **Multi-Type Support**: Supports Pipe, Action, Filter, and Tool plugins +- **Confirmation**: Shows plugin list before installing, allows selective installation +- **i18n**: Supports 11 languages + +## Flow + +``` +User Input + │ + ▼ +┌─────────────────────────────────────┐ +│ Discover Plugins from GitHub │ +│ (fetch file tree + parse .py) │ +└─────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────┐ +│ Filter by Type & Keywords │ +│ (tool/filter/pipe/action) │ +└─────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────┐ +│ Show Confirmation Dialog │ +│ (list plugins + exclude hint) │ +└─────────────────────────────────────┘ + │ + ├── [Cancel] → End + │ + ▼ +┌─────────────────────────────────────┐ +│ Install to OpenWebUI │ +│ (update or create each plugin) │ +└─────────────────────────────────────┘ + │ + ▼ + Done +``` + +## How to Use + +1. Open OpenWebUI and go to **Workspace > Tools** +2. Install **Batch Install Plugins from GitHub** from the official marketplace +3. Enable this tool for your model/chat +4. Ask the model to install plugins + +## Usage Examples + +``` +"Install all plugins" +"Install all plugins from github.com/username/repo" +"Install only pipe plugins" +"Install action and filter plugins" +"Install all plugins, exclude_keywords=copilot" +``` + +## Popular Plugin Repositories + +Here are some popular repositories with many plugins you can install: + +### Community Collections + +``` +# Install all plugins from iChristGit's collection +"Install all plugins from iChristGit/OpenWebui-Tools" + +# Install all tools from Haervwe's tools collection +"Install all plugins from Haervwe/open-webui-tools" + +# Install all plugins from Classic298's repository +"Install all plugins from Classic298/open-webui-plugins" + +# Install all functions from suurt8ll's collection +"Install all plugins from suurt8ll/open_webui_functions" + +# Install only specific types (e.g., only tools) +"Install only tool plugins from iChristGit/OpenWebui-Tools" + +# Exclude certain keywords while installing +"Install all plugins from Haervwe/open-webui-tools, exclude_keywords=test,deprecated" +``` + +### Supported Repositories + +- `Fu-Jie/openwebui-extensions` - Default, official plugin collection +- `iChristGit/OpenWebui-Tools` - Comprehensive tool and plugin collection +- `Haervwe/open-webui-tools` - Specialized tools and utilities +- `Classic298/open-webui-plugins` - Various plugin implementations +- `suurt8ll/open_webui_functions` - Function-based plugins + +## Default Repository + +When no repository is specified, defaults to `Fu-Jie/openwebui-extensions`. + +## Plugin Detection Rules + +### Fu-Jie/openwebui-extensions (Strict) + +For the default repository, plugins must have: +1. A `.py` file containing `class Tools:`, `class Filter:`, `class Pipe:`, or `class Action:` +2. A docstring with `title:`, `description:`, and **`openwebui_id:`** fields +3. Filename must not end with `_cn` + +### Other GitHub Repositories + +For other repositories: +1. A `.py` file containing `class Tools:`, `class Filter:`, `class Pipe:`, or `class Action:` +2. A docstring with `title:` and `description:` fields + +## Configuration (Valves) + +| Parameter | Default | Description | +| --- | --- | --- | +| `SKIP_KEYWORDS` | `test,verify,example,template,mock` | Comma-separated keywords to skip | +| `TIMEOUT` | `20` | Request timeout in seconds | + +## Confirmation Timeout + +User confirmation dialogs have a default timeout of **2 minutes (120 seconds)**, allowing sufficient time for users to: +- Read and review the plugin list +- Make installation decisions +- Handle network delays + +## Support + +If this plugin has been useful, a star on [OpenWebUI Extensions](https://github.com/Fu-Jie/openwebui-extensions) is a big motivation for me. Thank you for the support. diff --git a/docs/plugins/tools/batch-install-plugins-tool.zh.md b/docs/plugins/tools/batch-install-plugins-tool.zh.md new file mode 100644 index 0000000..4386ff0 --- /dev/null +++ b/docs/plugins/tools/batch-install-plugins-tool.zh.md @@ -0,0 +1,139 @@ +# Batch Install Plugins from GitHub - 从 GitHub 批量安装插件 + +**作者:** [Fu-Jie](https://github.com/Fu-Jie) | **版本:** 1.0.0 | **项目:** [OpenWebUI Extensions](https://github.com/Fu-Jie/openwebui-extensions) | **许可:** MIT + +--- + +一键从 GitHub 仓库批量安装插件到你的 OpenWebUI 实例。 + +## ✨ 主要特性 + +- **一键安装**: 一条命令安装所有插件 +- **自动更新**: 自动更新之前已安装的插件 +- **GitHub 支持**: 支持从任何 GitHub 仓库安装插件 +- **多类型支持**: 支持 Pipe、Action、Filter 和 Tool 插件 +- **确认机制**: 安装前显示插件列表,允许选择性安装 +- **国际化**: 支持 11 种语言 + +## 工作流 + +``` +用户输入 + │ + ▼ +┌─────────────────────────────────────┐ +│ 从 GitHub 发现插件 │ +│ (获取文件树 + 解析 .py) │ +└─────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────┐ +│ 按类型和关键词过滤 │ +│ (tool/filter/pipe/action) │ +└─────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────┐ +│ 显示确认对话框 │ +│ (插件列表 + 排除提示) │ +└─────────────────────────────────────┘ + │ + ├── [取消] → 结束 + │ + ▼ +┌─────────────────────────────────────┐ +│ 安装到 OpenWebUI │ +│ (更新或创建每个插件) │ +└─────────────────────────────────────┘ + │ + ▼ + 完成 +``` + +## 🚀 使用方法 + +1. 打开 OpenWebUI,进入 **工作区 > 工具** +2. 从官方市场安装 **Batch Install Plugins from GitHub** +3. 为你的模型/聊天启用此工具 +4. 让模型安装插件 + +## 使用示例 + +``` +"安装所有插件" +"从 github.com/username/repo 安装所有插件" +"仅安装 pipe 插件" +"安装 action 和 filter 插件" +"安装所有插件,exclude_keywords=copilot" +``` + +## 热门插件仓库 + +这些是包含大量插件的热门仓库,你可以从中安装插件: + +### 社区合集 + +``` +# 从 iChristGit 的集合安装所有插件 +"从 iChristGit/OpenWebui-Tools 安装所有插件" + +# 从 Haervwe 的工具集合只安装工具 +"从 Haervwe/open-webui-tools 安装所有插件" + +# 从 Classic298 的仓库安装所有插件 +"从 Classic298/open-webui-plugins 安装所有插件" + +# 从 suurt8ll 的集合安装所有函数 +"从 suurt8ll/open_webui_functions 安装所有插件" + +# 仅安装特定类型的插件(比如只安装工具) +"从 iChristGit/OpenWebui-Tools 仅安装 tool 插件" + +# 安装时排除特定关键词 +"从 Haervwe/open-webui-tools 安装所有插件,exclude_keywords=test,deprecated" +``` + +### 支持的仓库 + +- `Fu-Jie/openwebui-extensions` - 默认的官方插件集合 +- `iChristGit/OpenWebui-Tools` - 全面的工具和插件集合 +- `Haervwe/open-webui-tools` - 专业的工具和实用程序 +- `Classic298/open-webui-plugins` - 各种插件实现 +- `suurt8ll/open_webui_functions` - 基于函数的插件 + +## 默认仓库 + +未指定仓库时,默认使用 `Fu-Jie/openwebui-extensions`。 + +## 插件检测规则 + +### Fu-Jie/openwebui-extensions(严格模式) + +对于默认仓库,插件必须有: +1. 包含 `class Tools:`、`class Filter:`、`class Pipe:` 或 `class Action:` 的 `.py` 文件 +2. 包含 `title:`、`description:` 和 **`openwebui_id:`** 字段的文档字符串 +3. 文件名不能以 `_cn` 结尾 + +### 其他 GitHub 仓库 + +对于其他仓库: +1. 包含 `class Tools:`、`class Filter:`、`class Pipe:` 或 `class Action:` 的 `.py` 文件 +2. 包含 `title:` 和 `description:` 字段的文档字符串 + +## 配置 (Valves) + +| 参数 | 默认值 | 描述 | +| --- | --- | --- | +| `SKIP_KEYWORDS` | `test,verify,example,template,mock` | 要跳过的关键词,用逗号分隔 | +| `TIMEOUT` | `20` | 请求超时时间(秒) | + +## 确认超时时间 + +用户确认对话框的默认超时时间为 **2 分钟(120 秒)**,为用户提供充足的时间来: +- 阅读和查看插件列表 +- 做出安装决定 +- 处理网络延迟 + +## 支持 + +如果这个插件对你有帮助,欢迎到 [OpenWebUI Extensions](https://github.com/Fu-Jie/openwebui-extensions) 点个 Star,这将是我持续改进的动力,感谢支持。 diff --git a/docs/plugins/tools/index.md b/docs/plugins/tools/index.md index 5ff63ca..18c7d20 100644 --- a/docs/plugins/tools/index.md +++ b/docs/plugins/tools/index.md @@ -4,5 +4,6 @@ OpenWebUI native Tool plugins that can be used across models. ## Available Tool Plugins +- [Batch Install Plugins from GitHub](batch-install-plugins-tool.md) (v1.0.0) - One-click batch install plugins from GitHub repositories with confirmation and multi-language support. - [OpenWebUI Skills Manager Tool](openwebui-skills-manager-tool.md) (v0.3.0) - Simple native skill management (`list/show/install/create/update/delete`). - [Smart Mind Map Tool](smart-mind-map-tool.md) (v1.0.0) - Intelligently analyzes text content and proactively generates interactive mind maps to help users structure and visualize knowledge. diff --git a/docs/plugins/tools/index.zh.md b/docs/plugins/tools/index.zh.md index f4a3e2a..3b7ba57 100644 --- a/docs/plugins/tools/index.zh.md +++ b/docs/plugins/tools/index.zh.md @@ -4,5 +4,6 @@ ## 可用 Tool 插件 +- [Batch Install Plugins from GitHub](batch-install-plugins-tool.zh.md) (v1.0.0) - 一键从 GitHub 仓库批量安装插件,支持确认和多语言。 - [OpenWebUI Skills 管理工具](openwebui-skills-manager-tool.zh.md) (v0.3.0) - 简化技能管理(`list/show/install/create/update/delete`)。 - [智能思维导图工具 (Smart Mind Map Tool)](smart-mind-map-tool.zh.md) (v1.0.0) - 智能分析文本内容并主动生成交互式思维导图,帮助用户结构化与可视化知识。 diff --git a/plugins/tools/batch-install-plugins/README.md b/plugins/tools/batch-install-plugins/README.md new file mode 100644 index 0000000..f70e08e --- /dev/null +++ b/plugins/tools/batch-install-plugins/README.md @@ -0,0 +1,137 @@ +# Batch Install Plugins from GitHub + +**Author:** [Fu-Jie](https://github.com/Fu-Jie) | **Version:** 1.0.0 | **Project:** [OpenWebUI Extensions](https://github.com/Fu-Jie/openwebui-extensions) + +One-click batch install plugins from GitHub repositories to your OpenWebUI instance. + +## Key Features + +- **One-Click Install**: Install all plugins with a single command +- **Auto-Update**: Automatically updates previously installed plugins +- **GitHub Support**: Install plugins from any GitHub repository +- **Multi-Type Support**: Supports Pipe, Action, Filter, and Tool plugins +- **Confirmation**: Shows plugin list before installing, allows selective installation +- **i18n**: Supports 11 languages + +## Flow + +``` +User Input + │ + ▼ +┌─────────────────────────────────────┐ +│ Discover Plugins from GitHub │ +│ (fetch file tree + parse .py) │ +└─────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────┐ +│ Filter by Type & Keywords │ +│ (tool/filter/pipe/action) │ +└─────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────┐ +│ Show Confirmation Dialog │ +│ (list plugins + exclude hint) │ +└─────────────────────────────────────┘ + │ + ├── [Cancel] → End + │ + ▼ +┌─────────────────────────────────────┐ +│ Install to OpenWebUI │ +│ (update or create each plugin) │ +└─────────────────────────────────────┘ + │ + ▼ + Done +``` + +## How to Use + +1. Open OpenWebUI and go to **Workspace > Tools** +2. Install **Batch Install Plugins from GitHub** from the official marketplace +3. Enable this tool for your model/chat +4. Ask the model to install plugins + +## Usage Examples + +``` +"Install all plugins" +"Install all plugins from github.com/username/repo" +"Install only pipe plugins" +"Install action and filter plugins" +"Install all plugins, exclude_keywords=copilot" +``` + +## Popular Plugin Repositories + +Here are some popular repositories with many plugins you can install: + +### Community Collections + +``` +# Install all plugins from iChristGit's collection +"Install all plugins from iChristGit/OpenWebui-Tools" + +# Install all tools from Haervwe's tools collection +"Install all plugins from Haervwe/open-webui-tools" + +# Install all plugins from Classic298's repository +"Install all plugins from Classic298/open-webui-plugins" + +# Install all functions from suurt8ll's collection +"Install all plugins from suurt8ll/open_webui_functions" + +# Install only specific types (e.g., only tools) +"Install only tool plugins from iChristGit/OpenWebui-Tools" + +# Exclude certain keywords while installing +"Install all plugins from Haervwe/open-webui-tools, exclude_keywords=test,deprecated" +``` + +### Supported Repositories + +- `Fu-Jie/openwebui-extensions` - Default, official plugin collection +- `iChristGit/OpenWebui-Tools` - Comprehensive tool and plugin collection +- `Haervwe/open-webui-tools` - Specialized tools and utilities +- `Classic298/open-webui-plugins` - Various plugin implementations +- `suurt8ll/open_webui_functions` - Function-based plugins + +## Default Repository + +When no repository is specified, defaults to `Fu-Jie/openwebui-extensions`. + +## Plugin Detection Rules + +### Fu-Jie/openwebui-extensions (Strict) + +For the default repository, plugins must have: +1. A `.py` file containing `class Tools:`, `class Filter:`, `class Pipe:`, or `class Action:` +2. A docstring with `title:`, `description:`, and **`openwebui_id:`** fields +3. Filename must not end with `_cn` + +### Other GitHub Repositories + +For other repositories: +1. A `.py` file containing `class Tools:`, `class Filter:`, `class Pipe:`, or `class Action:` +2. A docstring with `title:` and `description:` fields + +## Configuration (Valves) + +| Parameter | Default | Description | +| --- | --- | --- | +| `SKIP_KEYWORDS` | `test,verify,example,template,mock` | Comma-separated keywords to skip | +| `TIMEOUT` | `20` | Request timeout in seconds | + +## Confirmation Timeout + +User confirmation dialogs have a default timeout of **2 minutes (120 seconds)**, allowing sufficient time for users to: +- Read and review the plugin list +- Make installation decisions +- Handle network delays + +## Support + +If this plugin has been useful, a star on [OpenWebUI Extensions](https://github.com/Fu-Jie/openwebui-extensions) is a big motivation for me. Thank you for the support. diff --git a/plugins/tools/batch-install-plugins/README_CN.md b/plugins/tools/batch-install-plugins/README_CN.md new file mode 100644 index 0000000..d988e74 --- /dev/null +++ b/plugins/tools/batch-install-plugins/README_CN.md @@ -0,0 +1,137 @@ +# Batch Install Plugins from GitHub + +**作者:** [Fu-Jie](https://github.com/Fu-Jie) | **版本:** 1.0.0 | **项目:** [OpenWebUI Extensions](https://github.com/Fu-Jie/openwebui-extensions) + +一键将 GitHub 仓库中的插件批量安装到你的 OpenWebUI 实例。 + +## 主要功能 + +- 一键安装:单个命令安装所有插件 +- 自动更新:自动更新之前安装过的插件 +- GitHub 支持:从任意 GitHub 仓库安装插件 +- 多类型支持:支持 Pipe、Action、Filter 和 Tool 插件 +- 安装确认:安装前显示插件列表,支持选择性安装 +- 国际化:支持 11 种语言 + +## 流程 + +``` +用户输入 + │ + ▼ +┌─────────────────────────────────────┐ +│ 从 GitHub 发现插件 │ +│ (获取文件树 + 解析 .py 文件) │ +└─────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────┐ +│ 按类型和关键词过滤 │ +│ (tool/filter/pipe/action) │ +└─────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────┐ +│ 显示确认对话框 │ +│ (插件列表 + 排除提示) │ +└─────────────────────────────────────┘ + │ + ├── [取消] → 结束 + │ + ▼ +┌─────────────────────────────────────┐ +│ 安装到 OpenWebUI │ +│ (更新或创建每个插件) │ +└─────────────────────────────────────┘ + │ + ▼ + 完成 +``` + +## 使用方法 + +1. 打开 OpenWebUI,进入 **Workspace > Tools** +2. 从官方市场安装 **Batch Install Plugins from GitHub** +3. 为你的模型/对话启用此工具 +4. 让模型调用工具方法 + +## 使用示例 + +``` +"安装所有插件" +"从 github.com/username/repo 安装所有插件" +"只安装 pipe 插件" +"安装 action 和 filter 插件" +"安装所有插件, exclude_keywords=copilot" +``` + +## 热门插件仓库 + +这些是包含大量插件的热门仓库,你可以从中安装插件: + +### 社区合集 + +``` +# 从 iChristGit 的集合安装所有插件 +"从 iChristGit/OpenWebui-Tools 安装所有插件" + +# 从 Haervwe 的工具集合只安装工具 +"从 Haervwe/open-webui-tools 安装所有插件" + +# 从 Classic298 的仓库安装所有插件 +"从 Classic298/open-webui-plugins 安装所有插件" + +# 从 suurt8ll 的集合安装所有函数 +"从 suurt8ll/open_webui_functions 安装所有插件" + +# 只安装特定类型的插件(比如只安装工具) +"从 iChristGit/OpenWebui-Tools 只安装 tool 插件" + +# 安装时排除特定关键词 +"从 Haervwe/open-webui-tools 安装所有插件, exclude_keywords=test,deprecated" +``` + +### 支持的仓库 + +- `Fu-Jie/openwebui-extensions` - 默认的官方插件集合 +- `iChristGit/OpenWebui-Tools` - 全面的工具和插件集合 +- `Haervwe/open-webui-tools` - 专业的工具和实用程序 +- `Classic298/open-webui-plugins` - 各种插件实现 +- `suurt8ll/open_webui_functions` - 基于函数的插件 + +## 默认仓库 + +未指定仓库时,默认为 `Fu-Jie/openwebui-extensions`。 + +## 插件检测规则 + +### Fu-Jie/openwebui-extensions(严格模式) + +默认仓库的插件必须满足: +1. 包含 `class Tools:`、`class Filter:`、`class Pipe:` 或 `class Action:` 的 `.py` 文件 +2. Docstring 中包含 `title:`、`description:` 和 **`openwebui_id:`** 字段 +3. 文件名不能以 `_cn` 结尾 + +### 其他 GitHub 仓库 + +其他仓库的插件必须满足: +1. 包含 `class Tools:`、`class Filter:`、`class Pipe:` 或 `class Action:` 的 `.py` 文件 +2. Docstring 中包含 `title:` 和 `description:` 字段 + +## 配置(Valves) + +| 参数 | 默认值 | 描述 | +| --- | --- | --- | +| `SKIP_KEYWORDS` | `test,verify,example,template,mock` | 逗号分隔的跳过关键词 | +| `TIMEOUT` | `20` | 请求超时时间(秒)| + +## 确认超时时间 + +用户确认对话框的默认超时时间为 **2 分钟(120 秒)**,为用户提供充足的时间来: +- 阅读和查看插件列表 +- 做出安装决定 +- 处理网络延迟 + +## 支持 + +如果这个插件对你有帮助,欢迎到 [OpenWebUI Extensions](https://github.com/Fu-Jie/openwebui-extensions) 点个 Star,这将是我持续改进的动力,感谢支持。 diff --git a/plugins/tools/batch-install-plugins/batch_install_plugins.py b/plugins/tools/batch-install-plugins/batch_install_plugins.py new file mode 100644 index 0000000..be9d07d --- /dev/null +++ b/plugins/tools/batch-install-plugins/batch_install_plugins.py @@ -0,0 +1,1262 @@ +""" +title: Batch Install Plugins from GitHub +author: Fu-Jie +author_url: https://github.com/Fu-Jie/openwebui-extensions +funding_url: https://github.com/open-webui +version: 1.0.0 +description: One-click batch install plugins from GitHub repositories to your OpenWebUI instance. +""" + +import asyncio +import json +import logging +import os +import re +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +import httpx +from pydantic import BaseModel, Field + +logger = logging.getLogger(__name__) + +DEFAULT_REPO = "Fu-Jie/openwebui-extensions" +DEFAULT_BRANCH = "main" +DEFAULT_TIMEOUT = 20 +DEFAULT_SKIP_KEYWORDS = "test,verify,example,template,mock" +GITHUB_TIMEOUT = 30.0 +CONFIRMATION_TIMEOUT = 120.0 # 2 minutes for user confirmation +GITHUB_API = "https://api.github.com" +GITHUB_RAW = "https://raw.githubusercontent.com" +SELF_EXCLUDE_HINT = "batch-install-plugins" +SELF_EXCLUDE_TERMS = ( + SELF_EXCLUDE_HINT, + "batch install plugins from github", +) +DOCSTRING_PATTERN = re.compile(r'^\s*"""\n(.*?)\n"""', re.DOTALL) +CLASS_PATTERN = re.compile(r'^class (Tools|Filter|Pipe|Action)\s*[\(:]', re.MULTILINE) +EMOJI_PATTERN = re.compile(r'[\U00010000-\U0010ffff]', re.UNICODE) + +TRANSLATIONS = { + "en-US": { + "status_fetching": "Fetching plugin list from GitHub...", + "status_installing": "Installing [{type}] {title}...", + "status_done": "Installation complete: {success}/{total} plugins installed.", + "status_list_title": "Available Plugins ({count} total)", + "list_item": "- [{type}] {title}", + "err_no_api_key": "Authentication required. Please ensure you are logged in.", + "err_connection": "Cannot connect to OpenWebUI. Is it running?", + "success_updated": "Updated: {title}", + "success_created": "Created: {title}", + "failed": "Failed: {title} - {error}", + "error_timeout": "request timed out", + "error_http_status": "status {status}: {message}", + "error_request_failed": "request failed: {error}", + "confirm_title": "Confirm Installation", + "confirm_message": "Found {count} plugins to install:\n\n{plugin_list}{hint}\n\nDo you want to proceed with installation?", + "confirm_excluded_hint": "\n\n(Excluded: {excluded})", + "confirm_copy_exclude_hint": "\n\nCopy to exclude plugins:\n```\nexclude_keywords={keywords}\n```", + "confirm_cancelled": "Installation cancelled by user.", + "err_confirm_unavailable": "Confirmation timed out or failed. Installation cancelled.", + "err_no_plugins": "No installable plugins found.", + "err_no_match": "No plugins match the specified types.", + }, + "zh-CN": { + "status_fetching": "正在从 GitHub 获取插件列表...", + "status_installing": "正在安装 [{type}] {title}...", + "status_done": "安装完成:成功安装 {success}/{total} 个插件。", + "status_list_title": "可用插件(共 {count} 个)", + "list_item": "- [{type}] {title}", + "err_no_api_key": "需要认证。请确保已登录。", + "err_connection": "无法连接 OpenWebUI。请检查是否正在运行?", + "success_updated": "已更新:{title}", + "success_created": "已创建:{title}", + "failed": "失败:{title} - {error}", + "error_timeout": "请求超时", + "error_http_status": "状态 {status}:{message}", + "error_request_failed": "请求失败:{error}", + "confirm_title": "确认安装", + "confirm_message": "发现 {count} 个插件待安装:\n\n{plugin_list}{hint}\n\n是否继续安装?", + "confirm_excluded_hint": "\n\n(已排除:{excluded})", + "confirm_copy_exclude_hint": "\n\n复制以下内容可排除插件:\n```\nexclude_keywords={keywords}\n```", + "confirm_cancelled": "用户取消安装。", + "err_confirm_unavailable": "确认操作超时或失败,已取消安装。", + "err_no_plugins": "未发现可安装的插件。", + "err_no_match": "没有符合指定类型的插件。", + }, + "zh-HK": { + "status_fetching": "正在從 GitHub 取得外掛列表...", + "status_installing": "正在安裝 [{type}] {title}...", + "status_done": "安裝完成:成功安裝 {success}/{total} 個外掛。", + "status_list_title": "可用外掛(共 {count} 個)", + "list_item": "- [{type}] {title}", + "err_no_api_key": "需要驗證。請確保已登入。", + "err_connection": "無法連線至 OpenWebUI。請檢查是否正在執行?", + "success_updated": "已更新:{title}", + "success_created": "已建立:{title}", + "failed": "失敗:{title} - {error}", + "error_timeout": "請求逾時", + "error_http_status": "狀態 {status}:{message}", + "error_request_failed": "請求失敗:{error}", + "confirm_title": "確認安裝", + "confirm_message": "發現 {count} 個外掛待安裝:\n\n{plugin_list}{hint}\n\n是否繼續安裝?", + "confirm_excluded_hint": "\n\n(已排除:{excluded})", + "confirm_copy_exclude_hint": "\n\n複製以下內容可排除外掛:\n```\nexclude_keywords={keywords}\n```", + "confirm_cancelled": "用戶取消安裝。", + "err_confirm_unavailable": "確認操作逾時或失敗,已取消安裝。", + "err_no_plugins": "未發現可安裝的外掛。", + "err_no_match": "沒有符合指定類型的外掛。", + }, + "zh-TW": { + "status_fetching": "正在從 GitHub 取得外掛列表...", + "status_installing": "正在安裝 [{type}] {title}...", + "status_done": "安裝完成:成功安裝 {success}/{total} 個外掛。", + "status_list_title": "可用外掛(共 {count} 個)", + "list_item": "- [{type}] {title}", + "err_no_api_key": "需要驗證。請確保已登入。", + "err_connection": "無法連線至 OpenWebUI。請檢查是否正在執行?", + "success_updated": "已更新:{title}", + "success_created": "已建立:{title}", + "failed": "失敗:{title} - {error}", + "error_timeout": "請求逾時", + "error_http_status": "狀態 {status}:{message}", + "error_request_failed": "請求失敗:{error}", + "confirm_title": "確認安裝", + "confirm_message": "發現 {count} 個外掛待安裝:\n\n{plugin_list}{hint}\n\n是否繼續安裝?", + "confirm_excluded_hint": "\n\n(已排除:{excluded})", + "confirm_copy_exclude_hint": "\n\n複製以下內容可排除外掛:\n```\nexclude_keywords={keywords}\n```", + "confirm_cancelled": "用戶取消安裝。", + "err_confirm_unavailable": "確認操作逾時或失敗,已取消安裝。", + "err_no_plugins": "未發現可安裝的外掛。", + "err_no_match": "沒有符合指定類型的外掛。", + }, + "ko-KR": { + "status_fetching": "GitHub에서 플러그인 목록을 가져오는 중...", + "status_installing": "[{type}] {title} 설치 중...", + "status_done": "설치 완료: {success}/{total}개 플러그인 설치됨.", + "status_list_title": "사용 가능한 플러그인 (총 {count}개)", + "list_item": "- [{type}] {title}", + "err_no_api_key": "인증이 필요합니다. 로그인되어 있는지 확인하세요.", + "err_connection": "OpenWebUI에 연결할 수 없습니다. 실행 중인가요?", + "success_updated": "업데이트됨: {title}", + "success_created": "생성됨: {title}", + "failed": "실패: {title} - {error}", + "error_timeout": "요청 시간이 초과되었습니다", + "error_http_status": "상태 {status}: {message}", + "error_request_failed": "요청 실패: {error}", + "confirm_title": "설치 확인", + "confirm_message": "설치할 플러그인 {count}개를 발견했습니다:\n\n{plugin_list}{hint}\n\n설치를 계속하시겠습니까?", + "confirm_excluded_hint": "\n\n(제외됨: {excluded})", + "confirm_copy_exclude_hint": "\n\n플러그인을 제외하려면 아래를 복사하세요:\n```\nexclude_keywords={keywords}\n```", + "confirm_cancelled": "사용자가 설치를 취소했습니다.", + "err_confirm_unavailable": "확인 요청이 시간 초과되었거나 실패하여 설치를 취소했습니다.", + "err_no_plugins": "설치 가능한 플러그인을 찾을 수 없습니다.", + "err_no_match": "지정된 유형과 일치하는 플러그인이 없습니다.", + }, + "ja-JP": { + "status_fetching": "GitHubからプラグインリストを取得中...", + "status_installing": "[{type}] {title} をインストール中...", + "status_done": "インストール完了: {success}/{total}個のプラグインがインストールされました。", + "status_list_title": "利用可能なプラグイン (合計{count}個)", + "list_item": "- [{type}] {title}", + "err_no_api_key": "認証が必要です。ログインしていることを確認してください。", + "err_connection": "OpenWebUIに接続できません。実行中ですか?", + "success_updated": "更新: {title}", + "success_created": "作成: {title}", + "failed": "失敗: {title} - {error}", + "error_timeout": "リクエストがタイムアウトしました", + "error_http_status": "ステータス {status}: {message}", + "error_request_failed": "リクエスト失敗: {error}", + "confirm_title": "インストール確認", + "confirm_message": "インストールするプラグインが{count}個見つかりました:\n\n{plugin_list}{hint}\n\nインストールを続行しますか?", + "confirm_excluded_hint": "\n\n(除外: {excluded})", + "confirm_copy_exclude_hint": "\n\nプラグインを除外するには次をコピーしてください:\n```\nexclude_keywords={keywords}\n```", + "confirm_cancelled": "ユーザーがインストールをキャンセルしました。", + "err_confirm_unavailable": "確認がタイムアウトしたか失敗したため、インストールをキャンセルしました。", + "err_no_plugins": "インストール可能なプラグインが見つかりません。", + "err_no_match": "指定されたタイプのプラグインがありません。", + }, + "fr-FR": { + "status_fetching": "Récupération de la liste des plugins depuis GitHub...", + "status_installing": "Installation de [{type}] {title}...", + "status_done": "Installation terminée: {success}/{total} plugins installés.", + "status_list_title": "Plugins disponibles ({count} au total)", + "list_item": "- [{type}] {title}", + "err_no_api_key": "Authentification requise. Veuillez vous assurer d'être connecté.", + "err_connection": "Impossible de se connecter à OpenWebUI. Est-il en cours d'exécution?", + "success_updated": "Mis à jour: {title}", + "success_created": "Créé: {title}", + "failed": "Échec: {title} - {error}", + "error_timeout": "délai d'attente de la requête dépassé", + "error_http_status": "statut {status} : {message}", + "error_request_failed": "échec de la requête : {error}", + "confirm_title": "Confirmer l'installation", + "confirm_message": "{count} plugins à installer:\n\n{plugin_list}{hint}\n\nVoulez-vous procéder à l'installation?", + "confirm_excluded_hint": "\n\n(Exclus : {excluded})", + "confirm_copy_exclude_hint": "\n\nCopiez ceci pour exclure des plugins :\n```\nexclude_keywords={keywords}\n```", + "confirm_cancelled": "Installation annulée par l'utilisateur.", + "err_confirm_unavailable": "La confirmation a expiré ou a échoué. Installation annulée.", + "err_no_plugins": "Aucun plugin installable trouvé.", + "err_no_match": "Aucun plugin ne correspond aux types spécifiés.", + }, + "de-DE": { + "status_fetching": "Plugin-Liste wird von GitHub abgerufen...", + "status_installing": "[{type}] {title} wird installiert...", + "status_done": "Installation abgeschlossen: {success}/{total} Plugins installiert.", + "status_list_title": "Verfügbare Plugins (insgesamt {count})", + "list_item": "- [{type}] {title}", + "err_no_api_key": "Authentifizierung erforderlich. Bitte stellen Sie sicher, dass Sie angemeldet sind.", + "err_connection": "Verbindung zu OpenWebUI nicht möglich. Läuft es?", + "success_updated": "Aktualisiert: {title}", + "success_created": "Erstellt: {title}", + "failed": "Fehlgeschlagen: {title} - {error}", + "error_timeout": "Zeitüberschreitung bei der Anfrage", + "error_http_status": "Status {status}: {message}", + "error_request_failed": "Anfrage fehlgeschlagen: {error}", + "confirm_title": "Installation bestätigen", + "confirm_message": "{count} Plugins zur Installation gefunden:\n\n{plugin_list}{hint}\n\nMöchten Sie mit der Installation fortfahren?", + "confirm_excluded_hint": "\n\n(Ausgeschlossen: {excluded})", + "confirm_copy_exclude_hint": "\n\nZum Ausschließen von Plugins kopieren:\n```\nexclude_keywords={keywords}\n```", + "confirm_cancelled": "Installation vom Benutzer abgebrochen.", + "err_confirm_unavailable": "Bestätigung abgelaufen oder fehlgeschlagen. Installation abgebrochen.", + "err_no_plugins": "Keine installierbaren Plugins gefunden.", + "err_no_match": "Keine Plugins entsprechen den angegebenen Typen.", + }, + "es-ES": { + "status_fetching": "Obteniendo lista de plugins de GitHub...", + "status_installing": "Instalando [{type}] {title}...", + "status_done": "Instalación completada: {success}/{total} plugins instalados.", + "status_list_title": "Plugins disponibles ({count} en total)", + "list_item": "- [{type}] {title}", + "err_no_api_key": "Se requiere autenticación. Asegúrese de haber iniciado sesión.", + "err_connection": "No se puede conectar a OpenWebUI. ¿Está en ejecución?", + "success_updated": "Actualizado: {title}", + "success_created": "Creado: {title}", + "failed": "Fallido: {title} - {error}", + "error_timeout": "la solicitud agotó el tiempo de espera", + "error_http_status": "estado {status}: {message}", + "error_request_failed": "solicitud fallida: {error}", + "confirm_title": "Confirmar instalación", + "confirm_message": "Se encontraron {count} plugins para instalar:\n\n{plugin_list}{hint}\n\n¿Desea continuar con la instalación?", + "confirm_excluded_hint": "\n\n(Excluidos: {excluded})", + "confirm_copy_exclude_hint": "\n\nCopia esto para excluir plugins:\n```\nexclude_keywords={keywords}\n```", + "confirm_cancelled": "Instalación cancelada por el usuario.", + "err_confirm_unavailable": "La confirmación expiró o falló. Instalación cancelada.", + "err_no_plugins": "No se encontraron plugins instalables.", + "err_no_match": "No hay plugins que coincidan con los tipos especificados.", + }, + "it-IT": { + "status_fetching": "Recupero lista plugin da GitHub...", + "status_installing": "Installazione di [{type}] {title}...", + "status_done": "Installazione completata: {success}/{total} plugin installati.", + "status_list_title": "Plugin disponibili ({count} totali)", + "list_item": "- [{type}] {title}", + "err_no_api_key": "Autenticazione richiesta. Assicurati di aver effettuato l'accesso.", + "err_connection": "Impossibile connettersi a OpenWebUI. È in esecuzione?", + "success_updated": "Aggiornato: {title}", + "success_created": "Creato: {title}", + "failed": "Fallito: {title} - {error}", + "error_timeout": "richiesta scaduta", + "error_http_status": "stato {status}: {message}", + "error_request_failed": "richiesta non riuscita: {error}", + "confirm_title": "Conferma installazione", + "confirm_message": "Trovati {count} plugin da installare:\n\n{plugin_list}{hint}\n\nVuoi procedere con l'installazione?", + "confirm_excluded_hint": "\n\n(Esclusi: {excluded})", + "confirm_copy_exclude_hint": "\n\nCopia questo per escludere plugin:\n```\nexclude_keywords={keywords}\n```", + "confirm_cancelled": "Installazione annullata dall'utente.", + "err_confirm_unavailable": "La conferma è scaduta o non è riuscita. Installazione annullata.", + "err_no_plugins": "Nessun plugin installabile trovato.", + "err_no_match": "Nessun plugin corrisponde ai tipi specificati.", + }, + "vi-VN": { + "status_fetching": "Đang lấy danh sách plugin từ GitHub...", + "status_installing": "Đang cài đặt [{type}] {title}...", + "status_done": "Cài đặt hoàn tất: {success}/{total} plugin đã được cài đặt.", + "status_list_title": "Plugin khả dụng ({count} tổng cộng)", + "list_item": "- [{type}] {title}", + "err_no_api_key": "Yêu cầu xác thực. Vui lòng đảm bảo bạn đã đăng nhập.", + "err_connection": "Không thể kết nối đến OpenWebUI. Có đang chạy không?", + "success_updated": "Đã cập nhật: {title}", + "success_created": "Đã tạo: {title}", + "failed": "Thất bại: {title} - {error}", + "error_timeout": "yêu cầu đã hết thời gian chờ", + "error_http_status": "trạng thái {status}: {message}", + "error_request_failed": "yêu cầu thất bại: {error}", + "confirm_title": "Xác nhận cài đặt", + "confirm_message": "Tìm thấy {count} plugin để cài đặt:\n\n{plugin_list}{hint}\n\nBạn có muốn tiếp tục cài đặt không?", + "confirm_excluded_hint": "\n\n(Đã loại trừ: {excluded})", + "confirm_copy_exclude_hint": "\n\nSao chép nội dung sau để loại trừ plugin:\n```\nexclude_keywords={keywords}\n```", + "confirm_cancelled": "Người dùng đã hủy cài đặt.", + "err_confirm_unavailable": "Xác nhận đã hết thời gian chờ hoặc thất bại. Đã hủy cài đặt.", + "err_no_plugins": "Không tìm thấy plugin nào có thể cài đặt.", + "err_no_match": "Không có plugin nào khớp với các loại được chỉ định.", + }, +} + +FALLBACK_MAP = {"zh": "zh-CN", "zh-TW": "zh-TW", "zh-HK": "zh-HK", "en": "en-US", "ko": "ko-KR", "ja": "ja-JP", "fr": "fr-FR", "de": "de-DE", "es": "es-ES", "it": "it-IT", "vi": "vi-VN"} + + +def _resolve_language(user_language: str) -> str: + value = str(user_language or "").strip() + if not value: + return "en-US" + normalized = value.replace("_", "-") + if normalized in TRANSLATIONS: + return normalized + lower_fallback = {k.lower(): v for k, v in FALLBACK_MAP.items()} + base = normalized.split("-")[0].lower() + return lower_fallback.get(base, "en-US") + + +def _t(lang: str, key: str, **kwargs) -> str: + lang_key = _resolve_language(lang) + text = TRANSLATIONS.get(lang_key, TRANSLATIONS["en-US"]).get(key, key) + if kwargs: + try: + text = text.format(**kwargs) + except KeyError: + pass + return text + + +async def _emit_status(emitter: Optional[Any], description: str, done: bool = False) -> None: + if emitter: + await emitter( + {"type": "status", "data": {"description": description, "done": done}} + ) + + +async def _emit_notification( + emitter: Optional[Any], + content: str, + ntype: str = "info", +) -> None: + if emitter: + await emitter( + {"type": "notification", "data": {"type": ntype, "content": content}} + ) + + +async def _finalize_message( + emitter: Optional[Any], + message: str, + notification_type: Optional[str] = None, +) -> str: + await _emit_status(emitter, message, done=True) + if notification_type: + await _emit_notification(emitter, message, ntype=notification_type) + return message + + +async def _emit_frontend_debug_log( + event_call: Optional[Any], + title: str, + data: Dict[str, Any], + level: str = "debug", +) -> None: + if not event_call: + return + + console_method = level if level in {"debug", "log", "warn", "error"} else "debug" + js_code = f""" + try {{ + const payload = {json.dumps(data, ensure_ascii=False)}; + const runtime = {{ + href: typeof window !== "undefined" ? window.location.href : "", + origin: typeof window !== "undefined" ? window.location.origin : "", + lang: ( + (typeof document !== "undefined" && document.documentElement && document.documentElement.lang) || + (typeof localStorage !== "undefined" && (localStorage.getItem("locale") || localStorage.getItem("language"))) || + (typeof navigator !== "undefined" && navigator.language) || + "" + ), + readyState: (typeof document !== "undefined" && document.readyState) || "", + }}; + const merged = Object.assign({{ frontend: runtime }}, payload); + console.groupCollapsed( + "%c" + {json.dumps(f"[Batch Install] {title}", ensure_ascii=False)}, + "color:#2563eb;font-weight:bold;" + ); + console.{console_method}(merged); + if (merged.base_url && runtime.origin && merged.base_url !== runtime.origin) {{ + console.warn("[Batch Install] Frontend origin differs from backend target", {{ + frontend_origin: runtime.origin, + backend_target: merged.base_url, + }}); + }} + console.groupEnd(); + return true; + }} catch (e) {{ + console.error("[Batch Install] Failed to emit frontend debug log", e); + return false; + }} + """ + + try: + await asyncio.wait_for( + event_call({"type": "execute", "data": {"code": js_code}}), + timeout=2.0, + ) + except asyncio.TimeoutError: + logger.warning("Frontend debug log timed out: %s", title) + except Exception as exc: + logger.warning("Frontend debug log failed for %s: %s", title, exc) + + +async def _get_user_context( + __user__: Optional[dict], + __event_call__: Optional[Any] = None, + __request__: Optional[Any] = None, +) -> Dict[str, str]: + if isinstance(__user__, (list, tuple)): + user_data = __user__[0] if __user__ else {} + elif isinstance(__user__, dict): + user_data = __user__ + else: + user_data = {} + user_language = user_data.get("language", "en-US") + if __request__ and hasattr(__request__, "headers"): + accept_lang = __request__.headers.get("accept-language", "") + if accept_lang: + user_language = accept_lang.split(",")[0].split(";")[0] + if __event_call__: + try: + js_code = """ + try { + return ( + document.documentElement.lang || + localStorage.getItem('locale') || + localStorage.getItem('language') || + navigator.language || + 'en-US' + ); + } catch (e) { + return 'en-US'; + } + """ + frontend_lang = await asyncio.wait_for( + __event_call__({"type": "execute", "data": {"code": js_code}}), + timeout=2.0, + ) + if frontend_lang and isinstance(frontend_lang, str): + user_language = frontend_lang + except asyncio.TimeoutError: + logger.warning("Frontend language detection timed out.") + except Exception as exc: + logger.warning("Frontend language detection failed: %s", exc) + return { + "user_id": str(user_data.get("id", "")).strip(), + "user_name": user_data.get("name", "User"), + "user_language": user_language, + } + + +class PluginCandidate: + def __init__( + self, + plugin_type: str, + file_path: str, + metadata: Dict[str, str], + content: str, + function_id: str, + ): + self.plugin_type = plugin_type + self.file_path = file_path + self.metadata = metadata + self.content = content + self.function_id = function_id + + @property + def title(self) -> str: + return self.metadata.get("title", Path(self.file_path).stem) + + @property + def version(self) -> str: + return self.metadata.get("version", "unknown") + + +def extract_metadata(content: str) -> Dict[str, str]: + match = DOCSTRING_PATTERN.search(content) + if not match: + return {} + metadata: Dict[str, str] = {} + for raw_line in match.group(1).splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or ":" not in line: + continue + key, value = line.split(":", 1) + metadata[key.strip().lower()] = value.strip() + return metadata + + +def detect_plugin_type(content: str) -> Optional[str]: + if "\nclass Tools:" in content or "\nclass Tools (" in content: + return "tool" + if "\nclass Filter:" in content or "\nclass Filter (" in content: + return "filter" + if "\nclass Pipe:" in content or "\nclass Pipe (" in content: + return "pipe" + if "\nclass Action:" in content or "\nclass Action (" in content: + return "action" + return None + + +def has_valid_class(content: str) -> bool: + return CLASS_PATTERN.search(content) is not None + + +def has_emoji(text: str) -> bool: + return bool(EMOJI_PATTERN.search(text)) + + +def should_skip_file(file_path: str, is_default_repo: bool, skip_keywords: str = "test") -> Optional[str]: + stem = Path(file_path).stem.lower() + if is_default_repo and stem.endswith("_cn"): + return "localized _cn file" + if skip_keywords: + keywords = [k.strip().lower() for k in skip_keywords.split(",") if k.strip()] + for kw in keywords: + if kw in stem: + return f"contains '{kw}'" + return None + + +def slugify_function_id(value: str) -> str: + cleaned = EMOJI_PATTERN.sub("", value) + slug = re.sub(r"[^a-z0-9_\u4e00-\u9fff]+", "_", cleaned.lower()).strip("_") + slug = re.sub(r"_+", "_", slug) + return slug or "plugin" + + +def build_function_id(file_path: str, metadata: Dict[str, str]) -> str: + if metadata.get("id"): + return slugify_function_id(metadata["id"]) + if metadata.get("title"): + return slugify_function_id(metadata["title"]) + return slugify_function_id(Path(file_path).stem) + + +def build_payload(candidate: PluginCandidate) -> Dict[str, object]: + manifest = dict(candidate.metadata) + manifest.setdefault("title", candidate.title) + manifest.setdefault("author", "Fu-Jie") + manifest.setdefault("author_url", "https://github.com/Fu-Jie/openwebui-extensions") + manifest.setdefault("funding_url", "https://github.com/open-webui") + manifest.setdefault( + "description", f"{candidate.plugin_type.title()} plugin: {candidate.title}" + ) + manifest.setdefault("version", "1.0.0") + manifest["type"] = candidate.plugin_type + if candidate.plugin_type == "tool": + return { + "id": candidate.function_id, + "name": manifest["title"], + "meta": { + "description": manifest["description"], + "manifest": {}, + }, + "content": candidate.content, + "access_grants": [], + } + return { + "id": candidate.function_id, + "name": manifest["title"], + "meta": { + "description": manifest["description"], + "manifest": manifest, + "type": candidate.plugin_type, + }, + "content": candidate.content, + } + + +def build_api_urls(base_url: str, candidate: PluginCandidate) -> Tuple[str, str]: + if candidate.plugin_type == "tool": + return ( + f"{base_url}/api/v1/tools/id/{candidate.function_id}/update", + f"{base_url}/api/v1/tools/create", + ) + return ( + f"{base_url}/api/v1/functions/id/{candidate.function_id}/update", + f"{base_url}/api/v1/functions/create", + ) + + +def _response_message(response: httpx.Response) -> str: + try: + return json.dumps(response.json(), ensure_ascii=False) + except ValueError: + return response.text[:500] + + +def _matches_self_plugin(candidate: PluginCandidate) -> bool: + haystack = f"{candidate.title} {candidate.file_path}".lower() + return any(term in haystack for term in SELF_EXCLUDE_TERMS) + + +def _candidate_debug_data(candidate: PluginCandidate) -> Dict[str, str]: + return { + "title": candidate.title, + "type": candidate.plugin_type, + "file_path": candidate.file_path, + "function_id": candidate.function_id, + "version": candidate.version, + } + + +def _filter_candidates( + candidates: List[PluginCandidate], + plugin_types: List[str], + repo: str, + exclude_keywords: str = "", +) -> List[PluginCandidate]: + allowed_types = {item.strip().lower() for item in plugin_types if item.strip()} + filtered = [c for c in candidates if c.plugin_type.lower() in allowed_types] + + if repo.lower() == DEFAULT_REPO.lower(): + filtered = [c for c in filtered if not _matches_self_plugin(c)] + + exclude_list = [item.strip().lower() for item in exclude_keywords.split(",") if item.strip()] + if exclude_list: + filtered = [ + c + for c in filtered + if not any( + keyword in c.title.lower() or keyword in c.file_path.lower() + for keyword in exclude_list + ) + ] + + return filtered + + +def _build_confirmation_hint(lang: str, repo: str, exclude_keywords: str) -> str: + is_default_repo = repo.lower() == DEFAULT_REPO.lower() + excluded_parts: List[str] = [] + + if exclude_keywords: + excluded_parts.append(exclude_keywords) + if is_default_repo: + excluded_parts.append(SELF_EXCLUDE_HINT) + + if excluded_parts: + return _t(lang, "confirm_excluded_hint", excluded=", ".join(excluded_parts)) + + return _t(lang, "confirm_copy_exclude_hint", keywords=SELF_EXCLUDE_HINT) + + +async def _request_confirmation( + event_call: Optional[Any], + lang: str, + message: str, +) -> Tuple[bool, Optional[str]]: + if not event_call: + return True, None + + try: + confirmed = await asyncio.wait_for( + event_call( + { + "type": "confirmation", + "data": { + "title": _t(lang, "confirm_title"), + "message": message, + }, + } + ), + timeout=CONFIRMATION_TIMEOUT, + ) + except asyncio.TimeoutError: + logger.warning("Installation confirmation timed out.") + return False, _t(lang, "err_confirm_unavailable") + except Exception as exc: + logger.warning("Installation confirmation failed: %s", exc) + return False, _t(lang, "err_confirm_unavailable") + + return bool(confirmed), None + + +def parse_github_url(url: str) -> Optional[Tuple[str, str, str]]: + match = re.match( + r"https://github\.com/([^/]+)/([^/]+?)(?:\.git)?(?:/tree/([^/]+))?/?$", + url, + ) + if not match: + return None + owner, repo, branch = match.group(1), match.group(2), (match.group(3) or DEFAULT_BRANCH) + return owner, repo, branch + + +async def fetch_github_tree( + client: httpx.AsyncClient, owner: str, repo: str, branch: str +) -> List[Dict]: + api_url = f"{GITHUB_API}/repos/{owner}/{repo}/git/trees/{branch}?recursive=1" + try: + resp = await client.get(api_url, headers={"User-Agent": "OpenWebUI-Tool"}) + resp.raise_for_status() + data = resp.json() + tree = data.get("tree", []) + return tree if isinstance(tree, list) else [] + except (httpx.HTTPError, ValueError) as exc: + logger.warning("Failed to fetch GitHub tree from %s: %s", api_url, exc) + return [] + + +async def fetch_github_file( + client: httpx.AsyncClient, owner: str, repo: str, branch: str, path: str +) -> Optional[str]: + raw_url = f"{GITHUB_RAW}/{owner}/{repo}/{branch}/{path}" + try: + resp = await client.get(raw_url, headers={"User-Agent": "OpenWebUI-Tool"}) + resp.raise_for_status() + return resp.text + except httpx.HTTPError as exc: + logger.warning("Failed to fetch GitHub file from %s: %s", raw_url, exc) + return None + + +async def discover_plugins( + url: str, + skip_keywords: str = "test", +) -> Tuple[List[PluginCandidate], List[Tuple[str, str]]]: + parsed = parse_github_url(url) + if not parsed: + return [], [("url", "invalid github url")] + owner, repo, branch = parsed + + is_default_repo = (owner.lower() == "fu-jie" and repo.lower() == "openwebui-extensions") + + async with httpx.AsyncClient( + timeout=httpx.Timeout(GITHUB_TIMEOUT), follow_redirects=True + ) as client: + tree = await fetch_github_tree(client, owner, repo, branch) + if not tree: + return [], [("url", "failed to fetch repository tree")] + + candidates: List[PluginCandidate] = [] + skipped: List[Tuple[str, str]] = [] + + for item in tree: + item_path = item.get("path", "") + if item.get("type") != "blob": + continue + if not item_path.endswith(".py"): + continue + + file_name = item_path.split("/")[-1] + skip_reason = should_skip_file(file_name, is_default_repo, skip_keywords) + if skip_reason: + skipped.append((item_path, skip_reason)) + continue + + content = await fetch_github_file(client, owner, repo, branch, item_path) + if not content: + skipped.append((item_path, "fetch failed")) + continue + + if not has_valid_class(content): + skipped.append((item_path, "no valid class")) + continue + + metadata = extract_metadata(content) + if not metadata: + skipped.append((item_path, "missing docstring")) + continue + + if "title" not in metadata or "description" not in metadata: + skipped.append((item_path, "missing title/description")) + continue + + if has_emoji(metadata.get("title", "")): + skipped.append((item_path, "title contains emoji")) + continue + + if is_default_repo and not metadata.get("openwebui_id"): + skipped.append((item_path, "missing openwebui_id")) + continue + + plugin_type = detect_plugin_type(content) + if not plugin_type: + skipped.append((item_path, "unknown plugin type")) + continue + + candidates.append( + PluginCandidate( + plugin_type=plugin_type, + file_path=item_path, + metadata=metadata, + content=content, + function_id=build_function_id(item_path, metadata), + ) + ) + + candidates.sort(key=lambda x: (x.plugin_type, x.file_path)) + return candidates, skipped + + +class ListParams(BaseModel): + repo: str = Field( + default=DEFAULT_REPO, + description="GitHub repository (owner/repo)", + ) + plugin_types: List[str] = Field( + default=["pipe", "action", "filter", "tool"], + description="Plugin types to list (pipe, action, filter, tool)", + ) + + +class InstallParams(BaseModel): + repo: str = Field( + default=DEFAULT_REPO, + description="GitHub repository (owner/repo)", + ) + plugin_types: List[str] = Field( + default=["pipe", "action", "filter", "tool"], + description="Plugin types to install (pipe, action, filter, tool)", + ) + timeout: int = Field( + default=DEFAULT_TIMEOUT, + description="Request timeout in seconds", + ) + + +class Tools: + class Valves(BaseModel): + SKIP_KEYWORDS: str = Field( + default=DEFAULT_SKIP_KEYWORDS, + description="Comma-separated keywords to skip (e.g., 'test,verify,example')", + ) + TIMEOUT: int = Field( + default=DEFAULT_TIMEOUT, + description="Request timeout in seconds", + ) + + def __init__(self): + self.valves = self.Valves() + + async def list_plugins( + self, + __user__: Optional[dict] = None, + __event_call__: Optional[Any] = None, + __request__: Optional[Any] = None, + valves: Optional[Any] = None, + repo: str = DEFAULT_REPO, + plugin_types: List[str] = ["pipe", "action", "filter", "tool"], + ) -> str: + user_ctx = await _get_user_context(__user__, __event_call__, __request__) + lang = user_ctx.get("user_language", "en-US") + + skip_keywords = DEFAULT_SKIP_KEYWORDS + if valves and hasattr(valves, "SKIP_KEYWORDS") and valves.SKIP_KEYWORDS: + skip_keywords = valves.SKIP_KEYWORDS + + repo_url = f"https://github.com/{repo}" + candidates, _ = await discover_plugins(repo_url, skip_keywords) + + if not candidates: + return _t(lang, "err_no_plugins") + + filtered = _filter_candidates(candidates, plugin_types, repo) + if not filtered: + return _t(lang, "err_no_match") + + lines = [f"## {_t(lang, 'status_list_title', count=len(filtered))}\n"] + for c in filtered: + lines.append( + _t(lang, "list_item", type=c.plugin_type, title=c.title) + ) + return "\n".join(lines) + + async def install_all_plugins( + self, + __user__: Optional[dict] = None, + __event_call__: Optional[Any] = None, + __request__: Optional[Any] = None, + __event_emitter__: Optional[Any] = None, + emitter: Optional[Any] = None, + valves: Optional[Any] = None, + repo: str = DEFAULT_REPO, + plugin_types: List[str] = ["pipe", "action", "filter", "tool"], + exclude_keywords: str = "", + timeout: int = DEFAULT_TIMEOUT, + ) -> str: + user_ctx = await _get_user_context(__user__, __event_call__, __request__) + lang = user_ctx.get("user_language", "en-US") + event_emitter = __event_emitter__ or emitter + + skip_keywords = DEFAULT_SKIP_KEYWORDS + if valves and hasattr(valves, "SKIP_KEYWORDS") and valves.SKIP_KEYWORDS: + skip_keywords = valves.SKIP_KEYWORDS + + if valves and hasattr(valves, "TIMEOUT") and valves.TIMEOUT: + timeout = valves.TIMEOUT + timeout = max(int(timeout), 1) + + # Resolve base_url for OpenWebUI API calls + # Priority: request.base_url (with smart fallback to 8080) > env vars (for advanced users) + base_url = None + fallback_base_url = "http://localhost:8080" + + # First try request.base_url (works for domains, localhost, normal deployments) + if __request__ and hasattr(__request__, "base_url"): + base_url = str(__request__.base_url).rstrip("/") + logger.info("[Batch Install] Primary base_url from request: %s", base_url) + else: + base_url = fallback_base_url + logger.info("[Batch Install] Using fallback base_url: %s", base_url) + + # Check for environment variable override (for container mapping issues) + env_override = os.getenv("OPENWEBUI_URL") or os.getenv("OPENWEBUI_API_BASE_URL") + if env_override: + base_url = env_override.rstrip("/") + logger.info("[Batch Install] Environment variable override applied: %s", base_url) + + logger.info("[Batch Install] Initial base_url: %s", base_url) + + api_key = "" + if __request__ and hasattr(__request__, "headers"): + auth = __request__.headers.get("Authorization", "") + if auth.startswith("Bearer "): + api_key = auth.split(" ", 1)[1] + + if not api_key: + api_key = os.getenv("OPENWEBUI_API_KEY", "") + + if not api_key: + return await _finalize_message( + event_emitter, _t(lang, "err_no_api_key"), notification_type="error" + ) + + base_url = base_url.rstrip("/") + + await _emit_status(event_emitter, _t(lang, "status_fetching"), done=False) + + repo_url = f"https://github.com/{repo}" + candidates, _ = await discover_plugins(repo_url, skip_keywords) + + if not candidates: + return await _finalize_message( + event_emitter, _t(lang, "err_no_plugins"), notification_type="error" + ) + + filtered = _filter_candidates(candidates, plugin_types, repo, exclude_keywords) + + if not filtered: + return await _finalize_message( + event_emitter, _t(lang, "err_no_match"), notification_type="warning" + ) + + plugin_list = "\n".join([f"- [{c.plugin_type}] {c.title}" for c in filtered]) + hint_msg = _build_confirmation_hint(lang, repo, exclude_keywords) + confirm_msg = _t( + lang, + "confirm_message", + count=len(filtered), + plugin_list=plugin_list, + hint=hint_msg, + ) + + confirmed, confirm_error = await _request_confirmation( + __event_call__, lang, confirm_msg + ) + if confirm_error: + return await _finalize_message( + event_emitter, confirm_error, notification_type="warning" + ) + if not confirmed: + return await _finalize_message( + event_emitter, + _t(lang, "confirm_cancelled"), + notification_type="info", + ) + + await _emit_frontend_debug_log( + __event_call__, + "Starting OpenWebUI install requests", + { + "repo": repo, + "base_url": base_url, + "note": "Backend uses default port 8080 (containerized environment)", + "plugin_count": len(filtered), + "plugin_types": plugin_types, + "exclude_keywords": exclude_keywords, + "timeout": timeout, + "has_api_key": bool(api_key), + }, + level="debug", + ) + + headers = { + "Accept": "application/json", + "Content-Type": "application/json", + "Authorization": f"Bearer {api_key}", + } + + success_count = 0 + results: List[str] = [] + attempted_fallback = False # Track if we've already tried fallback + + async with httpx.AsyncClient( + timeout=httpx.Timeout(timeout), follow_redirects=True + ) as client: + for candidate in filtered: + await _emit_status( + event_emitter, + _t( + lang, + "status_installing", + type=candidate.plugin_type, + title=candidate.title, + ), + done=False, + ) + + payload = build_payload(candidate) + update_url, create_url = build_api_urls(base_url, candidate) + + try: + await _emit_frontend_debug_log( + __event_call__, + "Posting plugin install request", + { + "base_url": base_url, + "update_url": update_url, + "create_url": create_url, + "candidate": _candidate_debug_data(candidate), + }, + level="debug", + ) + update_response = await client.post( + update_url, + headers=headers, + json=payload, + ) + if 200 <= update_response.status_code < 300: + success_count += 1 + results.append(_t(lang, "success_updated", title=candidate.title)) + continue + + await _emit_frontend_debug_log( + __event_call__, + "Update endpoint returned non-2xx; trying create endpoint", + { + "base_url": base_url, + "update_url": update_url, + "create_url": create_url, + "update_status": update_response.status_code, + "update_message": _response_message(update_response), + "candidate": _candidate_debug_data(candidate), + }, + level="warn", + ) + create_response = await client.post( + create_url, + headers=headers, + json=payload, + ) + if 200 <= create_response.status_code < 300: + success_count += 1 + results.append(_t(lang, "success_created", title=candidate.title)) + continue + + create_error = _response_message(create_response) + await _emit_frontend_debug_log( + __event_call__, + "Create endpoint returned non-2xx", + { + "base_url": base_url, + "update_url": update_url, + "create_url": create_url, + "update_status": update_response.status_code, + "create_status": create_response.status_code, + "create_message": create_error, + "candidate": _candidate_debug_data(candidate), + }, + level="error", + ) + error_msg = ( + _t( + lang, + "error_http_status", + status=create_response.status_code, + message=create_error, + ) + ) + results.append( + _t(lang, "failed", title=candidate.title, error=error_msg) + ) + except httpx.TimeoutException: + await _emit_frontend_debug_log( + __event_call__, + "OpenWebUI request timed out", + { + "base_url": base_url, + "update_url": update_url, + "create_url": create_url, + "timeout": timeout, + "candidate": _candidate_debug_data(candidate), + }, + level="warn", + ) + results.append( + _t( + lang, + "failed", + title=candidate.title, + error=_t(lang, "error_timeout"), + ) + ) + except httpx.ConnectError as exc: + # Smart fallback: if connection fails and we haven't tried fallback yet, switch to 8080 + if not attempted_fallback and base_url != fallback_base_url and not env_override: + logger.warning( + "[Batch Install] Connection to %s failed; attempting fallback to %s", + base_url, + fallback_base_url, + ) + attempted_fallback = True + base_url = fallback_base_url + + await _emit_frontend_debug_log( + __event_call__, + "Primary base_url failed; switching to fallback", + { + "failed_base_url": base_url, + "fallback_base_url": fallback_base_url, + "candidate": _candidate_debug_data(candidate), + "error": str(exc), + }, + level="warn", + ) + + # Retry this candidate with the fallback base_url + logger.info("[Batch Install] Retrying plugin with fallback base_url: %s", candidate.title) + update_url, create_url = build_api_urls(base_url, candidate) + + try: + update_response = await client.post( + update_url, + headers=headers, + json=payload, + ) + if 200 <= update_response.status_code < 300: + success_count += 1 + results.append(_t(lang, "success_updated", title=candidate.title)) + continue + + create_response = await client.post( + create_url, + headers=headers, + json=payload, + ) + if 200 <= create_response.status_code < 300: + success_count += 1 + results.append(_t(lang, "success_created", title=candidate.title)) + else: + create_error = _response_message(create_response) + error_msg = _t( + lang, + "error_http_status", + status=create_response.status_code, + message=create_error, + ) + results.append( + _t(lang, "failed", title=candidate.title, error=error_msg) + ) + except httpx.ConnectError as fallback_exc: + # Fallback also failed, cannot recover + logger.error("[Batch Install] Fallback retry failed: %s", fallback_exc) + await _emit_frontend_debug_log( + __event_call__, + "OpenWebUI connection failed (both primary and fallback)", + { + "primary_base_url": base_url, + "fallback_base_url": fallback_base_url, + "candidate": _candidate_debug_data(candidate), + "error": str(fallback_exc), + }, + level="error", + ) + return await _finalize_message( + event_emitter, + _t(lang, "err_connection"), + notification_type="error", + ) + except Exception as retry_exc: + logger.error("[Batch Install] Fallback retry failed with other error: %s", retry_exc) + results.append( + _t( + lang, + "failed", + title=candidate.title, + error=_t(lang, "error_request_failed", error=str(retry_exc)), + ) + ) + else: + # Already tried fallback or env var is set, cannot recover + logger.error( + "OpenWebUI connection failed for %s (%s). " + "base_url=%s update_url=%s create_url=%s error=%s", + candidate.title, + candidate.function_id, + base_url, + update_url, + create_url, + exc, + ) + await _emit_frontend_debug_log( + __event_call__, + "OpenWebUI connection failed", + { + "repo": repo, + "base_url": base_url, + "update_url": update_url, + "create_url": create_url, + "timeout": timeout, + "candidate": _candidate_debug_data(candidate), + "error_type": type(exc).__name__, + "error": str(exc), + "note": "This API request runs from the OpenWebUI backend process, so localhost refers to the server/container environment.", + }, + level="error", + ) + return await _finalize_message( + event_emitter, + _t(lang, "err_connection"), + notification_type="error", + ) + except httpx.HTTPError as exc: + await _emit_frontend_debug_log( + __event_call__, + "OpenWebUI request raised HTTPError", + { + "base_url": base_url, + "update_url": update_url, + "create_url": create_url, + "candidate": _candidate_debug_data(candidate), + "error_type": type(exc).__name__, + "error": str(exc), + }, + level="error", + ) + results.append( + _t( + lang, + "failed", + title=candidate.title, + error=_t(lang, "error_request_failed", error=str(exc)), + ) + ) + + summary = _t(lang, "status_done", success=success_count, total=len(filtered)) + output = "\n".join(results + [summary]) + notification_type = "success" + if success_count == 0: + notification_type = "error" + elif success_count < len(filtered): + notification_type = "warning" + + await _emit_status(event_emitter, summary, done=True) + await _emit_notification(event_emitter, summary, ntype=notification_type) + + return output diff --git a/plugins/tools/batch-install-plugins/v1.0.0.md b/plugins/tools/batch-install-plugins/v1.0.0.md new file mode 100644 index 0000000..7c76a21 --- /dev/null +++ b/plugins/tools/batch-install-plugins/v1.0.0.md @@ -0,0 +1,67 @@ +[![](https://img.shields.io/badge/OpenWebUI%20Community-Get%20Plugin-blue?style=for-the-badge)](https://openwebui.com/t/fujie/batch_install_plugins) + +## Overview + +Batch Install Plugins from GitHub is a new tool for OpenWebUI that enables one-click installation of multiple plugins directly from GitHub repositories. This initial release includes comprehensive features for discovering, filtering, and installing plugins with user confirmation, extensive multi-language support, and robust debugging capabilities for container deployments. + +**[📖 README](https://github.com/Fu-Jie/openwebui-extensions/blob/main/plugins/tools/batch-install-plugins/README.md)** + +## Features + +- **One-Click Installation**: Install all plugins from a repository with a single command +- **Smart Plugin Discovery**: Parse Python files to extract metadata and validate plugins automatically +- **Multi-Type Support**: Support Pipe, Action, Filter, and Tool plugins in a single operation +- **Confirmation Dialog**: Display plugin list before installation for user review and approval +- **Selective Installation**: Exclude specific plugins using keyword-based filtering +- **Smart Fallback**: Container deployments auto-retry with localhost:8080 if primary connection fails +- **Enhanced Debugging**: Rich frontend JavaScript and backend Python logs for troubleshooting +- **Extended Timeout**: 120-second confirmation window for thoughtful decision-making +- **Async Architecture**: Non-blocking I/O operations for better performance +- **Full Internationalization**: Complete support for 11 languages with proper fallback maps +- **Auto-Update**: Automatically updates previously installed plugins +- **Self-Exclusion**: Automatically excludes the tool itself from batch operations + +## Technical Highlights + +- **httpx Integration**: Modern async HTTP client for reliable, non-blocking requests +- **Event Emitter Support**: Proper handling of OpenWebUI event injection with fallbacks +- **Timeout Protection**: Wrapped frontend execution with timeout guards to prevent hanging +- **Filtered List Consistency**: Uses single source of truth for confirmation and installation +- **Error Localization**: All error messages are user-facing and properly localized across languages +- **Deployment Resilience**: Intelligent base URL resolution handles domain, localhost, and containerized environments + +## Supported Repositories + +- **Default**: Fu-Jie/openwebui-extensions (strict validation) +- **Custom**: Any GitHub repository with Python plugin files + +## Testing + +Comprehensive regression tests included: +- Filtered installation list consistency +- Missing event emitter handling +- Confirmation timeout verification +- Full failure scenarios +- Localization completeness +- Connection error debug logging and smart fallback + +All 6 tests pass successfully. + +## Documentation + +- English README with flow diagrams and usage examples +- Chinese README (README_CN.md) with complete translations +- Mirrored documentation for official docs site +- Plugin index entries in both English and Chinese + +## Compatibility + +- OpenWebUI: 0.2.x - 0.8.x +- Python: 3.9+ +- Dependencies: httpx (async HTTP client), pydantic (type validation) + +## Release Notes + +- This initial v1.0.0 release includes complete plugin infrastructure with smart deployment handling. +- The plugin is designed to handle diverse deployment scenarios (domain, localhost, containerized) with minimal configuration. + diff --git a/plugins/tools/batch-install-plugins/v1.0.0_CN.md b/plugins/tools/batch-install-plugins/v1.0.0_CN.md new file mode 100644 index 0000000..98818e0 --- /dev/null +++ b/plugins/tools/batch-install-plugins/v1.0.0_CN.md @@ -0,0 +1,67 @@ +[![](https://img.shields.io/badge/OpenWebUI%20Community-Get%20Plugin-blue?style=for-the-badge)](https://openwebui.com/t/fujie/batch_install_plugins) + +## 概述 + +从 GitHub 批量安装插件是一款全新的 OpenWebUI 工具,支持直接从 GitHub 仓库一键安装多个插件。此首个发布版本包含了全面的插件发现、过滤和安装功能,支持用户确认流程、广泛的多语言支持,以及针对容器部署的健壮调试能力。 + +**[📖 README](https://github.com/Fu-Jie/openwebui-extensions/blob/main/plugins/tools/batch-install-plugins/README_CN.md)** + +## 主要功能 + +- **一键安装**:通过单个命令安装仓库中的所有插件 +- **智能插件发现**:解析 Python 文件提取元数据并自动验证插件 +- **多类型支持**:在单个操作中支持 Pipe、Action、Filter 和 Tool 插件 +- **确认对话框**:安装前显示插件列表供用户审查和批准 +- **选择性安装**:通过基于关键词的过滤排除特定插件 +- **智能降级**:容器环境中主 URL 连接失败时自动重试 localhost:8080 +- **增强调试**:前端 JavaScript 和后端 Python 富日志输出,便于排查问题 +- **延长超时**:120 秒确认窗口,给用户充分的思考时间 +- **异步架构**:非阻塞 I/O 操作,性能更优 +- **完整国际化**:支持 11 种语言,包含适当的回退机制 +- **自动更新**:自动更新之前安装过的插件 +- **自排除机制**:自动排除工具自身,避免在批量操作中重复安装 + +## 技术亮点 + +- **httpx 集成**:现代化的异步 HTTP 客户端,请求更可靠且非阻塞 +- **事件注入支持**:正确处理 OpenWebUI 事件注入,提供回退支持 +- **超时保护**:前端执行周围包装了超时保护,防止进程挂起 +- **过滤列表一致性**:确认和安装使用同一份过滤列表 +- **错误本地化**:所有错误消息都是面向用户的,已正确本地化到各语言 +- **部署弹性**:智能 Base URL 解析处理域名、localhost 和容器化环境 + +## 支持的仓库 + +- **默认**:Fu-Jie/openwebui-extensions(严格验证) +- **自定义**:任意 GitHub 仓库中的 Python 插件文件 + +## 测试覆盖 + +包含全面的回归测试: +- 过滤安装列表一致性 +- 缺少事件注入器时的处理 +- 确认超时验证 +- 完全失败场景 +- 本地化完整性 +- 连接错误调试日志和智能降级 + +所有 6 个测试均通过。 + +## 文档 + +- 英文 README,包含流程图和使用示例 +- 中文 README (README_CN.md),完整翻译 +- 官方文档站点的镜像文档 +- 英文和中文的插件索引条目 + +## 兼容性 + +- OpenWebUI:0.2.x - 0.8.x +- Python:3.9+ +- 依赖:httpx(异步 HTTP 客户端)、pydantic(类型验证) + +## 发布说明 + +- 本首发 v1.0.0 版本包含完整的插件基础设施和智能部署处理能力。 +- 该插件设计用于处理多种部署场景(域名、localhost、容器化),配置最少。 + diff --git a/tests/plugins/tools/test_batch_install_plugins.py b/tests/plugins/tools/test_batch_install_plugins.py new file mode 100644 index 0000000..a3f9134 --- /dev/null +++ b/tests/plugins/tools/test_batch_install_plugins.py @@ -0,0 +1,302 @@ +import asyncio +import importlib.util +import sys +from pathlib import Path + +import httpx +import pytest + + +MODULE_PATH = ( + Path(__file__).resolve().parents[3] + / "plugins" + / "tools" + / "batch-install-plugins" + / "batch_install_plugins.py" +) +SPEC = importlib.util.spec_from_file_location("batch_install_plugins", MODULE_PATH) +batch_install_plugins = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +sys.modules[SPEC.name] = batch_install_plugins +SPEC.loader.exec_module(batch_install_plugins) + + +def make_candidate(title: str, file_path: str, function_id: str): + return batch_install_plugins.PluginCandidate( + plugin_type="tool", + file_path=file_path, + metadata={"title": title, "description": f"{title} description"}, + content="class Tools:\n pass\n", + function_id=function_id, + ) + + +def make_request(): + class Request: + base_url = "http://localhost:3000/" + headers = {"Authorization": "Bearer token"} + + return Request() + + +class DummyResponse: + def __init__(self, status_code: int, json_data=None, text: str = ""): + self.status_code = status_code + self._json_data = json_data + self.text = text + + def json(self): + if self._json_data is None: + raise ValueError("no json body") + return self._json_data + + +class FakeAsyncClient: + posts = [] + responses = [] + + def __init__(self, *args, **kwargs): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def post(self, url, headers=None, json=None): + type(self).posts.append((url, headers, json)) + if not type(self).responses: + raise AssertionError("No fake response configured for POST request") + response = type(self).responses.pop(0) + if isinstance(response, Exception): + raise response + return response + + +@pytest.mark.asyncio +async def test_install_all_plugins_only_installs_filtered_candidates(monkeypatch): + keep = make_candidate("Keep Plugin", "plugins/tools/keep/keep.py", "keep_plugin") + exclude = make_candidate( + "Exclude Me", + "plugins/tools/exclude-me/exclude_me.py", + "exclude_me", + ) + self_plugin = make_candidate( + "Batch Install Plugins from GitHub", + "plugins/tools/batch-install-plugins/batch_install_plugins.py", + "batch_install_plugins", + ) + + async def fake_discover_plugins(url, skip_keywords): + return [keep, exclude, self_plugin], [] + + monkeypatch.setattr(batch_install_plugins, "discover_plugins", fake_discover_plugins) + FakeAsyncClient.posts = [] + FakeAsyncClient.responses = [DummyResponse(404), DummyResponse(201)] + monkeypatch.setattr(batch_install_plugins.httpx, "AsyncClient", FakeAsyncClient) + + events = [] + captured = {} + + async def event_call(payload): + if payload["type"] == "confirmation": + captured["message"] = payload["data"]["message"] + elif payload["type"] == "execute": + captured.setdefault("execute_codes", []).append(payload["data"]["code"]) + return True + + async def emitter(event): + events.append(event) + + result = await batch_install_plugins.Tools().install_all_plugins( + __user__={"id": "u1", "language": "en-US"}, + __event_call__=event_call, + __request__=make_request(), + __event_emitter__=emitter, + repo=batch_install_plugins.DEFAULT_REPO, + plugin_types=["tool"], + exclude_keywords="exclude", + ) + + assert "Created: Keep Plugin" in result + assert "Exclude Me" not in result + assert "1/1" in result + assert captured["message"].count("[tool]") == 1 + assert "Keep Plugin" in captured["message"] + assert "Exclude Me" not in captured["message"] + assert "Batch Install Plugins from GitHub" not in captured["message"] + assert "exclude, batch-install-plugins" in captured["message"] + + urls = [url for url, _, _ in FakeAsyncClient.posts] + assert urls == [ + "http://localhost:3000/api/v1/tools/id/keep_plugin/update", + "http://localhost:3000/api/v1/tools/create", + ] + assert any( + "Starting OpenWebUI install requests" in code + for code in captured.get("execute_codes", []) + ) + assert events[-1]["type"] == "notification" + assert events[-1]["data"]["type"] == "success" + + +@pytest.mark.asyncio +async def test_install_all_plugins_supports_missing_event_emitter(monkeypatch): + keep = make_candidate("Keep Plugin", "plugins/tools/keep/keep.py", "keep_plugin") + + async def fake_discover_plugins(url, skip_keywords): + return [keep], [] + + monkeypatch.setattr(batch_install_plugins, "discover_plugins", fake_discover_plugins) + FakeAsyncClient.posts = [] + FakeAsyncClient.responses = [DummyResponse(404), DummyResponse(201)] + monkeypatch.setattr(batch_install_plugins.httpx, "AsyncClient", FakeAsyncClient) + + result = await batch_install_plugins.Tools().install_all_plugins( + __user__={"id": "u1", "language": "en-US"}, + __request__=make_request(), + repo="example/repo", + plugin_types=["tool"], + ) + + assert "Created: Keep Plugin" in result + assert "1/1" in result + + +@pytest.mark.asyncio +async def test_install_all_plugins_handles_confirmation_timeout(monkeypatch): + keep = make_candidate("Keep Plugin", "plugins/tools/keep/keep.py", "keep_plugin") + + async def fake_discover_plugins(url, skip_keywords): + return [keep], [] + + async def fake_wait_for(awaitable, timeout): + awaitable.close() + raise asyncio.TimeoutError + + monkeypatch.setattr(batch_install_plugins, "discover_plugins", fake_discover_plugins) + monkeypatch.setattr(batch_install_plugins.asyncio, "wait_for", fake_wait_for) + + events = [] + + async def event_call(payload): + return True + + async def emitter(event): + events.append(event) + + result = await batch_install_plugins.Tools().install_all_plugins( + __user__={"id": "u1", "language": "en-US"}, + __event_call__=event_call, + __request__=make_request(), + __event_emitter__=emitter, + repo="example/repo", + plugin_types=["tool"], + ) + + assert result == "Confirmation timed out or failed. Installation cancelled." + assert events[-1]["type"] == "notification" + assert events[-1]["data"]["type"] == "warning" + + +@pytest.mark.asyncio +async def test_install_all_plugins_marks_total_failure_as_error(monkeypatch): + keep = make_candidate("Keep Plugin", "plugins/tools/keep/keep.py", "keep_plugin") + + async def fake_discover_plugins(url, skip_keywords): + return [keep], [] + + monkeypatch.setattr(batch_install_plugins, "discover_plugins", fake_discover_plugins) + FakeAsyncClient.posts = [] + FakeAsyncClient.responses = [ + DummyResponse(500, {"detail": "update failed"}, "update failed"), + DummyResponse(500, {"detail": "create failed"}, "create failed"), + ] + monkeypatch.setattr(batch_install_plugins.httpx, "AsyncClient", FakeAsyncClient) + + events = [] + + async def emitter(event): + events.append(event) + + result = await batch_install_plugins.Tools().install_all_plugins( + __user__={"id": "u1", "language": "en-US"}, + __request__=make_request(), + __event_emitter__=emitter, + repo="example/repo", + plugin_types=["tool"], + ) + + assert "Failed: Keep Plugin - status 500:" in result + assert "0/1" in result + assert events[-1]["type"] == "notification" + assert events[-1]["data"]["type"] == "error" + + +@pytest.mark.asyncio +async def test_install_all_plugins_localizes_timeout_errors(monkeypatch): + keep = make_candidate("Keep Plugin", "plugins/tools/keep/keep.py", "keep_plugin") + + async def fake_discover_plugins(url, skip_keywords): + return [keep], [] + + monkeypatch.setattr(batch_install_plugins, "discover_plugins", fake_discover_plugins) + FakeAsyncClient.posts = [] + FakeAsyncClient.responses = [httpx.TimeoutException("timed out")] + monkeypatch.setattr(batch_install_plugins.httpx, "AsyncClient", FakeAsyncClient) + + result = await batch_install_plugins.Tools().install_all_plugins( + __user__={"id": "u1", "language": "zh-CN"}, + __request__=make_request(), + repo="example/repo", + plugin_types=["tool"], + ) + + assert "失败:Keep Plugin - 请求超时" in result + + +@pytest.mark.asyncio +async def test_install_all_plugins_emits_frontend_debug_logs_on_connect_error( + monkeypatch, +): + keep = make_candidate("Keep Plugin", "plugins/tools/keep/keep.py", "keep_plugin") + + async def fake_discover_plugins(url, skip_keywords): + return [keep], [] + + monkeypatch.setattr(batch_install_plugins, "discover_plugins", fake_discover_plugins) + FakeAsyncClient.posts = [] + # Both initial attempt and fallback retry should fail + FakeAsyncClient.responses = [httpx.ConnectError("connect failed"), httpx.ConnectError("connect failed")] + monkeypatch.setattr(batch_install_plugins.httpx, "AsyncClient", FakeAsyncClient) + + execute_codes = [] + events = [] + + async def event_call(payload): + if payload["type"] == "execute": + execute_codes.append(payload["data"]["code"]) + return True + if payload["type"] == "confirmation": + return True + raise AssertionError(f"Unexpected event_call payload type: {payload['type']}") + + async def emitter(event): + events.append(event) + + result = await batch_install_plugins.Tools().install_all_plugins( + __user__={"id": "u1", "language": "en-US"}, + __event_call__=event_call, + __request__=make_request(), + __event_emitter__=emitter, + repo="example/repo", + plugin_types=["tool"], + ) + + assert result == "Cannot connect to OpenWebUI. Is it running?" + assert any("OpenWebUI connection failed" in code for code in execute_codes) + assert any("console.error" in code for code in execute_codes) + assert any("http://localhost:3000" in code for code in execute_codes) + assert events[-1]["type"] == "notification" + assert events[-1]["data"]["type"] == "error"