""" 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.1.0 openwebui_id: c9fd6e80-d58f-4312-8fbb-214d86bbe599 description: One-click batch install plugins from one or more GitHub repositories to your OpenWebUI instance. If a user mentions multiple repositories in one request, combine them into a single tool call. """ import ast import asyncio import json import logging import os import re import textwrap from pathlib import Path from typing import Any, Dict, List, Optional, Set, 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 = 900.0 # 15 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*(?P"""|\'\'\')\s*(.*?)\s*(?P=quote)', re.DOTALL) CLASS_PATTERN = re.compile(r'^class (Tools|Filter|Pipe|Action)\s*[\(:]', re.MULTILINE) EMOJI_PATTERN = re.compile(r'[\U00010000-\U0010ffff]', re.UNICODE) METADATA_KEY_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_-]*$") 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"} SELECTION_DIALOG_TEXTS = { "en-US": { "select_all": "Select all", "clear_all": "Clear all", "quick_select": "Filter by type", "all_types": "All", "repo_filter": "Filter by repository", "all_repos": "All repositories", "search_label": "Search", "search_placeholder": "Search title, description, file path...", "no_results": "No plugins match the current filter.", "selected_count": "{count} selected", "install_selected": "Install Selected", "cancel": "Cancel", "version_label": "Version", "file_label": "File", "description_label": "Description", "repo_label": "Repository", }, "zh-CN": { "select_all": "全选", "clear_all": "清空", "quick_select": "按类型筛选", "all_types": "全部", "repo_filter": "按仓库筛选", "all_repos": "全部仓库", "search_label": "搜索", "search_placeholder": "搜索标题、描述、文件路径...", "no_results": "当前筛选条件下没有匹配的插件。", "selected_count": "已选 {count} 项", "install_selected": "安装所选插件", "cancel": "取消", "version_label": "版本", "file_label": "文件", "description_label": "描述", "repo_label": "仓库", }, "zh-HK": { "select_all": "全選", "clear_all": "清空", "quick_select": "按類型篩選", "all_types": "全部", "repo_filter": "按倉庫篩選", "all_repos": "全部倉庫", "search_label": "搜尋", "search_placeholder": "搜尋標題、描述、檔案路徑...", "no_results": "目前篩選條件下沒有相符的外掛。", "selected_count": "已選 {count} 項", "install_selected": "安裝所選外掛", "cancel": "取消", "version_label": "版本", "file_label": "檔案", "description_label": "描述", "repo_label": "倉庫", }, "zh-TW": { "select_all": "全選", "clear_all": "清空", "quick_select": "按類型篩選", "all_types": "全部", "repo_filter": "按倉庫篩選", "all_repos": "全部倉庫", "search_label": "搜尋", "search_placeholder": "搜尋標題、描述、檔案路徑...", "no_results": "目前篩選條件下沒有符合的外掛。", "selected_count": "已選 {count} 項", "install_selected": "安裝所選外掛", "cancel": "取消", "version_label": "版本", "file_label": "檔案", "description_label": "描述", "repo_label": "倉庫", }, "ko-KR": { "select_all": "전체 선택", "clear_all": "선택 해제", "quick_select": "유형별 필터", "all_types": "전체", "repo_filter": "저장소별 필터", "all_repos": "전체 저장소", "search_label": "검색", "search_placeholder": "제목, 설명, 파일 경로 검색...", "no_results": "현재 필터와 일치하는 플러그인이 없습니다.", "selected_count": "{count}개 선택됨", "install_selected": "선택한 플러그인 설치", "cancel": "취소", "version_label": "버전", "file_label": "파일", "description_label": "설명", "repo_label": "저장소", }, "ja-JP": { "select_all": "すべて選択", "clear_all": "クリア", "quick_select": "タイプで絞り込み", "all_types": "すべて", "repo_filter": "リポジトリで絞り込み", "all_repos": "すべてのリポジトリ", "search_label": "検索", "search_placeholder": "タイトル、説明、ファイルパスを検索...", "no_results": "現在の条件に一致するプラグインはありません。", "selected_count": "{count}件を選択", "install_selected": "選択したプラグインをインストール", "cancel": "キャンセル", "version_label": "バージョン", "file_label": "ファイル", "description_label": "説明", "repo_label": "リポジトリ", }, "fr-FR": { "select_all": "Tout sélectionner", "clear_all": "Tout effacer", "quick_select": "Filtrer par type", "all_types": "Tous", "repo_filter": "Filtrer par dépôt", "all_repos": "Tous les dépôts", "search_label": "Rechercher", "search_placeholder": "Rechercher par titre, description, fichier...", "no_results": "Aucun plugin ne correspond au filtre actuel.", "selected_count": "{count} sélectionnés", "install_selected": "Installer la sélection", "cancel": "Annuler", "version_label": "Version", "file_label": "Fichier", "description_label": "Description", "repo_label": "Dépôt", }, "de-DE": { "select_all": "Alle auswählen", "clear_all": "Auswahl löschen", "quick_select": "Nach Typ filtern", "all_types": "Alle", "repo_filter": "Nach Repository filtern", "all_repos": "Alle Repositories", "search_label": "Suchen", "search_placeholder": "Titel, Beschreibung, Dateipfad durchsuchen...", "no_results": "Keine Plugins entsprechen dem aktuellen Filter.", "selected_count": "{count} ausgewählt", "install_selected": "Auswahl installieren", "cancel": "Abbrechen", "version_label": "Version", "file_label": "Datei", "description_label": "Beschreibung", "repo_label": "Repository", }, "es-ES": { "select_all": "Seleccionar todo", "clear_all": "Limpiar", "quick_select": "Filtrar por tipo", "all_types": "Todos", "repo_filter": "Filtrar por repositorio", "all_repos": "Todos los repositorios", "search_label": "Buscar", "search_placeholder": "Buscar por titulo, descripcion o archivo...", "no_results": "Ningun plugin coincide con el filtro actual.", "selected_count": "{count} seleccionados", "install_selected": "Instalar seleccionados", "cancel": "Cancelar", "version_label": "Versión", "file_label": "Archivo", "description_label": "Descripción", "repo_label": "Repositorio", }, "it-IT": { "select_all": "Seleziona tutto", "clear_all": "Cancella", "quick_select": "Filtra per tipo", "all_types": "Tutti", "repo_filter": "Filtra per repository", "all_repos": "Tutti i repository", "search_label": "Cerca", "search_placeholder": "Cerca per titolo, descrizione o file...", "no_results": "Nessun plugin corrisponde al filtro attuale.", "selected_count": "{count} selezionati", "install_selected": "Installa selezionati", "cancel": "Annulla", "version_label": "Versione", "file_label": "File", "description_label": "Descrizione", "repo_label": "Repository", }, "vi-VN": { "select_all": "Chọn tất cả", "clear_all": "Bỏ chọn", "quick_select": "Lọc theo loại", "all_types": "Tất cả", "repo_filter": "Lọc theo kho", "all_repos": "Tất cả kho", "search_label": "Tìm kiếm", "search_placeholder": "Tìm theo tiêu đề, mô tả, đường dẫn tệp...", "no_results": "Không có plugin nào khớp với bộ lọc hiện tại.", "selected_count": "Đã chọn {count}", "install_selected": "Cài đặt mục đã chọn", "cancel": "Hủy", "version_label": "Phiên bản", "file_label": "Tệp", "description_label": "Mô tả", "repo_label": "Kho", }, } 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 def _selection_t(lang: str, key: str, **kwargs) -> str: lang_key = _resolve_language(lang) text = SELECTION_DIALOG_TEXTS.get( lang_key, SELECTION_DIALOG_TEXTS["en-US"] ).get(key, SELECTION_DIALOG_TEXTS["en-US"][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, source_repo: str, ): self.plugin_type = plugin_type self.file_path = file_path self.metadata = metadata self.content = content self.function_id = function_id self.source_repo = source_repo @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") @property def selection_id(self) -> str: return f"{self.source_repo}::{self.file_path}::{self.function_id}" def extract_metadata(content: str) -> Dict[str, str]: docstring = _extract_module_docstring(content) if not docstring: return {} metadata: Dict[str, str] = {} lines = docstring.splitlines() index = 0 while index < len(lines): raw_line = lines[index] stripped = raw_line.strip() if not stripped or stripped.startswith("#"): index += 1 continue if raw_line[:1].isspace() or ":" not in raw_line: index += 1 continue key, value = raw_line.split(":", 1) key = key.strip().lower() if not METADATA_KEY_PATTERN.match(key): index += 1 continue value = value.strip() if value and value[0] in {">", "|"}: block_lines, index = _consume_indented_block(lines, index + 1) metadata[key] = ( _fold_yaml_block(block_lines) if value[0] == ">" else _preserve_yaml_block(block_lines) ) continue metadata[key] = value index += 1 return metadata def _extract_module_docstring(content: str) -> str: normalized = content.lstrip("\ufeff") try: module = ast.parse(normalized) except SyntaxError: module = None if module is not None: docstring = ast.get_docstring(module, clean=False) if isinstance(docstring, str): return docstring fallback = normalized.replace("\r\n", "\n").replace("\r", "\n") match = DOCSTRING_PATTERN.search(fallback) return match.group(2) if match else "" def _consume_indented_block(lines: List[str], start_index: int) -> Tuple[List[str], int]: block: List[str] = [] index = start_index while index < len(lines): line = lines[index] if not line.strip(): block.append("") index += 1 continue if line[:1].isspace(): block.append(line) index += 1 continue break dedented = textwrap.dedent("\n".join(block)).splitlines() return dedented, index def _fold_yaml_block(lines: List[str]) -> str: paragraphs: List[str] = [] current: List[str] = [] for line in lines: stripped = line.strip() if not stripped: if current: paragraphs.append(" ".join(current)) current = [] continue current.append(stripped) if current: paragraphs.append(" ".join(current)) return "\n\n".join(paragraphs).strip() def _preserve_yaml_block(lines: List[str]) -> str: return "\n".join(line.rstrip() for line in lines).strip() 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, "source_repo": candidate.source_repo, "file_path": candidate.file_path, "function_id": candidate.function_id, "version": candidate.version, } def _parse_repo_inputs(repo_value: str) -> List[str]: parts = re.split(r"[\n,;,;、]+", str(repo_value or DEFAULT_REPO)) repos: List[str] = [] seen: Set[str] = set() for part in parts: candidate = part.strip().strip("/") if not candidate: continue normalized = candidate.lower() if normalized in seen: continue seen.add(normalized) repos.append(candidate) return repos or [DEFAULT_REPO] def _sort_candidates_by_repo_order( candidates: List[PluginCandidate], repos: List[str], ) -> List[PluginCandidate]: repo_order = {repo.lower(): index for index, repo in enumerate(repos)} fallback_index = len(repo_order) return sorted( candidates, key=lambda item: ( repo_order.get(item.source_repo.lower(), fallback_index), item.source_repo.lower(), item.plugin_type, item.file_path, ), ) def _filter_candidates( candidates: List[PluginCandidate], plugin_types: List[str], repos: List[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] includes_default_repo = any(item.lower() == DEFAULT_REPO.lower() for item in repos) if includes_default_repo: filtered = [ c for c in filtered if not (c.source_repo.lower() == DEFAULT_REPO.lower() and _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: repo_list = _parse_repo_inputs(repo) is_default_repo = any(item.lower() == DEFAULT_REPO.lower() for item in repo_list) 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 "" def _build_selection_dialog_js( options: List[Dict[str, str]], ui_text: Dict[str, str], ) -> str: lines = [ "return new Promise((resolve) => {", " try {", f" const options = {json.dumps(options, ensure_ascii=False)};", f" const ui = {json.dumps(ui_text, ensure_ascii=False)};", " const dialogId = 'batch-install-plugin-selector';", " const body = typeof document !== 'undefined' ? document.body : null;", " const existing = body ? document.getElementById(dialogId) : null;", " if (existing) { existing.remove(); }", " if (!body) {", " resolve({ confirmed: false, error: 'document.body unavailable', selected_ids: [] });", " return;", " }", " const selected = new Set(options.map((item) => item.id));", " let activeFilter = '';", " let activeRepoFilter = '';", " let searchTerm = '';", " const escapeHtml = (value) => String(value ?? '').replace(/[&<>\"']/g, (char) => ({", " '&': '&',", " '<': '<',", " '>': '>',", " '\"': '"',", " \"'\": ''',", " }[char]));", " const overlay = document.createElement('div');", " overlay.id = dialogId;", " overlay.style.cssText = [", " 'position:fixed',", " 'inset:0',", " 'padding:24px',", " 'background:rgba(15,23,42,0.52)',", " 'backdrop-filter:blur(3px)',", " 'display:flex',", " 'align-items:center',", " 'justify-content:center',", " 'z-index:9999',", " 'box-sizing:border-box',", " ].join(';');", " overlay.innerHTML = `", "
", "
", "
", "
${escapeHtml(ui.title)}
", "
${escapeHtml(ui.list_title)}
", "
", "
", "
", "
", "
", "
", " ", " ", "
", "
", "
", "
", "
", "
${escapeHtml(ui.quick_select)}
", "
", "
", "
", "
${escapeHtml(ui.repo_filter)}
", "
", "
", "
", "
${escapeHtml(ui.search_label)}
", " ", "
", "
", "
", "
", "
", " ", " ", "
", "
", " `;", " body.appendChild(overlay);", " const previousOverflow = body.style.overflow;", " body.style.overflow = 'hidden';", " const listEl = overlay.querySelector('#batch-install-plugin-selector-list');", " const countEl = overlay.querySelector('#batch-install-plugin-selector-count');", " const hintEl = overlay.querySelector('#batch-install-plugin-selector-hint');", " const typesEl = overlay.querySelector('#batch-install-plugin-selector-types');", " const repoRowEl = overlay.querySelector('#batch-install-plugin-selector-repo-row');", " const reposEl = overlay.querySelector('#batch-install-plugin-selector-repos');", " const searchInput = overlay.querySelector('#batch-install-plugin-selector-search');", " const submitBtn = overlay.querySelector('#batch-install-plugin-selector-submit');", " const cancelBtn = overlay.querySelector('#batch-install-plugin-selector-cancel');", " const selectAllBtn = overlay.querySelector('#batch-install-plugin-selector-select-all');", " const clearAllBtn = overlay.querySelector('#batch-install-plugin-selector-clear-all');", " const typeMap = options.reduce((groups, item) => {", " if (!groups[item.type]) {", " groups[item.type] = [];", " }", " groups[item.type].push(item);", " return groups;", " }, {});", " const repoMap = options.reduce((groups, item) => {", " if (!groups[item.repo]) {", " groups[item.repo] = [];", " }", " groups[item.repo].push(item);", " return groups;", " }, {});", " const typeEntries = Object.entries(typeMap);", " const repoEntries = Object.entries(repoMap);", " const matchesSearch = (item) => {", " const haystack = [item.title, item.description, item.file_path, item.type, item.repo].join(' ').toLowerCase();", " return !searchTerm || haystack.includes(searchTerm);", " };", " const getVisibleOptions = () => options.filter((item) => {", " const matchesType = !activeFilter || item.type === activeFilter;", " const matchesRepo = !activeRepoFilter || item.repo === activeRepoFilter;", " return matchesType && matchesRepo && matchesSearch(item);", " });", " const syncSelectionToVisible = () => {", " selected.clear();", " getVisibleOptions().forEach((item) => selected.add(item.id));", " };", " const formatMultilineText = (value) => escapeHtml(value).replace(/\\n+/g, '
');", " hintEl.textContent = ui.hint || '';", " hintEl.style.display = ui.hint ? 'block' : 'none';", " const renderTypeButtons = () => {", " const scopedOptions = options.filter((item) => {", " const matchesRepo = !activeRepoFilter || item.repo === activeRepoFilter;", " return matchesRepo && matchesSearch(item);", " });", " const filterEntries = [['', scopedOptions], ...typeEntries.map(([type]) => [type, scopedOptions.filter((item) => item.type === type)])];", " typesEl.innerHTML = filterEntries.map(([type, items]) => {", " const isActive = activeFilter === type;", " const background = isActive ? '#0f172a' : '#ffffff';", " const color = isActive ? '#ffffff' : '#0f172a';", " const border = isActive ? '#0f172a' : '#cbd5e1';", " const label = type || ui.all_types;", " return `", " ", " `;", " }).join('');", " typesEl.querySelectorAll('button[data-plugin-type]').forEach((button) => {", " button.addEventListener('click', () => {", " const pluginType = button.getAttribute('data-plugin-type') || '';", " activeFilter = activeFilter === pluginType ? '' : pluginType;", " syncSelectionToVisible();", " renderList();", " });", " });", " };", " const renderRepoButtons = () => {", " if (repoEntries.length <= 1) {", " repoRowEl.style.display = 'none';", " reposEl.innerHTML = '';", " activeRepoFilter = '';", " return;", " }", " repoRowEl.style.display = 'flex';", " const scopedOptions = options.filter((item) => {", " const matchesType = !activeFilter || item.type === activeFilter;", " return matchesType && matchesSearch(item);", " });", " const filterEntries = [['', scopedOptions], ...repoEntries.map(([repoName]) => [repoName, scopedOptions.filter((item) => item.repo === repoName)])];", " reposEl.innerHTML = filterEntries.map(([repoName, items]) => {", " const isActive = activeRepoFilter === repoName;", " const background = isActive ? '#1d4ed8' : '#ffffff';", " const color = isActive ? '#ffffff' : '#1d4ed8';", " const border = isActive ? '#1d4ed8' : '#bfdbfe';", " const label = repoName || ui.all_repos;", " return `", " ", " `;", " }).join('');", " reposEl.querySelectorAll('button[data-plugin-repo]').forEach((button) => {", " button.addEventListener('click', () => {", " const repoName = button.getAttribute('data-plugin-repo') || '';", " activeRepoFilter = activeRepoFilter === repoName ? '' : repoName;", " syncSelectionToVisible();", " renderList();", " });", " });", " };", " const updateState = () => {", " countEl.textContent = ui.selected_count.replace('{count}', String(selected.size));", " submitBtn.disabled = selected.size === 0;", " submitBtn.style.opacity = selected.size === 0 ? '0.45' : '1';", " submitBtn.style.cursor = selected.size === 0 ? 'not-allowed' : 'pointer';", " renderTypeButtons();", " renderRepoButtons();", " };", " const renderOptionCard = (item) => {", " const checked = selected.has(item.id) ? 'checked' : '';", " const description = item.description ? `", "
", "
${escapeHtml(ui.description_label)}
", "
${formatMultilineText(item.description)}
", "
", " ` : '';", " return `", " ", " `;", " };", " const renderList = () => {", " const visibleOptions = getVisibleOptions();", " if (!visibleOptions.length) {", " listEl.innerHTML = `
${escapeHtml(ui.no_results)}
`;", " updateState();", " return;", " }", " const groups = visibleOptions.reduce((bucket, item) => {", " if (!bucket[item.repo]) {", " bucket[item.repo] = [];", " }", " bucket[item.repo].push(item);", " return bucket;", " }, {});", " listEl.innerHTML = Object.entries(groups).map(([repoName, items]) => `", "
", "
", "
${escapeHtml(repoName)}
", "
${items.length}
", "
", "
${items.map((item) => renderOptionCard(item)).join('')}
", "
", " `).join('');", " listEl.querySelectorAll('input[data-plugin-id]').forEach((input) => {", " input.addEventListener('change', () => {", " const pluginId = input.getAttribute('data-plugin-id') || '';", " if (input.checked) {", " selected.add(pluginId);", " } else {", " selected.delete(pluginId);", " }", " updateState();", " });", " });", " updateState();", " };", " const cleanup = () => {", " body.style.overflow = previousOverflow;", " window.removeEventListener('keydown', onKeyDown, true);", " overlay.remove();", " };", " const finish = (confirmed) => {", " const selectedIds = confirmed ? options.filter((item) => selected.has(item.id)).map((item) => item.id) : [];", " cleanup();", " resolve({ confirmed, selected_ids: selectedIds });", " };", " const onKeyDown = (event) => {", " if (event.key === 'Escape') {", " event.preventDefault();", " finish(false);", " }", " };", " overlay.addEventListener('click', (event) => {", " if (event.target === overlay) {", " finish(false);", " }", " });", " selectAllBtn.addEventListener('click', () => {", " getVisibleOptions().forEach((item) => selected.add(item.id));", " renderList();", " });", " clearAllBtn.addEventListener('click', () => {", " getVisibleOptions().forEach((item) => selected.delete(item.id));", " renderList();", " });", " searchInput.addEventListener('input', () => {", " searchTerm = searchInput.value.trim().toLowerCase();", " syncSelectionToVisible();", " renderList();", " });", " cancelBtn.addEventListener('click', () => finish(false));", " submitBtn.addEventListener('click', () => {", " if (selected.size === 0) {", " updateState();", " return;", " }", " finish(true);", " });", " window.addEventListener('keydown', onKeyDown, true);", " renderList();", " } catch (error) {", " console.error('[Batch Install] Plugin selection dialog failed', error);", " resolve({", " confirmed: false,", " error: error instanceof Error ? error.message : String(error),", " selected_ids: [],", " });", " }", "});", ] return "\n".join(lines) async def _request_plugin_selection( event_call: Optional[Any], lang: str, candidates: List[PluginCandidate], hint: str, ) -> Tuple[Optional[List[PluginCandidate]], Optional[str]]: if not event_call: return candidates, None options = [ { "id": candidate.selection_id, "title": candidate.title, "type": candidate.plugin_type, "repo": candidate.source_repo, "version": candidate.version, "file_path": candidate.file_path, "description": candidate.metadata.get("description", ""), } for candidate in candidates ] ui_text = { "title": _t(lang, "confirm_title"), "list_title": _t(lang, "status_list_title", count=len(candidates)), "repo_label": _selection_t(lang, "repo_label"), "hint": hint.strip(), "select_all": _selection_t(lang, "select_all"), "clear_all": _selection_t(lang, "clear_all"), "quick_select": _selection_t(lang, "quick_select"), "all_types": _selection_t(lang, "all_types"), "repo_filter": _selection_t(lang, "repo_filter"), "all_repos": _selection_t(lang, "all_repos"), "search_label": _selection_t(lang, "search_label"), "search_placeholder": _selection_t(lang, "search_placeholder"), "no_results": _selection_t(lang, "no_results"), "selected_count": _selection_t(lang, "selected_count", count="{count}"), "install_selected": _selection_t(lang, "install_selected"), "cancel": _selection_t(lang, "cancel"), "version_label": _selection_t(lang, "version_label"), "file_label": _selection_t(lang, "file_label"), "description_label": _selection_t(lang, "description_label"), } js_code = _build_selection_dialog_js(options, ui_text) try: result = await asyncio.wait_for( event_call({"type": "execute", "data": {"code": js_code}}), timeout=CONFIRMATION_TIMEOUT, ) except asyncio.TimeoutError: logger.warning("Installation selection dialog timed out.") return None, _t(lang, "err_confirm_unavailable") except Exception as exc: logger.warning("Installation selection dialog failed: %s", exc) return None, _t(lang, "err_confirm_unavailable") if not isinstance(result, dict): logger.warning("Unexpected selection dialog result: %r", result) return None, _t(lang, "err_confirm_unavailable") if result.get("error"): logger.warning("Selection dialog returned error: %s", result.get("error")) return None, _t(lang, "err_confirm_unavailable") if not result.get("confirmed"): return [], None selected_ids = result.get("selected_ids") if not isinstance(selected_ids, list): logger.warning("Selection dialog returned invalid selected_ids: %r", selected_ids) return None, _t(lang, "err_confirm_unavailable") selected_id_set = {str(item).strip() for item in selected_ids if str(item).strip()} selected_candidates = [ candidate for candidate in candidates if candidate.selection_id in selected_id_set ] return selected_candidates, 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", source_repo: str = "", ) -> Tuple[List[PluginCandidate], List[Tuple[str, str]]]: parsed = parse_github_url(url) if not parsed: return [], [("url", "invalid github url")] owner, repo, branch = parsed resolved_repo = source_repo or f"{owner}/{repo}" 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 is_default_repo and 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), source_repo=resolved_repo, ) ) candidates.sort(key=lambda x: (x.plugin_type, x.file_path)) return candidates, skipped async def discover_plugins_from_repos( repos: List[str], skip_keywords: str = "test", ) -> Tuple[List[PluginCandidate], List[Tuple[str, str]]]: tasks = [ discover_plugins(f"https://github.com/{repo}", skip_keywords, source_repo=repo) for repo in repos ] results = await asyncio.gather(*tasks) all_candidates: List[PluginCandidate] = [] all_skipped: List[Tuple[str, str]] = [] for repo, (candidates, skipped) in zip(repos, results): all_candidates.extend(candidates) all_skipped.extend([(f"{repo}:{path}", reason) for path, reason in skipped]) return _sort_candidates_by_repo_order(all_candidates, repos), all_skipped class ListParams(BaseModel): repo: str = Field( default=DEFAULT_REPO, description="One or more GitHub repositories (owner/repo), separated by commas, semicolons, or new lines. If the user mentions multiple repositories in one request, combine them here and call the tool once.", ) 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="One or more GitHub repositories (owner/repo), separated by commas, semicolons, or new lines. If the user mentions multiple repositories in one request, combine them here and call the tool once instead of making separate calls.", ) 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: """List plugins from one or more repositories in a single call. If a user request mentions multiple repositories, combine them into the `repo` argument instead of calling this tool multiple times. """ 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_list = _parse_repo_inputs(repo) candidates, _ = await discover_plugins_from_repos(repo_list, skip_keywords) if not candidates: return _t(lang, "err_no_plugins") filtered = _filter_candidates(candidates, plugin_types, repo_list) if not filtered: return _t(lang, "err_no_match") lines = [f"## {_t(lang, 'status_list_title', count=len(filtered))}\n"] current_repo = "" for c in filtered: if c.source_repo != current_repo: lines.append(f"\n### {c.source_repo}") current_repo = c.source_repo 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: """Install plugins from one or more repositories in a single call. If a user request mentions multiple repositories, combine them into the `repo` argument and call this tool once instead of making parallel calls for each repository. """ 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_list = _parse_repo_inputs(repo) candidates, _ = await discover_plugins_from_repos(repo_list, 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_list, exclude_keywords) if not filtered: return await _finalize_message( event_emitter, _t(lang, "err_no_match"), notification_type="warning" ) hint_msg = _build_confirmation_hint(lang, repo, exclude_keywords) selected_candidates, confirm_error = await _request_plugin_selection( __event_call__, lang, filtered, hint_msg ) if confirm_error: return await _finalize_message( event_emitter, confirm_error, notification_type="warning" ) if not selected_candidates: 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, "repos": repo_list, "base_url": base_url, "note": "Backend uses default port 8080 (containerized environment)", "plugin_count": len(selected_candidates), "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 selected_candidates: 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(selected_candidates), ) output = "\n".join(results + [summary]) notification_type = "success" if success_count == 0: notification_type = "error" elif success_count < len(selected_candidates): notification_type = "warning" await _emit_status(event_emitter, summary, done=True) await _emit_notification(event_emitter, summary, ntype=notification_type) return output