├── .gitignore ├── README.md └── functions ├── actions └── example │ └── main.py ├── filters ├── agent_hotswap │ ├── LICENSE │ ├── README.md │ ├── main.py │ └── personas │ │ └── personas.json ├── context_clip │ └── main.py ├── dynamic_vision_router │ └── main.py └── max_turns │ └── main.py └── pipes ├── anthropic ├── README.md └── main.py └── openai └── main.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # UV 98 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | #uv.lock 102 | 103 | # poetry 104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 105 | # This is especially recommended for binary packages to ensure reproducibility, and is more 106 | # commonly ignored for libraries. 107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 108 | #poetry.lock 109 | 110 | # pdm 111 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 112 | #pdm.lock 113 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 114 | # in version control. 115 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 116 | .pdm.toml 117 | .pdm-python 118 | .pdm-build/ 119 | 120 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 121 | __pypackages__/ 122 | 123 | # Celery stuff 124 | celerybeat-schedule 125 | celerybeat.pid 126 | 127 | # SageMath parsed files 128 | *.sage.py 129 | 130 | # Environments 131 | .env 132 | .venv 133 | env/ 134 | venv/ 135 | ENV/ 136 | env.bak/ 137 | venv.bak/ 138 | 139 | # Spyder project settings 140 | .spyderproject 141 | .spyproject 142 | 143 | # Rope project settings 144 | .ropeproject 145 | 146 | # mkdocs documentation 147 | /site 148 | 149 | # mypy 150 | .mypy_cache/ 151 | .dmypy.json 152 | dmypy.json 153 | 154 | # Pyre type checker 155 | .pyre/ 156 | 157 | # pytype static type analyzer 158 | .pytype/ 159 | 160 | # Cython debug symbols 161 | cython_debug/ 162 | 163 | # PyCharm 164 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 165 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 166 | # and can be added to the global gitignore or merged into this file. For a more nuclear 167 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 168 | #.idea/ 169 | 170 | # Visual Studio Code 171 | # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore 172 | # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore 173 | # and can be added to the global gitignore or merged into this file. However, if you prefer, 174 | # you could uncomment the following to ignore the enitre vscode folder 175 | # .vscode/ 176 | 177 | # Ruff stuff: 178 | .ruff_cache/ 179 | 180 | # PyPI configuration file 181 | .pypirc 182 | 183 | # Cursor 184 | # Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to 185 | # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data 186 | # refer to https://docs.cursor.com/context/ignore-files 187 | .cursorignore 188 | .cursorindexingignore -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # open-webui/functions 🚀 2 | 3 | Curated custom functions approved by the Open WebUI core team. 4 | 5 | - ✅ High-quality, reliable, and ready to use 6 | - ⚡ Easy integration with your Open WebUI projects 7 | 8 | Looking for more? Discover community-contributed functions at [openwebui.com](http://openwebui.com/) 🌐 9 | -------------------------------------------------------------------------------- /functions/actions/example/main.py: -------------------------------------------------------------------------------- 1 | """ 2 | title: Example Action 3 | author: open-webui 4 | author_url: https://github.com/open-webui 5 | funding_url: https://github.com/open-webui 6 | version: 0.1.0 7 | required_open_webui_version: 0.3.9 8 | """ 9 | 10 | from pydantic import BaseModel, Field 11 | from typing import Optional, Union, Generator, Iterator 12 | 13 | import os 14 | import requests 15 | import asyncio 16 | 17 | 18 | class Action: 19 | class Valves(BaseModel): 20 | pass 21 | 22 | def __init__(self): 23 | self.valves = self.Valves() 24 | pass 25 | 26 | async def action( 27 | self, 28 | body: dict, 29 | __user__=None, 30 | __event_emitter__=None, 31 | __event_call__=None, 32 | ) -> Optional[dict]: 33 | print(f"action:{__name__}") 34 | 35 | response = await __event_call__( 36 | { 37 | "type": "input", 38 | "data": { 39 | "title": "write a message", 40 | "message": "here write a message to append", 41 | "placeholder": "enter your message", 42 | }, 43 | } 44 | ) 45 | print(response) 46 | 47 | if __event_emitter__: 48 | await __event_emitter__( 49 | { 50 | "type": "status", 51 | "data": {"description": "adding message", "done": False}, 52 | } 53 | ) 54 | await asyncio.sleep(1) 55 | await __event_emitter__({"type": "message", "data": {"content": response}}) 56 | await __event_emitter__( 57 | { 58 | "type": "status", 59 | "data": {"description": "added message", "done": True}, 60 | } 61 | ) 62 | -------------------------------------------------------------------------------- /functions/filters/agent_hotswap/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Joseph Wayne DeArmond 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /functions/filters/agent_hotswap/README.md: -------------------------------------------------------------------------------- 1 | # 🎭 Agent Hotswap 2 | 3 | > **Transform your OpenWebUI experience with intelligent AI persona switching** 4 | 5 | [![Version](https://img.shields.io/badge/version-0.1.0-blue.svg)](https://github.com/pkeffect/agent_hotswap) 6 | [![OpenWebUI](https://img.shields.io/badge/OpenWebUI-Compatible-green.svg)](https://github.com/open-webui/open-webui) 7 | [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) 8 | 9 | --- 10 | 11 | ## 🌟 Overview 12 | 13 | **Agent Hotswap** is a powerful OpenWebUI filter that enables seamless switching between specialized AI personas with a simple command system. Each persona comes with unique capabilities, expertise, and communication styles, all built on a **Master Controller** foundation that provides universal OpenWebUI-native features. 14 | 15 | ### ✨ Key Features 16 | 17 | - 🎛️ **Master Controller System** - Transparent foundation providing OpenWebUI capabilities to all personas 18 | - 🚀 **Instant Persona Switching** - Simple `!command` syntax for immediate role changes 19 | - 📦 **Remote Persona Downloads** - Automatically fetch and apply persona collections from repositories 20 | - 🔒 **Security-First Design** - Trusted domain whitelist and validation system 21 | - ⚡ **Performance Optimized** - Smart caching, pre-compiled patterns, and efficient loading 22 | - 🎨 **Rich Rendering Support** - LaTeX math, Mermaid diagrams, HTML artifacts built-in 23 | - 💾 **Automatic Backups** - Safe persona management with rollback capabilities 24 | 25 | --- 26 | 27 | ## 🚨 Important: Getting Started 28 | 29 | > **⚠️ FIRST STEP:** After installation, use the `!download_personas` command to get the complete persona collection. The system starts with basic defaults but the full experience requires downloading the official persona repository. 30 | 31 | --- 32 | 33 | ## 📋 Table of Contents 34 | 35 | - [🚀 Quick Start](#-quick-start) 36 | - [🏗️ Installation](#️-installation) 37 | - [🎯 Core Concepts](#-core-concepts) 38 | - [Master Controller System](#master-controller-system) 39 | - [Persona Architecture](#persona-architecture) 40 | - [Command System](#command-system) 41 | - [📥 Persona Management](#-persona-management) 42 | - [Downloading Personas](#downloading-personas) 43 | - [Security & Trust](#security--trust) 44 | - [Backup System](#backup-system) 45 | - [🛠️ Configuration](#️-configuration) 46 | - [Basic Settings](#basic-settings) 47 | - [Advanced Options](#advanced-options) 48 | - [Performance Tuning](#performance-tuning) 49 | - [💡 Usage Guide](#-usage-guide) 50 | - [Basic Commands](#basic-commands) 51 | - [Persona Switching](#persona-switching) 52 | - [System Commands](#system-commands) 53 | - [🏗️ System Architecture](#️-system-architecture) 54 | - [File Structure](#file-structure) 55 | - [Caching System](#caching-system) 56 | - [Pattern Matching](#pattern-matching) 57 | - [🔧 Troubleshooting](#-troubleshooting) 58 | - [🚀 Advanced Features](#-advanced-features) 59 | - [🤝 Contributing](#-contributing) 60 | 61 | --- 62 | 63 | ## 🚀 Quick Start 64 | 65 | ### 1️⃣ Install the Filter 66 | 1. Copy the complete filter code 67 | 2. Add as a new filter in OpenWebUI 68 | 3. Enable the filter and configure basic settings 69 | 70 | ### 2️⃣ Download Persona Collection 71 | ``` 72 | !download_personas 73 | ``` 74 | This downloads the official persona repository with 50+ specialized AI assistants. 75 | 76 | ### 3️⃣ Explore Available Personas 77 | ``` 78 | !list 79 | ``` 80 | 81 | ### 4️⃣ Switch to a Persona 82 | ``` 83 | !coder # Become a programming expert 84 | !writer # Transform into a creative writer 85 | !analyst # Switch to data analysis mode 86 | ``` 87 | 88 | ### 5️⃣ Reset When Needed 89 | ``` 90 | !reset # Return to default assistant 91 | ``` 92 | 93 | --- 94 | 95 | ## 🏗️ Installation 96 | 97 | ### Prerequisites 98 | - OpenWebUI instance with filter support 99 | - Administrator access to add filters 100 | - Internet connectivity for persona downloads 101 | 102 | ### Step-by-Step Installation 103 | 104 | 1. **Access Filter Management** 105 | - Navigate to OpenWebUI Settings 106 | - Go to Admin Panel → Filters 107 | - Click "Add Filter" 108 | 109 | 2. **Install Agent Hotswap** 110 | - Copy the complete filter code 111 | - Paste into the filter editor 112 | - Set filter name: "Agent Hotswap" 113 | - Save and enable the filter 114 | 115 | 3. **Initial Configuration** 116 | - Review default settings in the Valves section 117 | - Adjust `keyword_prefix` if desired (default: `!`) 118 | - Configure `trusted_domains` for security 119 | - Enable `create_default_config` for automatic setup 120 | 121 | 4. **First-Time Setup** 122 | ``` 123 | !download_personas 124 | ``` 125 | This command will: 126 | - Download the official persona collection 127 | - Create backup of existing configuration 128 | - Apply new personas with merge strategy 129 | - Clear caches for immediate availability 130 | 131 | --- 132 | 133 | ## 🎯 Core Concepts 134 | 135 | ### Master Controller System 136 | 137 | The **Master Controller** is the invisible foundation that powers every persona interaction: 138 | 139 | #### 🎛️ What It Provides 140 | - **LaTeX Mathematics**: `$$E=mc^2$$` rendering support 141 | - **Mermaid Diagrams**: Automatic flowchart and diagram generation 142 | - **HTML Artifacts**: Interactive content creation capabilities 143 | - **File Processing**: CSV, PDF, image upload handling 144 | - **Status Messages**: Real-time feedback and progress indicators 145 | 146 | #### 🔄 How It Works 147 | - **Always Active**: Automatically loads with every persona 148 | - **Transparent**: Users never see or interact with it directly 149 | - **Foundation Layer**: Provides OpenWebUI-native capabilities to all personas 150 | - **Smart Persistence**: Only removed on reset/default commands 151 | 152 | ### Persona Architecture 153 | 154 | Each persona consists of structured components: 155 | 156 | ```json 157 | { 158 | "persona_key": { 159 | "name": "🎭 Display Name", 160 | "prompt": "Detailed system prompt defining behavior", 161 | "description": "User-facing description of capabilities", 162 | "rules": ["Rule 1", "Rule 2", "..."] 163 | } 164 | } 165 | ``` 166 | 167 | #### 🧩 Persona Components 168 | - **Name**: Display name with emoji for visual identification 169 | - **Prompt**: Comprehensive system prompt defining personality and expertise 170 | - **Description**: User-facing explanation of capabilities 171 | - **Rules**: Structured guidelines for behavior and responses 172 | 173 | ### Command System 174 | 175 | Agent Hotswap uses a prefix-based command system: 176 | 177 | | Command Type | Syntax | Purpose | 178 | |-------------|--------|---------| 179 | | **Persona Switch** | `!persona_key` | Activate specific persona | 180 | | **List Personas** | `!list` | Show available personas in table format | 181 | | **Reset System** | `!reset`, `!default`, `!normal` | Return to standard assistant | 182 | | **Download Personas** | `!download_personas [url] [--replace]` | Fetch remote persona collections | 183 | 184 | --- 185 | 186 | **Available Personas** 187 | | Command | Name | Command | Name | 188 | | ---|--- | ---|--- | 189 | | `!airesearcher` | 🤖 AI Pioneer | `!analyst` | 📊 Data Analyst | 190 | | `!archaeologist` | 🏺 Relic Hunter | `!architect` | 🏗️ Master Builder | 191 | | `!artist` | 🎨 Creative Visionary | `!astronomer` | 🔭 Star Gazer | 192 | | `!biologist` | 🧬 Life Scientist | `!blockchaindev` | 🔗 Chain Architect | 193 | | `!careercounselor` | 🧑‍💼 Career Navigator | `!chef` | 🧑‍🍳 Culinary Genius | 194 | | `!chemist` | 🧪 Molecule Master | `!coder` | 💻 Code Assistant | 195 | | `!consultant` | 💼 Business Consultant | `!cybersecurityexpert` | 🛡️ Cyber Guardian | 196 | | `!debug` | 🐛 Debug Specialist | `!devopsengineer` | ⚙️ System Smoother | 197 | | `!doctor` | 🩺 Medical Informant | `!economist` | 📈 Market Analyst Pro | 198 | | `!environmentalist` | 🌳 Nature's Advocate | `!ethicist` | 🧭 Moral Compass | 199 | | `!fashiondesigner` | 👗 Style Icon | `!filmmaker` | 🎥 Movie Director | 200 | | `!financialadvisor` | 💰 Wealth Sage | `!fitnesstrainer` | 💪 Health Coach | 201 | | `!gamedesigner` | 🎮 Game Dev Guru | `!gardener` | 🌻 Green Thumb | 202 | | `!geologist` | 🌍 Earth Explorer | `!historian` | 📜 History Buff | 203 | | `!hrspecialist` | 🧑‍🤝‍🧑 People Partner Pro | `!interiordesigner` | 🛋️ Space Shaper | 204 | | `!journalist` | 📰 News Hound | `!lawyer` | ⚖️ Legal Eagle | 205 | | `!lifecoach` | 🌟 Goal Getter Guide | `!linguist` | 🗣️ Language Expert | 206 | | `!marketingguru` | 📢 Brand Booster | `!mathematician` | ➕ Math Whiz | 207 | | `!mechanic` | 🔧 Auto Ace | `!musician` | 🎶 Melody Maker | 208 | | `!negotiator` | 🤝 Deal Maker Pro | `!novelist` | 📚 Story Weaver | 209 | | `!nutritionist` | 🥗 Dietitian Pro | `!philosopher` | 🤔 Deep Thinker | 210 | | `!photographer` | 📸 Image Capturer | `!physicist` | ⚛️ Quantum Physicist | 211 | | `!poet` | ✒️ Verse Virtuoso | `!projectmanager` | 📋 Task Mastermind | 212 | | `!psychologist` | 🧠 Mind Mender | `!publicspeaker` | 🎤 Oratory Coach | 213 | | `!researcher` | 🔬 Researcher | `!roboticsengineer` | 🦾 Robot Builder | 214 | | `!salesexpert` | 🤝 Deal Closer Pro | `!scriptwriter` | 🎬 Screen Scribe | 215 | | `!sociologist` | 👥 Society Scholar | `!sommelier` | 🍷 Wine Connoisseur | 216 | | `!teacher` | 🎓 Educator | `!travelguide` | ✈️ World Wanderer | 217 | | `!writer` | ✍️ Creative Writer | | | 218 | 219 | To revert to the default assistant, use one of these commands: `!reset`, `!default`, `!normal` 220 | 221 | --- 222 | 223 | ## 📥 Persona Management 224 | 225 | ### Downloading Personas 226 | 227 | The download system enables automatic persona collection management: 228 | 229 | #### 🌐 Basic Download 230 | ```bash 231 | !download_personas 232 | ``` 233 | Downloads from the default repository with merge strategy. 234 | 235 | #### 🔗 Custom Repository 236 | ```bash 237 | !download_personas https://your-domain.com/personas.json 238 | ``` 239 | Download from a specific URL (must be in trusted domains). 240 | 241 | #### 🔄 Replace Mode 242 | ```bash 243 | !download_personas --replace 244 | ``` 245 | Completely replaces local personas with remote collection. 246 | 247 | #### 📊 Download Process 248 | 1. **URL Validation** - Verifies domain is trusted 249 | 2. **Content Retrieval** - Downloads JSON configuration 250 | 3. **Structure Validation** - Ensures proper persona format 251 | 4. **Backup Creation** - Saves current configuration 252 | 5. **Merge/Replace** - Applies new personas based on strategy 253 | 6. **Cache Invalidation** - Refreshes system for immediate use 254 | 255 | ### Security & Trust 256 | 257 | #### 🔒 Domain Whitelist 258 | ``` 259 | trusted_domains: "github.com,raw.githubusercontent.com,gitlab.com" 260 | ``` 261 | Only domains in this list can serve persona downloads. 262 | 263 | #### 🛡️ Validation System 264 | - **JSON Structure** - Validates persona configuration format 265 | - **Required Fields** - Ensures name, prompt, description are present 266 | - **Content Limits** - 1MB maximum download size 267 | - **Timeout Protection** - 30-second download timeout 268 | 269 | #### 🔍 Security Features 270 | - HTTPS-only downloads 271 | - Content-type validation 272 | - Malicious URL detection 273 | - Safe fallback on errors 274 | 275 | ### Backup System 276 | 277 | #### 💾 Automatic Backups 278 | - Created before every download/apply operation 279 | - Timestamped for easy identification 280 | - Stored in `backups/` subdirectory 281 | - Automatic cleanup (keeps 5 most recent) 282 | 283 | #### 📁 Backup Location 284 | ``` 285 | /app/backend/data/cache/functions/agent_hotswap/backups/ 286 | ├── personas_backup_2024-01-15_14-30-22.json 287 | ├── personas_backup_2024-01-15_14-25-18.json 288 | └── ... 289 | ``` 290 | 291 | --- 292 | 293 | ## 🛠️ Configuration 294 | 295 | ### Basic Settings 296 | 297 | #### 🎛️ Core Configuration 298 | | Setting | Default | Description | 299 | |---------|---------|-------------| 300 | | `keyword_prefix` | `!` | Command prefix for persona switching | 301 | | `case_sensitive` | `false` | Whether commands are case-sensitive | 302 | | `persistent_persona` | `true` | Keep persona active across messages | 303 | | `show_persona_info` | `true` | Display status messages for switches | 304 | 305 | #### 🗂️ File Management 306 | | Setting | Default | Description | 307 | |---------|---------|-------------| 308 | | `cache_directory_name` | `agent_hotswap` | Directory name for config storage | 309 | | `config_filename` | `personas.json` | Filename for persona configuration | 310 | | `create_default_config` | `true` | Auto-create default personas | 311 | 312 | ### Advanced Options 313 | 314 | #### 📡 Download System 315 | ```python 316 | default_personas_repo = "https://raw.githubusercontent.com/pkeffect/agent_hotswap/refs/heads/main/personas/personas.json" 317 | trusted_domains = "github.com,raw.githubusercontent.com,gitlab.com" 318 | download_timeout = 30 319 | backup_count = 5 320 | ``` 321 | 322 | #### ⚡ Performance Settings 323 | ```python 324 | debug_performance = false 325 | status_message_auto_close_delay_ms = 5000 326 | ``` 327 | 328 | ### Performance Tuning 329 | 330 | #### 🚀 Optimization Features 331 | - **Smart Caching** - Only reloads when files change 332 | - **Pattern Pre-compilation** - Regex patterns compiled once 333 | - **Lazy Loading** - Personas loaded on-demand 334 | - **Change Detection** - File modification time tracking 335 | 336 | --- 337 | 338 | ## 💡 Usage Guide 339 | 340 | ### Basic Commands 341 | 342 | #### 📋 List Available Personas 343 | ``` 344 | !list 345 | ``` 346 | Displays a formatted table showing: 347 | - Command syntax for each persona 348 | - Display names with emojis 349 | - Reset command options 350 | 351 | #### 🔄 Reset to Default 352 | ``` 353 | !reset # Primary reset command 354 | !default # Alternative reset 355 | !normal # Another reset option 356 | ``` 357 | 358 | ### Persona Switching 359 | 360 | #### 🎭 Activate Persona 361 | ``` 362 | !coder # Switch to Code Assistant 363 | !writer # Switch to Creative Writer 364 | !analyst # Switch to Data Analyst 365 | !teacher # Switch to Educator 366 | !researcher # Switch to Researcher 367 | ``` 368 | 369 | #### 💬 Persona Behaviors 370 | - **Automatic Introduction** - Each persona introduces itself on activation 371 | - **Persistent Context** - Persona remains active until changed 372 | - **Specialized Responses** - Tailored expertise and communication style 373 | - **Master Controller Foundation** - OpenWebUI capabilities always available 374 | 375 | ### System Commands 376 | 377 | #### 📥 Download Management 378 | ```bash 379 | # Download default collection 380 | !download_personas 381 | 382 | # Download from specific URL 383 | !download_personas https://example.com/personas.json 384 | 385 | # Replace all personas 386 | !download_personas --replace 387 | 388 | # Custom URL with replace 389 | !download_personas https://example.com/custom.json --replace 390 | ``` 391 | 392 | --- 393 | 394 | ## 🏗️ System Architecture 395 | 396 | ### File Structure 397 | 398 | ``` 399 | /app/backend/data/cache/functions/agent_hotswap/ 400 | ├── personas.json # Main configuration 401 | ├── backups/ # Automatic backups 402 | │ ├── personas_backup_2024-01-15_14-30-22.json 403 | │ └── personas_backup_2024-01-15_14-25-18.json 404 | └── logs/ # Debug logs (if enabled) 405 | ``` 406 | 407 | #### 📄 personas.json Structure 408 | ```json 409 | { 410 | "_master_controller": { 411 | "name": "🎛️ Master Controller", 412 | "hidden": true, 413 | "always_active": true, 414 | "priority": 0, 415 | "prompt": "=== OPENWEBUI MASTER CONTROLLER ===\n...", 416 | "description": "Universal OpenWebUI environment context" 417 | }, 418 | "coder": { 419 | "name": "💻 Code Assistant", 420 | "prompt": "You are the 💻 Code Assistant...", 421 | "description": "Expert programming assistance", 422 | "rules": [...] 423 | } 424 | } 425 | ``` 426 | 427 | ### Caching System 428 | 429 | #### 🗄️ Smart Cache Features 430 | - **File Modification Detection** - Only reloads when JSON changes 431 | - **Validation Caching** - Remembers successful validations 432 | - **Pattern Compilation Cache** - Stores compiled regex patterns 433 | - **Invalidation Triggers** - Manual cache clearing on downloads 434 | 435 | #### ⚡ Performance Benefits 436 | - **Reduced I/O** - Minimizes file system access 437 | - **Faster Switching** - Pre-compiled patterns for instant detection 438 | - **Memory Efficiency** - Lazy loading of persona data 439 | - **Change Tracking** - Timestamp-based modification detection 440 | 441 | ### Pattern Matching 442 | 443 | #### 🔍 Regex Compilation 444 | ```python 445 | # Compiled patterns for efficiency 446 | prefix_pattern = re.compile(rf"{escaped_prefix}coder\b", flags) 447 | reset_pattern = re.compile(rf"{escaped_prefix}(?:reset|default|normal)\b", flags) 448 | list_pattern = re.compile(rf"{escaped_prefix}list\b", flags) 449 | ``` 450 | 451 | #### 🎯 Detection Strategy 452 | 1. **Command Preprocessing** - Normalize case if needed 453 | 2. **Pattern Matching** - Use pre-compiled regex for speed 454 | 3. **Priority Ordering** - System commands checked first 455 | 4. **Fallback Handling** - Graceful degradation on errors 456 | 457 | --- 458 | 459 | ## 🔧 Troubleshooting 460 | 461 | ### Common Issues 462 | 463 | #### ❌ Download Failures 464 | **Problem**: `!download_personas` fails with domain error 465 | ``` 466 | Solution: Check trusted_domains configuration 467 | - Ensure domain is in whitelist: "github.com,raw.githubusercontent.com" 468 | - Verify HTTPS protocol is used 469 | - Check network connectivity 470 | ``` 471 | 472 | #### ❌ Persona Not Loading 473 | **Problem**: Persona doesn't activate after switching 474 | ``` 475 | Solution: Check configuration and cache 476 | 1. Use !list to verify persona exists 477 | 2. Check personas.json syntax 478 | 3. Clear cache: restart OpenWebUI or modify config file 479 | 4. Review logs for validation errors 480 | ``` 481 | 482 | #### ❌ Commands Not Recognized 483 | **Problem**: `!coder` or other commands don't work 484 | ``` 485 | Solution: Verify configuration 486 | - Check keyword_prefix setting (default: "!") 487 | - Ensure case_sensitive matches your usage 488 | - Verify filter is enabled and active 489 | - Test with !list first 490 | ``` 491 | 492 | ### Debug Mode 493 | 494 | #### 🐛 Enable Debugging 495 | ```python 496 | debug_performance = true 497 | ``` 498 | Provides detailed timing and operation logs. 499 | 500 | #### 📊 Performance Monitoring 501 | - Pattern compilation timing 502 | - File loading performance 503 | - Cache hit/miss ratios 504 | - Command detection speed 505 | 506 | ### Recovery Procedures 507 | 508 | #### 🔄 Reset Configuration 509 | 1. **Delete config file**: Remove `personas.json` 510 | 2. **Restart filter**: Toggle off/on in OpenWebUI 511 | 3. **Reload defaults**: System creates fresh configuration 512 | 4. **Re-download**: Use `!download_personas` to restore collection 513 | 514 | #### 💾 Restore from Backup 515 | 1. **Locate backup**: Check `backups/` directory 516 | 2. **Copy desired backup**: Rename to `personas.json` 517 | 3. **Clear cache**: Restart OpenWebUI or modify timestamp 518 | 4. **Verify**: Use `!list` to confirm restoration 519 | 520 | --- 521 | 522 | ## 🚀 Advanced Features 523 | 524 | ### Custom Persona Creation 525 | 526 | #### 🎨 Manual Persona Addition 527 | Edit `personas.json` directly to add custom personas: 528 | 529 | ```json 530 | { 531 | "custom_key": { 532 | "name": "🎯 Your Custom Persona", 533 | "prompt": "You are a specialized assistant for...", 534 | "description": "Brief description of capabilities", 535 | "rules": [ 536 | "1. First behavioral rule", 537 | "2. Second behavioral rule" 538 | ] 539 | } 540 | } 541 | ``` 542 | 543 | ### Repository Management 544 | 545 | #### 🌐 Creating Persona Repositories 546 | Structure for shareable persona collections: 547 | 548 | ```json 549 | { 550 | "meta": { 551 | "version": "1.0.0", 552 | "author": "Your Name", 553 | "description": "Collection description" 554 | }, 555 | "personas": { 556 | "specialist": { 557 | "name": "🎯 Specialist", 558 | "prompt": "...", 559 | "description": "..." 560 | } 561 | } 562 | } 563 | ``` 564 | 565 | ### Integration Patterns 566 | 567 | #### 🔗 Workflow Integration 568 | - **Development Teams**: Code review personas for different languages 569 | - **Content Creation**: Writing personas for different styles/audiences 570 | - **Education**: Teaching personas for different subjects/levels 571 | - **Analysis**: Specialized personas for different data types 572 | 573 | --- 574 | 575 | ## 🤝 Contributing 576 | 577 | ### Development Setup 578 | 579 | #### 🛠️ Local Development 580 | 1. **Fork Repository** - Create your own copy 581 | 2. **Clone Locally** - Set up development environment 582 | 3. **Test Changes** - Use OpenWebUI test instance 583 | 4. **Submit PR** - Follow contribution guidelines 584 | 585 | ### Persona Contributions 586 | 587 | #### 📝 Persona Guidelines 588 | - **Clear Purpose** - Well-defined role and expertise 589 | - **Comprehensive Prompt** - Detailed behavioral instructions 590 | - **User-Friendly Description** - Clear capability explanation 591 | - **Appropriate Rules** - Structured behavioral guidelines 592 | 593 | #### 🧪 Testing Requirements 594 | - **Validation** - Passes JSON schema validation 595 | - **Functionality** - Commands work as expected 596 | - **Performance** - No significant slowdown 597 | - **Compatibility** - Works with Master Controller system 598 | 599 | ### Bug Reports 600 | 601 | #### 🐛 Reporting Issues 602 | Include the following information: 603 | - **OpenWebUI Version** - Your OpenWebUI version 604 | - **Filter Configuration** - Relevant valve settings 605 | - **Error Messages** - Full error text and logs 606 | - **Reproduction Steps** - How to recreate the issue 607 | - **Expected Behavior** - What should happen instead 608 | 609 | --- 610 | 611 | ## 📄 License 612 | 613 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 614 | 615 | --- 616 | 617 | ## 🙏 Acknowledgments 618 | 619 | - **OpenWebUI Team** - For the amazing platform 620 | - **Community Contributors** - For persona collections and feedback 621 | - **Beta Testers** - For early feedback and bug reports 622 | 623 | --- 624 | 625 | ## 📞 Support 626 | 627 | - **GitHub Issues** - [Report bugs and request features](https://github.com/open-webui/functions/issues) 628 | - **Discussions** - [Community support and questions](https://github.com/open-webui/functions/discussions) 629 | - **Documentation** - This README and inline code documentation 630 | 631 | --- 632 | 633 |
634 | 635 | **🎭 Transform your AI interactions with Agent Hotswap!** 636 | 637 | *Seamless persona switching • Rich OpenWebUI integration • Secure & performant* 638 | 639 |
640 | -------------------------------------------------------------------------------- /functions/filters/agent_hotswap/main.py: -------------------------------------------------------------------------------- 1 | """ 2 | title: Agent Hotswap 3 | author: pkeffect 4 | author_url: https://github.com/pkeffect 5 | project_url: https://github.com/pkeffect/agent_hotswap 6 | funding_url: https://github.com/open-webui 7 | version: 0.1.0 8 | description: Switch between AI personas with optimized performance. Features: external config, pre-compiled regex patterns, smart caching, validation, and modular architecture. Commands: !list, !reset, !coder, !writer, etc. 9 | """ 10 | 11 | from pydantic import BaseModel, Field 12 | from typing import Optional, Dict, List, Callable, Any 13 | import re 14 | import json 15 | import asyncio 16 | import time 17 | import os 18 | import traceback 19 | import urllib.request 20 | import urllib.parse 21 | import urllib.error 22 | from datetime import datetime 23 | import difflib 24 | 25 | 26 | class PersonaDownloadManager: 27 | """Manages downloading and applying persona configurations from remote repositories.""" 28 | 29 | def __init__(self, valves, get_config_filepath_func): 30 | self.valves = valves 31 | self.get_config_filepath = get_config_filepath_func 32 | 33 | def is_trusted_domain(self, url: str) -> bool: 34 | """Check if URL domain is in the trusted whitelist.""" 35 | try: 36 | print(f"[DOMAIN DEBUG] Checking URL: {url}") 37 | parsed = urllib.parse.urlparse(url) 38 | print( 39 | f"[DOMAIN DEBUG] Parsed URL - scheme: {parsed.scheme}, netloc: {parsed.netloc}" 40 | ) 41 | 42 | if not parsed.scheme or parsed.scheme.lower() not in ["https"]: 43 | print(f"[DOMAIN DEBUG] Scheme check failed - scheme: '{parsed.scheme}'") 44 | return False 45 | 46 | print(f"[DOMAIN DEBUG] Scheme check passed") 47 | 48 | trusted_domains_raw = self.valves.trusted_domains 49 | trusted_domains = [ 50 | d.strip().lower() for d in trusted_domains_raw.split(",") 51 | ] 52 | print(f"[DOMAIN DEBUG] Trusted domains raw: '{trusted_domains_raw}'") 53 | print(f"[DOMAIN DEBUG] Trusted domains processed: {trusted_domains}") 54 | print(f"[DOMAIN DEBUG] URL netloc (lowercase): '{parsed.netloc.lower()}'") 55 | 56 | is_trusted = parsed.netloc.lower() in trusted_domains 57 | print(f"[DOMAIN DEBUG] Domain trusted check result: {is_trusted}") 58 | 59 | return is_trusted 60 | 61 | except Exception as e: 62 | print(f"[DOMAIN DEBUG] Exception in domain check: {e}") 63 | traceback.print_exc() 64 | return False 65 | 66 | async def download_personas(self, url: str = None) -> Dict: 67 | """Download personas from remote repository with validation.""" 68 | download_url = url or self.valves.default_personas_repo 69 | 70 | print(f"[DOWNLOAD DEBUG] Starting download from: {download_url}") 71 | 72 | # Validate URL 73 | print(f"[DOWNLOAD DEBUG] Checking if domain is trusted...") 74 | if not self.is_trusted_domain(download_url): 75 | error_msg = ( 76 | f"Untrusted domain. Allowed domains: {self.valves.trusted_domains}" 77 | ) 78 | print(f"[DOWNLOAD DEBUG] Domain check failed: {error_msg}") 79 | return {"success": False, "error": error_msg, "url": download_url} 80 | 81 | print(f"[DOWNLOAD DEBUG] Domain check passed") 82 | 83 | try: 84 | # Download with timeout 85 | print(f"[DOWNLOAD DEBUG] Creating HTTP request...") 86 | req = urllib.request.Request( 87 | download_url, headers={"User-Agent": "OpenWebUI-AgentHotswap/3.1"} 88 | ) 89 | print(f"[DOWNLOAD DEBUG] Request created, opening connection...") 90 | 91 | with urllib.request.urlopen( 92 | req, timeout=self.valves.download_timeout 93 | ) as response: 94 | print(f"[DOWNLOAD DEBUG] Connection opened, status: {response.status}") 95 | print(f"[DOWNLOAD DEBUG] Response headers: {dict(response.headers)}") 96 | 97 | if response.status != 200: 98 | error_msg = f"HTTP {response.status}: {response.reason}" 99 | print(f"[DOWNLOAD DEBUG] HTTP error: {error_msg}") 100 | return {"success": False, "error": error_msg, "url": download_url} 101 | 102 | print(f"[DOWNLOAD DEBUG] Reading response content...") 103 | content = response.read().decode("utf-8") 104 | content_size = len(content) 105 | print( 106 | f"[DOWNLOAD DEBUG] Content read successfully: {content_size} bytes" 107 | ) 108 | print( 109 | f"[DOWNLOAD DEBUG] Content preview (first 200 chars): {content[:200]}" 110 | ) 111 | 112 | # Basic size check (prevent huge files) 113 | if content_size > 1024 * 1024: # 1MB limit 114 | error_msg = f"File too large: {content_size} bytes (max 1MB)" 115 | print(f"[DOWNLOAD DEBUG] Size check failed: {error_msg}") 116 | return {"success": False, "error": error_msg, "url": download_url} 117 | 118 | print(f"[DOWNLOAD DEBUG] Size check passed") 119 | 120 | # Parse JSON 121 | print(f"[DOWNLOAD DEBUG] Parsing JSON...") 122 | try: 123 | remote_personas = json.loads(content) 124 | print( 125 | f"[DOWNLOAD DEBUG] JSON parsed successfully, {len(remote_personas)} items found" 126 | ) 127 | print( 128 | f"[DOWNLOAD DEBUG] Top-level keys: {list(remote_personas.keys())[:5]}" 129 | ) 130 | except json.JSONDecodeError as e: 131 | error_msg = f"Invalid JSON: {str(e)}" 132 | print(f"[DOWNLOAD DEBUG] JSON parsing failed: {error_msg}") 133 | print( 134 | f"[DOWNLOAD DEBUG] Content that failed parsing: {content[:500]}" 135 | ) 136 | return {"success": False, "error": error_msg, "url": download_url} 137 | 138 | # Validate structure 139 | print(f"[DOWNLOAD DEBUG] Validating persona structure...") 140 | validation_errors = PersonaValidator.validate_personas_config( 141 | remote_personas 142 | ) 143 | if validation_errors: 144 | error_msg = f"Validation failed: {'; '.join(validation_errors[:3])}" 145 | print(f"[DOWNLOAD DEBUG] Validation failed: {validation_errors}") 146 | return { 147 | "success": False, 148 | "error": error_msg, 149 | "url": download_url, 150 | "validation_errors": validation_errors, 151 | } 152 | 153 | print( 154 | f"[DOWNLOAD DEBUG] Validation passed - {len(remote_personas)} personas" 155 | ) 156 | 157 | return { 158 | "success": True, 159 | "personas": remote_personas, 160 | "url": download_url, 161 | "size": content_size, 162 | "count": len(remote_personas), 163 | } 164 | 165 | except urllib.error.URLError as e: 166 | error_msg = f"Download failed: {str(e)}" 167 | print(f"[DOWNLOAD DEBUG] URLError: {error_msg}") 168 | print(f"[DOWNLOAD DEBUG] URLError details: {type(e).__name__}: {e}") 169 | return {"success": False, "error": error_msg, "url": download_url} 170 | except Exception as e: 171 | error_msg = f"Unexpected error: {str(e)}" 172 | print(f"[DOWNLOAD DEBUG] Unexpected error: {error_msg}") 173 | print(f"[DOWNLOAD DEBUG] Exception type: {type(e).__name__}") 174 | traceback.print_exc() 175 | return {"success": False, "error": error_msg, "url": download_url} 176 | 177 | def analyze_differences(self, remote_personas: Dict, local_personas: Dict) -> Dict: 178 | """Analyze differences between remote and local persona configurations.""" 179 | analysis = { 180 | "new_personas": [], 181 | "updated_personas": [], 182 | "conflicts": [], 183 | "unchanged_personas": [], 184 | "summary": {}, 185 | } 186 | 187 | # Analyze each remote persona 188 | for persona_key, remote_persona in remote_personas.items(): 189 | if persona_key not in local_personas: 190 | # New persona 191 | analysis["new_personas"].append( 192 | { 193 | "key": persona_key, 194 | "name": remote_persona.get("name", persona_key.title()), 195 | "description": remote_persona.get( 196 | "description", "No description" 197 | ), 198 | "prompt_length": len(remote_persona.get("prompt", "")), 199 | } 200 | ) 201 | else: 202 | # Existing persona - check for differences 203 | local_persona = local_personas[persona_key] 204 | differences = [] 205 | 206 | # Compare key fields 207 | for field in ["name", "description", "prompt"]: 208 | local_val = local_persona.get(field, "") 209 | remote_val = remote_persona.get(field, "") 210 | if local_val != remote_val: 211 | differences.append(field) 212 | 213 | if differences: 214 | analysis["conflicts"].append( 215 | { 216 | "key": persona_key, 217 | "local": local_persona, 218 | "remote": remote_persona, 219 | "differences": differences, 220 | } 221 | ) 222 | else: 223 | analysis["unchanged_personas"].append(persona_key) 224 | 225 | # Generate summary 226 | analysis["summary"] = { 227 | "new_count": len(analysis["new_personas"]), 228 | "conflict_count": len(analysis["conflicts"]), 229 | "unchanged_count": len(analysis["unchanged_personas"]), 230 | "total_remote": len(remote_personas), 231 | "total_local": len(local_personas), 232 | } 233 | 234 | return analysis 235 | 236 | def generate_diff_view( 237 | self, local_persona: Dict, remote_persona: Dict, persona_key: str 238 | ) -> str: 239 | """Generate a detailed diff view for a specific persona conflict.""" 240 | diff_lines = [] 241 | 242 | # Compare key fields 243 | for field in ["name", "description", "prompt"]: 244 | local_val = local_persona.get(field, "") 245 | remote_val = remote_persona.get(field, "") 246 | 247 | if local_val != remote_val: 248 | diff_lines.append(f"\n**{field.upper()}:**") 249 | diff_lines.append("```diff") 250 | 251 | if field == "prompt": 252 | # For long prompts, show character count and first/last lines 253 | local_preview = ( 254 | f"{local_val[:100]}..." if len(local_val) > 100 else local_val 255 | ) 256 | remote_preview = ( 257 | f"{remote_val[:100]}..." 258 | if len(remote_val) > 100 259 | else remote_val 260 | ) 261 | diff_lines.append( 262 | f"- LOCAL ({len(local_val)} chars): {local_preview}" 263 | ) 264 | diff_lines.append( 265 | f"+ REMOTE ({len(remote_val)} chars): {remote_preview}" 266 | ) 267 | else: 268 | diff_lines.append(f"- {local_val}") 269 | diff_lines.append(f"+ {remote_val}") 270 | 271 | diff_lines.append("```") 272 | 273 | return "\n".join(diff_lines) 274 | 275 | def create_backup(self, current_personas: Dict) -> str: 276 | """Create a timestamped backup of current personas configuration.""" 277 | try: 278 | # Generate backup filename with timestamp 279 | timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") 280 | backup_filename = f"personas_backup_{timestamp}.json" 281 | 282 | # Create backups directory 283 | config_dir = os.path.dirname(self.get_config_filepath()) 284 | backup_dir = os.path.join(config_dir, "backups") 285 | os.makedirs(backup_dir, exist_ok=True) 286 | 287 | backup_path = os.path.join(backup_dir, backup_filename) 288 | 289 | # Write backup 290 | with open(backup_path, "w", encoding="utf-8") as f: 291 | json.dump(current_personas, f, indent=4, ensure_ascii=False) 292 | 293 | print(f"[BACKUP] Created backup: {backup_path}") 294 | 295 | # Auto-cleanup old backups 296 | self._cleanup_old_backups(backup_dir) 297 | 298 | return backup_filename 299 | 300 | except Exception as e: 301 | print(f"[BACKUP] Error creating backup: {e}") 302 | return f"Error: {str(e)}" 303 | 304 | def _cleanup_old_backups(self, backup_dir: str): 305 | """Remove old backup files, keeping only the most recent ones.""" 306 | try: 307 | # Get all backup files 308 | backup_files = [] 309 | for filename in os.listdir(backup_dir): 310 | if filename.startswith("personas_backup_") and filename.endswith( 311 | ".json" 312 | ): 313 | filepath = os.path.join(backup_dir, filename) 314 | mtime = os.path.getmtime(filepath) 315 | backup_files.append((mtime, filepath, filename)) 316 | 317 | # Sort by modification time (newest first) 318 | backup_files.sort(reverse=True) 319 | 320 | # Remove old backups beyond the limit 321 | files_to_remove = backup_files[self.valves.backup_count :] 322 | for _, filepath, filename in files_to_remove: 323 | os.remove(filepath) 324 | print(f"[BACKUP] Removed old backup: {filename}") 325 | 326 | except Exception as e: 327 | print(f"[BACKUP] Error during cleanup: {e}") 328 | 329 | async def download_and_apply_personas( 330 | self, url: str = None, merge_strategy: str = "merge" 331 | ) -> Dict: 332 | """Download personas and apply them immediately with backup.""" 333 | # Download first 334 | download_result = await self.download_personas(url) 335 | if not download_result["success"]: 336 | return download_result 337 | 338 | try: 339 | remote_personas = download_result["personas"] 340 | 341 | # Load current personas for backup and analysis 342 | current_personas = self._read_current_personas() 343 | 344 | # Create backup first 345 | backup_name = self.create_backup(current_personas) 346 | print(f"[DOWNLOAD APPLY] Backup created: {backup_name}") 347 | 348 | # Analyze differences for reporting 349 | analysis = self.analyze_differences(remote_personas, current_personas) 350 | 351 | # Apply merge strategy 352 | if merge_strategy == "replace": 353 | # Replace entire configuration 354 | final_personas = remote_personas.copy() 355 | else: 356 | # Merge strategy (default) 357 | final_personas = current_personas.copy() 358 | 359 | # Add new personas 360 | for new_persona in analysis["new_personas"]: 361 | key = new_persona["key"] 362 | final_personas[key] = remote_personas[key] 363 | 364 | # For conflicts, use remote version (simple strategy) 365 | for conflict in analysis["conflicts"]: 366 | key = conflict["key"] 367 | final_personas[key] = conflict["remote"] 368 | 369 | # Write the final configuration 370 | config_path = self.get_config_filepath() 371 | with open(config_path, "w", encoding="utf-8") as f: 372 | json.dump(final_personas, f, indent=4, ensure_ascii=False) 373 | 374 | print( 375 | f"[DOWNLOAD APPLY] Applied configuration - {len(final_personas)} personas" 376 | ) 377 | 378 | return { 379 | "success": True, 380 | "backup_created": backup_name, 381 | "personas_count": len(final_personas), 382 | "changes_applied": { 383 | "new_added": len(analysis["new_personas"]), 384 | "conflicts_resolved": len(analysis["conflicts"]), 385 | "total_downloaded": len(remote_personas), 386 | }, 387 | "analysis": analysis, 388 | "url": download_result["url"], 389 | "size": download_result["size"], 390 | } 391 | 392 | except Exception as e: 393 | print(f"[DOWNLOAD APPLY] Error applying download: {e}") 394 | traceback.print_exc() 395 | return {"success": False, "error": f"Failed to apply download: {str(e)}"} 396 | 397 | def _read_current_personas(self) -> Dict: 398 | """Read current personas configuration from file.""" 399 | try: 400 | config_path = self.get_config_filepath() 401 | if not os.path.exists(config_path): 402 | return {} 403 | 404 | with open(config_path, "r", encoding="utf-8") as f: 405 | return json.load(f) 406 | except Exception as e: 407 | print(f"[DOWNLOAD APPLY] Error reading current personas: {e}") 408 | return {} 409 | 410 | 411 | class PersonaValidator: 412 | """Validates persona configuration structure.""" 413 | 414 | @staticmethod 415 | def validate_persona_config(persona: Dict) -> List[str]: 416 | """Validate a single persona configuration. 417 | 418 | Returns: 419 | List of error messages, empty if valid 420 | """ 421 | errors = [] 422 | required_fields = ["name", "prompt", "description"] 423 | 424 | for field in required_fields: 425 | if field not in persona: 426 | errors.append(f"Missing required field: {field}") 427 | elif not isinstance(persona[field], str): 428 | errors.append(f"Field '{field}' must be a string") 429 | elif not persona[field].strip(): 430 | errors.append(f"Field '{field}' cannot be empty") 431 | 432 | # Validate optional fields 433 | if "rules" in persona and not isinstance(persona["rules"], list): 434 | errors.append("Field 'rules' must be a list") 435 | 436 | return errors 437 | 438 | @staticmethod 439 | def validate_personas_config(personas: Dict) -> List[str]: 440 | """Validate entire personas configuration. 441 | 442 | Returns: 443 | List of error messages, empty if valid 444 | """ 445 | all_errors = [] 446 | 447 | if not isinstance(personas, dict): 448 | return ["Personas config must be a dictionary"] 449 | 450 | if not personas: 451 | return ["Personas config cannot be empty"] 452 | 453 | for persona_key, persona_data in personas.items(): 454 | if not isinstance(persona_key, str) or not persona_key.strip(): 455 | all_errors.append(f"Invalid persona key: {persona_key}") 456 | continue 457 | 458 | if not isinstance(persona_data, dict): 459 | all_errors.append(f"Persona '{persona_key}' must be a dictionary") 460 | continue 461 | 462 | persona_errors = PersonaValidator.validate_persona_config(persona_data) 463 | for error in persona_errors: 464 | all_errors.append(f"Persona '{persona_key}': {error}") 465 | 466 | return all_errors 467 | 468 | 469 | class PatternCompiler: 470 | """Pre-compiles and manages regex patterns for efficient persona detection.""" 471 | 472 | def __init__(self, config_valves): 473 | self.valves = config_valves 474 | self.persona_patterns = {} 475 | self.reset_pattern = None 476 | self.list_pattern = None 477 | self._last_compiled_config = None 478 | self._compile_patterns() 479 | 480 | def _compile_patterns(self): 481 | """Compile all regex patterns once for reuse.""" 482 | try: 483 | # Get current config state for change detection 484 | current_config = { 485 | "prefix": self.valves.keyword_prefix, 486 | "reset_keywords": self.valves.reset_keywords, 487 | "list_keyword": self.valves.list_command_keyword, 488 | "case_sensitive": self.valves.case_sensitive, 489 | } 490 | 491 | # Only recompile if config changed 492 | if current_config == self._last_compiled_config: 493 | return 494 | 495 | print( 496 | f"[PATTERN COMPILER] Compiling patterns for prefix '{self.valves.keyword_prefix}'" 497 | ) 498 | 499 | # Compile base patterns 500 | prefix_escaped = re.escape(self.valves.keyword_prefix) 501 | flags = 0 if self.valves.case_sensitive else re.IGNORECASE 502 | 503 | # Compile list command pattern 504 | list_cmd = self.valves.list_command_keyword 505 | if not self.valves.case_sensitive: 506 | list_cmd = list_cmd.lower() 507 | self.list_pattern = re.compile( 508 | rf"{prefix_escaped}{re.escape(list_cmd)}\b", flags 509 | ) 510 | 511 | # Compile reset patterns 512 | reset_keywords = [ 513 | word.strip() for word in self.valves.reset_keywords.split(",") 514 | ] 515 | reset_pattern_parts = [] 516 | for keyword in reset_keywords: 517 | if not self.valves.case_sensitive: 518 | keyword = keyword.lower() 519 | reset_pattern_parts.append(re.escape(keyword)) 520 | 521 | reset_pattern_str = ( 522 | rf"{prefix_escaped}(?:{'|'.join(reset_pattern_parts)})\b" 523 | ) 524 | self.reset_pattern = re.compile(reset_pattern_str, flags) 525 | 526 | # Compile download command pattern 527 | self.download_pattern = re.compile( 528 | rf"{prefix_escaped}download_personas\b", flags 529 | ) 530 | 531 | # Clear old persona patterns - they'll be compiled on demand 532 | self.persona_patterns.clear() 533 | 534 | self._last_compiled_config = current_config 535 | print(f"[PATTERN COMPILER] Patterns compiled successfully") 536 | 537 | except Exception as e: 538 | print(f"[PATTERN COMPILER] Error compiling patterns: {e}") 539 | traceback.print_exc() 540 | 541 | def get_persona_pattern(self, persona_key: str): 542 | """Get or compile a pattern for a specific persona.""" 543 | if persona_key not in self.persona_patterns: 544 | try: 545 | prefix_escaped = re.escape(self.valves.keyword_prefix) 546 | keyword_check = ( 547 | persona_key if self.valves.case_sensitive else persona_key.lower() 548 | ) 549 | flags = 0 if self.valves.case_sensitive else re.IGNORECASE 550 | pattern_str = rf"{prefix_escaped}{re.escape(keyword_check)}\b" 551 | self.persona_patterns[persona_key] = re.compile(pattern_str, flags) 552 | except Exception as e: 553 | print( 554 | f"[PATTERN COMPILER] Error compiling pattern for '{persona_key}': {e}" 555 | ) 556 | return None 557 | 558 | return self.persona_patterns[persona_key] 559 | 560 | def detect_keyword( 561 | self, message_content: str, available_personas: Dict 562 | ) -> Optional[str]: 563 | """Efficiently detect persona keywords using pre-compiled patterns.""" 564 | if not message_content: 565 | return None 566 | 567 | # Ensure patterns are up to date 568 | self._compile_patterns() 569 | 570 | content_to_check = ( 571 | message_content if self.valves.case_sensitive else message_content.lower() 572 | ) 573 | 574 | # Check list command (fastest check first) 575 | if self.list_pattern and self.list_pattern.search(content_to_check): 576 | return "list_personas" 577 | 578 | # Check reset commands 579 | if self.reset_pattern and self.reset_pattern.search(content_to_check): 580 | return "reset" 581 | 582 | # Check download command 583 | if self.download_pattern and self.download_pattern.search(content_to_check): 584 | return "download_personas" 585 | 586 | # Check persona commands 587 | for persona_key in available_personas.keys(): 588 | pattern = self.get_persona_pattern(persona_key) 589 | if pattern and pattern.search(content_to_check): 590 | return persona_key 591 | 592 | return None 593 | 594 | 595 | class SmartPersonaCache: 596 | """Intelligent caching system for persona configurations.""" 597 | 598 | def __init__(self): 599 | self._cache = {} 600 | self._file_mtime = 0 601 | self._validation_cache = {} 602 | self._last_filepath = None 603 | 604 | def get_personas(self, filepath: str, force_reload: bool = False) -> Dict: 605 | """Get personas with smart caching - only reload if file changed.""" 606 | try: 607 | # Check if file exists 608 | if not os.path.exists(filepath): 609 | print(f"[SMART CACHE] File doesn't exist: {filepath}") 610 | return {} 611 | 612 | # Check if we need to reload 613 | current_mtime = os.path.getmtime(filepath) 614 | filepath_changed = filepath != self._last_filepath 615 | file_modified = current_mtime > self._file_mtime 616 | 617 | if force_reload or filepath_changed or file_modified or not self._cache: 618 | print(f"[SMART CACHE] Reloading personas from: {filepath}") 619 | print( 620 | f"[SMART CACHE] Reason - Force: {force_reload}, Path changed: {filepath_changed}, Modified: {file_modified}, Empty cache: {not self._cache}" 621 | ) 622 | 623 | # Load from file 624 | with open(filepath, "r", encoding="utf-8") as f: 625 | loaded_data = json.load(f) 626 | 627 | # Validate configuration 628 | validation_errors = PersonaValidator.validate_personas_config( 629 | loaded_data 630 | ) 631 | if validation_errors: 632 | print(f"[SMART CACHE] Validation errors found:") 633 | for error in validation_errors[:5]: # Show first 5 errors 634 | print(f"[SMART CACHE] - {error}") 635 | if len(validation_errors) > 5: 636 | print( 637 | f"[SMART CACHE] ... and {len(validation_errors) - 5} more errors" 638 | ) 639 | 640 | # Don't cache invalid config, but still return it (graceful degradation) 641 | return loaded_data 642 | 643 | # Cache valid configuration 644 | self._cache = loaded_data 645 | self._file_mtime = current_mtime 646 | self._last_filepath = filepath 647 | self._validation_cache[filepath] = True # Mark as validated 648 | 649 | print(f"[SMART CACHE] Successfully cached {len(loaded_data)} personas") 650 | else: 651 | print( 652 | f"[SMART CACHE] Using cached personas ({len(self._cache)} personas)" 653 | ) 654 | 655 | return self._cache.copy() # Return copy to prevent external modification 656 | 657 | except json.JSONDecodeError as e: 658 | print(f"[SMART CACHE] JSON decode error in {filepath}: {e}") 659 | return {} 660 | except Exception as e: 661 | print(f"[SMART CACHE] Error loading personas from {filepath}: {e}") 662 | traceback.print_exc() 663 | return {} 664 | 665 | def is_config_valid(self, filepath: str) -> bool: 666 | """Check if a config file has been validated successfully.""" 667 | return self._validation_cache.get(filepath, False) 668 | 669 | def invalidate_cache(self): 670 | """Force cache invalidation on next access.""" 671 | self._cache.clear() 672 | self._validation_cache.clear() 673 | self._file_mtime = 0 674 | self._last_filepath = None 675 | print("[SMART CACHE] Cache invalidated") 676 | 677 | 678 | class Filter: 679 | class Valves(BaseModel): 680 | cache_directory_name: str = Field( 681 | default="agent_hotswap", 682 | description="Name of the cache directory to store personas config file", 683 | ) 684 | config_filename: str = Field( 685 | default="personas.json", 686 | description="Filename for the personas configuration file in cache directory", 687 | ) 688 | keyword_prefix: str = Field( 689 | default="!", 690 | description="Prefix character(s) that trigger persona switching (e.g., '!coder')", 691 | ) 692 | reset_keywords: str = Field( 693 | default="reset,default,normal", 694 | description="Comma-separated keywords to reset to default behavior", 695 | ) 696 | list_command_keyword: str = Field( 697 | default="list", 698 | description="Keyword (without prefix) to trigger listing available personas. Prefix will be added (e.g., '!list').", 699 | ) 700 | case_sensitive: bool = Field( 701 | default=False, description="Whether keyword matching is case-sensitive" 702 | ) 703 | show_persona_info: bool = Field( 704 | default=True, 705 | description="Show persona information when switching (UI status messages)", 706 | ) 707 | persistent_persona: bool = Field( 708 | default=True, 709 | description="Keep persona active across messages until changed", 710 | ) 711 | status_message_auto_close_delay_ms: int = Field( 712 | default=5000, 713 | description="Delay in milliseconds before attempting to auto-close UI status messages.", 714 | ) 715 | create_default_config: bool = Field( 716 | default=True, 717 | description="Create default personas config file if it doesn't exist", 718 | ) 719 | debug_performance: bool = Field( 720 | default=False, 721 | description="Enable performance debugging - logs timing information", 722 | ) 723 | # Download system configuration 724 | default_personas_repo: str = Field( 725 | default="https://raw.githubusercontent.com/open-webui/functions/refs/heads/main/functions/filters/agent_hotswap/personas/personas.json", 726 | description="Default repository URL for persona downloads", 727 | ) 728 | trusted_domains: str = Field( 729 | default="github.com,raw.githubusercontent.com,gitlab.com", 730 | description="Comma-separated whitelist of trusted domains for downloads", 731 | ) 732 | backup_count: int = Field( 733 | default=5, 734 | description="Number of backup files to keep (auto-cleanup old ones)", 735 | ) 736 | download_timeout: int = Field( 737 | default=30, 738 | description="Download timeout in seconds", 739 | ) 740 | 741 | def __init__(self): 742 | self.valves = self.Valves() 743 | self.toggle = True 744 | self.icon = """data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiIHZpZXdCb3g9IjAgMCAyNCAyNCIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZT0iY3VycmVudENvbG9yIj4KICA8cGF0aCBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGQ9Ik0xNS43NSA1QzE1Ljc1IDMuMzQzIDE0LjQwNyAyIDEyLjc1IDJTOS43NSAzLjM0MyA5Ljc1IDV2MC41QTMuNzUgMy43NSAwIDAgMCAxMy41IDkuMjVjMi4xIDAgMy44MS0xLjc2NyAzLjc1LTMuODZWNVoiLz4KICA8cGF0aCBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGQ9Ik04LjI1IDV2LjVhMy43NSAzLjc1IDAgMCAwIDMuNzUgMy43NWMuNzE0IDAgMS4zODUtLjIgMS45Ni0uNTU2QTMuNzUgMy43NSAwIDAgMCAxNy4yNSA1djAuNUMxNy4yNSAzLjM0MyAxNS45MDcgMiAxNC4yNSAyczMuNzUgMS4zNDMgMy43NSAzdjAuNUEzLjc1IDMuNzUgMCAwIDAgMjEuNzUgOWMuNzE0IDAgMS4zODUtLjIgMS45Ni0uNTU2QTMuNzUgMy43NSAwIDAgMCAyMS4yNSA1djAuNSIvPgo8L3N2Zz4=""" 745 | 746 | # State management 747 | self.current_persona = None 748 | self.was_toggled_off_last_call = False 749 | self.active_status_message_id = None 750 | self.event_emitter_for_close_task = None 751 | 752 | # Performance optimization components 753 | self.pattern_compiler = PatternCompiler(self.valves) 754 | self.persona_cache = SmartPersonaCache() 755 | 756 | # Download system 757 | self.download_manager = PersonaDownloadManager( 758 | self.valves, self._get_config_filepath 759 | ) 760 | 761 | # Initialize config file if it doesn't exist 762 | if self.valves.create_default_config: 763 | self._ensure_config_file_exists() 764 | 765 | @property 766 | def config_filepath(self): 767 | """Dynamic property to get the current config file path.""" 768 | return self._get_config_filepath() 769 | 770 | def _get_config_filepath(self): 771 | """Constructs the config file path within the tool's cache directory. 772 | 773 | Creates path: /app/backend/data/cache/functions/agent_hotswap/personas.json 774 | """ 775 | base_cache_dir = "/app/backend/data/cache/functions" 776 | target_dir = os.path.join(base_cache_dir, self.valves.cache_directory_name) 777 | filepath = os.path.join(target_dir, self.valves.config_filename) 778 | return filepath 779 | 780 | def get_master_controller_persona(self) -> Dict: 781 | """Returns the master controller persona - always active foundation.""" 782 | return { 783 | "_master_controller": { 784 | "name": "🎛️ OpenWebUI Master Controller", 785 | "hidden": True, # Don't show in lists or status messages 786 | "always_active": True, # Always loads with every persona 787 | "priority": 0, # Highest priority - loads first 788 | "version": "0.6.5+", 789 | "rules": [ 790 | "1. This is the foundational system context for OpenWebUI environment", 791 | "2. Always active beneath any selected persona", 792 | "3. Provides comprehensive native capabilities and rendering context", 793 | "4. Transparent to user - no status messages about master controller", 794 | "5. Only deactivated on reset/default commands or system toggle off", 795 | ], 796 | "prompt": """=== OPENWEBUI MASTER CONTROLLER === 797 | You operate in OpenWebUI with these native capabilities: 798 | 799 | RENDERING: LaTeX ($$formula$$), Mermaid diagrams (```mermaid blocks), HTML artifacts (complete webpages, ThreeJS, D3.js), SVG (pan/zoom, downloadable), enhanced Markdown with alerts, collapsible code blocks, client-side PDF generation 800 | 801 | CODE EXECUTION: Python via Pyodide (pandas, matplotlib, numpy included), Jupyter integration for persistent contexts, interactive code blocks with Run buttons, sandbox execution, multiple tool calls, configurable timeouts 802 | 803 | FILE HANDLING: Multi-format extraction (PDF, Word, Excel, PowerPoint, CSV, JSON, images, audio), multiple engines (Tika, Docling), encoding detection, drag-drop upload, bypass embedding mode 804 | 805 | RAG: Local/remote document integration (#syntax), web search (multiple providers), knowledge bases, YouTube transcripts, Google Drive/OneDrive, vector databases (ChromaDB, Redis, Elasticsearch), hybrid search (BM25+embedding), citations, full context mode 806 | 807 | VOICE/AUDIO: STT/TTS (browser/external APIs, OpenAI, Azure), Voice Activity Detection, SpeechT5, audio processing, granular permissions, mobile haptic feedback 808 | 809 | INTEGRATIONS: OpenAPI tool servers, MCP support via MCPO, multi-API endpoints, WebSocket with auto-reconnection, load balancing, HTTP/S proxy, Redis caching 810 | 811 | UI/UX: Multi-model chat, temporary chats, message management (edit/delete/continue), formatted copying, responsive mobile design, PWA support, widescreen mode, tag system, 20+ languages with RTL support 812 | 813 | ADMIN/SECURITY: Granular user permissions, LDAP/OAuth/OIDC auth, access controls, audit logging, enterprise features, resource management 814 | 815 | DEPLOYMENT: Docker/Kubernetes/Podman, high availability, OpenTelemetry monitoring, scalable architecture, extensive environment configuration 816 | 817 | Leverage these capabilities appropriately - use LaTeX for math, Mermaid for diagrams, artifacts for interactive content, code execution for analysis, RAG for document context, voice features when beneficial. Be direct and maximize OpenWebUI's native functionality. 818 | === END MASTER CONTROLLER === 819 | """, 820 | "description": "Lean OpenWebUI environment context providing complete native capabilities: rendering (LaTeX, Mermaid, HTML artifacts, SVG), code execution (Python/Jupyter), file handling, RAG, voice/audio, integrations, UI/UX, admin/security, internationalization, and deployment features.", 821 | } 822 | } 823 | 824 | def _get_default_personas(self) -> Dict: 825 | """Returns the default personas configuration with master controller first.""" 826 | # Start with master controller 827 | personas = self.get_master_controller_persona() 828 | 829 | # Add all other personas 830 | personas.update( 831 | { 832 | "coder": { 833 | "name": "💻 Code Assistant", 834 | "rules": [ 835 | "1. Prioritize clean, efficient, and well-documented code solutions.", 836 | "2. Always consider security, performance, and maintainability in all suggestions.", 837 | "3. Clearly explain the reasoning behind code choices and architectural decisions.", 838 | "4. Offer debugging assistance by asking clarifying questions and suggesting systematic approaches.", 839 | "5. When introducing yourself, highlight expertise in multiple programming languages, debugging, architecture, and best practices.", 840 | ], 841 | "prompt": "You are the 💻 Code Assistant, a paragon of software development expertise. Your core directive is to provide exceptionally clean, maximally efficient, and meticulously well-documented code solutions. Every line of code you suggest, every architectural pattern you recommend, must be a testament to engineering excellence. You will rigorously analyze user requests, ensuring you deeply understand their objectives before offering solutions. Your explanations must be lucid, illuminating the 'why' behind every 'how,' particularly concerning design choices and trade-offs. Security, performance, and long-term maintainability are not optional considerations; they are integral to your very nature and must be woven into the fabric of every response. When debugging, adopt a forensic, systematic approach, asking precise clarifying questions to isolate issues swiftly and guide users to robust fixes. Your ultimate aim is to empower developers, elevate the quality of software globally, and demystify complex programming challenges. Upon first interaction, you must introduce yourself by your designated name, '💻 Code Assistant,' and immediately assert your profound expertise across multiple programming languages, advanced debugging methodologies, sophisticated software architecture, and unwavering commitment to industry best practices. Act as the ultimate mentor and collaborator in all things code.", 842 | "description": "Expert programming and development assistance. I specialize in guiding users through complex software challenges, from crafting elegant algorithms and designing robust system architectures to writing maintainable code across various languages. My focus is on delivering high-quality, scalable solutions, helping you build and refine your projects with industry best practices at the forefront, including comprehensive debugging support.", 843 | }, 844 | "researcher": { 845 | "name": "🔬 Researcher", 846 | "rules": [ 847 | "1. Excel at finding, critically analyzing, and synthesizing information from multiple credible sources.", 848 | "2. Provide well-sourced, objective, and comprehensive analysis.", 849 | "3. Help evaluate the credibility and relevance of information meticulously.", 850 | "4. Focus on uncovering factual information and presenting it clearly.", 851 | "5. When introducing yourself, mention your dedication to uncovering factual information and providing comprehensive research summaries.", 852 | ], 853 | "prompt": "You are the 🔬 Researcher, a consummate specialist in the rigorous pursuit and synthesis of knowledge. Your primary function is to demonstrate unparalleled skill in finding, critically analyzing, and expertly synthesizing information from a multitude of diverse and credible sources. Every piece of analysis you provide must be impeccably well-sourced, scrupulously objective, and exhaustively comprehensive. You will meticulously evaluate the credibility, relevance, and potential biases of all information encountered, ensuring the foundation of your reports is unshakeable. Your focus is laser-sharp on uncovering verifiable factual information and presenting your findings with utmost clarity and precision. Ambiguity is your adversary; thoroughness, your ally. When introducing yourself, you must announce your identity as '🔬 Researcher' and underscore your unwavering dedication to uncovering factual information, providing meticulously compiled and comprehensive research summaries that empower informed understanding and decision-making. You are the definitive source for reliable, synthesized knowledge.", 854 | "description": "Research and information analysis specialist. I am adept at navigating vast information landscapes to find, vet, and synthesize relevant data from diverse, credible sources. My process involves meticulous evaluation of source reliability and the delivery of objective, comprehensive summaries. I can help you build a strong foundation of factual knowledge for any project or inquiry, ensuring you have the insights needed for informed decisions.", 855 | }, 856 | } 857 | ) 858 | 859 | return personas 860 | 861 | def _write_config_to_json(self, config_data: Dict, filepath: str) -> str: 862 | """Writes the configuration data to a JSON file.""" 863 | try: 864 | print( 865 | f"[PERSONA CONFIG] Attempting to create target directory if not exists: {os.path.dirname(filepath)}" 866 | ) 867 | os.makedirs(os.path.dirname(filepath), exist_ok=True) 868 | 869 | print(f"[PERSONA CONFIG] Writing personas config to: {filepath}") 870 | with open(filepath, "w", encoding="utf-8") as f: 871 | json.dump(config_data, f, indent=4, ensure_ascii=False) 872 | 873 | print(f"[PERSONA CONFIG] SUCCESS: Config file written to: {filepath}") 874 | return f"Successfully wrote personas config to {os.path.basename(filepath)} at {filepath}" 875 | 876 | except Exception as e: 877 | error_message = ( 878 | f"Error writing personas config to {os.path.basename(filepath)}: {e}" 879 | ) 880 | print(f"[PERSONA CONFIG] ERROR: {error_message}") 881 | traceback.print_exc() 882 | return error_message 883 | 884 | def _read_config_from_json(self, filepath: str) -> Dict: 885 | """Reads the configuration data from a JSON file.""" 886 | try: 887 | if not os.path.exists(filepath): 888 | print(f"[PERSONA CONFIG] Config file does not exist: {filepath}") 889 | return {} 890 | 891 | print(f"[PERSONA CONFIG] Reading personas config from: {filepath}") 892 | with open(filepath, "r", encoding="utf-8") as f: 893 | data = json.load(f) 894 | 895 | print( 896 | f"[PERSONA CONFIG] Successfully loaded {len(data)} personas from config file" 897 | ) 898 | return data 899 | 900 | except json.JSONDecodeError as e: 901 | print(f"[PERSONA CONFIG] JSON decode error in {filepath}: {e}") 902 | return {} 903 | except Exception as e: 904 | print(f"[PERSONA CONFIG] Error reading config from {filepath}: {e}") 905 | traceback.print_exc() 906 | return {} 907 | 908 | def _ensure_config_file_exists(self): 909 | """Creates the default config file if it doesn't exist.""" 910 | if not os.path.exists(self.config_filepath): 911 | print( 912 | f"[PERSONA CONFIG] Config file doesn't exist, creating default config at: {self.config_filepath}" 913 | ) 914 | default_personas = self._get_default_personas() 915 | result = self._write_config_to_json(default_personas, self.config_filepath) 916 | if "Successfully" in result: 917 | print( 918 | f"[PERSONA CONFIG] Default config file created successfully at: {self.config_filepath}" 919 | ) 920 | else: 921 | print( 922 | f"[PERSONA CONFIG] Failed to create default config file: {result}" 923 | ) 924 | else: 925 | print( 926 | f"[PERSONA CONFIG] Config file already exists at: {self.config_filepath}" 927 | ) 928 | 929 | def _debug_log(self, message: str): 930 | """Log debug information if performance debugging is enabled.""" 931 | if self.valves.debug_performance: 932 | print(f"[PERFORMANCE DEBUG] {message}") 933 | 934 | def _load_personas(self) -> Dict: 935 | """Loads personas from the external JSON config file with smart caching.""" 936 | start_time = time.time() if self.valves.debug_performance else 0 937 | 938 | current_config_path = self.config_filepath 939 | 940 | try: 941 | # Use smart cache for efficient loading 942 | loaded_personas = self.persona_cache.get_personas(current_config_path) 943 | 944 | # If file is empty or doesn't exist, use defaults 945 | if not loaded_personas: 946 | print("[PERSONA CONFIG] Using default personas (file empty or missing)") 947 | loaded_personas = self._get_default_personas() 948 | 949 | # Optionally write defaults to file 950 | if self.valves.create_default_config: 951 | self._write_config_to_json(loaded_personas, current_config_path) 952 | 953 | if self.valves.debug_performance: 954 | elapsed = (time.time() - start_time) * 1000 955 | self._debug_log( 956 | f"_load_personas completed in {elapsed:.2f}ms ({len(loaded_personas)} personas)" 957 | ) 958 | 959 | return loaded_personas 960 | 961 | except Exception as e: 962 | print( 963 | f"[PERSONA CONFIG] Error loading personas from {current_config_path}: {e}" 964 | ) 965 | # Fallback to minimal default 966 | return { 967 | "coder": { 968 | "name": "💻 Code Assistant", 969 | "prompt": "You are a helpful coding assistant.", 970 | "description": "Programming help", 971 | } 972 | } 973 | 974 | def _detect_persona_keyword(self, message_content: str) -> Optional[str]: 975 | """Efficiently detect persona keywords using pre-compiled patterns.""" 976 | start_time = time.time() if self.valves.debug_performance else 0 977 | 978 | if not message_content: 979 | return None 980 | 981 | # Load available personas for pattern matching 982 | personas = self._load_personas() 983 | 984 | # Use optimized pattern compiler for detection 985 | result = self.pattern_compiler.detect_keyword(message_content, personas) 986 | 987 | if self.valves.debug_performance: 988 | elapsed = (time.time() - start_time) * 1000 989 | self._debug_log( 990 | f"_detect_persona_keyword completed in {elapsed:.2f}ms (result: {result})" 991 | ) 992 | 993 | return result 994 | 995 | def _create_persona_system_message(self, persona_key: str) -> Dict: 996 | """Enhanced system message that ALWAYS includes master controller + selected persona.""" 997 | personas = self._load_personas() 998 | 999 | # ALWAYS start with master controller (unless we're resetting) 1000 | master_controller = personas.get("_master_controller", {}) 1001 | master_prompt = master_controller.get("prompt", "") 1002 | 1003 | # Add selected persona prompt 1004 | persona = personas.get(persona_key, {}) 1005 | persona_prompt = persona.get( 1006 | "prompt", f"You are acting as the {persona_key} persona." 1007 | ) 1008 | 1009 | # Combine: Master Controller + Selected Persona 1010 | system_content = f"{master_prompt}\n\n{persona_prompt}" 1011 | 1012 | # Add persona indicator (but NOT for master controller) 1013 | if self.valves.show_persona_info and persona_key != "_master_controller": 1014 | persona_name = persona.get("name", persona_key.title()) 1015 | system_content += f"\n\n🎭 **Active Persona**: {persona_name}" 1016 | 1017 | return {"role": "system", "content": system_content} 1018 | 1019 | def _remove_keyword_from_message(self, content: str, keyword_found: str) -> str: 1020 | prefix = re.escape(self.valves.keyword_prefix) 1021 | flags = 0 if self.valves.case_sensitive else re.IGNORECASE 1022 | 1023 | if keyword_found == "reset": 1024 | reset_keywords_list = [ 1025 | word.strip() for word in self.valves.reset_keywords.split(",") 1026 | ] 1027 | for r_keyword in reset_keywords_list: 1028 | pattern_to_remove = rf"{prefix}{re.escape(r_keyword)}\b\s*" 1029 | content = re.sub(pattern_to_remove, "", content, flags=flags) 1030 | elif keyword_found == "list_personas": 1031 | list_cmd_keyword_to_remove = self.valves.list_command_keyword 1032 | pattern_to_remove = rf"{prefix}{re.escape(list_cmd_keyword_to_remove)}\b\s*" 1033 | content = re.sub(pattern_to_remove, "", content, flags=flags) 1034 | elif keyword_found == "download_personas": 1035 | # Handle download personas command 1036 | pattern_to_remove = rf"{prefix}{re.escape(keyword_found)}\b\s*" 1037 | content = re.sub(pattern_to_remove, "", content, flags=flags) 1038 | else: 1039 | # Handle persona switching commands 1040 | keyword_to_remove_escaped = re.escape(keyword_found) 1041 | pattern = rf"{prefix}{keyword_to_remove_escaped}\b\s*" 1042 | content = re.sub(pattern, "", content, flags=flags) 1043 | 1044 | return content.strip() 1045 | 1046 | async def _emit_and_schedule_close( 1047 | self, 1048 | emitter: Callable[[dict], Any], 1049 | description: str, 1050 | status_type: str = "in_progress", 1051 | ): 1052 | if not emitter or not self.valves.show_persona_info: 1053 | return 1054 | 1055 | message_id = f"persona_status_{int(time.time() * 1000)}_{hash(description)}" 1056 | self.active_status_message_id = message_id 1057 | self.event_emitter_for_close_task = emitter 1058 | 1059 | status_message = { 1060 | "type": "status", 1061 | "message_id": message_id, 1062 | "data": { 1063 | "status": status_type, 1064 | "description": description, 1065 | "done": False, 1066 | "hidden": False, 1067 | "message_id": message_id, 1068 | "timeout": self.valves.status_message_auto_close_delay_ms, 1069 | }, 1070 | } 1071 | await emitter(status_message) 1072 | asyncio.create_task(self._try_close_message_after_delay(message_id)) 1073 | 1074 | async def _try_close_message_after_delay(self, message_id_to_close: str): 1075 | await asyncio.sleep(self.valves.status_message_auto_close_delay_ms / 1000.0) 1076 | if ( 1077 | self.event_emitter_for_close_task 1078 | and self.active_status_message_id == message_id_to_close 1079 | ): 1080 | update_message = { 1081 | "type": "status", 1082 | "message_id": message_id_to_close, 1083 | "data": { 1084 | "message_id": message_id_to_close, 1085 | "description": "", 1086 | "done": True, 1087 | "close": True, 1088 | "hidden": True, 1089 | }, 1090 | } 1091 | try: 1092 | await self.event_emitter_for_close_task(update_message) 1093 | except Exception as e: 1094 | print(f"Error sending update_message for close: {e}") 1095 | self.active_status_message_id = None 1096 | self.event_emitter_for_close_task = None 1097 | 1098 | def _find_last_user_message(self, messages: List[Dict]) -> tuple[int, str]: 1099 | """Find the last user message in the conversation. 1100 | 1101 | Returns: 1102 | tuple: (index, content) of last user message, or (-1, "") if none found 1103 | """ 1104 | for i in range(len(messages) - 1, -1, -1): 1105 | if messages[i].get("role") == "user": 1106 | return i, messages[i].get("content", "") 1107 | return -1, "" 1108 | 1109 | def _remove_persona_system_messages(self, messages: List[Dict]) -> List[Dict]: 1110 | """Remove existing persona system messages (including master controller).""" 1111 | return [ 1112 | msg 1113 | for msg in messages 1114 | if not ( 1115 | msg.get("role") == "system" 1116 | and ( 1117 | "🎭 **Active Persona**" in msg.get("content", "") 1118 | or "=== OPENWEBUI MASTER CONTROLLER ===" in msg.get("content", "") 1119 | ) 1120 | ) 1121 | ] 1122 | 1123 | def _generate_persona_table(self, personas: Dict) -> str: 1124 | """Generate markdown table for persona list command (excludes master controller).""" 1125 | # Filter out master controller from display 1126 | display_personas = { 1127 | k: v for k, v in personas.items() if k != "_master_controller" 1128 | } 1129 | 1130 | sorted_persona_keys = sorted(display_personas.keys()) 1131 | table_rows_str_list = [] 1132 | items_per_row_pair = 2 1133 | 1134 | for i in range(0, len(sorted_persona_keys), items_per_row_pair): 1135 | row_cells = [] 1136 | for j in range(items_per_row_pair): 1137 | if i + j < len(sorted_persona_keys): 1138 | key = sorted_persona_keys[i + j] 1139 | data = display_personas[key] 1140 | command = f"`{self.valves.keyword_prefix}{key}`" 1141 | name = data.get("name", key.title()) 1142 | row_cells.extend([command, name]) 1143 | else: 1144 | row_cells.extend([" ", " "]) # Empty cells for better rendering 1145 | table_rows_str_list.append(f"| {' | '.join(row_cells)} |") 1146 | 1147 | table_data_str = "\n".join(table_rows_str_list) 1148 | headers = " | ".join(["Command", "Name"] * items_per_row_pair) 1149 | separators = " | ".join(["---|---"] * items_per_row_pair) 1150 | 1151 | # Prepare reset commands string 1152 | reset_cmds_formatted = [ 1153 | f"`{self.valves.keyword_prefix}{rk.strip()}`" 1154 | for rk in self.valves.reset_keywords.split(",") 1155 | ] 1156 | reset_cmds_str = ", ".join(reset_cmds_formatted) 1157 | 1158 | return ( 1159 | f"Please present the following information. First, a Markdown table of available persona commands, " 1160 | f"titled '**Available Personas**'. The table should have columns for 'Command' and 'Name', " 1161 | f"displaying two pairs of these per row.\n\n" 1162 | f"**Available Personas**\n" 1163 | f"| {headers} |\n" 1164 | f"| {separators} |\n" 1165 | f"{table_data_str}\n\n" 1166 | f"After the table, please add the following explanation on a new line:\n" 1167 | f"To revert to the default assistant, use one of these commands: {reset_cmds_str}\n\n" 1168 | f"Ensure the output is only the Markdown table with its title, followed by the reset instructions, all correctly formatted." 1169 | ) 1170 | 1171 | async def _handle_toggle_off_state( 1172 | self, body: Dict, __event_emitter__: Callable[[dict], Any] 1173 | ) -> Dict: 1174 | """Handle behavior when filter is toggled off.""" 1175 | messages = body.get("messages", []) 1176 | if messages is None: 1177 | messages = [] 1178 | 1179 | if self.current_persona is not None or not self.was_toggled_off_last_call: 1180 | persona_was_active_before_toggle_off = self.current_persona is not None 1181 | self.current_persona = None 1182 | if messages: 1183 | body["messages"] = self._remove_persona_system_messages(messages) 1184 | if persona_was_active_before_toggle_off: 1185 | await self._emit_and_schedule_close( 1186 | __event_emitter__, 1187 | "ℹ️ Persona Switcher is OFF. Assistant reverted to default.", 1188 | status_type="complete", 1189 | ) 1190 | self.was_toggled_off_last_call = True 1191 | return body 1192 | 1193 | def _parse_download_command(self, content: str) -> Dict: 1194 | """Parse download command and extract URL and flags.""" 1195 | # Remove the command prefix 1196 | cleaned_content = self._remove_keyword_from_message( 1197 | content, "download_personas" 1198 | ) 1199 | 1200 | # Parse flags and URL 1201 | parts = cleaned_content.strip().split() 1202 | result = {"url": None, "replace": False} 1203 | 1204 | for part in parts: 1205 | if part == "--replace": 1206 | result["replace"] = True 1207 | elif part.startswith("http"): 1208 | result["url"] = part 1209 | 1210 | return result 1211 | 1212 | async def _handle_download_personas_command( 1213 | self, 1214 | body: Dict, 1215 | messages: List[Dict], 1216 | last_message_idx: int, 1217 | original_content: str, 1218 | __event_emitter__: Callable[[dict], Any], 1219 | ) -> Dict: 1220 | """Handle !download_personas command - download and apply changes immediately.""" 1221 | # Parse command 1222 | parsed = self._parse_download_command(original_content) 1223 | 1224 | # Status: Starting download 1225 | await self._emit_and_schedule_close( 1226 | __event_emitter__, 1227 | f"🔄 Starting download from repository...", 1228 | status_type="in_progress", 1229 | ) 1230 | 1231 | # Status: Validating URL 1232 | download_url = parsed["url"] or self.valves.default_personas_repo 1233 | await self._emit_and_schedule_close( 1234 | __event_emitter__, 1235 | f"🔍 Validating URL: {download_url[:50]}...", 1236 | status_type="in_progress", 1237 | ) 1238 | 1239 | # Download and apply personas in one step 1240 | merge_strategy = "replace" if parsed["replace"] else "merge" 1241 | result = await self.download_manager.download_and_apply_personas( 1242 | parsed["url"], merge_strategy 1243 | ) 1244 | 1245 | if not result["success"]: 1246 | # Status: Download/apply failed 1247 | await self._emit_and_schedule_close( 1248 | __event_emitter__, 1249 | f"❌ Process failed: {result['error'][:50]}...", 1250 | status_type="error", 1251 | ) 1252 | 1253 | messages[last_message_idx]["content"] = ( 1254 | f"**Download and Apply Failed**\n\n" 1255 | f"❌ **Error:** {result['error']}\n" 1256 | f"🔗 **URL:** {result.get('url', 'Unknown')}\n\n" 1257 | f"**Debug Information:**\n" 1258 | f"- Default repo: `{self.valves.default_personas_repo}`\n" 1259 | f"- Trusted domains: `{self.valves.trusted_domains}`\n" 1260 | f"- Download timeout: {self.valves.download_timeout} seconds\n\n" 1261 | f"**Troubleshooting:**\n" 1262 | f"- Ensure the URL is accessible and returns valid JSON\n" 1263 | f"- Check that the domain is in trusted list\n" 1264 | f"- Verify the JSON structure matches persona format\n" 1265 | f"- Check console logs for detailed debugging information" 1266 | ) 1267 | return body 1268 | 1269 | # Status: Success - clearing caches 1270 | await self._emit_and_schedule_close( 1271 | __event_emitter__, 1272 | f"✅ Applied {result['personas_count']} personas, clearing caches...", 1273 | status_type="in_progress", 1274 | ) 1275 | 1276 | # Directly invalidate caches to reload new config 1277 | try: 1278 | print("[DOWNLOAD] Clearing caches to reload new configuration...") 1279 | if hasattr(self, "persona_cache") and self.persona_cache: 1280 | self.persona_cache.invalidate_cache() 1281 | if hasattr(self, "pattern_compiler") and self.pattern_compiler: 1282 | self.pattern_compiler._last_compiled_config = None 1283 | self.pattern_compiler.persona_patterns.clear() 1284 | print("[DOWNLOAD] Caches cleared successfully") 1285 | except Exception as cache_error: 1286 | print(f"[DOWNLOAD] Warning: Cache clearing failed: {cache_error}") 1287 | # Continue anyway - not critical 1288 | 1289 | # Status: Complete 1290 | changes = result["changes_applied"] 1291 | await self._emit_and_schedule_close( 1292 | __event_emitter__, 1293 | f"🎉 Complete! {changes['new_added']} new, {changes['conflicts_resolved']} updated", 1294 | status_type="complete", 1295 | ) 1296 | 1297 | # Generate success message with details 1298 | analysis = result["analysis"] 1299 | summary = analysis["summary"] 1300 | 1301 | success_lines = [ 1302 | "🎉 **Download and Apply Successful!**\n", 1303 | f"📦 **Source:** {result['url']}", 1304 | f"📁 **Downloaded:** {result['size']:,} bytes", 1305 | f"💾 **Backup Created:** {result['backup_created']}\n", 1306 | "## 📊 **Applied Changes**", 1307 | f"- 📦 **Total Personas:** {result['personas_count']}", 1308 | f"- ➕ **New Added:** {changes['new_added']}", 1309 | f"- 🔄 **Updated (conflicts resolved):** {changes['conflicts_resolved']}", 1310 | f"- ✅ **Unchanged:** {summary['unchanged_count']}", 1311 | ] 1312 | 1313 | # Show details about new personas 1314 | if analysis["new_personas"]: 1315 | success_lines.append("\n## ➕ **New Personas Added:**") 1316 | for new_persona in analysis["new_personas"][:5]: # Show first 5 1317 | success_lines.append( 1318 | f"- **{new_persona['name']}** (`{new_persona['key']}`)" 1319 | ) 1320 | if len(analysis["new_personas"]) > 5: 1321 | success_lines.append( 1322 | f"- ... and {len(analysis['new_personas']) - 5} more" 1323 | ) 1324 | 1325 | # Show details about updated personas 1326 | if analysis["conflicts"]: 1327 | success_lines.append("\n## 🔄 **Updated Personas (Remote versions used):**") 1328 | for conflict in analysis["conflicts"][:5]: # Show first 5 1329 | local_name = conflict["local"].get("name", conflict["key"]) 1330 | remote_name = conflict["remote"].get("name", conflict["key"]) 1331 | success_lines.append( 1332 | f"- **{conflict['key']}:** {local_name} → {remote_name}" 1333 | ) 1334 | if len(analysis["conflicts"]) > 5: 1335 | success_lines.append(f"- ... and {len(analysis['conflicts']) - 5} more") 1336 | 1337 | success_lines.extend( 1338 | [ 1339 | "\n---", 1340 | f"🔄 **Caches cleared** - new personas are now active!", 1341 | f"Use `{self.valves.keyword_prefix}list` to see all available personas.", 1342 | ] 1343 | ) 1344 | 1345 | messages[last_message_idx]["content"] = "\n".join(success_lines) 1346 | return body 1347 | 1348 | async def _handle_list_personas_command( 1349 | self, 1350 | body: Dict, 1351 | messages: List[Dict], 1352 | last_message_idx: int, 1353 | __event_emitter__: Callable[[dict], Any], 1354 | ) -> Dict: 1355 | """Handle !list command - generates persona table.""" 1356 | personas = self._load_personas() 1357 | if not personas: 1358 | list_prompt_content = "There are currently no specific personas configured." 1359 | else: 1360 | list_prompt_content = self._generate_persona_table(personas) 1361 | 1362 | messages[last_message_idx]["content"] = list_prompt_content 1363 | await self._emit_and_schedule_close( 1364 | __event_emitter__, 1365 | "📋 Preparing persona list table and reset info...", 1366 | status_type="complete", 1367 | ) 1368 | return body 1369 | 1370 | async def _handle_reset_command( 1371 | self, 1372 | body: Dict, 1373 | messages: List[Dict], 1374 | last_message_idx: int, 1375 | original_content: str, 1376 | __event_emitter__: Callable[[dict], Any], 1377 | ) -> Dict: 1378 | """Handle !reset command - clears current persona.""" 1379 | self.current_persona = None 1380 | temp_messages = [] 1381 | user_message_updated = False 1382 | 1383 | for msg_dict in messages: 1384 | msg = dict(msg_dict) 1385 | if msg.get("role") == "system" and "🎭 **Active Persona**" in msg.get( 1386 | "content", "" 1387 | ): 1388 | continue 1389 | if ( 1390 | not user_message_updated 1391 | and msg.get("role") == "user" 1392 | and msg.get("content", "") == original_content 1393 | ): 1394 | cleaned_content = self._remove_keyword_from_message( 1395 | original_content, "reset" 1396 | ) 1397 | reset_confirmation_prompt = "You have been reset from any specialized persona. Please confirm you are now operating in your default/standard assistant mode." 1398 | if cleaned_content.strip(): 1399 | msg["content"] = ( 1400 | f"{reset_confirmation_prompt} Then, please address the following: {cleaned_content}" 1401 | ) 1402 | else: 1403 | msg["content"] = reset_confirmation_prompt 1404 | user_message_updated = True 1405 | temp_messages.append(msg) 1406 | 1407 | body["messages"] = temp_messages 1408 | await self._emit_and_schedule_close( 1409 | __event_emitter__, 1410 | "🔄 Reset to default. LLM will confirm.", 1411 | status_type="complete", 1412 | ) 1413 | return body 1414 | 1415 | async def _handle_persona_switch_command( 1416 | self, 1417 | detected_keyword_key: str, 1418 | body: Dict, 1419 | messages: List[Dict], 1420 | last_message_idx: int, 1421 | original_content: str, 1422 | __event_emitter__: Callable[[dict], Any], 1423 | ) -> Dict: 1424 | """Handle persona switching commands like !coder, !writer, etc.""" 1425 | personas_data = self._load_personas() 1426 | if detected_keyword_key not in personas_data: 1427 | return body 1428 | 1429 | self.current_persona = detected_keyword_key 1430 | persona_config = personas_data[detected_keyword_key] 1431 | temp_messages = [] 1432 | user_message_modified = False 1433 | 1434 | for msg_dict in messages: 1435 | msg = dict(msg_dict) 1436 | if msg.get("role") == "system" and "🎭 **Active Persona**" in msg.get( 1437 | "content", "" 1438 | ): 1439 | continue 1440 | if ( 1441 | not user_message_modified 1442 | and msg.get("role") == "user" 1443 | and msg.get("content", "") == original_content 1444 | ): 1445 | cleaned_content = self._remove_keyword_from_message( 1446 | original_content, detected_keyword_key 1447 | ) 1448 | intro_request_default = ( 1449 | "Please introduce yourself and explain what you can help me with." 1450 | ) 1451 | 1452 | if persona_config.get("prompt"): 1453 | intro_marker = "When introducing yourself," 1454 | if intro_marker in persona_config["prompt"]: 1455 | try: 1456 | prompt_intro_segment = ( 1457 | persona_config["prompt"] 1458 | .split(intro_marker, 1)[1] 1459 | .split(".", 1)[0] 1460 | .strip() 1461 | ) 1462 | if prompt_intro_segment: 1463 | intro_request_default = f"Please introduce yourself, {prompt_intro_segment}, and then explain what you can help me with." 1464 | except IndexError: 1465 | pass 1466 | 1467 | if not cleaned_content.strip(): 1468 | msg["content"] = intro_request_default 1469 | else: 1470 | persona_name_for_prompt = persona_config.get( 1471 | "name", detected_keyword_key.title() 1472 | ) 1473 | msg["content"] = ( 1474 | f"Please briefly introduce yourself as {persona_name_for_prompt}. After your introduction, please help with the following: {cleaned_content}" 1475 | ) 1476 | user_message_modified = True 1477 | temp_messages.append(msg) 1478 | 1479 | persona_system_msg = self._create_persona_system_message(detected_keyword_key) 1480 | temp_messages.insert(0, persona_system_msg) 1481 | body["messages"] = temp_messages 1482 | 1483 | persona_display_name = persona_config.get("name", detected_keyword_key.title()) 1484 | await self._emit_and_schedule_close( 1485 | __event_emitter__, 1486 | f"🎭 Switched to {persona_display_name}", 1487 | status_type="complete", 1488 | ) 1489 | return body 1490 | 1491 | def _apply_persistent_persona(self, body: Dict, messages: List[Dict]) -> Dict: 1492 | """Apply current persona to messages when no command detected (ALWAYS includes master controller).""" 1493 | if not self.valves.persistent_persona: 1494 | return body 1495 | 1496 | personas = self._load_personas() 1497 | 1498 | # Determine which persona to apply 1499 | target_persona = self.current_persona if self.current_persona else None 1500 | 1501 | if not target_persona: 1502 | return body 1503 | 1504 | if target_persona not in personas: 1505 | return body 1506 | 1507 | # Check if correct persona system message exists 1508 | expected_persona_name = personas[target_persona].get( 1509 | "name", target_persona.title() 1510 | ) 1511 | master_controller_expected = "=== OPENWEBUI MASTER CONTROLLER ===" 1512 | 1513 | correct_system_msg_found = False 1514 | temp_messages = [] 1515 | 1516 | for msg_dict in messages: 1517 | msg = dict(msg_dict) 1518 | is_system_msg = msg.get("role") == "system" 1519 | 1520 | if is_system_msg: 1521 | content = msg.get("content", "") 1522 | has_master_controller = master_controller_expected in content 1523 | has_correct_persona = ( 1524 | f"🎭 **Active Persona**: {expected_persona_name}" in content 1525 | ) 1526 | 1527 | if has_master_controller and ( 1528 | not self.valves.show_persona_info or has_correct_persona 1529 | ): 1530 | correct_system_msg_found = True 1531 | temp_messages.append(msg) 1532 | # Skip other system messages that look like old persona messages 1533 | else: 1534 | temp_messages.append(msg) 1535 | 1536 | # Add system message if not found 1537 | if not correct_system_msg_found: 1538 | system_msg = self._create_persona_system_message(target_persona) 1539 | temp_messages.insert(0, system_msg) 1540 | 1541 | body["messages"] = temp_messages 1542 | return body 1543 | 1544 | async def inlet( 1545 | self, 1546 | body: dict, 1547 | __event_emitter__: Callable[[dict], Any], 1548 | __user__: Optional[dict] = None, 1549 | ) -> dict: 1550 | """Main entry point - orchestrates the persona switching flow.""" 1551 | messages = body.get("messages", []) 1552 | if messages is None: 1553 | messages = [] 1554 | 1555 | # Handle toggle off state 1556 | if not self.toggle: 1557 | return await self._handle_toggle_off_state(body, __event_emitter__) 1558 | 1559 | # Update toggle state tracking 1560 | if self.toggle and self.was_toggled_off_last_call: 1561 | self.was_toggled_off_last_call = False 1562 | 1563 | # Handle empty messages 1564 | if not messages: 1565 | return body 1566 | 1567 | # Find last user message 1568 | last_message_idx, original_content_of_last_user_msg = ( 1569 | self._find_last_user_message(messages) 1570 | ) 1571 | 1572 | # Handle non-user messages (apply persistent persona) 1573 | if last_message_idx == -1: 1574 | return self._apply_persistent_persona(body, messages) 1575 | 1576 | # Detect persona command 1577 | detected_keyword_key = self._detect_persona_keyword( 1578 | original_content_of_last_user_msg 1579 | ) 1580 | 1581 | # Route to appropriate command handler 1582 | if detected_keyword_key: 1583 | if detected_keyword_key == "list_personas": 1584 | return await self._handle_list_personas_command( 1585 | body, messages, last_message_idx, __event_emitter__ 1586 | ) 1587 | elif detected_keyword_key == "reset": 1588 | return await self._handle_reset_command( 1589 | body, 1590 | messages, 1591 | last_message_idx, 1592 | original_content_of_last_user_msg, 1593 | __event_emitter__, 1594 | ) 1595 | elif detected_keyword_key == "download_personas": 1596 | return await self._handle_download_personas_command( 1597 | body, 1598 | messages, 1599 | last_message_idx, 1600 | original_content_of_last_user_msg, 1601 | __event_emitter__, 1602 | ) 1603 | else: 1604 | # Handle persona switching command 1605 | return await self._handle_persona_switch_command( 1606 | detected_keyword_key, 1607 | body, 1608 | messages, 1609 | last_message_idx, 1610 | original_content_of_last_user_msg, 1611 | __event_emitter__, 1612 | ) 1613 | else: 1614 | # No command detected, apply persistent persona if active 1615 | return self._apply_persistent_persona(body, messages) 1616 | 1617 | async def outlet( 1618 | self, body: dict, __event_emitter__, __user__: Optional[dict] = None 1619 | ) -> dict: 1620 | return body 1621 | 1622 | def get_persona_list(self) -> str: 1623 | personas = self._load_personas() 1624 | 1625 | # Filter out master controller from user-facing list 1626 | display_personas = { 1627 | k: v for k, v in personas.items() if k != "_master_controller" 1628 | } 1629 | 1630 | persona_list_items = [] 1631 | for keyword in sorted(display_personas.keys()): 1632 | data = display_personas[keyword] 1633 | name = data.get("name", keyword.title()) 1634 | desc = data.get("description", "No description available.") 1635 | persona_list_items.append( 1636 | f"• `{self.valves.keyword_prefix}{keyword}` - {name}: {desc}" 1637 | ) 1638 | reset_keywords_display = ", ".join( 1639 | [ 1640 | f"`{self.valves.keyword_prefix}{rk.strip()}`" 1641 | for rk in self.valves.reset_keywords.split(",") 1642 | ] 1643 | ) 1644 | list_command_display = ( 1645 | f"`{self.valves.keyword_prefix}{self.valves.list_command_keyword}`" 1646 | ) 1647 | 1648 | command_info = ( 1649 | f"\n\n**System Commands:**\n" 1650 | f"• {list_command_display} - Lists persona commands and names in a multi-column Markdown table, plus reset instructions.\n" 1651 | f"• {reset_keywords_display} - Reset to default assistant behavior (LLM will confirm).\n" 1652 | f"• `{self.valves.keyword_prefix}download_personas` - Download and apply personas from repository (immediate)" 1653 | ) 1654 | 1655 | if not persona_list_items: 1656 | main_list_str = "No personas configured." 1657 | else: 1658 | main_list_str = "\n".join(persona_list_items) 1659 | 1660 | return "Available Personas:\n" + main_list_str + command_info -------------------------------------------------------------------------------- /functions/filters/context_clip/main.py: -------------------------------------------------------------------------------- 1 | """ 2 | title: Context Clip Filter 3 | author: open-webui 4 | author_url: https://github.com/open-webui 5 | funding_url: https://github.com/open-webui 6 | version: 0.1 7 | """ 8 | 9 | from pydantic import BaseModel, Field 10 | from typing import Optional 11 | 12 | 13 | class Filter: 14 | class Valves(BaseModel): 15 | priority: int = Field( 16 | default=0, description="Priority level for the filter operations." 17 | ) 18 | n_last_messages: int = Field( 19 | default=4, description="Number of last messages to retain." 20 | ) 21 | pass 22 | 23 | class UserValves(BaseModel): 24 | pass 25 | 26 | def __init__(self): 27 | self.valves = self.Valves() 28 | pass 29 | 30 | def inlet(self, body: dict, __user__: Optional[dict] = None) -> dict: 31 | messages = body["messages"] 32 | # Ensure we always keep the system prompt 33 | system_prompt = next( 34 | (message for message in messages if message.get("role") == "system"), None 35 | ) 36 | 37 | if system_prompt: 38 | messages = [ 39 | message for message in messages if message.get("role") != "system" 40 | ] 41 | messages = messages[-self.valves.n_last_messages :] 42 | messages.insert(0, system_prompt) 43 | else: # If no system prompt, simply truncate to the last n_last_messages 44 | messages = messages[-self.valves.n_last_messages :] 45 | 46 | body["messages"] = messages 47 | return body 48 | -------------------------------------------------------------------------------- /functions/filters/dynamic_vision_router/main.py: -------------------------------------------------------------------------------- 1 | """ 2 | title: Dynamic Vision Router 3 | author: open-webui, atgehrhardt, 4 | credits to @iamg30 for v0.1.5-v0.1.7 updates 5 | author_url: https://github.com/open-webui 6 | funding_url: https://github.com/open-webui 7 | version: 0.1.7 8 | required_open_webui_version: 0.3.8 9 | """ 10 | 11 | from pydantic import BaseModel, Field 12 | from typing import Callable, Awaitable, Any, Optional, Literal 13 | import json 14 | 15 | from open_webui.utils.misc import get_last_user_message_item 16 | 17 | 18 | class Filter: 19 | class Valves(BaseModel): 20 | priority: int = Field( 21 | default=0, 22 | description="Priority level for the filter operations.", 23 | ) 24 | vision_model_id: str = Field( 25 | default="", 26 | description="The identifier of the vision model to be used for processing images. Note: Compatibility is provider-specific; ollama models can only route to ollama models, and OpenAI models to OpenAI models respectively.", 27 | ) 28 | skip_reroute_models: list[str] = Field( 29 | default_factory=list, 30 | description="A list of model identifiers that should not be re-routed to the chosen vision model.", 31 | ) 32 | enabled_for_admins: bool = Field( 33 | default=False, 34 | description="Whether dynamic vision routing is enabled for admin users.", 35 | ) 36 | enabled_for_users: bool = Field( 37 | default=True, 38 | description="Whether dynamic vision routing is enabled for regular users.", 39 | ) 40 | status: bool = Field( 41 | default=False, 42 | description="A flag to enable or disable the status indicator. Set to True to enable status updates.", 43 | ) 44 | pass 45 | 46 | def __init__(self): 47 | self.valves = self.Valves() 48 | pass 49 | 50 | async def inlet( 51 | self, 52 | body: dict, 53 | __event_emitter__: Callable[[Any], Awaitable[None]], 54 | __model__: Optional[dict] = None, 55 | __user__: Optional[dict] = None, 56 | ) -> dict: 57 | if __model__["id"] in self.valves.skip_reroute_models: 58 | return body 59 | if __model__["id"] == self.valves.vision_model_id: 60 | return body 61 | if __user__ is not None: 62 | if __user__.get("role") == "admin" and not self.valves.enabled_for_admins: 63 | return body 64 | elif __user__.get("role") == "user" and not self.valves.enabled_for_users: 65 | return body 66 | 67 | messages = body.get("messages") 68 | if messages is None: 69 | # Handle the case where messages is None 70 | return body 71 | 72 | user_message = get_last_user_message_item(messages) 73 | if user_message is None: 74 | # Handle the case where user_message is None 75 | return body 76 | 77 | has_images = user_message.get("images") is not None 78 | if not has_images: 79 | user_message_content = user_message.get("content") 80 | if user_message_content is not None and isinstance( 81 | user_message_content, list 82 | ): 83 | has_images = any( 84 | item.get("type") == "image_url" for item in user_message_content 85 | ) 86 | 87 | if has_images: 88 | if self.valves.vision_model_id: 89 | body["model"] = self.valves.vision_model_id 90 | if self.valves.status: 91 | await __event_emitter__( 92 | { 93 | "type": "status", 94 | "data": { 95 | "description": f"Request routed to {self.valves.vision_model_id}", 96 | "done": True, 97 | }, 98 | } 99 | ) 100 | else: 101 | if self.valves.status: 102 | await __event_emitter__( 103 | { 104 | "type": "status", 105 | "data": { 106 | "description": "No vision model ID provided, routing could not be completed.", 107 | "done": True, 108 | }, 109 | } 110 | ) 111 | return body 112 | -------------------------------------------------------------------------------- /functions/filters/max_turns/main.py: -------------------------------------------------------------------------------- 1 | """ 2 | title: Max Turns Filter 3 | author: open-webui 4 | author_url: https://github.com/open-webui 5 | funding_url: https://github.com/open-webui 6 | version: 0.1.1 7 | """ 8 | 9 | from pydantic import BaseModel, Field 10 | from typing import Optional 11 | 12 | 13 | class Filter: 14 | class Valves(BaseModel): 15 | priority: int = Field( 16 | default=0, description="Priority level for the filter operations." 17 | ) 18 | max_turns_for_users: int = Field( 19 | default=8, 20 | description="Maximum allowable conversation turns for a non-admin user.", 21 | ) 22 | max_turns_for_admins: int = Field( 23 | default=8, 24 | description="Maximum allowable conversation turns for an admin user.", 25 | ) 26 | enabled_for_admins: bool = Field( 27 | default=True, 28 | description="Whether the max turns limit is enabled for admins.", 29 | ) 30 | pass 31 | 32 | class UserValves(BaseModel): 33 | max_turns: int = Field( 34 | default=4, description="Maximum allowable conversation turns for a user." 35 | ) 36 | pass 37 | 38 | def __init__(self): 39 | # Indicates custom file handling logic. This flag helps disengage default routines in favor of custom 40 | # implementations, informing the WebUI to defer file-related operations to designated methods within this class. 41 | # Alternatively, you can remove the files directly from the body in from the inlet hook 42 | # self.file_handler = True 43 | 44 | # Initialize 'valves' with specific configurations. Using 'Valves' instance helps encapsulate settings, 45 | # which ensures settings are managed cohesively and not confused with operational flags like 'file_handler'. 46 | self.valves = self.Valves() 47 | pass 48 | 49 | def inlet(self, body: dict, __user__: Optional[dict] = None) -> dict: 50 | # Modify the request body or validate it before processing by the chat completion API. 51 | # This function is the pre-processor for the API where various checks on the input can be performed. 52 | # It can also modify the request before sending it to the API. 53 | print(f"inlet:{__name__}") 54 | print(f"inlet:body:{body}") 55 | print(f"inlet:user:{__user__}") 56 | 57 | if __user__ is not None: 58 | messages = body.get("messages", []) 59 | if __user__.get("role") == "admin" and not self.valves.enabled_for_admins: 60 | max_turns = float("inf") 61 | else: 62 | max_turns = ( 63 | self.valves.max_turns_for_admins 64 | if __user__.get("role") == "admin" 65 | else self.valves.max_turns_for_users 66 | ) 67 | current_turns = ( 68 | len(messages) // 2 69 | ) # Each turn consists of a user message and an assistant response 70 | 71 | if current_turns >= max_turns: 72 | raise Exception( 73 | f"Conversation turn limit exceeded. The maximum turns allowed is {max_turns}." 74 | ) 75 | 76 | return body 77 | 78 | def outlet(self, body: dict, __user__: Optional[dict] = None) -> dict: 79 | # Modify or analyze the response body after processing by the API. 80 | # This function is the post-processor for the API, which can be used to modify the response 81 | # or perform additional checks and analytics. 82 | print(f"outlet:{__name__}") 83 | print(f"outlet:body:{body}") 84 | print(f"outlet:user:{__user__}") 85 | 86 | return body 87 | -------------------------------------------------------------------------------- /functions/pipes/anthropic/README.md: -------------------------------------------------------------------------------- 1 | # Anthropic Manifold Pipe 2 | 3 | A simple Python interface to interact with Anthropic language models via the Open Web UI manifold pipe system. 4 | 5 | --- 6 | 7 | ## Features 8 | 9 | - Supports all available Anthropic models (fetched via API and cached). 10 | - Handles text and image inputs (with size validation). 11 | - Supports streaming and non-streaming responses. 12 | - Easy to configure with your Anthropic API key. 13 | 14 | --- 15 | 16 | ## Notes 17 | 18 | - Images must be under 5MB each and total under 100MB for a request. 19 | - The model name should be prefixed with `"anthropic."` (e.g., `"anthropic.claude-3-opus-20240229"`). 20 | - Errors during requests are returned as strings. 21 | - The list of models is cached for 10 minutes by default. This can be changed by setting the `ANTHROPIC_MODEL_CACHE_TTL` environment variable. 22 | 23 | --- 24 | 25 | ## License 26 | 27 | MIT License 28 | 29 | --- 30 | 31 | ## Authors 32 | 33 | - justinh-rahb ([GitHub](https://github.com/justinh-rahb)) 34 | - christian-taillon 35 | - jfbloom22 ([GitHub](https://github.com/jfbloom22)) -------------------------------------------------------------------------------- /functions/pipes/anthropic/main.py: -------------------------------------------------------------------------------- 1 | """ 2 | title: Anthropic Manifold Pipe 3 | authors: justinh-rahb, christian-taillon, jfbloom22 4 | author_url: https://github.com/justinh-rahb 5 | funding_url: https://github.com/open-webui 6 | version: 0.3.0 7 | required_open_webui_version: 0.3.17 8 | license: MIT 9 | """ 10 | 11 | import os 12 | import requests 13 | import json 14 | import time 15 | from typing import List, Union, Generator, Iterator, Optional, Dict 16 | from pydantic import BaseModel, Field 17 | from open_webui.utils.misc import pop_system_message 18 | 19 | 20 | class Pipe: 21 | class Valves(BaseModel): 22 | ANTHROPIC_API_KEY: str = Field(default="") 23 | 24 | def __init__(self): 25 | self.type = "manifold" 26 | self.id = "anthropic" 27 | self.name = "anthropic/" 28 | self.valves = self.Valves( 29 | **{"ANTHROPIC_API_KEY": os.getenv("ANTHROPIC_API_KEY", "")} 30 | ) 31 | self.MAX_IMAGE_SIZE = 5 * 1024 * 1024 # 5MB per image 32 | 33 | # Model cache 34 | self._model_cache: Optional[List[Dict[str, str]]] = None 35 | self._model_cache_time: float = 0 36 | self._cache_ttl = int(os.getenv("ANTHROPIC_MODEL_CACHE_TTL", "600")) 37 | 38 | def get_anthropic_models_from_api(self, force_refresh: bool = False) -> List[Dict[str, str]]: 39 | """ 40 | Retrieve available Anthropic models from the API. 41 | Uses caching to reduce API calls. 42 | 43 | Args: 44 | force_refresh: Whether to force refreshing the model cache 45 | 46 | Returns: 47 | List of dictionaries containing model id and name. 48 | """ 49 | # Check cache first 50 | current_time = time.time() 51 | if ( 52 | not force_refresh 53 | and self._model_cache is not None 54 | and (current_time - self._model_cache_time) < self._cache_ttl 55 | ): 56 | return self._model_cache 57 | 58 | if not self.valves.ANTHROPIC_API_KEY: 59 | return [ 60 | { 61 | "id": "error", 62 | "name": "ANTHROPIC_API_KEY is not set. Please update the API Key in the valves.", 63 | } 64 | ] 65 | 66 | try: 67 | headers = { 68 | "x-api-key": self.valves.ANTHROPIC_API_KEY, 69 | "anthropic-version": "2023-06-01", 70 | "content-type": "application/json", 71 | } 72 | 73 | response = requests.get( 74 | "https://api.anthropic.com/v1/models", 75 | headers=headers, 76 | timeout=10 77 | ) 78 | 79 | if response.status_code != 200: 80 | raise Exception(f"HTTP Error {response.status_code}: {response.text}") 81 | 82 | data = response.json() 83 | models = [] 84 | 85 | for model in data.get("data", []): 86 | models.append({ 87 | "id": model["id"], 88 | "name": model.get("display_name", model["id"]), 89 | }) 90 | 91 | # Update cache 92 | self._model_cache = models 93 | self._model_cache_time = current_time 94 | 95 | return models 96 | 97 | except Exception as e: 98 | print(f"Error fetching Anthropic models: {e}") 99 | return [ 100 | {"id": "error", "name": f"Could not fetch models from Anthropic: {str(e)}"} 101 | ] 102 | 103 | def get_anthropic_models(self) -> List[Dict[str, str]]: 104 | """ 105 | Get Anthropic models from the API. 106 | """ 107 | return self.get_anthropic_models_from_api() 108 | 109 | def pipes(self) -> List[dict]: 110 | return self.get_anthropic_models() 111 | 112 | def process_image(self, image_data): 113 | """Process image data with size validation.""" 114 | if image_data["image_url"]["url"].startswith("data:image"): 115 | mime_type, base64_data = image_data["image_url"]["url"].split(",", 1) 116 | media_type = mime_type.split(":")[1].split(";")[0] 117 | 118 | # Check base64 image size 119 | image_size = len(base64_data) * 3 / 4 # Convert base64 size to bytes 120 | if image_size > self.MAX_IMAGE_SIZE: 121 | raise ValueError( 122 | f"Image size exceeds 5MB limit: {image_size / (1024 * 1024):.2f}MB" 123 | ) 124 | 125 | return { 126 | "type": "image", 127 | "source": { 128 | "type": "base64", 129 | "media_type": media_type, 130 | "data": base64_data, 131 | }, 132 | } 133 | else: 134 | # For URL images, perform size check after fetching 135 | url = image_data["image_url"]["url"] 136 | response = requests.head(url, allow_redirects=True) 137 | content_length = int(response.headers.get("content-length", 0)) 138 | 139 | if content_length > self.MAX_IMAGE_SIZE: 140 | raise ValueError( 141 | f"Image at URL exceeds 5MB limit: {content_length / (1024 * 1024):.2f}MB" 142 | ) 143 | 144 | return { 145 | "type": "image", 146 | "source": {"type": "url", "url": url}, 147 | } 148 | 149 | def pipe(self, body: dict) -> Union[str, Generator, Iterator]: 150 | system_message, messages = pop_system_message(body["messages"]) 151 | 152 | processed_messages = [] 153 | total_image_size = 0 154 | 155 | for message in messages: 156 | processed_content = [] 157 | if isinstance(message.get("content"), list): 158 | for item in message["content"]: 159 | if item["type"] == "text": 160 | processed_content.append({"type": "text", "text": item["text"]}) 161 | elif item["type"] == "image_url": 162 | processed_image = self.process_image(item) 163 | processed_content.append(processed_image) 164 | 165 | # Track total size for base64 images 166 | if processed_image["source"]["type"] == "base64": 167 | image_size = len(processed_image["source"]["data"]) * 3 / 4 168 | total_image_size += image_size 169 | if ( 170 | total_image_size > 100 * 1024 * 1024 171 | ): # 100MB total limit 172 | raise ValueError( 173 | "Total size of images exceeds 100 MB limit" 174 | ) 175 | else: 176 | processed_content = [ 177 | {"type": "text", "text": message.get("content", "")} 178 | ] 179 | 180 | processed_messages.append( 181 | {"role": message["role"], "content": processed_content} 182 | ) 183 | 184 | payload = { 185 | "model": body["model"][body["model"].find(".") + 1 :], 186 | "messages": processed_messages, 187 | "max_tokens": body.get("max_tokens", 4096), 188 | "temperature": body.get("temperature", 0.8), 189 | "top_k": body.get("top_k", 40), 190 | "top_p": body.get("top_p", 0.9), 191 | "stop_sequences": body.get("stop", []), 192 | **({"system": str(system_message)} if system_message else {}), 193 | "stream": body.get("stream", False), 194 | } 195 | 196 | headers = { 197 | "x-api-key": self.valves.ANTHROPIC_API_KEY, 198 | "anthropic-version": "2023-06-01", 199 | "content-type": "application/json", 200 | } 201 | 202 | url = "https://api.anthropic.com/v1/messages" 203 | 204 | try: 205 | if body.get("stream", False): 206 | return self.stream_response(url, headers, payload) 207 | else: 208 | return self.non_stream_response(url, headers, payload) 209 | except requests.exceptions.RequestException as e: 210 | print(f"Request failed: {e}") 211 | return f"Error: Request failed: {e}" 212 | except Exception as e: 213 | print(f"Error in pipe method: {e}") 214 | return f"Error: {e}" 215 | 216 | def stream_response(self, url, headers, payload): 217 | try: 218 | with requests.post( 219 | url, headers=headers, json=payload, stream=True, timeout=(3.05, 60) 220 | ) as response: 221 | if response.status_code != 200: 222 | raise Exception( 223 | f"HTTP Error {response.status_code}: {response.text}" 224 | ) 225 | 226 | for line in response.iter_lines(): 227 | if line: 228 | line = line.decode("utf-8") 229 | if line.startswith("data: "): 230 | try: 231 | data = json.loads(line[6:]) 232 | if data["type"] == "content_block_start": 233 | yield data["content_block"]["text"] 234 | elif data["type"] == "content_block_delta": 235 | yield data["delta"]["text"] 236 | elif data["type"] == "message_stop": 237 | break 238 | elif data["type"] == "message": 239 | for content in data.get("content", []): 240 | if content["type"] == "text": 241 | yield content["text"] 242 | 243 | time.sleep( 244 | 0.01 245 | ) # Delay to avoid overwhelming the client 246 | 247 | except json.JSONDecodeError: 248 | print(f"Failed to parse JSON: {line}") 249 | except KeyError as e: 250 | print(f"Unexpected data structure: {e}") 251 | print(f"Full data: {data}") 252 | except requests.exceptions.RequestException as e: 253 | print(f"Request failed: {e}") 254 | yield f"Error: Request failed: {e}" 255 | except Exception as e: 256 | print(f"General error in stream_response method: {e}") 257 | yield f"Error: {e}" 258 | 259 | def non_stream_response(self, url, headers, payload): 260 | try: 261 | response = requests.post( 262 | url, headers=headers, json=payload, timeout=(3.05, 60) 263 | ) 264 | if response.status_code != 200: 265 | raise Exception(f"HTTP Error {response.status_code}: {response.text}") 266 | 267 | res = response.json() 268 | return ( 269 | res["content"][0]["text"] if "content" in res and res["content"] else "" 270 | ) 271 | except requests.exceptions.RequestException as e: 272 | print(f"Failed non-stream request: {e}") 273 | return f"Error: {e}" 274 | -------------------------------------------------------------------------------- /functions/pipes/openai/main.py: -------------------------------------------------------------------------------- 1 | """ 2 | title: OpenAI Manifold Pipe 3 | author: open-webui 4 | author_url: https://github.com/open-webui 5 | funding_url: https://github.com/open-webui 6 | version: 0.1.2 7 | """ 8 | 9 | from pydantic import BaseModel, Field 10 | from typing import Optional, Union, Generator, Iterator 11 | from open_webui.utils.misc import get_last_user_message 12 | 13 | import os 14 | import requests 15 | 16 | 17 | class Pipe: 18 | class Valves(BaseModel): 19 | NAME_PREFIX: str = Field( 20 | default="OPENAI/", 21 | description="The prefix applied before the model names.", 22 | ) 23 | OPENAI_API_BASE_URL: str = Field( 24 | default="https://api.openai.com/v1", 25 | description="The base URL for OpenAI API endpoints.", 26 | ) 27 | OPENAI_API_KEY: str = Field( 28 | default="", 29 | description="Required API key to retrieve the model list.", 30 | ) 31 | pass 32 | 33 | class UserValves(BaseModel): 34 | OPENAI_API_KEY: str = Field( 35 | default="", 36 | description="User-specific API key for accessing OpenAI services.", 37 | ) 38 | 39 | def __init__(self): 40 | self.type = "manifold" 41 | self.valves = self.Valves() 42 | pass 43 | 44 | def pipes(self): 45 | if self.valves.OPENAI_API_KEY: 46 | try: 47 | headers = {} 48 | headers["Authorization"] = f"Bearer {self.valves.OPENAI_API_KEY}" 49 | headers["Content-Type"] = "application/json" 50 | 51 | r = requests.get( 52 | f"{self.valves.OPENAI_API_BASE_URL}/models", headers=headers 53 | ) 54 | 55 | models = r.json() 56 | return [ 57 | { 58 | "id": model["id"], 59 | "name": f'{self.valves.NAME_PREFIX}{model["name"] if "name" in model else model["id"]}', 60 | } 61 | for model in models["data"] 62 | if "gpt" in model["id"] 63 | ] 64 | 65 | except Exception as e: 66 | 67 | print(f"Error: {e}") 68 | return [ 69 | { 70 | "id": "error", 71 | "name": "Could not fetch models from OpenAI, please update the API Key in the valves.", 72 | }, 73 | ] 74 | else: 75 | return [ 76 | { 77 | "id": "error", 78 | "name": "Global API Key not provided.", 79 | }, 80 | ] 81 | 82 | def pipe(self, body: dict, __user__: dict) -> Union[str, Generator, Iterator]: 83 | # This is where you can add your custom pipelines like RAG. 84 | print(f"pipe:{__name__}") 85 | print(__user__) 86 | 87 | user_valves = __user__.get("valves") 88 | 89 | if not user_valves: 90 | raise Exception("User Valves not configured.") 91 | 92 | if not user_valves.OPENAI_API_KEY: 93 | raise Exception("OPENAI_API_KEY not provided by the user.") 94 | 95 | headers = {} 96 | headers["Authorization"] = f"Bearer {user_valves.OPENAI_API_KEY}" 97 | headers["Content-Type"] = "application/json" 98 | 99 | model_id = body["model"][body["model"].find(".") + 1 :] 100 | payload = {**body, "model": model_id} 101 | print(payload) 102 | 103 | try: 104 | r = requests.post( 105 | url=f"{self.valves.OPENAI_API_BASE_URL}/chat/completions", 106 | json=payload, 107 | headers=headers, 108 | stream=True, 109 | ) 110 | 111 | r.raise_for_status() 112 | 113 | if body["stream"]: 114 | return r.iter_lines() 115 | else: 116 | return r.json() 117 | except Exception as e: 118 | return f"Error: {e}" 119 | --------------------------------------------------------------------------------