├── .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 | [](https://github.com/pkeffect/agent_hotswap)
6 | [](https://github.com/open-webui/open-webui)
7 | [](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 |
--------------------------------------------------------------------------------